# Architecture Decision Records (ADR)

## 2026-09-09 — 改语言这件事**没有** MCP 入口(PAT 姿态与 HTTP 孪生一致)

**决定.** `identity_persons.preferred_language` 的写入面只有两个:网页个人设置页
(`PATCH /auth/me/profile`)和飞书帮助卡上的语言按钮。**不提供 MCP 工具。**

**为什么.** HTTP 孪生明确拒绝 PAT(ADR「PAT cannot patch profile preferences」:
一个被偷走的 PAT 不该能把人的语言悄悄换成他读不懂的那种,让他安静地错过通知)。
而 MCP 的鉴权只有 PAT / MCP-OAuth,**从来不是浏览器会话** —— 给它加一个工具就是
给那条被明确拒绝的路开一个后门。

飞书帮助卡那条路不受这条约束:回调的操作者身份由飞书验签,等价于本人在自己的
客户端上按了一下。

**未采纳:按 `reveal_my_contact` 的先例保留工具但「非 jwt 一律拒绝」。** 那个先例
是为了给一条**攻击者会去探的** PII 边界留一条明确的拒绝路径;语言不是那种边界,
而一个在任何可达上下文里都不会成功的工具是死代码。更糟的是它会被
`derived_actions` 派生成一张卡片,**在员工点完确认之后才拒绝** —— 正是 curated-card
四道闸要防的形状。

**留下的痕迹(给下一个想再加一次的人).** `mcp/tools.py` 的 `_REGISTERED_TOOLS_
COUNT_HINT` 注释里记了「当天加、当天删」;`test_the_catalogue_cannot_grow_
unnoticed` 的 docstring 里同样;`tests/test_assistant/test_language_self_service_
2026_09_09.py` 有一条反向断言,再加回来会当场红。

## 2026-09-09 — 对话的回复语言跟随员工写来的那句话;通知、卡片文案仍按 `resolve_lang`

**背景.** ADR 2026-08-09 把 `app.core.locale.resolve_lang` 定为全平台「这个人读哪种语言」的
唯一权威,并禁止再手写语言判断。助手照此实现:无论员工用什么语言写来,一律按他的
`preferred_language` 作答 —— 提示词里甚至专门写了一句「不要看他打的字」。

**问题.** 那条规则对**通知**是对的(收件人不一定是刚才说话的人,而且 outbox 里的行会被
重渲染),对**对话**是错的。一个把 Helix 语言设成中文的同事临时用英文问一句(给外籍同事
转述、从英文报错里粘一段),得到的仍然是中文回答;反过来,偏好英文的人用中文问,得到的
是英文。**回答一句话的语言,应该是那句话的语言。**

**决定.** 新增 `app.core.locale.detect_text_lang`(纯函数,只判脚本)与
`app.assistant.reply_language.for_turn`(策略),**只用于对话回合**:

1. 这句话本身判得出来 → 用它,并按 (person, chat) 记进 Redis(TTL 与线程同长);
2. 判不出来 → 沿用这个会话上一次用的语言;
3. 还没有 → `resolve_lang(person)`。

`resolve_lang` 仍是唯一权威,**权威的问题没有变**:它回答「这个人读哪种语言」,新函数回答
「这一句是用什么语言问的」。通知、邮件、卡片模板、离职/入职文案一律不变。这次改动也**不**
写回 `preferred_language` —— 临时用一次英文不是改偏好。

**判定细节(三条,都是踩点):**

- **假名出现即日语**:日语句子必然带假名,而假名不出现在中文里。
- **汉字与拉丁字母比分量,不比有无**(一个汉字按 2 个字母计):只看「有没有汉字」会让
  「How do I connect to 飞书?」判成中文 —— 那正是旧提示词那句警告要保住的例子。
- **太短的纯拉丁串不算数**(少于 4 个字母):`OK` / `VPN` / `hi` 返回「判不出来」,于是
  一段中文对话不会因为一句「ok」整个翻过去。

**同时改的两处:**

- 网页 IT 求助(`/copilot/chat`)走同一个判定 —— 否则同一个人在两个面上问同一句话会拿到
  两种语言的答案(#3413 把两个面并成一个助手,语言不该在这里重新分叉)。
- **身份还没解析出来的发件人也按写来的语言答**。以前 `person is None` 一律 `DEFAULT_LANG`
  (中文),而收到那句话的人恰恰是平台还不认识的那个 —— 一个英文同事收到中文的
  「请先用飞书登录一次 Helix」,和没收到差不多。

**未采纳:** ① 按飞书客户端语言渲染卡片(`i18n_body`)—— 那会引入第二个语言权威,而卡上
按钮点下去的那一轮又只能按 `resolve_lang` 回答,两者会不一致;② 让模型自己判(它已经会
这么做,而那正是「一个中文名词就翻车」的来源,也无法用于确定性文案)。

## 2026-09-07 — 数仓个人与 Service Account 申请都只需资源 Owner 审批

**Decision（用户明确要求，覆盖先前 SA manager-first 条款）.** 每个数据集有其唯一 Owner；
自助申请的审批链为「提交 → 所选资源 Owner 批准 → 自动授权」，不再要求申请人直属经理、
工作负载经理或 SA 技术负责人审批。个人与 SA 的发布模板、Helix mapping 和用户指引必须一致，
只保留动态 `data_owner_review` 节点。不改变既有资源选择、表级 Owner 解析、审批节点决策方式、
身份/资源快照校验、callback→binding/effect→reconciler、精确 IAM 与审计。

**边界.** Group 新申请已退役，本次不重开或改动其历史流程。仓库 artifact 是发布输入，不是
飞书线上模板；上线必须独立发布/readback definition 并 upsert 相应 Helix flow mapping。
已在途的历史实例不会仅因代码或模板更新自动去掉经理节点，不伪造审批结果、不直改请求为 approved。

---

## 2026-09-07 — 已批准的 DAX Service Account 无需提前登记 principal 白名单

**Decision（用户明确确认）.** 删除 `service_account_apply_principal_allowlist` 这一额外
Provider 执行门槛。员工输入规范化 SA 邮箱，经过既有经理/资源 Owner 审批后生成精确
binding/compiled effect，就是授权依据；不能在审批通过后再要求 IT 提前登记这个新 principal UUID。

**保留边界.** integration `apply_mode_enabled` 与 `service_account_apply_enabled` 仍须显式开启；
present 只处理有效 binding，并重新检查当前 dataset/table 的 active、requestable、Owner authority
及 effect locator 一致性。Absent 仍从既有 revoke 意图和 revoked binding 执行，不因目录下架阻断回收。
不改变审批人、角色、申请范围、身份校验、Provider 最小权限、etag/幂等、审计或 no-project-IAM/no-jobUser 边界。

**Compatibility / rollout.** 旧 principal allowlist 不解析、不生效，包含畸形旧值也不会再挡住整个
integration；apply-mode config merge 会移除 principal 和 dataset 两种遗留名单，其他字段与加密凭证保持。
无需迁移，也不自动打开任何 apply 开关。发布后已有符合上述条件的 pending/retryable SA effect
会由既有定时任务执行；上线前应只读检查积压范围，而非继续维护另一份名单。

**Supersedes.** 取代 2026-08-10、2026-08-16 和 2026-08-24 ADR 中保留的 principal allowlist 条款；
其他审批与 Provider 边界不变。旧 dataset allowlist 已于 2026-08-24 退役，本次不重新引入。

---

## 2026-09-07 — 数据仓库生命周期演示整体下线;它的两条 ADR(2026-08-31 V1/V2)自此作废

**Decision**(用户指示)。删除 `/my/data-warehouse/lifecycle-demo/*` 及其在侧边栏注入的
**数据仓库演示** 一级分组。本条**取代** 2026-08-31 的两条演示 ADR(「Warehouse lifecycle
prototype lives beside production workflows」与「Warehouse demonstration V2」)—— 那两条记录
保留在下方作为历史,但它们描述的东西已经不在仓库里,读到时不要据此复原任何页面。

**Scope**:纯前端删除。`web/src/pages/dataWarehouse/lifecycle/` 全目录(27 文件)、App.tsx
的 lazy 导入与路由、MainLayout 的菜单注入与 6 处演示分支、三语各 551 条 `dwDemo.*` key、
`docs/testing/data-warehouse-lifecycle-demo.md`、PLATFORM.md 的演示边界一节。**0 后端 / 0 迁移
/ 0 API / 0 cron / 0 RBAC / 0 MCP** —— 当初这个演示就是「只读复用既有 GET + 内存态模拟」,
所以它下线时没有任何服务端资产需要回收,也没有任何真实数据随之消失。

**保留的不是演示的一部分,别顺手删**:官方数据仓库/DAX 面 —— `/my/data-warehouse`(指引页)、
`/admin/data-warehouse`(总览)、`/admin/data-warehouse/grants`、`/admin/data-warehouse/access-history`、
`DataAccessGrantLifecyclePage`、`server/app/domains/data_access/lifecycle*`、手册第 15 篇。
这些从来是真功能,只是名字里同样带 warehouse / lifecycle。演示的唯一识别特征是
`dataWarehouse/lifecycle/` 目录、`lifecycle-demo` 路径段和 `dwDemo.` key 前缀。

---

## 2026-09-06 — Shared Staging 取消 `schedule:`,回到「按需发布」;每日 cron 与「计划发布永不迁移」在本仓的合流速率下不可能同时成立

**Decision**: 删除 `.github/workflows/staging-deploy.yml` 的 `schedule:` 触发器,只保留
`workflow_dispatch`。**2026-08-13 那条 ADR 的安全边界一个字不动** —— 计划发布仍然永不迁移
Staging 数据库,人工 dispatch 仍是唯一能 `alembic upgrade head` 的路径,两阶段
(迁移 → 重新脱敏/再证明 → 关掉迁移再发一次)仍然是唯一的恢复流程。变的只是「每天自动试一次」
这件事本身。

**Why**: 这两条约束在本仓的合流速率下互斥,不是配置问题。日程发布**失败闭合**于
「Staging 数据库不在这次发布的 Alembic head 上」;而 `main` 上迁移的落地节奏是
**每个工作日 3–13 个**(2026-08-24 至 09-06 逐日实测,中位数约 7,没有一个工作日为 0)。
于是只要当天合入过迁移,当晚的计划发布必然红。历史完全吻合:该 cron 一生跑了 24 次、
**成功 4 次**(16.7%),4 次全部落在 2026-08-21～08-23 那个**零迁移的周末**;迁移 528 在
08-24 落地的当天它就再次转红,并**连续红了 13 个夜晚**(08-24 → 09-05)。把 Staging 迁到
head 只能买到一个晚上 —— 而且要那晚恰好没有迁移合入,自 08-24 起这样的工作日一次都没出现过。

一个**结构上不可能变绿**的每夜告警,除了训练所有人忽略 Actions 上的红叉之外不提供任何信息。
按需触发说的是实话:Staging 本来就只在有人主动发布时才会更新 —— 8 月 20 日那次恢复正是
一天之内 8 次手工 dispatch 做出来的。

**Boundaries**: 本决定**不**恢复 pre-production 闸门,也**不**声称 Staging 是最新的。它只
停止一个假信号。要重新拿到每日闸门,必须先回答一个**安全边界**问题而不是排程问题:
无人值守的一次运行能否自己完成「迁移 → 重新证明 → 发布」?
`deploy/staging/reattest-dax-retained-on-ecs.sh` 已经是**读活库、失败闭合**的证明器
(`scheduled_jobs_enabled == 0`、三个 apply 闸、唯一一个启用的 BigQuery 集成),所以
「迁移可能播下启用态可执行状态」这个实质担忧是由证明器本身守住的,人工 read-back 是纵深防御。
但那条链路**今天并不存在**:该脚本把源 receipt 的 `database` 块原样抄进新 receipt,
因此**在原理上无法证明一个 sanitizer 没见过的 revision**,迁移之后必然 `exit 8`。
所以每日闸门的前置是一个新决定 + 一条新工具链,不在本次范围内。

**顺带更正的一处运行手册错误**:`docs/deployment/staging.md` 原先把
「重新脱敏 **或** 重新证明」当成两条等价的续接。**重新证明这条是死路** —— 见上一段,
迁移之后它只会 `exit 8`。迁移之后唯一能走通的是**完整重跑 sanitizer**、产出一份新的
`staging-sanitization-*.json`。脚本现在会把这句话直接打在那次 `exit 8` 上。

**Alternatives rejected**: (1) 让计划发布带上 `apply_migrations=true` —— 直接推翻
2026-08-13 已批准的安全边界,且如上所述当前工具链也做不到自动再证明;(2) 把 schema 落后这一
状态判为绿 —— 一个没有发布任何东西的绿勾比红叉更坏;(3) 保留 cron 只改文案 —— 每夜同一条
红仍然是每夜同一条红。

## 2026-09-06 — 推进对账的「跑不了」排除,**修在对账侧而不是闸门侧**(合并后复审)

> **状态: 我在定时任务优化系列合并后的整体复审里拍板。** 用户授权「按你理解的最生产的方案落地」。记在这里是因为**被否掉的那个做法更短、更显然,一定会被下一个人(或下一个我)重新提出来**。

### 背景

推进对账(`scheduler.chain_advance`)判断一个 chained 下游是否落后,靠比较 `child.last_run_at` 与 `parent.last_run_at`,并**依赖这个条件在下游跑过之后自动消失**。绑定了停用集成的行会在 `execute_job` 的集成闸门前被挡回去,而闸门为了不刷空 100 行的运行台账,**第一条之后连时钟都不写**。于是这种下游永远「落后」:每 5 分钟被触发、每次被挡、时钟不动,并且长期霸占每跳 12 个的名额。

### 被否掉的做法:让跳过路径顺手写一下 `last_run_at`

一行就能修,判据自动消失,还顺带让 worker 的过期补跑不再把它当成漏跑。**否掉的理由只有一条,但足够:**

`scheduled_jobs.last_run_at` **不是**定时任务域私有的列。合规态势闸(`app/domains/auditor/compliance_service.py::freshness`)拿它判断「这个控制项背后的信号还新鲜吗」,超过阈值就把控制项判为 `stale`。一个**什么活都不干、却每 5 分钟报告一次新鲜时钟**的任务,会让一个控制项在**没有人在采集的数据上显示为绿色**。

这正是 #2217 花了力气堵上的那个洞 —— 当时的原话是「一个坏掉的 provider 会变成一次 ok 状态的运行,`last_run_at` 照常前进,CCM 新鲜度闸照常绿」。**为了省对账一次查询而把它重新打开,是拿一个跨域的安全信号换一行代码。**

(核过:今天 8 个控制项的 `signal_ref` 没有一个是绑集成的 handler,所以这个改动**今天**不会触发。但耦合是真的 —— 只要有人给某个控制项的信号任务配上 `integration_id`,或者新增一个指向绑集成 handler 的控制项,闸门就会静默地开始说谎。**不制造这个耦合**比「今天没事」值钱。)

### 采纳的做法:让对账学会第二种「跑不了」

对账**本来就认得第一种** —— 查询里已经有 `enabled.is_(True)`。集成闸门加进来的是第二种「跑不了」,而对账没被同步更新。所以这不是一个新机制,是补上一个已有排除条件里漏掉的分支。

三条配套约束,每一条都是复审里现场踩出来的:

1. **两侧共用同一个谓词。** 「这一行绑了哪个集成」原本闸门侧和对账侧各写一遍,对畸形 `config`(`integration_id` 不是字符串)判断相反:闸门当「没绑」照跑,我新写的那份当「绑了但集成不存在」排除掉 —— **一条链会因此停住而没有任何地方说为什么**。收敛成 `hygiene.config_integration_id_of`,两侧调同一个函数。
2. **排除必须是暂停,不是遗忘。** 集成恢复后下游仍然落后于上游,下一跳就会放它出来。这是「排除而不是清零」的全部安全论证,所以它有一条自己的测试,而不是只写在注释里。
3. **沉默要有出口。** 对账保持沉默是对的(这不是故障),但代价是管理台上这条链**看起来完全健康却什么都不做**。任务链面板每个成员因此多一个 `blocked_reason`,与对账的排除条件逐条镜像,并有测试断言两侧对「谁卡住了」给出同一个判断。

### 复用这条判断

**任何时候想让一条「决定不干活」的路径去写 `last_run_at` / `last_status` / `run_count`,先问这三列还有谁在读。** 这个平台上它们至少喂着:合规态势的新鲜度闸、worker 的过期补跑、Prometheus 的 `runs_total{ok} = run_count - failure_count` 恒等式、体检的 `never_run` 判定。定时任务域改自己的列,改的往往不只是自己的语义。
## 2026-09-06 — Zoom 终删遇到「转交对象被厂商拒绝」:按拒绝的**种类**分流,不再按一句话匹配

**Decision**: Zoom 在 `DELETE ?action=delete` 时会**先校验 `transfer_email`、再决定删不删**,
收件人不合格就整个调用拒绝(HTTP 400 + `code: 200`,与「PATCH 档位被拒」同一个万能码,
所以 code 不携带信息、只能读 message)。这一族现在由 driver 统一打标签
(`ZOOM_TRANSFER_RECIPIENT_NO_WEBINAR` / `ZOOM_TRANSFER_RECIPIENT_INVALID`),coordinator
以 `transfer_refusal` 透传,sweep **只读标签、不读厂商散文**,并按种类分流:

1. **收件人没有 Webinars 许可** → 维持 2026-08-23 的运营决定:不带转移重删一次,审计留痕。
2. **其它一切与收件人有关的拒绝**(主要是「data receiver does not exist」,即解析出的交接人
   在 Zoom 里根本没有账号)→ **拒绝删除**。记 `blocked_transfer_recipient`(**非终态**)、
   走独立计数器(不进 `errors`)、用独立提醒种类 `del_blocked_zoom_recipient` **只告警一次**,
   账号保持停用。每跳仍会重试,所以 IT 改一个有 Zoom 账号的交接人之后**自动就好了,不需要补跑**。

**Context**: 生产上 2026-08-20 ~ 08-23 连红 4 晚,报文是
`Unable to transfer data because data receiver does not have Zoom Webinars licenses.`
(#3152 于 08-23 修掉,当晚手动跑一次删除成功;此后至 09-05 全绿,**没有任何离职者卡在队列里**)。
但那次修法是**对一句话做子串匹配**,只认它自己见过的那一句。任何别的收件人拒绝都会掉进通用
`failed` 分支 —— 而 `failed` 是非终态、每晚重试、每晚重新报错,**永远到不了终态也永远不会停**。
2026-09-06 抽查:416 名在职员工里有 **6 人在 Zoom 快照里没有账号**;他们今天都不是任何流程的
交接人,所以这条路径还没被走到 —— 但只要其中一人当上 lead 或被指为默认交接人就会走到。

**为什么不沿用「不带转移重删」**:那正是 2026-08-13 推翻的做法。收件人不存在时重删一定成功,
云录制随号永久消失(Zoom 无 undelete),而日志会显示一次干净的删除。**「宁可号卡着,也不能
悄悄毁数据」** 这条不变。missing-webinar 那一支之所以例外,是因为它有明确的运营决定背书。

**为什么不判定为 cron 失败**:直接沿用 2026-08-13 (d) —— **安全的拒绝不是故障**,进 `errors`
会淹没真故障,而每晚报一次的告警等于没有告警。

**Alternatives**:(a) 删除前先查收件人在不在 Zoom —— 未做,不是因为不好,而是它属于「自动往
上一级交接人回退」那个更大的改动;当前这版拒绝 + 单次告警已经把不可逆的破坏挡住了,回退链
可以单独评估。(b) 把 `manual_handoff`(2026-08-20 那版的软件负责人手工任务)修好复用 —— 被否:
它依赖「该流程存在 Zoom 坐席 blocker + 活跃连接器」,查不到就返回 None 再掉进 failed,
这正是 08-21~08-23 连红的直接原因;该函数自 #3152 起已不可达,本次一并删除,`manual_handoff`
也从 `_DELETION_TERMINAL` 移除(它是一个**没有任何代码能写入、却被当作终态**的状态,
留着等于给「永远卡住」留了一条后路;生产零行)。

## 2026-09-06 — 到期告警**不**统一;云资源目录增量投影暂缓(两条都是我自己拍的,附证据)

> **状态: 我在定时任务优化系列(PR1–PR5)里自己拍板。** 用户授权「按你理解的最生产的方案落地」,这两条都是我先提议、读完代码/量完数据之后**否掉自己**的,写下来是因为它们都会被下一次体检自动重新提上来。

### 决定 1:`apikey.expiry_check` / `auth.pat_expiry_warning` / `cmdb.access_expiry_sweep` **不**接统一到期告警引擎

我在 PR1 的分析里把它们列为「各自手写阶梯逻辑」的重复,提议接进 `cmdb/expiry_alert_engine.py`。**读完代码后这个提议是错的** —— 它们不是同一个产品实现了四遍,是四个不同的产品:

| | 统一引擎(证书 / 域名) | `apikey.expiry_check` |
|---|---|---|
| 阶梯 | T-30 一次 → T-14 一次 → ≤7 天**每天** → 过期后**每天** | 恰好 days_left==7 与 ==1 各一次 |
| 收件人 | 本人;进入最后一公里再升级到**域名负责人 + 直属上级** | 申请人 + 直属上级,**始终** |
| 静音 | owner 标「续期中」→ `acknowledged_until` 全面压制 | 无;去重表按 `(assignment, threshold, valid_until)` |
| 首次启用 | 存量积压不轰炸(`escalation_started_on` 留 NULL) | 不适用 |
| 离职 | 不特殊处理 | 申请人 left / leaver_pending 直接跳过(离职级联接管) |

引擎的模型是「**一个有负责人和到期日的组织资产,沿责任链逐级升级**」;API Key 和 PAT 是**个人凭证**,社会模型不一样 —— 没有「域名负责人」这一层,而「人已经离职了就别提醒了」是它独有的。

**统一意味着必须选一种产品行为。** 保留各家现有阈值与收件人(我在开工时对用户的承诺)同时又共用引擎,只能把引擎参数化成「阶梯 + 收件人策略」两个策略点,再配三份实现 —— 那是**更多**代码,不是更少,而且把一个语义清晰的引擎变成一个策略外壳。若不参数化,员工收到提醒的频次会从「2 次」变成「≤7 天每天」,那是没有人要求过的产品变更。

**结论:保持四份实现。** 真正的重复只有「算 days_left、查阈值、去重、发卡」这条骨架,而每一份的去重键、静音语义和收件人都不同 —— 抽出来的公共部分会小到不值得那个间接层。**下次体检再看到「四处手写到期阶梯」时,先读这一条再动手。**

### 决定 2:`cmdb.resource_directory_refresh` 增量投影暂缓,并记下那个陷阱

PR1 把它从每 15 分钟改成每小时(重量级算力占用 41,095 → ~10,000 slot-秒/天),但**没有**做「只投影变过的东西」。理由不是没想到:

* **收益已经不急。** 改完之后重量级类的整体占用是 **13%**(需求 12.2h/天 vs 容量 96h/天)。再省下的 ~8,000 秒/天是把 13% 变成 11%。
* **最直觉的做法是错的,而且错得静悄悄。** 「按 `max(synced_at)` 给每个集成打水位,没变就跳过」看起来显然正确 —— 但 `_safe_sweep`(`app/domains/cmdb/sync.py`)软删一行时**只写 `deleted_at`,不碰 `synced_at`**。于是一次「云上这个资源没了」的同步不会抬高水位,投影跳过该集成,**目录里那条已经消失的资源会一直挂着**,而且没有任何报错。要安全就得按 `GREATEST(synced_at, deleted_at)` 取水位,还得确认 `deleted_at` 有索引,否则 7 张源表 × 13 个集成 = 91 次 MAX 全表扫。
* **它喂的是员工可见的面。** 云资源目录 + 治理发现读这张投影表,投影错的后果是资源凭空消失或凭空存在,不是慢一点。

**结论:等到重量级占用真的成为约束再做**,做的时候用「按集成增量 + 每天一次全量兜底(全量那一次同时负责消失检测与历史清理)」,不要用裸水位。

## 2026-09-05(同日第四轮)— 台账对账做成常设功能;对账写面不给 MCP 工具

> **状态: 用户批准(本轮对话)。** 原话:「做成常设的台账对账功能,不做一次性脚本」。

**背景**:migration 604 + 同型号中位数回填把资产财务完整度推到 **36.6%** 就到顶。⚠️ 这个数字 2026-09-05 生产实跑之后更正过 —— 原文写的「约 50%、692 件里约 200 件有参考价」是**落笔前的估算**,实测是 **52 件**可估(502 件同型号有价样本不足 3 个),写入 94 行、290/1050 → 384/1050。结论不变(仍在 30 分档),但数字高估了约 14 个百分点。剩下的只能来自财务固定资产台账。

**决定 1:做成常设的上传-预览-写入功能,而不是一次性脚本。**
理由不是工作量,是**对账不是一次迁移事件**:台账每个月结重新导出、Helix 一直在进新设备,而且最有价值的产出不只是补上来的金额,而是两组分歧 —— `conflict`(Helix 已录入不同金额,两本账对不上)和 `unmatched`(**财务在折旧一台资产台账根本没有的设备**,这是完整度缺口的反面,平台里别处看不到)。脚本会把这两组打印到终端一次然后丢掉。

**配套的不可省约束**:
* **预览不写任何东西**,写入是单独一次显式调用;
* **写入前用预览时的快照重新核对**,不一致就拒绝并把行报出来 —— 在写入时重新取当前值等于把一次并发修正藏起来;
* **匹配一律拒绝猜**:序列号撞号是 `ambiguous` 永不写入;缺序列号/解析失败/**有金额没币种**都是 `invalid` 并报出;
* **`purchase_date` 只补不改** —— 台账的入账日期是会计日期,覆盖会无声改期整条折旧计划;
* 冲突写入要一个**独立的显式开关**。

**决定 2:对账的四个写动作刻意不给 MCP 工具。**
upload 本身无法镜像(MCP 传不了文件);而 apply 是「把一份 LLM 没上传、也看不到原件的台账里的金额写到资产行上」—— 那正是必须一个人看过预览再点头的动作。给它一个工具调用等于把「两本账不一致要人来判」这条设计从流程里拿掉。只给两个只读工具,让 AI 能读出并汇报三组分歧(尤其 unmatched)。同 `answer_security_quiz`(不给替员工答题的工具)与 `decide_knowledge_proposal` 的先例。

**决定 3:落在硬件财务页而不是资产清单。**
这是财务工作流,硬件财务就是面向财务的那一页。两个 tab,不新增第十个侧栏项 —— 与同日第二轮「扁平 ≤10 项 + 页内 Tabs」的决定一致。

## 2026-09-05(同日第二轮)— 资产菜单扁平 9 项;长期闲置电脑做成清单页而不是自动处置

> **状态: 用户批准(本轮对话)。** 原话两处:「资产相关的前端的菜单是不是太多了 有没有可以合的 也一起做了」;闲置电脑的处理方式选「这个先不动在平台里能有列表看到 直观显示的暴露问题 我让孟凡生去处理」。

**决定 1:资产管理侧栏合并到 9 项,新页面一律先考虑做成 tab。**
本仓约定是扁平 ≤10 项 + 页内 Tabs(2026-06-28 用户明确否决 antd 分组菜单样式)。这轮把操作记录折进资产工作台(那个 tab 本来就是同一份数据的预览)、回收站折进资产清单 hub,新增的长期闲置电脑作为资产清单的 tab 落地,不新增菜单项。

**明确保留在顶层的两项及理由**:资产入库是高频**表单**而非列表,工作台主按钮指向它;硬件财务折进工作台会让那页变成 8 个 tab,只是把拥挤从侧栏搬到页内。

**决定 2:闲置电脑这一轮只做「看得见」,不做自动处置、不批量发起。**
用户明确说要一张能直观暴露问题的清单,由孟凡生去处理。所以端点是**纯只读**的:预留给新人、发起处置、加入盘点这三个动作都已有各自带审计和区域闸的写接口,在这里复刻一遍等于多一条要同步维护的写路径。

**配套的呈现纪律(不可省)**:
* **被需求扣减掉的机器要列出来并标「留给待入职」** —— 藏起来总数解释不通,标成盈余会让人去处置下一个新人的笔记本。
* **口径写在数字旁边** —— 这个指标刻意拒绝的两种读法(`updated_at` 当闲置时钟 / 外设算进分母)正是最容易误读的两种。
* **「从未盘点」单独成一个 KPI** —— 处置一台没人确认还在的机器和处置一台确认在库的机器是两种风险。
* **筛选项从未筛选集派生**,合计除 `filtered_count` 外不跟着筛选变(否则一加筛选就显得和健康分对不上)。

## 2026-09-05 — Day-1 硬件维度接受「已预留」;11 条历史欠账走 IT 内部,不给员工发消息

> **状态: 用户批准(本轮对话)。** 两处原话:硬件口径选「改:预留即就绪 + T+0 自动领用」;11 条欠账的核实方式选「不对外发,IT 内部消化」。

**背景**:领域健康页 Day-1 就绪率 22.5%(30 分,覆盖率最低档)。生产近 6 个月 40 条入职单只有 9 条就绪,31 条失败里 29 条**只差硬件这一维**。

**先查后改的三个事实**(每一条都改变了方案):
1. 20 条失败是**入职后 1–7 天(两条 21/22 天)才点的领用** —— 机器在手上,台账动作晚了。
2. 11 条至今 `live` 且台账里**一台电脑都没有**(海外自购/直邮从没补录)。
3. 备机闭环的两个工具**零使用**:`reserved_for_person_id` 全表 0 行,`hardware_allocation` 历史上设过 1 次。

**决定 1:硬件维度接受 `assigned` 或 `reserved`,并在 T+0 自动把预留转成领用。**
理由是激励错位:预留满足不了任何维度,唯一得分路径是有人在第一天早上记得点领用。这条改动让「提前备机」成为同时也得分的那条路。

**配套的不变量(不可省)**:`hard_ready_at` 在硬性维度重新 pending 时**只在入职日之前**清空。无条件清空会让三个月后换电脑回写已经结账的月份(Day-1 和 SLA 两个指标都读它);永不清空则让「预留一小时再释放」白拿分。判据只能是 `hire_passed`。

**明确否决的两个替代方案**:
* **放宽评分阈值** —— 指标测的就是台账纪律,数字是真的。
* **只改 T+0 自动领用、预留本身不算就绪** —— 备好机器但没点领用仍然算失败,激励错位没有解除。

**决定 2:11 条历史欠账不给员工发消息,做成 IT 侧待核实队列。**
用户明确选择 IT 内部消化。所以本轮**没有**做「员工自助登记页 + 催办阶梯」那一版方案,改为入职管理 ▸ 硬件待核实 tab:窗口含 `completed` 入职单(驾驶舱只看 open,那 20 条在任何看板上都不出现),每行给 MDM 线索作为证据,两个动作(补录设备 / 无需设备)原地关闭。这条与「别给员工加新推送」的既有红线一致。

**MDM 线索的呈现纪律**:查不到必须显式渲染「MDM 无记录」并在 tooltip 里说明这**只是没有线索,不代表这个人没有电脑**(该表当时只覆盖 43 台设备)。把证据缺失呈现成结论是这个面最容易犯的错。

## 2026-09-04(第三轮,同日)— `finance` 拿到整个云/网络域的只读权限;三个页面明确不给

> **状态: 用户批准(本轮对话)。** 原话:「让财务的角色也有云这个里面所有的只读的权限 都给财务」。扩展 2026-05-16 那条「云资产总览给财务看」的窄口子。

**背景**:财务视角侧栏里云这一块只有一个「云资产总览」。总览回答不了「这笔账挂在什么东西上」—— 做成本归属要能翻下面的资源台账。

**关键事实(先查后改)**:后端读闸**早就**放行 finance —— `can_read_cloud` 和 `can_view_cloud_resources` 两个谓词的名单里都写着 `FINANCE_ROLE`。真正挡住财务的是**前端** `App.tsx` 里那条 `RoleRoute roles={['global_admin','cloud_admin']}`:页面 403 在路由上,不在 API 上。所以这条决定的落地几乎全在前端。

**决定**:
1. 把云/网络的**只读面**整体放给 `finance`(路由 + 侧栏):云服务器 / 域名 / 证书 / DNS / CDN / 负载均衡 / 对象存储 / IAM 用户(含离职残留 tab)/ 子账号·订阅(含待审批、已下线两个 tab)/ 网络总览 / 专线。侧栏**复用 admin 菜单的同一个 group 对象**(`withoutChildKeys`),所以以后往云资产加页面,财务自动跟上,两份菜单不会漂。
2. **明确不给**三个页面:`回收站` / `角色目录` / `阿里云百炼`。判据是**它们的 READ 端点本身就是 `can_manage_cloud`**(外加 `API Key 管理` 是 global_admin+asset_admin)—— 给了菜单入口只会得到一个首屏就 403 的页面。要不要放宽这三个后端闸,是与「让财务读云台账」不同的另一个决定,本轮没被要求,不做。
3. 写操作一律不给:`canManageCloud()`(= global_admin + cloud_admin)继续管前端按钮,`can_manage_cloud` 继续管后端。补上了三处此前**只按 mode/页面在渲染写操作、没查角色**的地方 —— `CloudScopesPage` 的 立即同步/下线、`CloudScopeRequestsPage` 的 审批/驳回/手动开通(原本条件是 `mode === 'admin'`,而 mode 只说「这是谁的队列」,不说「你能不能批」)、`CloudIamUsersPage` 的 改绑负责人/清理、`LeaverResiduePanel` 的 忽略/重新扫描。

**边界写成了测试而不是注释**:[`test_finance_cloud_read_parity_2026_09_04.py`](../../server/tests/test_cmdb/test_finance_cloud_read_parity_2026_09_04.py) 带 `X-View-Role: finance`(**必须带** —— 不带的话 fixture 用户还挂着 `employee`,好几个读会因为错误的原因通过)逐个打 13 个读端点断言 200、对 2 个 admin-only 读断言 403、并断言一次写被拒。以后谁把某个读闸收紧成 `can_manage_cloud` 而侧栏还留着入口,这个文件会红。

## 2026-09-04(第二轮,同日)— 汇联易 Budget Owner(6102)= 所属一级 BG 的负责人;部门负责人推导按子树计下属

> **状态: 用户批准(本轮对话,财务确认口径)。推翻同日早些时候「6102 = 部门自身负责人」那条。**

**背景**:上一条改完后财务复看,第二个部门仍不对 —— `Product` 的 Budget Owner 推成 `P0001 刘奇`,应是部门负责人 `P01120 Liya Du`。查证根因:**Product 节点上只有 Liya Du 一个在职成员,她的 8 个直属下属全部在子部门**(Core Components / AI Lab / TiDB X Core Product / …),而负责人推导要求「至少管着**本部门**一个成员」→ 她被判成 0 → 该部门「无负责人」→ 回落到「成员的部门外经理」= 她自己的上级 CEO。**同一形状还有 HR(唐小丽独占节点,8 个下属全在 HR Operations/HRBP/C&B/Talent Acquisition)、APAC Business Group、Central Enabling Group、EMEA Business Group** —— 这也解释了为什么汇联易上这几个 BG 的「部门负责人」是空的:Helix 从没推过,北森时代也没填。

**决定(两条)**:
1. **`_leader_emp_no` 的下属计数改为按子树统计**(候选人仍限本部门直属成员)。叶子部门子树==自身 ⇒ **构造上不受影响**;生产灰度:能推出负责人的部门 61 → 66(96 个中),且**没有任何一个既有结论发生变化**。副作用是正面的:6103 从此会推给 HR / APAC BG / Central Enabling Group(原本空白),并把 EMEA BG 从 Ben Yang 纠正为 Lijia Xu(HR 确认后者对)。
2. **`_budget_owner_emp_no` = 该部门所属一级 BG 的负责人**(`_bg_code` 上溯到 parent 为空的那个节点),BG 自身内部完全没有管理关系时回落到「BG 子树成员的部门外经理众数」(`AI Innovation` / `Strategic Consultant` → 刘奇 P0001,HR 确认)。

**财务明确接受的代价**:上一轮刚纠正的 `Intelligent Support Enablement` 会从部门负责人 **朱明 P0809** 变成 Engineering Group 的 **Mark Xu P0346** —— 因为朱明是三级部门负责人,不是 BG 负责人。用户原话:「从朱明变成 Mark Xu 他们接受」。

**用户逐个确认的 14 个 BG 负责人**(派生结果与之 6/6 命中):AI Innovation=刘奇 P0001、HR=唐小丽 P0100、Product=Liya Du P01120、APAC Business Group=唐刘 P0005、Central Enabling Group=范若晗 P0450、EMEA BG=Lijia Xu P0331;其余 8 个 BG 的派生值与汇联易现值本就一致。

**影响面(生产只读灰度)**:91 个有在职成员的部门 **全部解析出 Budget Owner(0 无解)**,取值收敛到 **12 个人**(Mark Xu×30、唐刘×14、Ben Yang×8、Liya Du×7 …);**74 个部门的 6102 变更**,**4 个部门的 6103 变更**。

实现见 [`_huilianyi_dept_attributes`](../../server/app/domains/identity_sync/service.py) 的 `_leader_emp_no` / `_bg_code` / `_budget_owner_emp_no`;测试 [`test_huilianyi_budget_owner_is_bg_head_2026_09_04.py`](../../server/tests/test_identity_sync/test_huilianyi_budget_owner_is_bg_head_2026_09_04.py)。

---

## 2026-09-04 — 汇联易 Budget Owner(6102)= 部门自己的负责人,不再取 SF 2447 调薪负责人

> **状态: 用户批准(本轮对话,选项「= 部门负责人,无负责人时上溯最近祖先」)。**

**背景**:财务在汇联易上看到 `Intelligent Support Enablement` 的 Budget Owner 是 **P0619 Yu Dong**,而该部门负责人是 **P0809 朱明**。生产只读查证:该部门 5 名在职成员(含朱明本人)的 `hris_budget_owner_person_id` **全部**指向 Yu Dong,所以按成员众数派生**永远**到不了部门负责人。

**根因**:`hris_budget_owner_person_id` 来自 SF `EmpJobRelationships` relationshipType **2447(调薪负责人)** —— 那是**按 BU 挂的薪酬调整审批人**,不是部门预算归属人。全司 91 个有在职成员的部门只解析出 **23 个不同的人**(Mark Xu 盖 15 个部门、Yu Dong 13 个、唐刘 8 个),**49/91** 推的 Budget Owner 不是该部门自己的负责人。2026-06 建这条链路时把 2447 的中文标签直译成 "Budget Owner" 就等同了两个概念,这是错的源头。

**决定**:**6102 改为派生自部门自身的组织负责人**(与 6103 同一套派生),取不到时依次回落 ① **成员的部门外经理**(众数,工号确定性 tie-break)② **最近有负责人的祖先部门**。这推翻 2026-07-14 那条「Budget Owner 仍按直属成员取 2447 众数」——当时只审了取数**范围**(直属 vs 子树),没审取数**字段**本身。

**为什么要两级回落**:推送对未解析的角色是**省略**而非置空(汇联易保留原值),所以解析成 None 的部门会被**永久冻结在现在这个错值上**。生产 91 个部门里有 **30 个是扁平队**(没有成员管理另一成员)算不出自己的负责人;只上溯祖先部门负责人能覆盖 16 个、剩 14 个仍为 None,加上「成员的外部经理」这级后 **91/91 全部解析出人**。两级都能解析的 16 个里 13 个答案一致,3 个不一致时经理更精确(如 Accounting → 其 controller Eric Chen,而非 CFO)。

**影响面(生产只读灰度,2026-09-04)**:91 个部门 → **65 个变更、26 个不变、0 个无解**。上报的那个部门变为 **P0809 朱明**。`hris_budget_owner_person_id` 列本身**不动**(SF 同步照写,用户详情页「HR 关系」卡片照读),只是不再喂这个厂商角色。

实现见 [`_huilianyi_dept_attributes`](../../server/app/domains/identity_sync/service.py) 的 `_budget_owner_emp_no` / `_external_manager_emp_no`;测试 [`test_huilianyi_budget_owner_is_dept_head_2026_09_04.py`](../../server/tests/test_identity_sync/test_huilianyi_budget_owner_is_dept_head_2026_09_04.py)。

---

## 2026-09-03 — AI 实验室:自研包一层而不引开源整站;不加工提示词;刻意不配 MCP 孪生

**背景.** 用户要求把公司已在付费的 Qwen 能力做成一个所有员工都能用的一级功能
(「AI 实验室」),明确了四条:界面要精致、能力越丰富越好、**不加工员工的提示词**、
以及一个「我们不看你的内容」的合规气泡。并明确不必操心成本(抵扣券在根账号级)。

### 决策 1:自研一层壳,不引入 LibreChat / Open WebUI / LobeChat

**Decision.** 在 Helix 内自研 `ai_labs` 域 + 前端页面。**不**部署任何开源 chat 整站。

**Why.**
1. 那三个方案各自自带用户体系、登录、数据库(LibreChat 要 MongoDB +
   Meilisearch)、i18n、主题 —— Helix 这些全有,接进来等于维护两个平台,并且
   直接违反「只用一个 TiDB,不引第二个 DB / broker / state store」这条常驻约束。
2. Open WebUI 在 2025 年底改了许可证,商用与白标需要企业协议。
3. 我们要的是壳而不是平台:合规承诺(管理员读不到内容、不加工提示词)只有自研
   才能保证说到做到 —— 那不是配置项,是代码结构。

### 决策 2:两条产品承诺写成代码约束与测试,而不是文档里的一句话

**Decision.**
- **管理面无法读到内容**,靠的是**结构**:管理端点只查 `ai_labs_usage_daily`,
  那张表没有一列能装下提示词 / 回复 / object key。删除是硬删(刻意不加
  `deleted_at`)并连带删 S3 对象。`presign-get` 里 `ai_labs_*` 是仅上传者、
  **无管理员兜底**。
- **不加工提示词**,靠 `test_prompt_passthrough_2026_09_03.py` 盯**真正发出去的
  HTTP body**:无 `system` 角色、用户文本逐字节一致、无能夹带指令的字段、
  `prompt_extend` 恒 False;并有一条测试断言 `GenerationCapability` 这个 dataclass
  不会长出 `system`/`preamble` 字段。

**Why.** 这类改动的危险不是恶意而是**善意**:有人为了输出更好加一句「简明作答」,
diff 看着像改进,产品就悄悄不是员工被告知的那个了。承诺如果只写在文档里,它不会
让任何构建变红。

### 决策 3:合规气泡加一句「请勿输入客户数据 / 密钥 / 未公开财务信息」

**Decision.** 气泡四句:内容只保存在 Helix 自己的库与存储、管理员和 IT 看不到正文、
你可以随时删除、请勿输入客户数据 / 密钥 / 未公开财务信息。

**Context.** 用户被问到时回答「听你的」。第四句是合规常规做法,且它是**对人的提醒**
而非对提示词的加工 —— 与决策 2 不冲突。

### 决策 4:配额与保留期默认全关(0 = 不限 / 不清理)

**Decision.** 五档每人每日配额(token / 图 / 视频秒 / 语音 / 转写)与保留期天数,
**全部默认 0**。另有一层与产品配额无关的防脚本限速(240 次/小时对话、60 次/小时生成)。

**Context.** 用户原话:「可以有这个规则能力 但是前期不限制 可以无限多 以后万一收费了
再说」。所以闸门必须**存在且可调**,好让「万一以后要花钱」是一次设置变更而不是一次
发版;但默认不生效,因为这个面的意义就是让能力被用起来。保留期同理:对员工自己的
工作区做未经宣告的过期删除是意外,而删除类的意外是最糟的一种。

### 决策 5:AI 实验室**不**配 MCP 孪生工具(MCP-sync 规矩的显式豁免)

**Decision.** 本域 13 个 HTTP 端点**不**派生任何 MCP 工具。
`_REGISTERED_TOOLS_COUNT_HINT` 不变。MCP 侧的 `presign_upload_get` 对
`ai_labs_*` purpose **显式拒绝**。

**Why.** 仓规矩是「每个新自助 HTTP 流程同 PR 补 1:1 MCP 工具,因为 MCP 是 AI 客户端
的一等界面」。这条规矩的前提是「AI 客户端代员工办事」有产品意义 —— 而 AI 实验室
是**给人用的裸模型面**:让一个 AI 客户端代员工去调另一个模型没有意义,只会给一个
私人工作区增加一条暴露路径,而 PAT 恰恰是 AI 客户端可能被劝说去重放的凭据。
与既有的 `force_cloud_grants_drift_sweep` 豁免同类:规矩有例外时,例外要写下来。

### 决策 6:视频不提供画幅控件;目录里的每一项都必须有实测日期

**Decision.** `catalog.py` 里每个模型 / 每个参数选项都带 `verified_on`,并由
`scripts/verify_ai_labs_catalog.py` 对着线上 key 可复跑。**视频不暴露画幅控件。**

**Why.** `wan3.0-video-prime` **静默忽略 `size`**:三次分别请求 `1280*720` /
`960*960` / `720*1280` 全部 200 成功,返回画幅 `1:1` / `16:9` / `16:9`,与请求
毫无关系。一个点了不起作用的下拉框比没有更糟。同一次探测里还发现半数看起来合理的
模型 id 在返 `403 AllocationQuota.FreeTierOnly` —— 那是百炼控制台每模型一个的
「免费额度用完即停」开关,不是账单问题,而错误文案偏偏在说「请完善支付信息」。
**按文档挑「一个成熟稳定的老模型」恰好会挑中这一类。** 所以这份目录的规则是:
不写没量过的东西;图片尺寸的判据是「请求像素 == 返回像素」而不是「调用成功」。

### 决策 7:文档问答不用 `qwen-long`,改服务端抽取文本;为此加一个依赖 `pypdf`

**Decision.** 新加 `pypdf` 依赖。PDF / .docx / .xlsx / 纯文本在**服务端**抽取文本,
只有抽出来的文字进模型;超长按 `DOCUMENT_TEXT_MAX_CHARS` 截断且明说截断了。

**Why.** 新加坡区没有 `qwen-long`,兼容模式的 files 接口也是空的(实测)。而 PDF 是
员工实际会附的格式,树里此前没有任何 PDF 文本抽取器(`reportlab` 只写不读)。
`pypdf` 是纯 Python、无原生编译,对镜像的代价只是一个 wheel。

## 2026-09-01 — 指标 key 与维度对齐(health-v4);一个目录只能有一种寻址形状

**Decision.** `people_org` 的四个指标 key 由 `people.*` 改为 `people_org.*`,`platform_ops` 的六个由 `platform_operations.*` 改为 `platform_ops.*`,`SCORE_VERSION` 升 **`health-v3` → `health-v4`**。历史快照**不重写、不重算**。同时新增 `validate_metric_key_shape_is_uniform`,在**两个**目录(平台八维 + 云订阅)的 import 期强制「一个目录只能有一种寻址形状」。

**Context.** 2026-09-01 生产:每周跨域简报被拒 `Unknown fact reference: /facts/people_org.prediction_data_coverage.value` —— 该指标**真实存在**。这不是幻觉,也不是地址簿解析不了(`_record_addresses` 让每条记录都能按自己发布的 `key` 被引用,实测 2397 条发布地址 100% 可解析)。真因是**目录里同时存在两种寻址形状**:40 个指标里 30 个的 key 重复自己的维度名,摊平时那层重复被剥掉,于是「发布的名字」= `<维度>.<叶子>`;另外 10 个的 key 用了别的命名空间,不重复所以不剥,地址多出一段。模型看了 30 个样本学到 `<维度>.<指标>` 这条规则,对那 10 个**每次都写错**。拿生产真快照静态实测:**30/40 可按自然拼法引用**,改名后 **40/40**。

**Why a version bump for a pure rename.** key 就是指标的身份。`intelligence_snapshots` 的唯一键含 `score_version`,`read_service` 有 `score_version_mismatch` 判据;不升版本,读侧会把上周的 `people.org_coverage` 和本周的 `people_org.org_coverage` 当成同一条序列去接、或当成两条互不相干的指标去比。**没有任何阈值、权重、口径、取数发生变化** —— 这是本次与 v2/v3 的区别,v2/v3 是真的改了定义。趋势图上会留一次台阶,这是「历史不重写」这条既有原则的既定代价。

**Why uniformity rather than "every key must start with its dimension".** 云订阅目录的 18 个指标**全部**是 `cloud.<组>.<叶子>`,**一个都不重复维度名**,而这个 subject 从来没犯过这个错 —— 因为没有相互竞争的多数样本,模型只能照抄发布的 key,而照抄是能通的。所以两种形状都合法,**混用才是缺陷**。若把规则写成「必须重复维度名」,就得连带改云订阅那 18 个 key(牵扯 `cloudScopeAnalysis.ts` 类型与 `_WORKBENCH_BY_DIMENSION` 维度键),**在没有任何失败证据的情况下动一个正常工作的对外契约** —— 这正是「一个案例的修法别按模式铺开」要避免的。

**Alternatives rejected.** ① **放宽接地闸**让第三种拼法也解析:2026-08-17 走过这条路(「引用扩展了已发布地址就放行」),实测把编造的更深路径 `assets.asset_count.invented.deeper` 也放了进来,已回退。闸不该为拼写错误让路。② **改摊平器**,让它剥掉任何命名空间而不只是重复的那层:地址会变整齐,但**发布的 key 仍然是 `people.prediction_data_coverage`**,模型自然写的 `people_org.prediction_data_coverage` 照样两头不沾 —— 静态实测确认这个方案根本不解决问题。③ **靠 prompt 让模型照抄清单**:prompt 里已经写了「必须从 `citable_fact_refs` 逐字复制」,它照样自己拼了;历史上 prompt 加固每次只买到一次部署。

**Cost.** 目录 10 行 + 两个 provider 10 行 + 4 处测试断言;**零迁移、零端点、零 i18n**(那 10 个指标没有 metric 级 `metricNextStep` 文案,回落 kind 级)。`test_metric_name_citation_2026_08_25.py` 的双形状夹具**刻意保留** —— 所有 `health-v3` 及更早的快照仍带 `people.*`,那些行永不重写,解析器必须继续认得它们。

---

## 2026-09-01 — 云资源变更历史保留 730 天,不再缩短

**Decision.**(用户明确否决了我的提议)`cmdb.resource_directory_refresh` 的 `history_retention_days` **维持 730 天(两年)**。用户原话:**「不能半年 就两年 不要再变了」**。这条不再重新提议、不再做 A/B 权衡,除非用户本人主动提出。

**Context.** 2026-09-01 的存储体检里,`cmdb_cloud_resource_changes.snapshot` 逐列实测 **1.568 GB**,是全库第二大的大对象列,而且是**唯一一个会自己持续增长**的:功能 2026-08 才上线,一个月即到 1.5 GB(含首次全量建档);按实测日均变更量(约 260–1,200 行/天 × 约 14 KB)推算,730 天到达平衡时约 **10 GB 逻辑 / 30 GB 物理**(3 副本)。我提议改成 180 天,理由是它是三个候选里唯一**零代码**的(`history_retention_days` 本来就是 `scheduled_jobs.config` 里的一个数字,迁移 529 播的种),长期上限可压到约 2.5 GB。

**Why the decision goes the other way.** 保留期是**合规/审计**属性,不是容量参数。这张表回答的是「这个云资源在某个时间点是什么样、谁改的、改了哪些字段」——两年是审计取证的窗口,而 30 GB 物理在 TiDB 上的成本远低于「需要举证时发现记录已经被自己删掉了」。**省下的存储是可量化的小收益,丢掉的举证能力是不可逆的大风险**,不对等。

**为什么写进 ADR 而不只是 CHANGELOG.** 因为这条会**反复自动浮上来**:任何一次存储体检,「一个纯配置、零代码、能省 7.5 GB 的改动」都会被排到建议列表第一位——我自己就是这么排的。没有这条记录,下一个 session(或下一个我)会原样再提一次,用户要再否一次。**看到 `history_retention_days` 想动它的人,先读这条。**

**Alternatives rejected.** ① 折中到 365 天:用户说的是「不要再变了」,继续在数字上还价违背这条决定的字面与用意。② 只对「低价值变更类型」缩短保留:引入一套「哪种变更算重要」的判断,而判断错的代价还是丢证据;复杂度换来的收益不值。③ 把老快照归档到对象存储再删表内行:需要一条新的归档链路 + 一个能从归档里查回来的读面,工作量远超收益,且在需要举证时多一个失败点。

**同期一并定下的:** `cmdb_cost_facts.raw` 保留 **90 天**(下限 45 天,`platform_settings['cost_facts.raw_retention_days']` 可调,PR #3376 / 迁移 584)。两者看似同类实则不同:`raw` **全仓没有任何读者**(grep 确认唯一引用是 ingest 的写入),清掉不损失任何可回答的问题;`snapshot` 是变更历史的**正文**,清掉就等于丢证据。**「都是保留期」不是把它们按同一标准处理的理由 —— 判据是「清掉之后,还有没有人能问出一个答不上来的问题」。**

---

## 2026-08-31 — Warehouse V4: intake is open to employees; enhancement follows ownership

**Decision.** User explicitly distinguishes requesting new data ingestion (all employees) from improving an existing data source, such as fields or business definitions (default: current asset Owner only). Owner remains a relationship, not a role, and administrators do not gain a bypass. These are two demand types in one demand center; a demand describes intent, while its linked changes represent delivery execution. A dataset can have many demands and many changes, including parallel work on different tables.

**Presentation.** Preserve six employee menus and five administrator menus rather than creating separate intake and change-request menu silos. Employee demand center distinguishes the two entries; administrator demand delivery handles the queue. Operational change execution remains under operations. Remove the duplicated delivery release-plan tab; keep all formal existing permission-application menus and routes unchanged.

**Evidence boundary.** The existing grant response contains granted/revoked timestamps, but no expiry or renewal records. Missing expiry is unknown, not perpetual. Full expiry/renewal interactions therefore stay explicit sample scenarios with a fixed displayed reference date. Renewal submission is pending, not an extension or authorization. Favorites over real catalog objects remain browser-memory session bookmarks, not production subscriptions. No new backend interfaces or features are authorized in this iteration.

## 2026-08-31 — Warehouse V3: Owner is a relationship; asset lifecycle is not a change workflow

**Decision.** User rejected the standalone “唯一 Owner” presentation role. Keep employee, administrator and auditor perspectives; **我负责的** belongs to employees and reflects their asset responsibilities. One owner per asset remains the responsibility model, not a separate RBAC role. Existing current-owner authority remains authoritative and administrators do not gain a bypass.

**Experience.** Show a full transfer experience aligned with the existing direct-transfer implementation: select successor, review scope, confirm, then update only sample ownership and record the receipt. Do not add recipient acceptance that the backend does not implement. Separate the long-lived asset's product/access/operations lifecycles from one change's definition, impact, sign-off, release, observation and archive stages.

**Evidence boundary.** Charts use existing overview counts with explicit units. Group coverage is not observed usage; approval is not applied access; current snapshots are not time series. Live diagnostics are deterministic and labeled as such. No warehouse-specific AI analysis API exists to reuse here, so the AI interaction remains a labeled, prewritten scenario preview. No backend expansion, model calls, real transfers, notifications or grants in this refinement.

## 2026-08-31 — Warehouse demonstration V2: system navigation, full pages, real data first

**Decision.** User explicitly rejected a second sidebar inside the page and drawers that constrain detail. Use one new top-level **数据仓库演示** group in the existing Helix sidebar with role-specific children. Details navigate to full pages. Real assets must follow datasets containing tables, using current APIs; an all-fictional demonstration is not acceptable. Match the established Helix/PDF visual language, not an unrelated dashboard style.

**Boundary.** Reuse real catalog, metadata, lineage, grants, requests and execution evidence wherever available. Missing governance records are visibly unconnected and can show stage requirements for the selected real object; sample scenarios remain explicitly separated. Approval is not execution, dependency is not observed usage, and initiating a transfer is not completed ownership transfer. Preserve all existing official menus and functions. No new backend work.

## 2026-08-31 — 离职告知信的时点是「HRIS 审批完成」,范围先限中国的正式员工与实习生

**Decision.**(HR 提出,用户确认)HR 的《离职流程指引》在 **Global HRIS(SF)离职审批批完、离职交接触发**的那一刻推给员工本人,**不**等到最后工作日。首批范围:`is_china_employee` 且 `employee_type_code ∈ {1070 正式员工, 1072 实习生}`;其他雇佣类型(EOR / 劳务顾问 / 外包 / 派遣)与非中国员工**暂不推送**。收件人只有本人,不含上级。

**Context.** 用户的原话是「现在我们的离职是不是没有推送啊」,核完的答案是:通知期内几乎没有 —— 87 条流程里只有 6 位离职者在 LWD 之前收到过任何离职域通知,其中只有 2 位真的 delivered;而 HR 这封《离职流程指引》本身此前不存在。既有 `offboarding.process_started_leaver` 生产上发过 39 次,其中 **36 次在最后工作日当天或之后**(3 次在之前),而信里写的是「请在离职日前完成交接」;39 行里只有 3 行真的全渠道送达。

**判据用 SF 三字段,不引入新集成。** `User.status='t'` + `EmpEmployment.endDate` 在未来 + 存在 `EmpEmploymentTermination` 离职原因行(实测,见 PLATFORM 同名小节)。曾考虑过接飞书审批实例:`registry_approval_flows` 49 条里**没有**离职流程,离职审批本来就在 SF 里,接飞书等于为一个不存在的入口造管道。

**「北京地区人事政策」那段按工作城市分流,不按 `office_code`。** HR 文档原文是「仅北京员工推送」。`office_code=2651` 是 **bj-office 邮件组**,生产里挂着大连/武汉/郑州/Amsterdam/Santa Clara 的 10 个人;反向还有一位 `city=北京市` 而 `office_code` 为空的在职员工。档案调转、北京市工作居住证、党组织关系都按劳动关系所在城市走,用邮件组分流会同时多发和少发。

**范围做成 job config 而不是常量。** `employee_type_codes` / `china_only` / `lookahead_days` / `dry_run` 都从 `scheduled_jobs.config` 读,默认值就是本决策的口径。HR 说的是「其他员工类型**暂时**不用推送」—— 扩范围应该是改一行配置,不是发一次版。

**Alternatives rejected.** ① 在离职流程启动时发:那时流程还不存在(生产 87 条里 80 条建在最后工作日当天或之后),等于没改。② 复用 `offboarding.process_started_leaver` 改文案:那是 IT 侧的资产归还/回购卡,与 HR 侧的流程指引不是同一件事、不是同一个时点,合并会同时毁掉两封。③ 顺手把 future-leaver defer 后不重放的洞一起修:那是离职流程本身的缺陷,爆炸半径涵盖 force-logout 与账号停用,单独一条(已记入 ROADMAP 开放项)。

---

## 2026-08-31 — Warehouse lifecycle prototype lives beside production workflows

**Decision.** User approved a production-hosted, frontend-only, interactive lifecycle demonstration in one PR. Add a separate entry within the existing Helix UI; never replace existing menus, request forms, authorization flows or routes. Reuse existing read-only APIs; label missing lifecycle data as fictional and make new actions memory-only. Real workflow links warn before leaving the demo. Do not add APIs, database tables, workers or governance execution logic.

**Roles and scope.** One Owner per asset; administrators are responsible for technical delivery and data quality. Demo persona switching is presentation, never authority. Demand records can describe business needs locally or reference Feishu documents, independent of Jira. Cover product, access and operations lifecycles and make their relationships navigable.

**Alternatives rejected.** Replacing shipped DAX pages would risk production workflows. Unlabeled mock fallback would misrepresent backend capabilities. Building backend lifecycle features now exceeds the explicitly approved scope.

---

User-approved design decisions. Every entry has a date, decision,
context, and alternatives considered. Newest on top.

When a new structural question gets answered by the user, append an
entry here so we don't ask it again.

---

## 2026-08-30 — 飞书服务台的知识库是**交接**进来的,不是再镜像一份;员工的知识入口是「知识库」而不是「制度中心」

**Decision.** 服务台那 124 条问答按 `source='helpdesk_faq'` 导入 `support_kb_articles`,导入之后 **Helix 就是编辑面**:管理员在「知识与制度 → 知识库」里正常增删改查,不加只读守门。导入幂等(按问题文本 upsert)、可预演、可重传。员工端「制度中心」重做成「知识库」,一个搜索框同时搜制度库与已发布知识条目,FAQ 就地展开读答案。右下角的 IT 小助手改为跳站内 `/me/support`。

**Context.** IT Agent 此前只有一路外部语料:飞书 wiki 文档的镜像。服务台自己那套知识库 —— 员工每天真正在问的那些问法 —— 一直在 Helix 之外。用户的要求是「加上这些知识库,而且允许管理员编辑和增删改查」。

**为什么是交接而不是第二个镜像.** wiki 镜像之所以只读,是因为飞书是那批文档的编辑面,人在 Helix 改会被下一轮同步覆盖。服务台不一样:它**正在被求助域收编**。给它加一个只读守门,等于把编辑面留在一个要下线的系统里,并且要求 IT 为了改一条 FAQ 回到那个系统去。所以这里不是「同步一份」,而是「搬过来」。

**为什么落同一张表而不是新建一张.** 语义检索、关键词检索、copilot 接地、deflection、MCP `search_support_kb`、员工搜索全都读这张表。新建一张表要么让六个消费方各自再联一次,要么让这批语料只在其中一两条路上可见 —— 而它们本来就是同一类东西:IT 写给员工的答案。

**「相似问题」必须单独存一列.** 这是这批数据里检索价值最高的一列 —— 员工问的是「打印机卡纸了怎么办」,条目标题写的是「打印机发生故障」。把它拼进正文会污染员工看到的答案;丢掉它等于把飞书机器人这些年攒下来的问法全扔了。存成换行分隔的纯文本(不是 JSON),关键词那一路 `ILIKE` 就能直接打得到。

**员工端为什么要改.** 那一页此前只有制度库的一半:**同一份被 IT Agent 拿来回答问题的语料,员工自己没有任何入口能翻**(`kbBrowse` 在 API client 里躺着,零调用方)。既然要让员工「看到更多信息和 FAQ」,那就是把这两半放进同一个搜索框,而不是再加一个二级菜单。

**小助手为什么不再跳飞书.** 那个目标在服务台还在飞书时是对的。现在它把员工直接领出产品,绕过自助分流、知识库和只有这边才有的工单历史 —— 站内 `/me/support` 里这些都在。飞书 applink 保留为**人工客服兜底**(帮助中心、工单页),那是一个有意的逃生口,不是默认的门。

**Alternatives(否决).** (a) 走飞书 helpdesk FAQ API 做实时镜像 —— 否决:那还是只读,与「允许管理员增删改查」直接冲突,而且服务台正在下线,给一个要退役的系统再接一条同步链没有意义。(b) 新建 `support_faq_entries` 表 —— 否决,见上,六个消费方都要改。(c) 把「相似问题」拼进正文 —— 否决:员工读到的答案里会多出一串重复问句。(d) 员工端另开一个「常见问题」二级菜单 —— 否决:员工不知道自己的问题算「制度」还是「FAQ」,分两个入口等于让他猜。(e) 员工搜索接语义检索 —— 否决(**这次**):`/support/kb` 没有限流,一个防抖搜索框会变成每次击键一次 embedding 调用;语义那一路留在有限流的 deflection / assistant 入口上。代价是词法有天花板,而且**我们知道它在哪**(「笔记本电脑坏了」召回不到「电脑更换」,因为语料从不写「笔记本」),所以 FAQ 区常驻一条「去 IT 求助」而不是只在空结果时才给。(g) 员工搜索沿用「分词 OR 匹配」—— 否决,**拿真实语料实测直接否掉的**:中文查询没有空格,「怎么连VPN」返回 0 条;英文问题返回 124 里的 116 条且首条无关。改成 CJK 二元组 shingle + Jaccard 排序 + 按文档频率选出的问句骨架词表 + 相对相关性悬崖,10 句真实问法 10/10 首条正确。(f) 路由从 `/policy-center` 改名 —— 否决:打断每一个书签和内链,而 URL 不是员工在读的东西。

## 2026-08-29 — 转岗复核的收件人是**新经理**,不是 IT;包裹按类别答,不按条目答

**Decision.** 转岗复核包裹(迁移 568)的读写授权**按包裹**给到 `reviewer_person_id` —— 接手这位同事的新经理,一个通常没有任何管理员角色的普通员工。同时,复核的最小操作单位是**类别**(「这一类全部保留」),不是条目。

**Context.** 565 修好检测之后,复核面本身仍然只有一类宽:只看云授权。而离职域早就把 ~24 类持有物枚举得清清楚楚。拿生产真人按同样谓词跑:一位资深工程师名下 **238 项 / 10 类**,其中 23 类在转岗时从来没被看过一眼,而且没有一类会自己失效。

**为什么必须是经理而不是 IT.** 一个管理员看到「这个人有 9 条云授权」**无从判断他还需不需要**。这不是权限不够,是信息不在那儿。唯一知道的人是刚把他接过来的那位。把复核关在 `require_admin` 后面,等于让唯一有能力回答的人碰不到问题 —— 这正是旧复核从来没真正 work 的原因,而不是它没人用。

**404 而不是 403.** 陌生人访问一份包裹返回 404。403 等于确认「这个包裹存在」,而某位同事发生了转岗这件事本身就是关于他的信息。

**为什么按类别答.** 238 个决策塞进一个界面,不会产生 238 个深思熟虑的判断,只会产生 238 次点击和一个不再阅读的复核人 —— 这就是 565 那条「不许制造复核风暴」在这里的同一个应用。111 条 DNS 是「关于 DNS 的一个判断」。整批**只动尚未决定的条目**:有人特意单独给过的答案不能被批量覆盖。

**模型的位置.** 类别粒度,不是逐项 —— 逐项是每次转岗 238 次调用,成本和延迟都不成立。叙述里每个整数必须是包裹发布过的数,模型自己做的算术(`111 − 9 = 102`)**也判不接地**,一条编造数字整份作废而不是修剪。`transfer` 不在模型词表里:选「转交给谁」是在点名一位同事。没有模型时包裹功能不减,`ai_failure_code` 说出为什么 —— 一片空白读起来像「没什么要担心的」。

**词表不新造.** `resource_type` 沿用离职域的 `RES_*`。两边用同一种语言描述「一个人持有什么」,才可能用结构性测试挡住「一边加了、另一边悄悄没有」—— 那正是 565 在这条路另一头刚修掉的那类 bug。

**不动的部分.** issue #623 一个字没改:转岗不自动回收任何权限。记 `revoke` 只是记下来,执行在那个资源自己的管理面 —— 那里有该域的审批、审计和幂等,挂在复核表上的第二条执行路径一样都没有。

**Alternatives(否决).** (a) 复核仍归 IT,给经理只读 —— 否决,那是把「唯一知道的人」降级成旁观者,和现状等价。(b) 逐条要求答复 —— 否决,见上。(c) 让模型逐项给建议 —— 否决:238 次调用,且模型对单条 DNS 记录的判断并不比类别层面更可靠。(d) 让「记 revoke」顺手执行回收 —— 否决,直接违反 #623,且会绕开各域自己的审批与幂等。(e) 把职级变动也开包裹 —— 否决,晋升开 238 项复核会训练人闭眼关掉下一份。
## 2026-08-29 — GitHub 组织成为一个域:台账以 handle 为键,配置权交给研发侧的 org owner

**Decision.** 把 GitHub 组织成员资格做成 Helix 的一个域(表前缀 `github_`),并新增第 11 个规范角色 `github_admin`。入口从飞书搬进 Helix,但**审批链仍在飞书**:Helix 通过 `instance/create` 往**现有**的审批定义(`AAF99056-F989-4D8C-93E4-321CB602CBA6`)发单,不新建定义。

**Context.** 「申请加入 GitHub 组织」审批通过后还有两个人工办理节点(有人去点邀请、申请人去点接受),Helix 全程看不见。真实取证:一张已通过的单子从提交到终态 8 天,后一半全在这两个节点上。后果不在入职侧而在离职侧 —— **离职流程从来没有回收过任何一条 GitHub 组织成员资格**。

**台账的键是 `(org, handle)`,不是 `(org, person)`.** GitHub 侧的事实是按 handle 说的,而 handle 未必对得上人:外部协作者、机器账号、以及我们根本没登记映射的人。以 person 为键,对账时「GitHub 上有、Helix 认不出是谁」这一行**根本无处安放**,只能丢弃 —— 而那恰恰是离职自动回收够不着的那部分,是最该被人看见的东西。以 handle 为键,它就是一行 `person_id IS NULL` 的正常记录,并且在页面上是一个单独的 KPI。

**「已邀请」和「已加入」是两个状态,不合并.** GitHub 的组织成员资格是邀请制:调完 API 对方并没有进组。合并成一个 `active` 会同时造成两个错误方向 —— 对外宣称一批还没进组的人已经有访问权,以及离职回收去撤一个不存在的成员资格。7 天过期也因此需要自己的状态:过期**可重发**,被拒绝**不可**,把它们并成一个终态就得靠人去猜该不该再发一次。

**飞书表单的选项值存进数据行,不写进代码.** 那串 `l9i7g355-les25siqrij-0` 既不可推导也不稳定,而 Helix 发起审批时必须原样回填。放进 `github_orgs.feishu_option_value`,「飞书那边加了个新组织选项」就是改一行数据;写进常量,它就是一次改代码 + 发版。

**复用现有审批定义,不新建.** 定义上的 org owner 审批节点是按所选组织路由的,重建会破坏审批人分派 —— 而那正是这个流程唯一真正有价值的部分。代价是我们要迁就一张不是自己设计的表单(包括那个「lark profile 里是否已设置 GitHub ID」的必填单选),这个代价比重配审批人小得多。

**`github_admin` 属于研发侧的 org owner,不是 IT.** Helix 接管执行之后,「谁该进哪个组织」「哪些部门天然就该在里面」「一个离职的人是不是仍然是社区里合法的 maintainer」仍然只有 org owner 答得了。配置权留在 `global_admin`,每加一条自动加入规则都要走 IT —— 自动化就退化成换了一个人工窗口。作用域是 GitHub 域的全部、域外一无所有,也不进任何硬编码 `global_admin` 的告警扇出:一个研发侧的组织 owner 不该因为拿了这个角色就开始收所有运维告警。与 `security_officer` 同款设计。

**迁移只建角色,不授予人.** 候选人从飞书流程管理员字段看得出来(刘寅 / 左武辉),但「谁在飞书上管这条流程」和「谁该在 Helix 里拿一个能改自动加入规则的角色」是两个决定。后者由人在 RBAC 页面上做。

**离职时社区组织默认也移除,豁免是每个组织自己的一个开关.** pingcap / tikv 是社区组织,一个离职的人可能仍是合法的 maintainer / committer,把他踢出去是对社区治理的越权。但「是社区组织」是事实,「离职后可以留下」是政策 —— 两个判断,所以 `is_community` 与 `offboarding_policy` 是两列,后者播种成 `remove`。**豁免必须是 org owner 明确做出的决定,不是一个安静的默认值**,而且做的时候界面上有一句后果说明。

**自动加入规则的部门按子树匹配,与 `provision_policies` 的精确相等不同.** 软件席位按精确部门配是合理的(不同二级部门用的工具不一样);而「研发线的人都该在 pingcap-inc 里」如果要精确匹配,得把每个叶子部门列一遍,并且组织架构一调整就静默失效 —— 那正是自动化想消灭的手工维护。子树按**路径段**比较而不是字符串前缀:`Engineering` 命中 `Engineering / TiDB`,不命中 `Engineering Support`(对外 API 的部门筛选为此修过一次,这里从第一天就按段比)。

**凭证方式(GitHub App vs PAT)不由本 ADR 决定,而且不必决定.** 两种鉴权都实现,做成集成配置里的一个选项。评估过复用 `ti-chi-bot` 那个现成的 GitHub App(它的权限清单里已经有 `members: write`),**否决**:它同时持有 `administration` / `contents` / `secrets` 的写权限,把私钥交给 IT 平台等于让 Helix 拿到能改所有仓库代码的钥匙,而且双方轮换密钥会互相牵连。要的是一个只勾 `Members: Read and write` 的专用 App。

**Alternatives(否决).** (a) 台账以 person 为键、认不出的丢弃 —— 否决,理由见上。(b) 复用 `provision_policies` 加一个 `target_type='github_org'` —— 否决:那张表的目标绑在软件目录上,整条 provisioning 队列的热路径都假设「席位/权限」,而 GitHub 是邀请制;更关键的是 `github_admin` 必须能配 GitHub 规则**且不能碰**软件席位策略,分表天然隔离,共表要在一个今天还是 `global_admin` 专属的页面上做行级过滤。(c) 新建一个自己的飞书审批定义 —— 否决,见上。(d) 把 `join_requests.membership_id` 也存上做双向指针 —— 否决,两表互指会在 ORM 元数据里形成不可解的环(SQLAlchemy 会警告并**放弃**考虑这些 FK,测试的建表/清表顺序失去保障),而这条反向边是冗余的:台账的自然键 `(org, handle)` 申请单两者都有。

---

## 2026-08-29 — 「初始播种」与「例行全量对账」拆成两个概念;组织漂移成为 action 决策的输入

**Decision.** 新增 `hr_sync_batches.is_seed`(迁移 565),把 `sync_mode='full'` 一直背着的两个含义拆开。**只有播种抑制生命周期协调器**;例行全量对账照常派发。同时,已在职员工的 `action` 决策不再只看「onboard 台账行在不在」,**组织漂移(经理 / 部门 / 职级任一)是它的输入**:有漂移就落 `update`,没有才 `skip`。

**Context.** 生产取证:十四个月里 `hr_lifecycle_events` 有 onboard 557、offboard 133、**role_change 1**,而 `cmdb_cloud_access_grants.review_status` 5273 行全为 NULL。Joiner / Leaver 是工业级的,Mover 事实上是空的。三个独立缺陷切在同一条路上,其中两个需要决策:①`is_initial_seed = (sync_mode == 'full')` 让每周对账继承了播种语义;②活跃员工的组织变动无条件落 `skip`,而 role_change 闸只认 update/reactivate —— 变动照样被 skip 的 fall-through 写进人员行,于是 Helix 改了经理却从不复核跟着老经理来的权限。

**为什么不是「删掉抑制」.** 抑制在播种时是对的:一个实例第一次整册入库会带进几百个历史离职者,派发协调器等于给 500 人建号、给 130 个历史离职者开流程。要保留的是**播种**,要去掉的是**「全量」这个词替人做的判断**。所以 `is_seed` 在建批次时由谓词(该实例既无人员也无生命周期事件)算出**并落库**,而不是在确认时现算 —— 确认时暂存行已经开始改变它要读的那个世界。运维可以用 `?as_seed=` 显式指定:一次刻意的历史回捞是人做的决定。

**谓词偏向哪一侧(重要).** 误判成「例行」的代价是几百次真实建号,误判成「播种」的代价是回到今天这个 bug。所以谓词**严格按 `hris_instance_id` 作用域**、不把 legacy NULL 行算进来 —— 新实例一律先判成播种。两个方向的错误不对称,谓词就不该对称。

**配套的刹车(不可省).** 那个被拆掉的抑制,事实上是「一份被污染的全量预览」和几百个真账号之间**唯一**的东西 —— 原来的刹车只数 `offboard/deactivate`。所以同 PR 补上对称的**群体建号刹车**,两条都按 `is_seed` 豁免。按生产分布校过:例行 scheduled full 每批最多 9 create / 16 deactivate,incremental 最多 13 create,2026-04 真播种批次 137–336 create,共用的 `max(25, 5%)` 门槛把两者分得很干净。**拆安全网必须同 PR 补新的**,这条写下来是给下一次的。

**检测与派发分开.** 确认路径内联调协调器,而协调器按平台惯例自吞异常 —— 一次失败完全隐形。因此暂存时把漂移标记落成**持久证据**,新 cron `role_change.dispatch_reconcile` 保证每条被记录的漂移最终都有事件与之对应。补发按**观察到的日期**落 `effective_date`,不 backdate 进今天的权限上下文。协调器去重键随之从「今天写过吗」改成 UNIQUE 索引本身的 `(person_id, event_type, effective_date)`。

**职级不是权限边界(本次新增的一条边界).** 缺陷 C 修好之后,「只改职级」第一次真的能走到协调器,而协调器原本会翻动该员工**全部**云授权。SF 一次职位目录改名就能让 5273 条授权同时进 `pending_review` —— 那种复核风暴只会训练 owner 闭眼点确认,等于把这道闸废掉。所以协调器现在区分**是否与权限相关**:部门或经理变了才翻授权;只改职级的写事件、不动授权。**权限跟的是「你向谁汇报、在哪个组织」,不是职级字符串。** 同理 `evaluate_auto_confirm` 仍然只认经理/部门 —— 一次晋升不该拦住整个批次。

**不动的部分.** issue #623 的既定策略一个字没改:**转岗不自动回收任何权限**,只标 `pending_review` 等人判;坐席只做 `preview_role_change` 不 apply;资产跟人走。这次修的是「这条路走不走得到」,不是「走到了以后做什么」。

**Alternatives(否决).** (a) 直接删掉 `is_initial_seed` —— 否决,真播种会变成一次全公司建号事故。(b) 让 cron 硬传 `is_seed=False` —— 否决,一个全新 HRIS 实例的第一次 scheduled full 就该是播种,硬编码会把它变成事故。(c) 谓词只看 `identity_persons` —— 否决,一个被清过人员但留着事件的环境会被误判成播种。(d) 保持 `skip` 不动、改成让 role_change 闸也认 `skip` —— 否决,那要让确认循环给 440 条 skip 行逐个算漂移,把成本挪到了每周全量的热路径上,而 action 本来就该反映「这条事件有没有内容」。(e) 回填历史转岗 —— **做不到,且不假装做得到**:修复前的移动大多以 `skip` 落地,人员行被就地改写,`identity_org_memberships` 只有 5 行软删,经理变更完全没有审计线索。对账 cron 只捞有显式标记的那部分。

---

## 2026-08-28 — 安全 FAQ 允许模型写字(推翻「渲染给员工的永远是库里原文」),条件是引用可核验;检索改为词法 + 语义 RRF 融合

**Decision.** `POST /me/security-faq/ask` 在召回到条目之后,允许模型写一段**引导性回答**并渲染给员工。这推翻 2026-08-27 建这个页面时定下的「Answers rendered to an employee are always the stored text, never model prose」。同时召回改为**词法 + 语义两路并行、RRF 融合**,而不是原来的「词法零命中才让模型挑 id」。

**Context.** 员工反馈「这个搜索很蠢」。查下去是结构性的:答案库 71 条全部按客户安全问卷口吻写成,员工的问法与条目**零公共子串**,词法必然失败(实测 10 个真实问法:4 个零结果、3 个命中错条目)。原来的形状里,模型只被允许挑 id,所以即使挑对了,员工读到的仍是一段「贵公司是否…」的对客陈述,要自己翻译成「那我到底能不能」。

**为什么可以放开.** 原决定保护的是一件具体的事:**不让一段没有出处的话以公司安全规定的身份出现在 500 人面前**。这件事现在由机制而不是由「不让模型写字」来保证:① 模型的输入**只有这次召回到的条目原文**,没有别的语料;② 必须回 `cited_ids`,服务端与召回集合求交,**空集就整段丢弃**降级成只列条目;③ 条目原文**永远**原样渲染在下面,前端把两者做成视觉上不同的东西(独立底色 + 「AI 依据条目整理」标签 + 可点回原条目的来源 chip);④ 模型说「这些条目答不了」时不写字,直接记缺口台账。**判据没有变松:一句话仍然必须能落到某一条人写的条目上,变的只是这句话可以由谁来组织。**

**Boundary(不放开的部分).** 模型不得引入条目里没有的事实(工具、时限、联系人、例外),提示词里是硬约束;正文上限 900 字;**任何一条对外/对客的问卷答案仍然是「起草可以自动、发送永远留人」**,这次改的只是员工内部读侧。三语文案同步改成「AI 只负责整理和引用,结论以条目原文为准」——**页面上不允许留一句在这件事上说谎的文案**。

**Alternatives(否决).** (a) 只补语料、不引入语义与成文 —— 部分有效(实测把 3/10 提到 10/10),但它修的是**已经想到要写的那些问法**;下一个没想到的问法仍然零结果,而这正是这个页面反复出问题的方式。已作为本次的第一步落地,不作为全部。(b) 只做语义检索、不让模型写字 —— 否决:检索到「贵公司是否部署 DLP 方案?」之后,员工仍要自己把对客陈述翻译成自己的处境,而这一步正是他觉得蠢的原因之一。(c) 让模型自由作答、把条目当参考 —— 否决:那就是把公司安全规定的解释权交给模型,且没有任何机制能在事后区分哪句有出处。(d) 语义**兜底**词法(而不是融合)—— 否决:一次勉强的字面命中(问句里碰巧有「公司」)会让语义那一路更好的答案永远出不来。(e) 用 TiDB VECTOR 列 —— 否决:本地 MySQL 的整套测试会因此不可跑,而库只有几百条,Python 余弦足够(与 `support_kb_articles` 同一判断)。

**Follow-up(留给下一次).** 语义索引的覆盖率是运维面而非产品面:没有向量的条目只能靠字面被搜到,而这个缺口**不产生任何一条错误日志**。管理台顶部的覆盖率条 + 每日回填 cron 是当前的答案;如果之后换嵌入模型,`pending_clause` 会把全库标成待回填(这是对的),但一次全量重嵌的**厂商成本**没有预算闸,规模再大一个数量级时需要一条。

---

## 2026-08-27 — 汇联易推手机号(推翻「租户唯一所以不推」);降级机制按「厂商有没有给错误码」分两套

**Decision.** `huilianyi` 目标推送 `mobile` + `countryCode`,覆盖整个在推人群。这推翻建目标时的注释「Intentionally NOT mapped: mobile (tenant-unique on the vendor side — pushing a stale HRIS number collides with reassigned numbers)」。**理由**:当时不推的真实原因是**没有降级路径**(一个被占用的号会让整个人变红),而不是「不该推」;降级路径做出来之后,不推的代价就变成了 234 个人的手机号在费控系统里长期空白 —— 而 汇联易 的差旅/报销要用它。上线前拿生产实测:**撞号 0 个**、Helix 内部重号 0、带区号前缀 0。

**Boundary(号码语义).** `mobile` 必须是**不含国家区号**的裸号:`phone_digits` 只去分隔符,**保留** `+`/`00`,由 runner 显式拒绝并丢弃该 slot —— 在一个**租户唯一**的字段上,把区号焊进国内号可能正好落到另一个真人身上,这种"修复"比留空危险得多。`countryCode` 走派生 token `mobile_country_iso2`(电话国家优先 → 工作国家回退 → 解析不出就不发),因为 HRIS 那一列混着 ISO-2/ISO-3/自由文本且 21 个外籍为 NULL,而**厂商对缺失的 countryCode 默认按中国大陆处理**,不发等于把外籍号错标成中国号。

**Boundary(降级按证据分两套).** 手机号的拒绝厂商**有明确错误码**(120206/120211/12002008/120223)→ 按**错误码**降级;直属领导厂商**没有**错误码 → 保持「去掉它重试一次」的**探针**。不允许用猜出来的 message marker 去做前者,也不允许用错误码去做后者。各自只触发一次,最坏 3 次厂商调用;必填字段永不牺牲(仍返回 `blocked`)。

**Alternatives(否决).** (a) 只推号不推 `countryCode` —— 否决:厂商会把 159 个外籍号默认存成中国大陆号,数据比空更坏;(b) 用 value_map 直接映 `mobile_country_code` —— 否决:strict map 会丢掉 ISO-3 那一批,passthrough 又会把 `GB / UK` 这种脏值原样发出去,且**无法表达「回退到工作国家」**;(c) 推 E.164(+86…)统一格式 —— 否决:厂商明确要求不含区号,且唯一约束下格式不一致会造出"看起来不同其实同人"的号。

---

## 2026-08-27 — 汇联易推「主岗直属领导」(逐人经理),推翻 2026-06 的「只走部门角色」

**Decision.** `huilianyi` 目标的员工映射新增 `directManagerEmpoyeeId ← {manager_employee_no}`（经理工号），对**整个在推人群**生效（生产 416 名在册 P 号员工）。这推翻 2026-06 建目标时写在 `presets.py` 里的旧决定：「Manager is likewise unpushed by decision (approval routing uses 部门角色, not per-person manager)」。**理由**：部门角色（6103 部门负责人 / 6105 HRBP / 6102 Budget Owner）解决的是**部门维度**的审批路由，它并不填「主岗直属领导」这一格；而汇联易的人员页、以及依赖逐人上级的审批链，读的就是这一格。Authing 退役后没有任何链路再写它，于是 Helix 建的每个人都是空值（报出来的是 P10019 陈建华）—— 旧决定的实际效果不是「用另一种方式路由」，而是「这个字段永久为空」。部门角色继续照原样推，两者并存不冲突。

**Boundary（不破坏既有值）.** Helix 只在能证明「汇联易确实有这个人」时才写这一格：经理必须落在该目标自己的 filter 人群内（在职状态、P 号工号、非 HRIS 墓碑、非 HR 测试号段、非自己）。解析不到 → **不发这个字段**（更新员工V2 中字段缺席 = 保持原值），厂商的 `"[NULL]"` 清空哨兵**一次都不发** —— HR 可能在汇联易侧维护了 Helix 不知道的领导，把它抹成空会直接破坏审批路由，这与 NetSuite 角色字段「不用启发式派生覆盖权威值」的既有底线一致。生产核对：416 人中 5 人无经理（组织头，字段本就缺席）、0 人的经理在推送人群之外、0 人自指。

**Boundary（一个可选字段不该让整个人变红）.** 厂商未为「直属领导不可用」定义错误码，故不猜 message marker：被拒时原样重试一次去掉该字段，成功则记 `slot_outcomes`（slot-degraded success，下一轮重试该 slot），失败则返回第一次的原始报错。

**Alternatives（否决）.** (a) 只给陈建华一个人补数 —— 否决：用户明确要求「整个体系运转正常」，且 411 人同症状；(b) 用 `directManagerOID`（厂商侧内部 UUID，优先级高于工号）—— 否决：需要额外一次逐人查询才能拿到 OID，而工号本身就是租户唯一键，Helix 是权威方；(c) 走 `userJobsDtos` 的逐岗位 `directManagerJobCode` —— 否决：那是「上级岗位」模型，需要 Helix 维护汇联易的岗位编码体系（Helix 无此概念），且 `coverModeForUserJobs` 有整体替换岗位列表的风险。

---

## 2026-08-26 — DAX 全局搜索只合并数据集目录、血缘表与 managed table，不横扫 Provider

**Decision.** 数据访问申请页的单一搜索框同时匹配数据集 project / id / 展示名 / `notes` 业务描述，以及已发布 dbt 血缘目录和已按需物化 `data_access_resources` 子行中的表名 / 表描述。匹配在服务端 count、offset、limit 之前完成；表命中返回所属数据集和有上限的匹配表摘要，用户点击后进入既有数据集抽屉继续读取表的字段、血缘、负责人和实时可申请状态。

**Boundary.** 全局搜索不得为了“搜全”而逐数据集调用 BigQuery `tables.list`，也不持久化完整 Provider 表目录。未进入血缘或 managed 投影的 `discovered` 表继续沿 2026-08-20 的既有决策：用户先打开一个数据集，再在该数据集范围内显式 Provider 搜索。搜索是只读发现能力，不改变 `requestable`、owner、审批、binding/effect、reconciler 或 IAM 权威链。

**Why.** 用户需要在不知道表属于哪个数据集时按表关键字反查；只扩展前端当前页过滤会漏分页后的数据集，只对每个数据集发 Provider 请求又会把一次输入放大成宽扫描。复用版本绑定的血缘产物和已存在的 managed table 行，可以让常用与已治理表跨数据集可发现，同时保持 BigQuery 物理目录的按需读取边界。

---

## 2026-08-26 — 年假折算分母固定 365.25，封顶取「各段基数的最大值」

**Decision（HR 确认）.** 年假折算公式改为 `在职天数 ÷ 365.25 × 年假基数`；取值先**截断**两位小数、再向上取整到 0.5 的倍数；折算结果超过该员工年假基数时按基数封顶。封顶用的「年假基数」= 该 block 内**各段基数的最大值**，法定与福利各自独立封顶，排在既有的 15 天合计封顶**之前**。

**Context.** 改之前分母已经是固定 `365`，不是需求描述里的「当财年实际天数（365/366）」—— 实际天数一直在分子。缺陷是它造成的：含 2/29 的财年有 366 天，整年在职、基数 10 的人拿到 `366/365 × 10 = 10.0274 → 10.03 → 10.5` 天，多半天纯粹因为闰日。要点是 **365.25 单独修不好这个**（`366/365.25 × 10 = 10.0205`，照样向上取整到 10.5），是封顶把它拉回 10；365.25 的作用在反方向 —— 保证非闰年整年在职不被更大的分母削掉（`365/365.25 × 10 = 9.9932 → 10.0`）。两条规则互为前提，任何一条单独上线都会留下一个真缺陷。

**为什么「基数」这件事需要拍板.** 计算器是分段的：一个财年内跨社会工龄 N=10/20 或司龄 S=1..8 任一档，基数就会变，所以一个 block 里根本没有单一「该员工的年假基数」。三种口径被评估：

(a) **各段基数取最大值** —— 采纳。只在「分子 > 分母」（即 block 跨度超过 365.25 天，实际上就是闰财年整年）时咬一口，正常情况完全惰性。
(b) **取期末基数** —— 否决，而且是**有害**的：社会工龄跨过 N=20 的人期末福利基数是 0，会把他当年前 275 天已挣得的福利假整个抹成 0。这不是保守，是倒扣。写成 `test_basis_cap_uses_max_basis_not_closing_basis` 钉住；把实现改成期末基数，该用例的 `welfare_capped` 直接变 `0.0`。
(c) **加权平均基数** —— 否决：等于对 raw 再封一次顶，几乎永远小于 ceil 后的值，会把「0.5 天向上取整」这条既有规则整个抵消掉。

**截断 vs 四舍五入是小事，且要说清楚它是小事.** 因为紧接着就 0.5 向上取整，两者只在半天档位 +0.005~+0.01 这条窄带里有差别（`10.5051` 四舍五入 → `10.51` → `11.0`；截断 → `10.50` → `10.5`）。它买到的是 0.01 的容差，不让一个算术零头花掉公司半天。不要指望它带来可见变化。

**`_years_between` 的 `/365` 不跟着改.** 它回答「第 N 个周年过了没有」、决定分段切点，不是「全年天数分母」；一起改会挪动切点，属于另一件没人要求的事。已改名为 `_TENURE_DAYS_IN_YEAR`，与 `_PRORATION_DAYS_IN_YEAR` 在命名上分开，免得下一个人把两者当成同一个常量合并。

**两个封顶各留各的标志（迁移 550）.** `basis_cap_applied_*` 不与既有 `cap_applied_*` 合并。这个工具的全部意义是 HR 能对着员工把一个数字讲清楚，而两个封顶对应两句不同的话（「按你自己的基数封顶」vs「法定+福利超过 15 天所以压减福利」）；合成一个标志就是把错的解释摆到员工面前。同理 `*_rounded` 列刻意保留**封顶前**的值，UI 上「取整后 / 封顶后最终值」并排显示，塌成一个数就把要解释的那一步藏掉了。

**历史行不动，也不做数据订正.** `get_history_item` 走 `_record_to_response`，按持久化列原样回放、不重算，所以旧行连同 `days/365×basis` 的公式串一起保持不变。代价是同一个人今天重算会得到与归档行不同的数字：**要不要重新结算已结算过的人，是 HR 的流程决定，平台不代劳**（已在交付时向用户明确提出）。

---

## 2026-08-26 — 云权限续期取消「>90 天需云管理员复核」：Owner 一签即生效

**Decision（用户拍板）.** 云订阅（`cmdb_cloud_scope`）的权限续期只需要**订阅 Owner 一个签名**，不论申请天数。2026-07-30(#2098) 引入的两档 TTL —— `≤GRANT_TTL_OWNER_APPROVABLE_DAYS`(90 天) Owner 可批、`>90` 天 Owner 批完再要一位云管理员会签且两签不得同人 —— 中间那一档整条移除。硬顶 `GRANT_TTL_HARD_CAP_DAYS`(180 天) 保留：超过直接 422，"需要长期访问就去申请常设角色"。原常量改名 `GRANT_TTL_DEFAULT_DAYS`，因为它现在只是表单默认值，不再是权限分界。

**Context.** 用户在订阅详情页看到一条 180 天的续期申请被标成「需云管理员复核 / 等待云管理员复核」，指出这不是他要的：**负责人审批了就行**。原设计的理由是"Owner 不该单方面发出活得比审查周期还长的访问"，但 90→180 天之间那段并没有第二个人真正能判断的信息 —— 会签的云管理员既不知道这个订阅上在做什么项目，也不认识申请人，只会照批，于是这一档买到的不是治理而是延迟和死单风险（Owner 批完之后队列里那行对他自己变成只读，要等另一个人）。真正兜住风险的是硬顶和到期回收，不是中间这一签。

**取消的范围与不取消的部分.** 删掉：`requires_admin_signature()`、`pending_owner → pending_admin` 的两段式、给云管理员的「待复核·超长续期」通知、`第一签与复核签不能是同一人` 的 409、API/MCP 上的 `requires_admin_signature` 字段。**保留四眼**：申请人永远不能审自己的续期 —— 那是 2026-07-29 取消自助续期的**全部**理由，和 TTL 分档无关。同样保留：离职/待离职申请人在决策时自动关单、`open_for_grant_id` UNIQUE 并发闸、prod 订阅不参与续期。

**`pending_admin` 留在枚举里不删.** 它是**落库值**：历史行、`statusColor` 的状态→颜色表、`myRenewals.status.*` 的状态→文案表都要能继续解析它。按 [[reference_persisted_enum_rename_cost_model]] 的口径，改名是"一次性迁移 vs 每个读者×永远"里明显亏的一边。取消当天生产 0 行 `pending_admin`（4 条 open 全是 `pending_owner`），但 decide 路径仍把万一出现的这种行当作"Owner 已签"直接终结，审批人待办和移动端行动中心也一起显示两个 open 状态 —— 2026-07-31 那条"普通 Owner 不显示 `pending_admin`"的规则前提是"点了会 403"，现在点了会成功，继续藏就等于把它埋死。

**Alternatives considered.** (a) 把阈值调到 180（等于硬顶）而不是删代码 —— 否：两段式的状态机、通知、前端闸全部留在原地当死码，下一个读的人还得重新推导它到底会不会触发。(b) 只对 Owner 角色的续期免除会签 —— 否：用户说的是这类申请不需要复核，不是这个角色不需要；按角色开特例会造出第三种档次。(c) 保留会签但允许同一人两签 —— 否：那只是把一次点击变成两次，一个签名假装成两个更糟。

---

## 2026-08-25 — DAX Owner 离职时复用 Helix 终态交接人并随流程完成原子替换

**Decision.** 离职流程完成时，如果离职者仍是任一非 deleted DAX dataset/table 的当前 Owner，Helix 必须把这些 Owner 事实交给同一离职单的终态交接人。交接人不在 DAX 另配：继续按流程显式交接人、经理链、平台兜底的既有顺序解析，并沿用“在职、未软删、有 work email、不能是本人”的资格闸。只有持有 DAX 资源却解析不到有效交接人时才阻断完成，且必须在 `flip_to_left` 前失败；不持有 DAX 资源的离职流程不新增前置条件。

**Transaction and ownership.** 完成路径先锁当前 `data_access_resources` owner 行，再在路由同一事务内执行人员状态、DAX Owner 与流程完成。active 与 suspended 当前资源都交接，deleted 行保留历史。Dataset 继续用 `catalog_transferred` / `bootstrap_transferred` 防止目录刷新覆盖人工结果；table 保留已有 `dataset_inherited` / `manual` 来源。重复完成沿用 `PROC_COMPLETED` no-op，不重复审计或交接。

**Boundary.** 这只是当前审批 authority 的生命周期接续。既有 request item owner snapshot、审批实例、binding、compiled effect/run、撤销和 BigQuery IAM 均不回写；不会调用 Provider，也不新增迁移、API、MCP、RBAC、cron 或 DAX 专属交接配置。

**Alternatives.** (a) 在 DAX 单独维护离职交接人——被否：与离职流程形成两个事实源。(b) 只转数据集并依赖继承传播——被否：离职者可能持有独立 manual table。(c) 交接失败仍完成、靠后续 cron 修复——被否：会制造 `left` 人员仍是审批 Owner 的半完成状态。(d) 回写历史审批和 IAM——被否：Owner 变化不等于重做历史授权或 Provider 权限。

## 2026-08-25 — DAX 负责人转让遵循「当前 Owner 只能转让自己的资源」

**Decision.** 当前数据集 Owner 可以把该数据集转让给另一名在职员工；所有继承型 child table 在同一事务中跟随，已有独立表 Owner 不变。当前有效表 Owner 可以把自己的表继续转让给另一名在职员工；数据集 Owner 对已经交给别人的 manual table 不再有覆盖权。管理员角色不构成旁路。两种资源都拒绝转给自己，转让后旧 Owner 立即失去再次转让权限。

**Authority and refresh.** `data_access_resources.owner_person_id` 仍是审批路由唯一运行时事实。数据集人工转让用 `catalog_transferred` / `bootstrap_transferred` 区分来源，catalog refresh 继续更新目录元数据但不覆盖 Owner；Bootstrap 被目录接管时变为 `catalog_transferred` 并保留人工 Owner。既有 request item owner snapshot、审批、binding、effect 与 Provider IAM 不回写。

**Supersedes.** 本决定只覆盖 2026-08-19 ADR 中「只有 dataset owner 可委派表、被委派 table owner 不能继续转授」这一权限结论；表级最小授权粒度、dataset 申请边界、审批快照和 reconciler 单一 Provider writer 等其余决定保持不变。

**Alternatives.** (a) 数据集 Owner 永久可改所有子表——被否：表交给真实负责人后仍保留第二写权，责任不唯一。(b) catalog 每次覆盖人工转让——被否：页面显示成功但下次刷新回滚。(c) global admin 旁路——未采用：本入口表达 Owner 自助交接，不是管理员纠错面；需要治理修复时应走独立、显式的管理流程。

## 2026-08-25 — 重估:命名错误的持久化枚举要改,「有历史行」不是留着它的理由

**Decision.** 盘点分桶 token `office_admin` → `location_admin`(迁移 543)。

**为什么推翻上一次判断。** 8-24 我写过「这是有历史行的持久化枚举,改名等于一次伪装成
重命名的数据迁移,买不到什么」。这个判断只算了**改的成本**,没算**不改的成本**:一个
指着错误角色的落库字符串,会让**每一个后续读者**重新做一次「这个桶到底发给谁」的考古,
而这个成本按读者人数累加、永远付下去。75 行的一条 UPDATE 是一次性的。

**读双写单。** 读接受 `HOLDER_KINDS_LOCATION_ADMIN` 两种,写只发新的。这不是洁癖:
部署**先跑迁移再重启容器**,而生产当时有 **4 个 active 周期、75 行**在这个桶里 ——
有人正在回答这些行。旧容器往已迁移的表里写旧 token 是必然会发生的,不是假想。

**最危险的一处不是过滤而是索引。** 提醒扇出用 `by_person[pid][holder_kind]` **直接把
holder_kind 当 key** 索引普通 dict。过滤写错只是少发,索引写错是**扇出中途 KeyError**,
而那时一部分收件人已经收到了 —— 这里能出的最糟糕的失败形状。`_bucket_key()` 归一化,
变异验证钉住。

**保留 legacy 别名不设期限。** 删掉它需要证明「任何地方都不存在旧 token 的行」,
包括备份恢复和重放。它是两行常量加一个 `in`,留着的成本远低于证明的成本。

**教训**:判断「要不要改名」时,把成本算成 *一次性迁移* vs *每个读者 × 永远*,
而不是 *改动风险* vs *零收益*。

---

## 2026-08-25 — 修正:「人锚定 vs 资产锚定」的判据是**函数拿到的是谁**,不是它属于哪个业务流程

**Decision.** 上一条 ADR(2026-08-24)把三条流程归给了办公室的当地 Admin,归错了。
判据订正为:**看这个函数的入参**。

| 函数拿到的是 | 应该问谁 | 流程 |
|---|---|---|
| 一台**资产** | 该资产**位置**的管理员 | 离职硬件收件人、recycle-to-office 收件人、离职交接建议、盘点扇出 |
| 一个**人** | 该人**办公室**的当地 Admin | 入职 Day-1 欢迎信抄送 |

**Why.** 「入职硬件准备」「离职交接」听起来像 HR 流程,所以当时按业务流程归类,
把它们放进了「人相关」那一边。但打开函数签名就会看到 `asset: AssetItem` ——
它问的是**「这台机器该归谁」**,而机器归属是位置问题。真正按人的只有欢迎信抄送,
它的入参是 `person`,解析的是**新人自己的**工作地。

**分类错了还盖住了一个真 bug。** 这三条都用 `asset.location == office.city` 严格
相等去找办公室,而生产上 `美国`≠`Sunnyvale`、`新加坡`≠`Singapore`、`日本`≠`Tokyo`、
`马来西亚`≠`Kuala Lumpur`。1099 台资产里 **300 台(27%)**匹配不上,离职时全部落回
直属经理 —— 正是那段注释声称要避免的跨地域场景。留在办公室那一侧,这个 bug 就永远
被「这条流程本来就该用办公室名单」的解释挡住,不会有人去看它到底匹配上了没有。

**Invariant.** 新增一个「往这个办公室寄东西归谁管」的函数
`resolve_office_location_primary_admin()`,与「这个办公室的人找谁」
`get_office_primary_local_admin()` **分成两个名字**。两份名单允许指不同的人,
测试里故意各放一个人钉住这条。

**教训(比这次改动本身更值钱)**:按**业务流程的名字**分类会错,按**数据锚点**分类才对。
调研报告把它们列在「person-anchored HR flows」标题下,我照单全收了 ——
报告的措辞不是判据,函数签名才是。

---

## 2026-08-24 — 位置管理员名单的写权限收窄到全局资产层(自我提权闸)

**Decision.** 受管位置字典(`POST/PATCH/DELETE /assets/locations`)、位置的资产管理员
名单(`PUT /assets/locations/{id}/admins`),以及带管理员名单的读
(`GET /assets/locations?with_admins=true`),全部要求
`require_global_asset_admin()` = `global_admin` + `asset_global_admin`。
区域 `asset_admin` 保留**全部只读**(位置字典本身对全员可读 —— 每个资产表单都拿它当下拉)。

**Why.** 迁移 535 之后,一个位置的管理员名单**就是** `asset_admin` 的授予集。
如果这个面留在 `require_asset_admin`,任何区域管理员都可以:

1. 把自己加进别人的位置(北京管理员给自己加东京),或
2. 新建一个位置并把它授给任何人。

这不是理论风险,是把「谁能授予角色」和「谁持有角色」放在同一个闸后面的必然结果。
授予角色必须比使用角色更高一层。

`with_admins=true` 一并收窄的理由不同但同类:位置行本身是公开的,**挂上管理员名单后,
同一个响应就变成了「谁持有 asset_admin、在哪个区域、工作邮箱是什么」的花名册**。
公开的行上挂一个管理层的读,整体就是管理层的读。这条是自审时发现的(我自己引入的),
补了回归测试并做变异验证。

**写面不上 MCP。** `list_asset_location_admins` / `get_asset_location_coverage` 两个
只读孪生上了 MCP,但 `PUT /…/admins` **故意留在 HTTP**:它在授予角色,那不该是一个
被盗的 PAT 能够到的动作。

**Alternatives considered.**

- *留在 `require_asset_admin`,靠区域闸拦住* —— 区域闸管的是**资产**写,不是**名单**写;
  名单写本身没有区域概念(你要改的就是区域的定义)。
- *给名单写单独造一个新角色* —— 第 10 个角色换一条已经存在的授权语义,不值得;
  `has_global_asset_scope` 已经是「资产域内到处都有权」的那个集合。
- *`with_admins` 对员工静默返回空 admins 数组* —— 静默降级比 403 更难排查,
  且会让调用方以为「这个位置没有管理员」。

---

## 2026-08-24 — DAX 血缘按所选 relation 的角色决定方向

**Decision.** 同一个 table-lineage 读路径回答两个互补问题：选中 dbt 生成的 model/view/table 时，
继续向上展示“它从哪里来”；选中 manifest `source` 时，改为向下展示“它被哪些 dbt model 引用”。
表清单为 catalog relation 增加 `lineage_direction` 与直接 `downstream_reference_count`：source 有引用时
显示“被引用 N 次”，source 没有实际 edge 时显示“暂无下游引用”，不能再用笼统的“血缘引用”制造
它一定被使用的错觉。详情响应增加 `direction=upstream|downstream`，但继续使用既有 HTTP 路由与 MCP twin。

**Why.** source relation 本身没有上游 dbt 父节点，旧实现只能返回一个孤立节点；这能证明它在 manifest
里，却无法回答用户查看 `gcp-digital-bi.metadb` 时真正关心的“下游是谁”。同时，manifest 可声明目前
未被任何 model 使用的 source；把“被收录”和“被引用”混成一个标签会把 0 条依赖说成有依赖。

**Boundary.** 反向遍历只消费随镜像发布的规范 relation/edge artifact，最多 300 个节点，并且只在用户
点击血缘 tab 时运行；列表引用数来自同一内存 catalog，不读取 BigQuery、表数据或 SQL，也不写目录、
申请、审批、Provider IAM。source dataset 是否自动进入 DAX inventory 的规则不变：仍要求该 dataset
至少有一个可申请结果可达的 source edge；dataset 已纳管后，其中其他 manifest source 可以显示 0 引用。

**Rejected alternatives.** (a) 新增 dataset 级反向血缘表或定时同步——被否：会复制现有 manifest
事实且难以表达 table-to-table 精确关系。(b) 新增第二个 downstream HTTP 路由——被否：同一 relation
查询只需一个 additive `direction` 字段，额外路由会制造重复授权与错误语义。(c) 所有节点同时展示上下游——
被否：大图噪声高；source 默认向下、dbt 结果默认向上更贴合两类主要问题。

---

## 2026-08-24 — 资产权限挂到「位置」,办公室的资产管理员改名当地 Admin

**Decision.** `asset_admin` 角色成员和资产域的区域写闸,唯一事实源改为新表
`asset_location_admins`(挂在受管位置 `asset_locations` 上,多选 + 一个主管理员)。
办公室 `asset_office_addresses` 上原有的资产管理员名单**保留但语义收窄为「当地 Admin」**
(表改名 `asset_office_local_admins`),只服务以「人」为锚的三条 HR 流程 —— Day-1 欢迎信抄送、
入职硬件准备收件人、离职交接建议 —— **不再授予任何角色、不参与任何权限判定**。
桥接列 `asset_office_addresses.asset_location`(迁移 525)废弃,下一个 PR 删列。

**Why.** 资产域的第一维度是位置:`asset_items.location` 是走 `ManagedLocationResolver`
写闸的受管列,生产 1099 台资产 100% 携带合法受管位置名。而权限挂在办公室上,导致
「谁管这台资产」必须走一条四层推断阶梯(holder_office_id → 持有人城市→office.city →
office.asset_location → 该国唯一办公室),每一级都是一个可能给出错误答案的地方,还倒逼加了
一根专门用来把位置翻回办公室的桥接列。挂到位置后这条阶梯塌成两层直查,
「该国唯一办公室」这条特例也自然消失 —— 国家(美国/日本/荷兰)本来就是受管位置。

语义上也更自然:**人**坐在办公室(一个有街道地址和电话的地方),**资产**放在位置。
两件事各自锚在正确的表上之后,不需要在它们之间来回翻译。

**Invariants.**

1. `asset_admin` 仍是全自动派生 + 拒绝手工授予(`ROLE_AUTO_DERIVED`),
   `asset_global_admin` 仍共生派生(无位置的全局授予源),每日 03:15 cron 仍是兜底对账。
2. 区域范围 = 我名下的位置集合。写闸取**不过滤 is_active** 的集合(位置停用后遗留资产仍归
   原管理员);intelligence 受众取**过滤 is_active** 的集合(不给已关闭的地方继续生成简报)。
   这个分歧是刻意的,两个函数分开命名。
3. 资产→位置:`asset.location` 直查 → 退到持有人 HR 工作地,两侧同一个 resolver。
   解析不到 = 不属于任何区域,只有全局层可动。**不读 `holder_office_id`** —— 那会把
   本次要拆掉的办公室依赖又接回来。办公室指针保留为物理保管地(能寄东西的街道地址),
   不参与任何授权判定。
4. **位置字典和位置管理员名单的写权限收窄到 `global_admin` + `asset_global_admin`。**
   名单即授予,留在 `require_asset_admin` 等于给每个区域管理员一条自我提权路径。
   区域管理员保留全部只读。
5. `holder_kind='office_admin'` 作为落库枚举**保留原字符串**。收件人已改为位置主管理员,
   但这是有历史行、有响应类型契约、有提醒分桶、有游戏化事实读取的持久化枚举,
   改名等于一次伪装成重命名的数据迁移,且没有任何收益。
6. 五个区域闸抽到 `app/domains/asset/region_gates.py`,HTTP 与 MCP **import 同一份**。
   此前两边各有一份手抄副本且已经漂移(MCP 版丢了显式派发位置回退、`pre_hire` 豁免多套了
   一层判断),同一次调用走两个面授权结果不同。一条授权规则只允许有一个实现。

**回填与失败模式.** office → 位置按 `office.asset_location` → `office.city` 同名两级映射。
生产实测 10 个有管理员的办公室全部第一级精确命中,零未映射零冲突,回填后角色成员集不变。
**映射不全时迁移 raise 中止部署**,不继续。理由:静默少复制一行等于悄悄撤销一名真实管理员的
权限,他要撞上 403 才发现,那时没人会把 403 联想到一次迁移;而中止部署是响的,
且把办公室指向一个位置就能在一分钟内解掉。

**Alternatives considered.**

- *保留办公室为权限锚,只把桥接列做实* —— 就是现状。四层阶梯和桥接列都还在,
  用户明确要求「彻底和这个没有任何关系」。
- *把 `holder_office_id` 也改成 `holder_location_id`* —— 会丢掉物理保管地。
  资产实际是寄到一个街道地址的,办公室表有 address/contact_phone,位置(美国/日本)没有。
  拒绝:授权和保管是两件事,只解耦授权。
- *办公室与位置双写、两边都能授权* —— 两个事实源必然漂移,这正是要拆掉的东西。
- *当地 Admin 也一并切到位置* —— 已与用户确认保留在办公室:入职欢迎信抄送的是
  「新人 Day-1 会见到谁」,那是办公室的属性,不是资产位置的属性。
## 2026-08-24 — 云订阅 Owner 确认不再升级给 IT，改为每天提醒 Owner 本人

**Decision.** 退掉「Owner 14 天未响应 → 升级给全部 IT 管理员」这条机制。未响应的
Owner 确认卡（半年确认 / 零花费两种）改为**每天重发给 Owner 本人**，直到他确认或
转让为止。没有自动升级、没有自动冻结、没有自动回收 —— 卡片本身就是唯一的推进手段。

配套三条边界：订阅已删/已下线 → 该卡作废（`voided_scope_closed`）；期间换过 Owner
→ 作废并记继任者（`voided_owner_changed`），由节奏 cron 用新快照问新 Owner；Owner
已离职 → **跳过但保留 open**（等离职强制转让改指向，再由上一条关闭），绝不给离职者
推卡。迁移 531 把存量 `status='escalated'` 全部重开为 `pending`。

**Why.** 两个理由，第二个是决定性的。

其一，把 ~90 条**别人的**未响应事项交给 5 个管理员，只造出一个催办队列 ——
管理员既不知道那个订阅在干什么，也不该替 Owner 做保留/清理的决定，推不动任何一个
决定。

其二，`escalated` 是响应端点**拒绝**的状态（`_get_pending` 只接受 `pending`），所以
一条「已升级」的卡**谁都答不了** —— Owner 点按钮报错，管理员也没有可点的东西。名义上
升级了，实际是死胡同。这个设计缺陷是在排查另一件事时才暴露的：角色谓词写成裸的
`it_admin`（生产 `auth_roles` 无此行），升级三个月零投递、8814 条失败审计，没人发现，
因为**即使投递成功也不会有人能处理**。

**Alternatives considered.**
- *修 roster 保留升级* —— 只解决投递，不解决「升级后无人能答」。而且首轮会把三个月
  积压一次性推给 5 个人。
- *到期自动冻结/回收订阅* —— 明确不做。云订阅冻结是不可逆的业务影响，必须人来定。
- *升级给经理链* —— 违反既有约定（[[feedback_no_new_pushes_to_employees]]：不给直属
  经理以外的经理层新增推送）。

**Operating boundary.** 每天一张，收件人只有 Owner 自己，内容只关于他自己的订阅，且
卡上就有停止它的按钮 —— 这是「可操作通知」而不是广播。稳态量级由订阅数封顶（每个未决
卡每天一张）。积压首轮可用 `config_override={"reminder_max_per_run": N}` 限速；run
summary 恒返回 `deferred_over_cap` / `skipped_owner_inactive`，被截断或全跳过的一轮
不会读成队列已清空。若日后要恢复某种升级，需要新 ADR，并且必须先解决「升级后谁能答」。

**追加（同日）：离职交接算作新 Owner 的「已确认」。** 离职强制转让时继任者已经收到
「[自动转让] … 已转给你」推送、正文点名了该订阅，所以交接即确认：交接时就盖
`last_owner_confirmed_at`，180 天时钟从被告知那一刻起算，不再在下一个节奏 tick 把
同一件事重问一遍。迁移 532 用每个订阅**最近一次成功交接**的时间戳回填历史（不用
`now` —— 那等于悄悄给这些订阅再送半年免打扰）。注意继任者可能是兜底人选（办公室
资产管理员 / 经理 / IT 联系人），推送同样发给了他，规则一致成立。

实现见 `OwnerConfirmationService.remind_unanswered_owners`；运维见
`docs/ops/cloud-lifecycle-ops.md` §17.5 / §17.5a。

---

## 2026-08-24 — DAX 个人 Provider gate 只能显式变更，普通集成更新必须保留

**Decision.** `Deploy DAX BigQuery Integration` 增加
`personal_apply_enabled=preserve|true|false`。默认 `preserve`，凭证轮换、全局 apply-mode 更新和
普通 integration upsert 不得隐式打开或关闭个人 BigQuery 写入；只有 Production workflow 的
显式 `true/false` 输入可以改变该键。workflow、SSM helper、Python upsert 和
`merge_data_access_apply_mode_config` 使用同一个三态契约，并继续保留 SA gate、principal
allowlist、无关 public config 与加密凭证。

**Why.** Production 已批准的个人申请已正确生成 exact-table effect，但因
`personal_apply_enabled` 默认关闭而持续 `allowed=0 / blocked=1 / provider_write_count=0`。
原 workflow 只能改变全局 apply mode，导致运维人员只能绕过受审查路径直接改数据库。把个人
gate 纳入现有 main-only、production-environment workflow，既提供可重复修复路径，也避免把
一次普通凭证投影误解释为个人授权政策变更。

**Operating boundary.** gate 变更不是单 effect allowlist：开启后所有当前及未来正常审批、仍
满足 requestable/active/owner/locator 闸的个人 present effect 都可由 reconciler 执行。因此每次
Production 开启前必须先读回待处理个人 effect 爆炸半径，先 dry-run，再 commit，并在正常
scheduler 后独立核验 request/binding/effect、provider write、精确 BigQuery IAM 和实际用户读取。
不得用直接 IAM 或手工改 effect 状态替代该路径。

## 2026-08-24 — 云资源目录采用统一索引，专用表继续承担领域真值

**Decision.** 新建统一、开放式云资源索引承载所有 provider-discovered 云对象；云服务器、
负载均衡、对象存储等不再以菜单决定资源类型。现有专用 CMDB 表继续作为服务器效率、DNS
生命周期、IAM 治理等成熟流程的事实源，通过幂等投影进入目录；未来 provider inventory
发现的未知类型可直接写同一契约并以通用详情展示，不要求新增菜单或迁移。

目录在产品上一次性完成资源清单、治理、效率与成本、数据看板与报表、AI 洞察五个 Tab，
不设置三级菜单；云资产总览继续独立存在。云侧永远只读，Helix 内允许责任人、分类、风险与
处置台账治理。global_admin / cloud_admin / auditor 可看全量资源，员工仅看本人负责或获授权的
资源，经理保留现有下属范围；敏感资源与成本口径继续走更严格的独立披露闸。

**Enforcement detail.** “全量”只认 global_admin / cloud_admin / auditor；management 与 finance
在目录中按普通关系范围收窄，不能复用历史 `can_read_cloud` 的全量语义。auditor 只读，员工仅能
确认自己可见的发现项，治理编辑、规则配置、指派/抑制/重开均由 global/cloud admin 完成。原始
metadata 与变化快照只向三类全量角色返回，但仍必须先在入库边界递归清除敏感键和值。治理规则只
选择代码内固定 evaluator，不接受表达式或任意代码；这样治理结果可复算、可审计且不会形成执行面。

**Why.** 仅合并页面无法兑现“扫描发现新类型后自动出现”，而把所有成熟专用表一次性替换成
通用 EAV 会扩大生命周期写路径风险。统一读索引把开放式发现与既有领域真值解耦，同时为
跨云搜索、变化历史、治理、AI 与报表提供一个可审计口径。

**Operating boundary.** 广泛发现优先使用云厂商官方 inventory/read API；需要一次性启用服务
或服务角色时由云管理员在 Provider 侧显式完成，Helix 不自动创建。消失资源不物理删除，保留
24 个月明细变化并长期保留聚合趋势。报表订阅默认关闭，用户可选关闭/每周/每月与飞书/邮件，
发送时必须重新鉴权。

## 2026-08-24 — DAX 个人申请只由所选资源 Owner 审批

**Decision.** `data_access_personal_grant` 删除申请人直属经理节点，个人申请直接进入
动态 `data_owner_review`。数据集申请使用数据集 Owner，数据表/视图申请使用当前有效表
Owner；申请人恰好也是资源 Owner 时，允许其以资源 authority 身份审批本人的只读申请。

此决策只适用于个人工作账号。`data_access_service_account_grant` 继续保留“直属经理 →
所选资源 Owner”，legacy Group 定义与存量生命周期也不改变。个人身份仍只能由当前登录的
active 员工 UUID/工作邮箱派生，不能替他人申请；审批通过仍只编译精确
`user:<work_email>` / `userByEmail` `dataViewer` effect，BigQuery 写入继续由 reconciler 和
默认关闭的 `personal_apply_enabled` 控制。

**Why.** 资源 Owner 已是 `data_access_resources.owner_person_id` 的运行时授权事实。旧个人流
无条件先找直属经理，导致资源 Owner 为自己申请其负责的数据表时，任务先错误地表现为经理待办，
随后又产生 Owner 自审。Amber 明确决定个人流不需要直属经理批准，避免把组织汇报关系当作数据
authority 的第二来源。

**Rollout boundary.** 源码合并不会修改已发布的 Feishu 定义。Production 必须用现有
approval code 更新 `data_access_personal_grant` 定义并 upsert 同一 mapping，再独立读回
owner-only 节点；已经创建的审批实例保留其创建时流程，不做隐式迁移或代审批。此次合同变更本身
不启用 `personal_apply_enabled`，不创建申请，也不写 BigQuery IAM。

## 2026-08-24 — BigQuery table IAM policy version 固定为 Provider 支持的 v1

**Decision.** `BigQueryDataAccessDriver` 的 table `get_iam_policy` 与 set 前并发读取都显式请求
policy version 1。当前 `google-cloud-bigquery` table API 只支持 v1；不得把通用 IAM 条件策略的
v3 假设套到 BigQuery table client。Dataset ACL 仍走独立 REST access policy 路径并保留
`accessPolicyVersion=3`，两者不是同一个协议面。

**Why.** Production exact-table effect 已通过 requestable apply gate，但两处 table IAM read 在任何
Provider I/O 前因 `requested_policy_version=3` 同步失败，持续产生 retryable effect 和 0 write。
Fake client 原先反向断言 v3，把不兼容行为误固化成测试合同；read 和 set 两条路径现在分别断言 v1。

**Boundary.** 只修请求版本，不改变 locator、role、member、etag 或 merge 语义。写入仍限于精确
dataset/table 的 `roles/bigquery.dataViewer` member，保留 unrelated bindings，不增加 project IAM、
`bigquery.jobUser`、credential 或扫描权限。

## 2026-08-24 — DAX Provider 范围只读 requestable 目录，不维护 SA dataset allowlist

**Decision.** 删除 `service_account_apply_dataset_allowlist`。`data_access_resources`
是“哪些 BigQuery dataset/table 可以申请并执行”的唯一运行时事实；reconciler 在每次 present
provider write 前重读 effect 对应资源，仅当资源仍为 `status='active'`、`requestable=true`、
负责人 authority resolved，且当前资源 locator 与 effect 目标快照完全一致时放行。Catalog 对
parent dataset 的状态、requestable 与继承 owner 变化继续传播到已物化的 table child，因此
table effect 读取同一投影，不另造 dataset 范围表。

Service Account 仍保留三道独立 rollout/身份门：integration `apply_mode_enabled`、
`service_account_apply_enabled` 和精确 `service_account_apply_principal_allowlist`。Personal
继续使用独立 `personal_apply_enabled`。Absent/revoke effect 不读取当前 requestable、active 或
owner authority；资源下架或负责人消失不能阻止收回已经存在的权限。遗留
`service_account_apply_dataset_allowlist` 不再参与 config 解析，并在下一次 apply-mode config
合并时删除；无需 DB migration。

**Why.** Dataset/table 的 requestable 状态已经在提交、审批回调和 catalog 投影中由同一资源行
管理。再手工维护一份 SA dataset allowlist 会形成第二个 SSoT：用户能申请、负责人能审批，
但 reconciler 仍可能因为漏配而永久 `pending_reconcile`。2026-08-24 Production 的
`anls_business_operations.dim_account` 申请正是这种漂移；这不是额外安全校验，而是不可观察的
运营死锁。

**Consequences.** 对通过 principal rollout gate 的 SA，任何经正常申请、经理/资源 owner 审批且
当前仍可申请的精确 dataset/table effect 都可由 reconciler 执行。目录被暂停、设为不可申请或
失去 owner authority 后，新的 present write fail closed；已有授权的 absent 仍可执行。部署代码
本身不等于 Production 修复，仍须独立验证运行 SHA、待执行 effect、Provider IAM 和业务读回。

**Supersedes.** 覆盖 2026-08-10 与 2026-08-16 SA ADR 中“还需 exact dataset allowlist”的
provider-mutation 条款，以及旧 M3 canary 执行顺序中的人工 dataset allowlist；其余全局开关、
SA 开关、principal allowlist、审批、精确 effect、无 project IAM/jobUser 等边界不变。

**Rejected alternatives.** (a) 从 repo inventory 自动生成第二份 dataset allowlist——被否：即使
自动生成仍是可漂移副本，且 Production catalog 与代码发布有独立时序。(b) 保留手工 allowlist
但在 UI 暴露阻断原因——被否：改善可见性但没有消除重复 authority。(c) 资源不可申请后同时
阻断 revoke——被否：会把下架资源上的旧权限永久留在 Provider。

## 2026-08-22 — DAX 申请使用统一资源选择器，并允许跨数据集混合粒度

**Decision.** 个人账号与 GCP Service Account 申请不再提供全局「按数据集 / 按数据表」模式。
目录行复选框直接选择整个 dataset；点击数据集名称继续按需打开 drawer，选择具体 table/view。
不同数据集的整库与单表可以在同一次申请中组合，例如整库 Finance 加 Warehouse 的一张表。
同一数据集不得同时选择父 dataset 与其 child table：选择整库会清掉该数据集的已选表，选择表会
取消整库，避免重复审批和重叠 Provider 权限。

**API and authority.** 个人与 Service Account 提交 schema 允许同时携带 `resource_ids` 和
`table_selections`，但至少提供一种、合计最多 50 个资源，并拒绝同一 dataset 的父子重叠。
Router 只为实际提交的 table selections 调用单数据集 metadata driver、按需物化 child resource，
再与 dataset resource ids 合并进入既有申请服务。每个 request item 仍以自身 resource id 快照负责人：
dataset item 由 dataset owner 审批并编译 dataset ACL，table item 由有效 table owner 审批并编译
精确 table IAM；reconciler 仍是唯一 Provider writer。

**Why.** 资源粒度是每个选中项的属性，不是整张表单的操作模式。全局切换迫使用户先理解实现细节，
还会在切换时丢失选择；统一选择器更符合“勾整库，展开后勾表”的直接心智模型，也允许一个业务理由下
表达真实的跨数据集最小权限组合。

**Boundary and supersession.** 表清单、字段和血缘继续懒加载，不预读所有表，不读取表数据；不新增
迁移、路由、MCP、Terraform、Provider 权限或 IAM 写路径。本决定取代 2026-08-21 决定中的全局
互斥模式、默认表级模式和 exactly-one-shape 校验；保留 dataset/table 精确资源、负责人、审批、
binding/effect、历史兼容与 Provider 写入边界。

## 2026-08-22 — Test Estate Reset 第二批退出管理读模型逐行为保护

**Decision.** 第二批把管理列表、搜索/筛选、XLSX/CSV 导出、analytics/summary、AI 辅助呈现和
mobile aggregate 视为可接受降级的 P2 测试资产。删除 53 个 `test_*.py`，覆盖 Approval、
Provisioning、Support、Auditor、API Key、CMDB、Asset、Notifications、On/Offboarding、
Procurement、Identity、Me/Team 等读模型；没有替这些逐字段/逐卡片断言建立一一等价的新测试。

**Measured result.** 2026-08-24 重基后，相对 `main@e564df053`，测试文件 1,181 → 1,128，
物理行 404,347 → 394,489（-9,858），静态测试函数 10,754 → 10,502（-252），pytest
收集场景 12,522 → 12,260（-262）。连同第一批，两批累计测试源码净减 24,931 行。

**Boundary.** 保留所有生产写路径测试，以及对象级 RBAC/隐私的领域主套件、审批 callback 和
submission intent、Provisioning dispatcher/retry、Support 工单生命周期与 create dedup、JML、
DAX effect/readback、Provider mutation/补偿、金额计算、Applied migration/TiDB 和全部前端测试。
通用 route-access matrix 与 exporting 原语仍提供入口和基础文件安全合同，但不再承诺每个已删除
read model 的查询组合、导出列、分析卡片或 AI 文案行为。

**Why.** 这些测试大量重复“同一查询换筛选参数、同一导出换领域列、同一看板换卡片”的交付形状，
对微服务边界、写入一致性和不可逆副作用的保护价值低于维护成本。第二批接受后台展示回归更多依赖
人工发现，换取另一个可审计的近 10k 行缩减；P0/P1 写入和安全合同仍不能凭目录或日期批量删除。

## 2026-08-21 — DAX 新申请同时保留数据集级与数据表级范围

**Status.** 已由 2026-08-22 的统一资源选择器决定取代；以下保留为历史背景。

**Decision.** 个人账号与 GCP Service Account 的新数据访问申请提供两个互斥范围：
「按数据集申请」提交一个或多个 dataset `resource_ids`，获得数据集内全部表的 read 权限；
「按数据表申请」提交一个或多个 `table_selections`，只获得指定 table/view 的 read 权限。
页面默认采用权限更小的表级模式。切换范围会清空当前选择，单次请求不得混合 dataset 与 table
资源。数据集模式直接在目录表格勾选；表模式继续打开懒加载 drawer 后勾选具体表。

**Authority and execution.** 两种范围复用同一 DAX 申请、审批、binding、compiled effect、reconciler
与审计链。dataset item 由 dataset owner 审批并写精确 dataset ACL；table item 由有效 table owner
审批并写精确 table IAM。历史 dataset 申请不是只读兼容特例，而是继续可由新 UI 正常创建。
服务端现有“`resource_ids` 与 `table_selections` 必须且只能提供一种”的校验保持权威。

**Why.** 表级申请符合最小权限，但确有需要访问整个数据集的工作负载。新增表级能力不应删除原有
dataset 级申请能力，也不应要求用户逐张选择大数据集中的所有表。

**Boundary.** 不新增数据库迁移、API 路由、Provider 权限、Terraform 或 BigQuery 扫描。表清单、
字段和血缘继续懒加载；不可申请的数据集在两种范围下都不可选择。本决定部分取代 2026-08-19
“所有新申请升级为表级资源”的结论，但保留其 table owner、table IAM 与历史兼容设计。

## 2026-08-21 — 微服务拆分前按风险重置测试资产，第一批净减 15,073 行

**Decision.** Test Estate Reset 第一批不再把每个历史行为都视为永久回归合同。相对
`main@a7801f65`，删除 74 个低价值 `test_*.py`：Arcade、Gamification、Security Quiz 等
非关键功能整包退出；Dashboard、Feed、Assistant、Intelligence、Me Universe 与 Me/Team 只清理
展示、历史 review、重复编排和预测/读模型型保护。同时将 44 个逐路由 401/403 断言收成一个
L0 route-access matrix，将 9 份 DAX control/persistence Provider 写入边界扫描收成一个 10 文件
合同，并参数化四组同形 schema/时间/widget/object-key 用例。

**Measured result.** `test_*.py` 从 1,247 个 / 417,797 行 / 11,339 个静态测试函数 /
13,129 个 pytest 收集场景降到 1,175 个 / 402,724 行 / 10,710 个 / 12,480 个；测试源码净减
15,073 行、静态函数减少 629、收集场景减少 649。删除测试的功能仍存在，但不再承诺逐行为
回归保护；这是明确的覆盖率取舍，不把删测试包装成等价覆盖。

**Boundary.** 保留对象级 RBAC 与自助范围、隐私/敏感数据、幂等/并发、入转离、DAX
effect/readback、Provider mutation 与未知结果补偿、金额/审计、Applied migration/TiDB，以及全部
前端行为测试。生产代码、API、数据库、CI、云资源和部署均不修改。后续批次仍按“失效成本 ×
唯一性”判断，不能只凭日期或当前 CI 未执行就删除 P0/P1 合同。

**Why.** 单体内的展示与历史 review 测试已经把服务边界变更成本放大到不可接受；保留全部会让
拆分继续被 40 万行测试资产拖住。第一批接受低风险功能的回归发现能力下降，换取可审计的
15k+ 净减量；跨服务后真正需要长期保留的保护应收敛为少量消费者合同、领域不变量与端到端
业务 readback，而不是继续复制每个页面和编排细节。

## 2026-08-21 — DAX 每日对账 reviewed catalog，源头遍历只从可申请 dbt 结果集开始

**Decision.** Production 每天 06:20（Asia/Shanghai）在默认分支执行一次幂等 DAX catalog
reconciliation，输入仍是已审查的 repo inventory 与版本绑定的 dbt lineage artifact。源头投影只把
active/requestable、非血缘派生 inventory 数据集内的 dbt model/seed/snapshot relation 作为遍历起点，
沿 artifact 依赖向上搜索并在 dbt `source` 节点终止；仅有 source relation 的数据集本身不是新的遍历
起点。由血缘自动派生但已不可达的目录行退出权威投影，人工维护的目录项和明确 owner 永不由该规则删除
或改写。缺失源头默认 owner 为 `xiaopeng.zhu@pingcap.com`。DAX owner label 只显示姓名，只有姓名缺失
时才回退邮箱，不显示 `姓名 - 邮箱`。

**Boundary.** 每日对账不扫描整个 BigQuery 项目、不增加 `bigquery.datasets.list`、不读取表数据，也不
自动执行 Terraform/IAM。BigQuery 表清单和字段结构继续按用户展开、搜索和点选懒加载。repo lineage
artifact 的更新仍必须来自受信任、可审查的 dbt manifest 发布；BigQuery 物理元数据不能替代 dbt
依赖事实。catalog seed 只更新 Helix 目录，新的 Provider controller binding 仍经过独立 Terraform
门禁。CI workflow 是 lineage-root inventory 的唯一组装点；EC2 helper 只投影预组装结果，不允许运行中
的旧 Production image 再次解释 lineage artifact，避免 source-selection 算法版本漂移。

**Why.** Production 在运行最新应用镜像时仍可能保留旧 catalog seed；将目录对账绑定到应用部署又会让
数据目录更新依赖整站发布。每日幂等对账消除这种漂移，同时避免为“发现”授予项目级 BigQuery 广扫
权限。以可申请结果集为遍历起点还避免把只服务于未纳管实验/中间模型的 source 自动暴露为可申请资源。

## 2026-08-21 — DAX 申请目录必须显式标记可申请状态，并在分页前筛选

**Decision.** 数据访问申请页为每个当前可见的数据集增加独立「申请状态」列，只采用
后端已有 `availability` 权威：`requestable` 显示「可申请」，其他可见状态显示「不可申请」。
工具栏提供「全部申请状态 / 可申请 / 不可申请」三态单选筛选，默认展示全部。筛选不是
浏览器内对当前页数组做过滤，而是通过
`GET /api/v1/data-access/requestable-resources?requestability=...` 在数据库 count、offset 与
limit 之前执行，并在条件改变时回到第一页。默认「全部」结果同样不能在浏览器当前页临时
排序：服务端必须在 offset/limit 前先列完 active + requestable 行，再列其他可见的不可申请行，
各组内按 project/dataset/id 保持稳定顺序。

**Why.** global admin 目录有 66 个身份且分页大小为 50；仅过滤前端当前页会漏掉后续页，
同时现有「部分目录记录不能选择」警告不能回答具体哪一行可申请。独立状态列让每一行可直接
判断，服务端筛选保证搜索、分页和总数使用同一口径；服务端分组排序保证申请人无需跨过不可
申请项，才能在后续页找到仍可申请的数据集。

**Boundary.** 普通员工可读取 owner-resolved 的 active 目录行，其中 active/non-requestable
只用于发现且不可选择或提交；global admin 额外可见既有 suspended 行。筛选和排序参数都不能
扩大这条可见性边界。此变更不创建申请、不改变 owner、审批、表结构懒加载、Provider 调用或
BigQuery IAM，也不改变 Staging 测试数据保留策略。

## 2026-08-21 — DAX 源头数据集由版本绑定的血缘产物自动投影，不依赖人工发现

**Decision.** DAX catalog seed 必须同时接收 repo inventory 与同一发布版本的 dbt
lineage catalog，并在写入 `data_access_resources` 前调用统一的
`sync_lineage_root_datasets` 规则。凡作为真实 dependency edge 上游的非 dbt `source`
数据集，缺失时自动加入 active/requestable 目录并默认 owner
`xiaopeng.zhu@pingcap.com`；已有 locator 的明确 owner 不改写。未被引用的 source 声明、
model、seed、snapshot 和中间表不因该机制加入。

**Environment contract.** Staging 的每次成功 runtime 发布都必须先下载并校验
content-addressed inventory，再由 exact release server image 内的 lineage catalog 幂等
投影目录。由于脱敏环境只保留验证人员身份，inventory 中未保留的明确 owner 仅在 Staging
投影时回退到 `xiaopeng.zhu@pingcap.com`；回退身份本身无法解析、集成不唯一或投影没有
commit 证据时整次部署失败。Staging bootstrap 测试行不因 catalog seed 被删除，worker 与
所有 apply gate 仍关闭。Production 不传 owner fallback，明确 owner 继续保持权威。Production
继续使用显式、main-only 的 catalog seed 发布门，但 workflow 自己上传 inventory 与
lineage artifact 并自动组装源头，不再要求操作者先发现或手工补源头。

Staging 的 DAX 测试目录默认跨部署保留，不再要求下一次发布前清空。原始
`staging-sanitization-*` receipt 继续只代表“无 Provider locator/credential”的干净基线；
重新引入经过授权的元数据验证后，必须生成独立的 schema-version-2
`staging-dax-retained-*` receipt，明确记录 `preserve_across_deploys`、无需下次清理、单一
metadata-only integration，以及 worker/apply/personal/service-account gates 全关闭。部署
脚本只接受这两类精确 receipt，不能通过改名或把 `provider_locators_removed=false` 写进旧
receipt 来绕过隔离契约。

**Why.** 仅在 repo 中提供一个可手动执行的同步脚本，无法保证每个环境实际投影了同一
份目录；这会产生“血缘图能看到 source，但申请列表没有”的漂移。发布时重新计算并以
digest/commit receipt 绑定版本，才能让新 source 的发现成为可验证机制，而不是依赖用户
点开下游表后报告。

**Non-goals.** 不做运行时 BigQuery 全量扫描，不自动加载 table/column schema，不新增
定时 worker、Terraform 权限或 Provider mutation。表目录继续采用 lineage/managed 精选
首屏与 Provider 分页懒加载；Production 的 catalog 写入与 Terraform IAM apply 仍是两个
独立授权门。

## 2026-08-20 — DAX 表目录采用精选投影与 Provider 按需发现，不复制完整 BigQuery 目录

**Decision.** BigQuery dataset 继续是 Provider 物理边界，Helix 只做面向申请与治理的精选投影。数据集进入 DAX 后，表分为三类：已发布 dbt 血缘引用的 `lineage` 表、曾申请或配置独立负责人的 `managed` 表，以及仅存在于 BigQuery 的 `discovered` 表。前两类进入 Helix 首屏目录；第三类不批量写入 `data_access_resources`，默认折叠并通过 Provider cursor 每次最多发现 20 张。用户选择申请或修改单表 owner 时，才沿既有按需物化路径创建稳定 table resource。

**Read contract.** 打开数据集只读 lineage catalog 与已有 table resources，不调用 BigQuery 全表枚举，也不自动选中第一张表。其他表只有在用户展开时调用一页 `tables.list`；显式搜索可以扫描 Provider 的粗略 table id/type 元数据以找到后续页，但仍不读数据行、不运行 query job、不写 Helix 目录。字段、描述和精确存在性继续只在用户点击单表后通过 `tables.get` 读取；血缘详情只在打开血缘 tab 后读取。既有 `scope=all` offset 接口保持兼容，新 UI 使用 additive `scope=tracked|other` 与 opaque cursor。

**Why.** `gcp-digital-bi.nexuscrm_final` 当前有 174 张 Provider 表，但已发布 dbt 血缘只引用 25 张；全量拉取并渲染会把真正有用的关系淹没，也会在用户尚未表达意图时产生无价值的元数据与 DOM 工作。完全隐藏其他表又会阻断尚未进入 dbt、但确有申请需求的对象。精选首屏加按需发现同时保留可发现性、申请能力和单一 Provider 事实源。

**Non-goals.** 不同步完整 BigQuery table/column 目录，不新增 `bigquery.jobs.create` 或表数据读取权限，不因发现一张表就自动持久化，不用“未进入 Helix”作为额外授权策略。若未来要限制哪些 Provider 表允许申请，应增加显式治理状态，而不是通过搜索不可见性暗中实现。

## 2026-08-20 — 被引用的 dbt source 数据集是可申请资源，不再只是血缘附属元数据

**Decision.** 已发布 dbt 血缘中作为至少一条依赖边上游的每个规范 `source` 数据集，都进入 DAX active/requestable 目录；缺失 locator 自动以朱晓鹏为默认 owner，已有 locator 保留原 owner。申请可按整个 dataset 或具体 table/view；Provider 元数据健康时源表可选择申请，Provider 不可用时静态目录只负责让源数据集及源表可见，仍以 `selectable=false`、`schema_available=false` 禁止表级申请和字段结构。未被依赖的 source 声明、parentless model、seed、snapshot 和中间数据集不因本决策进入目录。

**Context.** 先前实现把 23 个缺失源数据集设为 `requestable=false`，员工只能进入某张下游表的血缘图后发现它们。用户明确要求被引用源数据集直接出现在申请列表。当前 artifact 有 32 个被引用 source 数据集；它们与原 23 个业务可申请数据集有 7 个重叠，且 Provider 明确缺失的 `usage_us_west2` 保持暂停，因此 SSOT 为 47 个 active/requestable locator，其中项目范围恰为 `gcp-digital-bi` 与 `gcp-digital-bi-dev`。

**Safety boundary.** 目录 seed、Terraform dataset binding 和运行时申请继续分层；合并代码不会自动写 Production。Terraform 只接受显式项目 allowlist，不能因 inventory 新增任意 project 自动扩权。owner/审批、table-level IAM、apply gate、Provider existence/schema revalidation 和 no-table-data/no-query-job 边界均不变。

## 2026-08-20 — IT 服务以服务请求为主线，报表与操作台分离

**Decision.** Agent 会话、Helix 工单和飞书服务台在并行期进入同一套运营口径，但保留各自事实；新增 `support_service_requests` 表达员工的一次需要，工单只是可能产生的人工工作项。Agent 消息逐条保留服务请求归属；请求进入自助解决、升级或放弃等终态后，同一聊天里的下一问开启新请求，旧回答后续评价仍回写旧请求。无评价、无升级且七日无动作的 Agent 请求进入“放弃”，不永久悬挂。工单同时记录人工诉求发起人、实际建单方、建单方式和入口。运管驾驶舱的“工单分析”是只读管理报表，IT 支持台是操作队列，两个入口不再渲染同一页面。跨源只接受人工确认的精确关联，不做概率自动合并；飞书退出后保留历史镜像和口径。

**Context.** 原页面把分析和处理混在一起，只有少数数字，缺少响应率、满意度覆盖、SLA、催办触达和 Agent 是否真正自助解决；同时无法分辨“员工要人工”与“Agent 实际建单”。这会让未评价、放弃和自助解决混成一个数，也无法回答当前责任人是谁。

**Consequences.** 员工明确点赞才计 Agent 自助解决，未选择/过期单列；点踩可补原因并成为知识缺口，关闭必须有处理说明。首响以人工有效回复或直接解决为准，认领不算首响；等待员工期间暂停解决 SLA。催办按当前责任方自动发送并以 Outbox 状态证明是否触达。

**Alternatives.** 继续在支持台堆报表被否决，因为处理人与管理者目标不同；把每次 Agent 对话都算工单被否决，因为会虚增人工量；按标题/人员猜测飞书重复被否决，因为可能吞掉真实工单。

## 2026-08-20 — Approval Center is a cross-domain read model, not a workflow engine

**Decision.** Every real human approval must register once in the approval source registry and thereby appear in pending/history/detail/export/analytics and MCP surfaces. Domain-owned rows remain authoritative; the approval domain does not duplicate execution state or introduce a second workflow engine. A source may be excluded only when it is not an approval (support work, automatic provisioning, discovered/imported grants) or when the product has no actual decision path (current software-permission self service).

**Privacy and grouping.** Detail adapters enforce source-specific redaction and authorization. Generic SSO detail never returns SAML XML or certificate bodies. A business submission that fans out into multiple immutable rows (cloud renewal roles) is one approval-centre item keyed by its request group.

## 2026-08-19 — DAX 新申请升级为表级资源与表级负责人

**Decision.** 新的个人账号与 GCP Service Account 数据访问申请以 BigQuery table/view
为最小资源。员工点击数据集后选择具体表；Helix 按需物化 child
`data_access_resources(resource_type='table')`，只持久化稳定 locator 与负责人，不复制
BigQuery schema。新表默认 `owner_source='dataset_inherited'`，数据集负责人变化会跟随；
已存在但 `owner_person_id` 为空的表同样按数据集负责人解释，并在选表物化时持久修复为
`dataset_inherited`；非空 `manual` 负责人绝不被该兜底覆盖。
数据集负责人可把单表委派给其他在职员工并标记 `manual`，被委派人负责审批但不能继续转授。
审批后 reconciler 对精确表调用 table IAM，历史 dataset 级资源和授权完整兼容。

**Why.** 数据集级 read 会把同一数据集内无关表一起开放，且只有一个 dataset owner，无法
表达不同表的真实审批责任。表 locator、负责人快照、审批 item、compiled effect 和 provider
IAM 必须使用同一粒度，不能在 UI 显示表级、运行时却继续写 dataset ACL。

**Consequences.** 迁移 523 增加 parent/table locator 与 effect/run 的 `target_table_id`；
compiler/payload 升为 v2/v4。数据集仍是可申请边界与 Terraform allowlist，schema 继续按需读
BigQuery。负责人修改同时提供 HTTP 与 MCP 写入口并审计。专用 GCP 自定义角色增加且仅增加
`bigquery.tables.getIamPolicy` / `bigquery.tables.setIamPolicy`，不增加 table data、query job
或 project IAM。

**Superseded in part (2026-08-21).** “所有新的个人/Service Account 申请必须是表级”已由同日
双范围 ADR 取代；表级 owner、审批、编译与精确 table IAM 契约继续有效。

**Supersedes.** 本决定覆盖 2026-06-09 ADR 中“V1 不做 table-level access”的旧范围决定，
也覆盖 2026-08-19 schema-browser ADR 中“浏览不改变授权粒度”的旧结论；两者仍作为历史背景。

**Alternatives.** (a) 页面选表但继续授 dataset 权限——被否：权限展示与实际范围不一致。
(b) 同步完整 table/column 目录——未采用：形成第二份易漂移 schema 事实。(c) 允许 table owner
继续转授——被否：会绕过 dataset owner 的委派边界。(d) 删除/迁移历史 dataset grants——
被否：会破坏既有审批、撤销和审计链。

---

## 2026-08-19 — 动态广场匿名采用“对员工匿名、对平台受控可追溯”

**Decision.** 动态广场新增全公司范围的匿名树洞。匿名只改变对普通读者的身份
呈现：帖子与评论的真实作者仍保存于既有 author FK，用于作者自助编辑/删除、离职
写入闸、反滥用与审计；普通 feed DTO 不返回任何作者 PII。匿名内容不允许部门范围、
图片/附件或 @ 提及；匿名帖下的所有评论强制匿名。员工可举报内容，HR 与 Global
Admin 优先处理/隐藏内容；真实身份只能在浏览器会话内、填写理由并写审计记录后查阅。

**Why.** 公司内的匿名表达需要降低发言顾虑，但“绝对不可追溯”会让骚扰、歧视、泄密
和定向攻击没有可执行的治理路径。禁用附件、部门范围及 @ 避免最常见的旁路身份泄露。

**Alternatives.** (a) 完全不可追溯匿名——被否：无法处理严重违规。(b) 仅前端隐藏
姓名——被否：API、浏览器工具和通知仍会泄露 PII。(c) 匿名内容允许图片与 @——暂缓：
须先具备服务端元数据清理和匿名通知模型。

---

## 2026-08-18（第二轮）— 入职 SLA 改按历史达成计分（health-v3）；软件/API 三处取数按已上线策略修正

**Decision.** `lifecycle.onboarding_sla` 不再读**当前态** `sla_breach_level`，改为
按已上线的 72h SLA 判**历史达成**：`hard_ready_at <= hire_date + 72h`，分母只取
**SLA 窗口已关闭**的入职单（已有 `hard_ready_at`，或今天已过 `hire_date + 3 天`）。
这是定义变更，评分版本升到 **`health-v3`**，历史 `health-v1/v2` 快照原样保留、不回填。
**与同日资产维度那批（#3055）共用 `health-v3`**：该批合入时生产尚无任何 `health-v3` 快照（最新仍是 10:37 的 v2），所以 v3 从第一行起就同时包含资产三项与本项，版本含义一致，不存在「已写的 v3 行被事后改写口径」。若日后 v3 已有快照再动定义，必须另起 v4。

同批三处 **software_api 取数缺陷修正**（指标 key / 标签 / kind / 权重 / 阈值均未动，
属缺陷修复，随本次版本一起生效）：

* `key_expiry_coverage` 的分母收敛为「Helix 自己签发且在用的凭据」。剔除两类：
  `status='approved'`（已批未签发，`key_value`/`valid_from`/`valid_until` 都要等管理员
  签发时才写，此刻根本不存在可过期的凭据）与 `detail.source in (adopted,
  adopted_manual)` 的**控制台存量纳管 Key**（`bailian_adopt` 明确不写 `key_value`，
  Helix 从未持有明文，结构上无法给它盖有效期）。分子从「到期日在 30 天以外」改为
  「有有效期」—— 申请上限一年，旧写法让每把**治理良好**的 Key 在最后一个月必然掉出覆盖。
* `reconciliation_coverage` 的分子加上**已备案保留**的外部持有者，与已上线的
  `asset.router._netted_categories`（`external` / `leaver_residual` × `external_account_registry`
  的 KNOWN 分类）逐字对齐；证据列表同步剔除同一批行。
* 新增 `over_recorded_quota_plan_count` 事实：`used > max_seats` 的档位是**配额记录过期**，
  不是容量墙。

**Why.** 判据仍是「目标档在当前策略下是否算术可达」：

* `onboarding_sla` 读的 `sla_breach_level` 被 `evaluate_sla` 在**任何终态**清成 `none`，
  blocker 清零也立刻回 `none`。生产 25 单已完成入职里只有 16 单真的踩进 72h，指标却报
  67.57% —— 它测的是「此刻没变红的比例」，和标签承诺的达成率无关。改后 45.71%，数字更低
  但**是真的**，且和 `day1_ready`（当日就绪）形成两个不同的档次而不是同一件事说两遍。
* Key 有效期：生产 61 条「在用」里 44 条结构上不可能带 Helix 有效期（26 条纳管 + 15 条
  已批未签发），95% 档**算术不可达**。收敛后 24.59% → 80.00%，真缺口 4 条。
* 席位对账：生产 326 条未匹配厂商席位里 105 条是管理员已在 `external_account_registry`
  备案的保留账号 —— 健康度把它们算成缺口，等于和拥有这批行的对账面板自相矛盾。
  89.38% → 92.80%。

**Alternatives considered.** (a) 只改 provider 取数、不升版本 —— 对三处 software_api
修正成立，但 `onboarding_sla` 是定义变更，`catalog.py` 顶部的约定要求升版本；两者
同批发布省掉一次多余的版本台阶（`health-v2` 当天才上线，只有一个快照受影响）。
(b) 把 `capacity_headroom` 也一起放宽 —— 拒绝：3 个档位「无余量」是 `max_seats` 配置
过期的**真账**（Zoom Large Meeting 记 1 实占 148、Webinars Plus 记 2 实占 190、
Confluence agent 记 10 实占 101），且 `over_quota_policy='reject'` 会真的挡住下一次开通。
正确的修法是订正配额数字（属业务事实，需人确认），不是改口径。
(c) 给 `day1_ready` 放宽档位 —— 拒绝：16.22% 是真实运维债（12 个在职员工的入职单
卡在 `hardware_allocation`，Helix 台账里一台电脑都没有），95% 档算术上可达，属
「可达但难」，按既定判据不动口径。
## 2026-08-18 — 资产评分口径修正走 health-v3，退役没有生产者的保修指标

**Decision.** 资产维度三处口径修正，评分版本升到 **`health-v3`**（同日 v2 之后的第二步，
历史快照一律原样保留，不回填、不重算）：

1. `assets.inventory_accuracy` 的分母从「本轮盘点全部条目」改为「**已回复**的条目」
   （confirmed + issue_reported），未回复条目改为压**可信度**（`responded / total`）。
2. `assets.idle_rate` 的「闲置多久」不再读 `asset_items.updated_at`，改读**最后一次真实
   流转**（`asset_operations` 的 max，无历史则回退到入库时间）；计分口径从「全部资产里
   状态闲置的比例」改为「**近期需求保护后的电脑盈余 / 电脑资产总数**」，与已上线的资产
   优化面（`idle_surplus_count`，`asset-optimization-v1`）同一口径。标签同步改为
   「长期闲置电脑盈余率」。
3. **退役** `assets.warranty_coverage`。其 .15 权重与其余四项拉平为各 `.20`。

生产复算：资产域 **59.00 → 70.00**，域可信度 100% → 98.68%（盘点回复率 93.38% 现在
如实体现），平台总分 +1.1（资产域权重 0.10）。

**Why.** 三条都是可证伪的口径缺陷，不是运维现状：

* **未回复 ≠ 记录错误。** 「准确率」的旧分母把「还没人回」和「查出来不对」同等对待，
  于是一轮盘点**开盘即最差档**；更致命的是 `AssetInventoryCycle` 关闭时把剩余条目
  **冻结在 `pending`**（模型注释明写），所以任何带未回复条目关闭的盘点会把这个指标
  **永久钉死** —— 运维再怎么改善也动不了。生产最新一轮：352/393 = 89.57%（60 分）
  实为 352/367 = 95.91%（100 分）+ 回复率 93.38%。
* **`updated_at` 不是闲置时钟，而且方向是反的。** 生产实证：2026-07-14 / 07-15 / 07-21
  分别有 134 / 108 / 87 行资产被更新，其中 134 / 104 / 85 行的 `last_inventory_at` 同日变动
  —— 即**员工回复盘点**这一个动作，在没有任何设备移动的情况下把 70 台闲置电脑的闲置时钟
  清零（当天全平台真实资产操作只有 3 条）。批量编辑同理。换成真实流转事件后，长期闲置
  电脑从 70 台变成 145 台。
* **闲置口径与已上线的处置口径必须是同一个。** provider 自己的优化块早已按
  `COMPUTER_CATEGORIES` + 扣减未覆盖的近期入职需求算出可回收盈余，前端 `AssetOptimizationPanel`
  与 H5 都在渲染它；而指标读的是「全部资产里状态闲置的比例」，把 297 台库房显示器、
  49 台办公网络设备、会议室显示屏、打印机都算成待处置的沉淀资本 —— 证据列表给出的 50 条
  也因此全是显示器，而不是那 132 台真的能回收再发的电脑。
* **`warranty_until` 没有生产者。** 全表 1,099 行（含 2026-04 的易盘点历史迁移）**0 行**
  写过；前端任何录入/编辑表单都**没有这个控件**（只在详情页、工作台卡片、H5 只读渲染），
  唯一写入路径是批量导入的可选列，五年没被用过。95% 档在现有产品面下不可达，而
  `MetricKind.COVERAGE` 的通用文案还在让管理员「补齐未覆盖对象后回来复验」一个填不了的字段。
  保留为事实（`warranty_population_count` / `warranty_complete_count`），不再进分。

**Alternatives considered.**

* *就地改 health-v2*：拒绝。退役指标要动权重，catalog 顶部约定改定义必须升版本。
* *把 `warranty_coverage` 改读「purchase_date + 折旧策略有效期」*：拒绝。`purchase_date`
  生产 1053/1053 齐全、折旧策略有平台默认值，这样一改指标恒等 100% —— 是给分数注水，
  不是修口径。要重新计分，先有真实生产者（录入控件、或厂商保修查询连接器），再以新版本引入。
* *把 `assets.financial_coverage` 一并放宽*：拒绝。它 27.64% 是**真账**：`purchase_cost`
  324/1053、`currency` 333/1053，且逐年好转（2021 年入库的 405 台里 46 台有成本，
  2026 年 55 台里 50 台有），说明生产者是活的、录入在发生。`asset_items.procurement_order_id`
  生产 0 行，Helix 内没有第二个成本来源可读 —— 只能靠补录，不能靠改口径。按「目标档是否
  算术可达」的判据：可达但难 ⇒ 是真账，留着。
* *把闲置率的档位从 1/3/10% 放宽*：拒绝。档位是全平台共享的 `MetricKind` 规则表，
  为一个指标改档位会静默改掉其它维度所有 failure_rate 指标。

**修完之后仍然扣分的是真运维债（不要再当口径问题查）.** 资产域剩下的 30 分缺口全在两处：
① `idle_rate` 20 分 —— 145 台电脑最后一次真实流转在 90 天前，扣掉近期入职需求后
**132 台是净盈余**，需要真人做再分配/调拨/处置；② `financial_coverage` 30 分 ——
762 台缺成本或币种，需要补录历史采购数据。两者都不是代码能修的。

---

## 2026-08-18 — 普通代码合并只跑三项 go-ci；CodeQL 周扫，Terraform 按 IaC 路径触发

**Decision.** 保持现有三项 post-merge `go-ci` 在每个 `main` SHA 上运行；移除 CodeQL 的逐 `push: main` 触发，仅保留每周三 `03:17 UTC` 与手动运行；Terraform Validate 仅在 `infra/**` 或 `.github/workflows/terraform-plan.yml` 变化后自动运行，并保留手动入口。

**Why.** 普通业务代码合并不会改变 Terraform 输入，也不需要每个 SHA 都重复三语言 CodeQL 扫描。旧配置让一次普通合并固定展开 11 个 jobs，检查噪音与 runner 消耗超过其新增证据。三项 `go-ci` 仍为每个 main SHA 提供后端发布守卫、前端验证和精确 SHA 汇总；IaC 修改仍完整运行五项 Terraform，安全扫描仍有周期性覆盖和按需入口。

**Boundaries.** 本决定只缩小触发频率，不删除 CodeQL/Terraform 内部检查，不把镜像发布、Staging 或 Production 合入 CI，也不改变当前无 pre-merge required-status-check 的规则集。若未来需要按变更即时安全扫描，应由明确的风险路径或发布闸触发，而不是恢复所有 main push 的无条件扇出。

**Alternatives considered.** 把所有检查塞进一个 job 会牺牲并行诊断；完全删除 CodeQL/Terraform 会失去安全与 IaC 反馈；两者均拒绝。路径/周期触发保留证据，同时让普通合并只看到核心三项。
## 2026-08-18 — 「未观测」在 MFA 数据上是第三种状态；安全维度取数口径修正不 bump SCORE_VERSION

**Decision.** `auditor_mfa_status` 新增 `enrollment_observed` / `enforcement_observed`
两个**可空布尔**（迁移 517，**不回填**），只由 `MfaSyncService` 写。所有消费
`is_enrolled` / `is_enforced` 的代码必须先过这两个闸；NULL 读作「未观测」。
`security_compliance.mfa_enforcement_coverage` 改为**按人**统计、population 对齐
dashboard/worklist 早就在用的「在职 + 未软删」，分母只含 enforcement 可观测的观测；
不可观测的份额进**可信度**，不进分子分母。
`public_exposure_rate` / `credential_risk_rate` 的可信度改为取自
`scheduled_jobs.last_result_summary` 的真实覆盖面（既被扫到又报错的源算「瞎的」）。
**不 bump `SCORE_VERSION`**（沿用 main 当前的 `health-v3`）。

**Why.** `is_enrolled` 是 NOT NULL 布尔，所以「provider 答不出来」和「实测未启用」
在库里长得一样。生产 528 条 Atlassian 行全带 `policy_status='policy_absent'`
——Atlassian 公开 API 根本没有逐用户 2FA 属性，只能读组织认证策略，而该策略读不到；
适配器 docstring 自己写着「We never fabricate per-user enrollment we cannot observe」，
但落库只能写 `False`。六个面把这个未知读成了实测不合规，其中最贵的是 ISMS 评估
**证据表**里的「MFA 覆盖率 0.0%（0/533）」。同时真正的权威源 Google Workspace
（2SV 组织级 100% enforced、97.64% 已注册）因为一个 config key 名字不对而**一条行都没进来**。

判据用的是既有那条（[health 指标口径三陷阱]）：**算一下目标档在当前策略下是否可达**。
95% 覆盖率在「MFA 权威源 0 行 + 528 条不可观测记为缺口」下**算术上不可达** ⇒ 一定是取数错了。
反过来，同维度剩下的 30 分（特权复核逾期、502 条逾期密钥轮转）**可达但难** ⇒ 是真债，
口径不动。

**Why 不 bump SCORE_VERSION.** 指标 key / 标签 / kind / 权重 / 阈值全未动，改的是
provider 观测口径，属缺陷修复 —— 与 2026-08-18 云与网络维度（PR #3043）同一先例。
为一个维度全局 bump 会把八个维度的历史全判成异版本（`models.py` 唯一键含 score_version，
`read_service` 有 `score_version_mismatch`）。历史快照不重写，趋势上留一次台阶。

**Why 不回填.** 每个消费者都把 NULL 读作「未观测」，方向是安全的：该行退出分母，
而不是变成一句假指控。次日 08:00 的 `auditor.mfa_sync_daily` 会把每行按上游真值重述，
所以在迁移里从 `raw_payload` 派生只是把 sync 要做的事提前做一遍，还会把某一天的
provider 组合写进 schema 历史。过渡期（<24h）MFA 指标 confidence=0 ⇒ 被 `score_dimension`
排除而不是打成 0 分 —— 「我们还不知道」和「你考了 30 分」是两回事。

**Alternatives considered.**
* *用一个字符串列存 `policy_status`*：只对 Atlassian 有意义，feishu/github 的不可观测
  原因完全不同（缺 scope / 端点不暴露），迟早要在四个消费者里各自解析同一个字符串。
* *沿用 `raw_payload` 的 JSON extract*：dashboard 与风险评分是 SQL 层聚合，JSON 路径
  在四处重复写，且拼错不会报错、只会静默给出旧行为。
* *把 Atlassian 整体移出 MFA 计分*（云维度 prod 订阅的做法）：会连「Atlassian 策略哪天
  可读了」这条真信号一起扔掉；observability 闸门在策略可读的那一刻自动把它算进来。

**Follow-up（未做，需单独拍板）.** `AwsAuditorAdapter` 现在只扫**已配置的那一个** region
（此前是写死 `us-east-1`）。改成 opted-in 全区域发现（对齐
`CmdbAwsProvider._ec2_regions_sync`）改变的是「扫到什么」而不只是「扫哪里」——
约 17 区域 × N 账号，会一次性冒出一批新 finding，需要单独一轮。

---

## 2026-08-18 — 生命周期评分口径修正走 health-v2，退役取不到数的转岗指标

**Decision.** `lifecycle.leaver_failure_rate` 的判据从「任何 blocker `error_count > 0`
或 process 状态 `blocked`」改为「仍有**未解决**的 blocker，且该 blocker 确实执行失败过
（`error_count ≥ 1` 或关联 provisioning task `failed`）或已卡过 T+0+24h 强制收口窗口」。
同时**退役** `lifecycle.transfer_recovery`，其 15% 权重重分为 `day1_ready .30`、
`leaver_failure_rate .30`、`owner_continuity .20`，`onboarding_sla` 留在 `.20`。
两项都不就地改 v1：评分版本升到 **`health-v2`**，`intelligence_snapshots` 的历史
`health-v1` 行原样保留，不回填、不重算。

**Why.** 两个都是可证伪的口径缺陷，不是运维现状：

* `error_count` 是列注释自己定义的「total failures **since blocker creation**」，
  解决时不清零。平台判断「blocker 还是不是问题」的唯一真值 `compute_triage_bucket()`
  第一条规则就是 `resolution != pending → resolved`；旧判据读了 `error_count` 却没带
  这个前置，于是「失败 58 次然后 `revoked_auto`」被永久记为失败。
* `blocked` 在离职域是 `_recompute_status` 对**任何** pending 行都会设的常态在途状态
  （models.py 注释明写），不是失败态；旧判据把每个正在跑的离职单算成失败。
* 生产实测：78 单窗口旧判据报 12 单失败，真实未解决失败 0 单；且因为约 14% 的离职单
  历史上有过瞬时重试，该指标随窗口滚动**单向恶化**，永久钉死在最差档，运维改善无法反映
  —— 界面「修正后确认失败率回落」的指引在旧定义下不可能实现。
* `transfer_recovery` 的唯一信号源 `provisioning_tasks.source='role_change'` 生产全表
  历史 0 行（只有手动「应用角色变更」会写，无人使用；自动转岗链路不写 provisioning task）。
  永久 `confidence=0` 意味着它既被排除在打分外、把分母压到 0.85 放大其余指标，又扣掉
  15 个可信度点。留一个永远取不到数的指标，比没有这个指标更误导。

**Alternatives considered.**

* *就地改 health-v1*：拒绝。catalog 顶部约定「changing a definition requires a new score
  version and never rewrites historical snapshots」，就地改会让历史快照与当前口径不可比。
* *把 `transfer_recovery` 换成真实转岗信号*（云授权 review 闭环 / `transfer_forecast_facts`）：
  推迟。Helix 目前**没有**转岗回收的执行面信号，换一个近似源等于换一种误导。等
  `apply_role_change` 或等价执行面有真实用量后，以 v3 重新引入。
* *对齐完整的 `needs_it` triage 判据*：拒绝。它的 `severity == 'blocker'` 兜底分支会把
  「没映射自动处理器、但还没失败」的新资源类型算成失败——那是「要人看」，不是「失败了」。
  只取它两个 evidence-of-failure 分支。
* *按 `resolution == 'skipped'` 计失败*：推迟。生产 53 条 skipped 里 45 条是机器
  `[stale_after_rescan]`（资源本来就不在了），只有 3 条是真给弃（连接器要交接人但无主管记录），
  区分二者目前只能解析自由文本 `notes`，不能拿它当指标地基。未来若要衡量「实际没回收」的
  残留风险，应新增结构化信号后单独立一个 `leaver_residual_rate`。

**未一并修改（需单独拍板）.** `onboarding_sla` 读的是当前态 `sla_breach_level`，语义是
「当前没变红的比例」而非标签宣称的历史达成率；覆盖率档位 95/90/80 以下无梯度，
使 `day1_ready` 在跨过 80% 前无法体现改善。两项都不影响本次两个缺陷的正确性，单独决策。

---

## 2026-08-18 — Retire Helix Qwen PR review and automatic review merge workflows

**Decision.** Remove the repository PR review workflow
`.github/workflows/deepseek-review.yml` and its dependent workflow-run merge
controller `.github/workflows/deepseek-exact-main-handoff.yml`, plus every
dedicated review adapter, publisher/controller, prompt/schema and test. Helix
no longer runs a provider-backed code review, publishes `Qwen Review /
Required`, or automatically squash-merges a PR from a review result.

**Boundary.** This is a repository review-subsystem retirement. It does not
delete, rotate, expose or otherwise alter the isolated runner's provider
credential. The active `main` ruleset has no required status-check rule, so
the removal does not leave PRs blocked by an absent check.

**Supersedes.** The active-runtime and enablement portions of the 2026-08-17
OpenCode/Qwen decision below, plus all earlier DeepSeek/Codex/Claude review
activation decisions. Historical notes remain evidence, not an executable
path. Reintroducing automated review requires a new owner decision and design;
it is not a feature-flag change.

---

## 2026-08-18（第二轮）— 不提醒名单是「默认值」而不是「硬闸」；寄语随机按批次不按人

**Decision.** HR 看完第一轮的真机效果后又给了三条,其中两条有结构含义:

1. **「手动排除」= 默认不勾选,不是从名单里拿掉。** HR 的原话就是「在这个列表里的
   默认复选框不选人」。所以命中的人**照常出现在圈人列表里**、带「不提醒」标记、
   初始不勾选 —— 和既有的 `reminded_recently` 完全同一形状。理由:名单是从 HRIS 数据
   长出来的,它判不出「长期病假 / 正在交接 / HR 已线下处理」,但把人**藏起来**会让 HR
   失去「这个人为什么没收到」的答案。平台的职责是记住那条长期决定,不是替按鼠标的人
   做决定 —— 所以 HR 想在某一次发送里带上他,勾上就行。
   存成表(`hr_leave_reminder_exclusions`,迁移 516)而不是 platform_settings JSON:
   要能记住**谁**在**什么时候**、**为什么**排除的 —— 没有理由的行会活得比它的理由久。

2. **寄语随机是「每批次一句」,不是「每人一句」。** 逐人随机更「有趣」,但它会直接
   打死这个功能的核心语义 **预览即发送** —— HR 在预览里看到的那句话就只对第一个收件人
   成立。随机还必须**可复现**(种子 = 批次 id),否则站内通知回看、resend-failed 的克隆
   行每次读都换一句。选中的那句和 `_hris_url` 一样冻结进批次快照。

**另一条不是决定而是教训**:第一轮的「放大数字」在真机上根本没生效(`_render_stat_grid`
会把值再包一层 `**`,嵌套加粗让飞书把加粗和放大一起丢掉)。我当时把「和已上线的 `[r]`
同形」当成了证据 —— 而那个形状本身就是坏的,只是 `[r]` 坏了没人报。**同形不是实测。**

**Alternatives considered.**
- *排除 = 直接从名单里过滤掉*:否。HR 会失去「他为什么没收到」的答案,而这正是他下次
  要回答的问题。
- *把寄语做成 `card_footer` 的单个可编辑字段*:不够。HR 要的是一组话 + 随机,单字段
  只能表达「固定一句」。

---

## 2026-08-18 — 福利年假也会清零；年假提醒改成「一个大数字 + 一个 SF 按钮」

**Decision.** HR 复盘首轮群发后给了一份逐条修改单，其中一条改的是**口径**而不是文案：

1. **福利年假（`CHN_PL`）和法定年假一样会清零。** HR 原话：「年假和福利年假都是
   一年半有效期，9.30 就会过期」。此前 `_DEFAULT_FORFEIT_CODES` 只含
   `CHN_AnnualLeave` + `MY_AnnuL`，福利年假被判成「不清零」，于是**员工自己的假期
   卡片不显示倒计时、休假健康洞察的「临期未休」漏算、提醒里对它写的是「最晚可用于」
   而不是「清零」** —— 三个面一致地少报了一份真实会损失的额度。三个面读的是同一个
   函数，所以改一处就同时纠正。租户 override 仍然优先（把这个码改判成 comp/exclude
   就不再清零）。
2. **提醒卡片的额度区只留余额，数字放大。** 参照 SF 的展示：一格一个额度，标题用
   **SF 里的叫法**（`法定年假 2025`，不是 `法定年假 FY2025`），主数值是放大的剩余
   天数，不再写清零日 / 已用 / 累计授予。清零日在标题和正文里各说过一遍，挤进两列
   网格只会把「你还剩几天」这一个数淹掉。
3. **余额为 0 的桶照常显示，但两个桶都为 0 的人不发。** 所以 `min_balance_days`
   从**账户级**门槛改成**人级**（合计）门槛：「法定 15 + 福利 0」的人两格都出现
   （0 如实写 0），「0 + 0」的人一封都不发。0 天的桶只是展示项 —— 它不参与「最早
   清零日」的计算，否则标题里的日期会指向一个什么都不会损失的日子。
4. **卡片只留一个按钮，落在 HR 指定的请假入口。** 此前是「提交请假申请（HRIS）」+
   「查看我的假期（Helix）」两个。余额以 HRIS 为准、申请也在那边提，给两个入口等于
   把「该去哪」丢回给员工。邮件正文里那行 `请到 Helix 查看: https://…`（`{cta_line}`）
   一并去掉。入口仍然是 HR 可编辑的配置项，只是出厂值不再是空的：HR 先给了 SF 的
   Time Off 链接、随后改成**飞书 applink**（主渠道就是飞书卡片，applink 直接在客户端
   内打开）。⚠️ applink 的 `client/message/link/open?token=…` 打开的是**一条飞书消息**，
   能不能打开取决于收件人对那条消息所在会话有没有可见性 —— 这是 HR 的选择，Helix 侧
   无法校验。**另一条教训**：HR 最初给的 SF 链接带 `?_s.crb=…`，那是**发链接的人自己**
   那次登录的会话令牌，原样发给 500 个人只会各自失效 —— 从 IM 里复制来的链接必须先
   看清 query 里带的是不是某个人的会话态。
5. **卡片副标题（「余额来自 HRIS(SuccessFactors)同步」）关掉。** 内置模板里直接
   没有这个 key —— 留空字符串不够：文案覆盖是**字段级合并**，空值会被当成「没覆盖」
   而回落到内置默认。HR 想要副标题时在文案编辑器里填回来即可。
6. **正文不再出现任何余额数字。** HR 定的：数字只在卡片格子里，邮件正文是纯说明。
   占位符（`account_lines` / `total_balance_days` / …）仍然在白名单里，HR 想写回
   正文随时可以。

**Consequences / 已知取舍.**
- 出厂正文是 HR 逐字给的，**周期写死**（2025-04-01 发放的 FY26 额度、2026-09-30
  到期、10-01 清零、FY27 顺延）。标题的日期走 `{deadline_date}` 会跟着名单走，正文
  的中文日期不会 —— 下一个周期 HR 要在页面上改这段。这是刻意接受的：HR 的原文是
  一段针对具体周期的说明，没有任何占位符能生成「9 月 30 日下班前」这种话术。
- 同一份正文描述的是**中国主体**的发放规则（4/1 发放、18 个月）。名单里如果混进
  `MY_AnnuL`（马来西亚）收件人，这段话对他们不成立 —— 口径是 HR 每次发送时自己勾的，
  页面上看得见，但这是一条真实的踩坑路径。
- 福利年假纳入清零口径后，「临期未休」总数会**变大**。那不是回归，是此前少报。

**Alternatives considered.**
- *卡片固定画两格（法定 + 福利），数据没有就画空格*：否。卡片画的是这个人**真实
  存在**的桶；给没有福利年假的人画一个空格是凭空造一个额度。
- *给正文加一个 `{fiscal_label}`（`FY{fiscal_year + 1}`）占位符*：否。正文其余日期
  全是写死的中文话术，只把「FY26」动态化不会让这段文案多活一个周期。

---

## 2026-08-17 — 年假到期提醒交给 HR 自助；「不新增员工推送」的边界改成「不新增**自动**推送」

**Decision.** HR 提出「2025 年发放的年假 9/30 清零，想提前提醒员工休假，并在通知里
呈现该员工当时的假期额度」。定下两条结构性决定：

1. **HR 自助操作一个通知群发面。** 这是平台第一次把「群发给员工」交给非
   `global_admin`：`require_hr_admin()`（global_admin + hr）能圈人、改三语文案、
   发送。口子刻意只有这一个模板、只有命中假期口径的收件人；整个 `/notifications`
   后台（outbox、邮件模板编辑器、送达报表）仍然是 global_admin 的地盘。写侧另加
   `require_browser_session`，所以被偷的 PAT 打不动，MCP 侧也**只有读工具**。
2. **「不新增员工推送」的既定规矩，边界明确为「不新增*自动*推送」。**
   2026-06 入职升级链事故之后立的规矩是「新增通知/提醒/升级推送一律 IT-only」，
   其针对的是**平台自己判断时机、自动开火**的推送（那次是 cron 在部署当天对全部
   存量数据开火，55 张卡轰到 5 名非 IT 员工）。这个功能没有 cron、没有定时任务、
   没有「T-14 天自动发」——**唯一**的发送入口是 HR 看着名单点确认，收件人、口径、
   文案、时机全部是人的决定。新增的 `hr.leave_reminder_reap` cron 只给僵死批次
   收尸，永不发送任何通知。要做「到期前自动提醒」必须另立 ADR。

**口径全部交给 HR，代码不替他判断。** 假期类型码表从 `hr_leave_accounts` 真实数据
长出来（带分类 / 是否清零 / 覆盖人数），财年、剩余天数阈值、到期窗口都可选。出厂
默认只勾会清零的法定年假（`CHN_AnnualLeave` + `MY_AnnuL`）；福利年假按
`forfeits_at_period_end` 不清零，**要不要一起提醒是 HR 的判断**，默认不勾但可勾。

**用户明确拍板的三点**（2026-08-17 会话）：
- 站内通知保持纯文本快照 + 深链，富呈现由飞书互动卡片和邮件承担（不为站内通知
  新建一套富卡片渲染原语）。
- HRIS 请假入口做成 HR 可编辑的配置项（也可以直接写进文案），不硬编码。
- 不做自动发送，纯手动。

**Alternatives considered.**
- *IT 代发*（HR 提需求 → global_admin 操作）：否。HR 每年要按不同口径催几轮，
  每次都排队等 IT 是把一个 HR 的日常动作变成跨团队工单。
- *给站内通知加富卡片*：推迟。飞书是主渠道，站内文本 + 深链已经能把数字说清楚；
  为一个功能新建通知渲染原语不划算（用户同意）。
- *到期前自动分级提醒*（T-90/T-45/T-14）：明确不做。见上面第 2 点。

**Consequences.** 余额是「点确认那一刻」的快照并冻结进批次行——之后员工休了假，
通知里的数字也不变，因为那是**当时的告知记录**而不是实时看板。已过清零日的桶可以
在预览里看见（HR 有复核理由）但**永不进入发送**：一封说「即将到期 · 2025-09-30 清零 ·
距清零还有 0 天」的信是事实错误。

---

## 2026-08-17 — Qwen 3.8 Max requires OpenCode, not Claude Code

**Decision.** The review provider is `qwen3.8-max` through DashScope
International's OpenAI-compatible API, and the single agent runtime is pinned
OpenCode 1.18.18 with `@ai-sdk/openai-compatible`. Keep the existing one
lenient review pass, trusted-base checkout, inert candidate materialization,
Read/Glob/Grep-only tool surface, complete-candidate-read evidence,
schema-bound exact-head check/comment, and separate GitHub App exact-SHA squash
controller. The published identity becomes `Qwen Review / Required`,
`helix-qwen-review/v1`, and `review_engine=opencode`.

The retained `deepseek-review.yml` path and self-hosted runner label are
operational continuity names only: GitHub has it manually disabled while the
former DeepSeek account has no remaining API budget. A new
`HELIX_QWEN_REVIEW_ENABLED` flag is fail-closed. It may turn true only after
the isolated runner has the Qwen credential and OpenCode installation and a
new internal PR proves check, visible comment, guarded merge and post-merge
`go-ci`. Qwen credentials must remain runner-local and never appear in source,
workflow YAML, actions logs, checks, comments or controller configuration.

**Why.** The owner requires Qwen 3.8 Max. Claude Code cannot invoke this
international model through the Anthropic-compatible API it requires; OpenCode
supports the model's documented OpenAI-compatible Chat Completions endpoint
while still allowing a hard read-only tool policy and machine-readable event
audit. A direct local adapter probe proved the model, event stream, five
separate grounded Read steps and strict JSON output together before this source
change.

**Alternatives.** (a) Keep Claude Code and use another Qwen model — rejected:
the owner explicitly requires 3.8 Max. (b) Change to a one-shot Qwen API
screen — rejected: it would lose the agent/tool grounding contract. (c) Add a
second agent or review path — rejected: the existing one-pass contract remains
the sole review mechanism.

**Supersedes.** The provider/runtime portion of the 2026-08-14 Claude Code +
`deepseek-v4-flash` decisions below. It does not supersede their exact-head,
read-only, controller separation or post-merge CI boundaries.

---

## 2026-08-16 — 员工助手走飞书对话入口，聊天面可调 MCP 只读子集

**Decision.** 建设「Ask Helix」员工助手：复用**现有飞书自建应用**（`chat_approval`
所用的同一行凭证，与飞书 SSO 同款复用模式）新增 `im.message.receive_v1` 事件订阅，
不新建机器人、不新增凭证、不新增环境变量。对话内核复用已上线的
`app/domains/support/copilot.py`（可插拔百炼/Anthropic + 工具循环），不建第二套
Agent 框架。

同时**改写 2026-06 求助域主题的「员工聊天面永远到不了 MCP/CLI」决策**：该隔离
当时针对的是 web 聊天界面形态，不是安全原则。新边界按「最后一下由谁按」分三档：

- **只读**：聊天 dispatcher 直连 MCP 工具层，`mutates=True` 硬过滤 +
  `annotations.DESTRUCTIVE_TOOLS` 永久排除，以**服务端认定的调用者身份**执行，
  RBAC 由工具内部原有闸自然裁剪。模型自动执行。
- **普通写**：模型只能对**白名单** mutating 流程输出「提案」，服务端校验参数后
  渲染飞书交互卡片；一次性 action token 绑定**卡片所示参数的快照哈希**，
  仅本人、短 TTL；用户点确认后由服务端带幂等键执行既有服务与审批流。
- **不可逆/高危**：永不进入聊天面，只走原有页面 + 审批。

身份一律由 `union_id → identity_external_identities → IdentityPerson` 服务端解析，
绝不接受消息正文或模型输出中的身份声明。每轮对话、每次工具调用（含被闸拒绝的
调用）落审计并带 trace_id；Token 计入既有 `ai_spending` 口径。总闸
`assistant.enabled` fail-closed，默认关闭。

**Why.** 2026-08-15 ADR 曾以「MCP 已解决 Agent 入口，重复做聊天机器人无增量」
否掉聊天面——该判断对**已装 Claude Code 的工程师**成立，对其余约 480 名员工不
成立：他们既进不了 MCP，也不该被要求学 CLI。飞书是全员既有的日常界面，把入口
搬过去才让平台能力真正到达全员。放开只读 MCP 子集而不再手工维护聊天工具注册表，
是因为既有 **MCP-sync 铁律**（每个新自助流程同 PR 配 MCP 工具）已保证工具面随
平台自动生长——手工注册表会永远落后于平台。安全上，隔离真正保护的是「越权」与
「误执行」，这两者分别由工具内 RBAC 和只读硬闸/确认卡片承担，与工具**来源**无关；
最坏情况下提示词注入也只能让模型答错话，拿不到调用者本就无权看的数据。

**Alternatives.** (a) 新建独立飞书机器人——被否：员工要多加一个应用，且凭证与
应用可用范围要重配，收益为零。(b) 为聊天面手工维护专用只读工具注册表（原
2026-06 形态的扩展）——被否：与 MCP-sync 规则重复，长期维护成本随平台线性增长。
(c) 聊天面直接放开 mutating 工具由模型自动执行——被否：权限合规不等于理解正确，
写错的代价是真实世界状态被改动；确认卡片把「理解偏差」这一类风险交还给人，且
成本只有一次点击。(d) 把 copilot 塞进飞书服务台的 FAQ 机器人框架——被否：拿不到
接地气的工具调用，等于给外部产品打工。

---

## 2026-08-15 — AI 大脑使用确定性内核、匿名 AI 和现有执行面

**Decision.** 建设一级产品 AI 大脑 / Intelligence Center，但不建设 RAG、
独立工作流或第二套模型凭证。Helix 以版本化规则计算八维健康、可信度和预测
基础事实；百炼只做跨域关联、解释、排序和建议。模型只接收聚合值和匿名对象
编号，中英输出必须通过 JSON 合同、数字和引用校验，再按当前用户 RBAC 在
本地映射真实对象。共享层采用业务折前成本；实付层使用独立 audience/cache。

全平台每日计算、事实 Hash 变化时每日最多生成一次，页面打开零模型调用。
云订阅只在详情页点击生成，同订阅成员共享；未变事实七天复用，普通成员不能
强刷。动作复用 Helix API、幂等、审计、Provisioning、Outbox 和飞书审批。
全部切片在 `intelligence.enabled=false` 后完成，整体生产验收后才正式开放，
不发布实验版或残缺入口。

**Why.** MCP 已经解决 Agent 操作 Helix 的通用入口，重复做聊天机器人没有
产品增量；管理层真正缺的是可核验的跨域经营判断。确定性评分和本地权限映射
让 AI 不成为事实源或越权通道，按变化/按点击生成则把 Token 成本绑定到实际
决策价值。

---

## 2026-08-14 — Claude Code PASS requires multi-turn grounded evidence

**Decision.** Keep the owner-selected one lenient Claude Code +
`deepseek-v4-flash` pass and the read-only `Read`/`Glob`/`Grep` tool surface.
Do not add an adversarial pass, shell execution, a quota framework or an
artificial delay. A successful review must nevertheless read the trusted
binding, inert candidate data and two independent trusted-base sources.
Navigation/search remains available but is not a separate quota. The adapter rejects a
short-circuit answer and records only aggregate turn/tool counts in Actions
logs. It continues to expose no raw tool output, source excerpts, credential or
private reasoning.

**Why.** Disposable PR #2741 did invoke Claude Code but the v16 wrapper accepted
one read-only observation and returned PASS after 33 seconds. Its separate
controller also failed closed because it still expected `review_engine=codex-cli`
while the source migration correctly emitted `claude-code`. Neither result is
activation evidence. `HELIX_DEEPSEEK_REVIEW_ENABLED` is false until a new
exact-head canary proves the revised agent, visible comment, guarded squash
merge and post-merge Go CI.

**Superseded 2026-08-18 (Qwen bundle input).** The old per-materialized-file
read rule and diff-scaled 32–72 turn budget made large PRs deterministically
time out before review completion. One manifest-hash-bound candidate bundle
now carries all exact changed paths and patches. One 14-turn Qwen/OpenCode pass must read the binding,
manifest and bundle; trusted-base navigation remains available for a needed
causal check, so it remains grounded and
fail-closed without serial reads proportional to file count. The ten-minute
wall-clock limit is unchanged.

---

## 2026-08-14 — Claude Code is the DeepSeek review agent runtime

**Decision.** Helix replaces the failed Codex CLI custom-provider experiment
with one direct NexusCRM-style Claude Code review invocation. Claude Code is
the agent runtime; deepseek-v4-flash is supplied through DeepSeek's documented
Anthropic-compatible endpoint. The existing check name remains DeepSeek Review
/ Required.

The agent gets a trusted-base checkout plus bounded, inert candidate data and
runs read-only with only Read, Glob and Grep. It has no Claude login,
auth.json, App private key, GitHub token, merge capability, candidate execution
authority, second pass, quota, calibration, shadow or recovery framework. The
trusted result adapter alone binds head/base/policy/input/model/engine into the
immutable check record; the existing short-lived App controller alone can
exact-SHA squash merge a PASS.

**Supersedes.** The 2026-08-14 v15 Codex CLI provider decision. Codex
Responses-mode events failed to prove agent tools; Claude Code plus DeepSeek
passed a fresh runner canary with a real Read event and schema-constrained
result. Historical receipts remain superseded as deep-agent evidence; a new PR
after this source lands must prove the Claude Code/DeepSeek workflow before it
is described as live.

## Superseded 2026-08-14 — simplify Helix to one lenient DeepSeek review pass

**Decision.** Directly reuse NexusCRM's standard review path and substitute
only `deepseek-v4-flash` for its Codex invocation. Helix runs one pass, not a
standard-plus-adversarial pair. It blocks only a newly introduced or materially
worsened HIGH/CRITICAL defect with an exact changed-line causal path; style,
historic debt, optional tests and speculative risk remain non-blocking.

**Supersedes.** All earlier DeepSeek ADR details that require two passes,
research-call quotas, recovery loops, deterministic preflights, shadow corpus
or failure-injection acceptance. The trusted-base snapshot, read-only tool
boundary, exact check record and separate GitHub App exact-SHA squash controller
remain active. `go-ci` remains post-merge evidence.

**Activation.** `HELIX_DEEPSEEK_REVIEW_ENABLED` stays false until one disposable
internal PR proves the single review check, visible comment, exact-SHA squash
merge, and resulting post-merge `go-ci` run.

---

## 2026-08-13 — Helix stays on DeepSeek; the direct screen is superseded by a bounded two-pass agent

**Decision.** Helix's provider remains `deepseek-v4-flash`; it does not use
Codex, a ChatGPT subscription, `CODEX_HOME`, or `auth.json`. Supersede the v3
one-shot, thinking-disabled direct JSON publisher as the active merge gate with
one DeepSeek **agent** review that contains two independent passes (standard
and adversarial). Each pass uses documented DeepSeek thinking/tool calls and
must make eight or more bounded read/search/diff calls before a final structured
decision, with at most two calls per provider turn so both passes are
multi-round. The expected normal review envelope is 5–10 minutes with a 15-minute
fail-closed cap; this is an evidence-depth target, not an artificial sleep.

**Safety boundary.** `pull_request_target` checks out only the exact base.
Candidate head blobs are API-fetched as size-bounded UTF-8 inert data under a
per-run temporary root and are bound by a manifest hash; they are never checked
out, imported or executed. The agent has no arbitrary shell/network/write/test
tool: only bounded trusted-base/candidate-data readers, literal search, path
listing and PR-diff reading. Candidate manifest drift, traversal/secret-path
attempts, invalid tool arguments, insufficient grounded research, provider
failure, malformed decision, timeout or either pass's BLOCK are non-success.
Raw source, tool output and private reasoning are not retained; only immutable
record hashes, visible finding projection, aggregate pass/tool counts and the
redacted shadow receipt may persist.

**Merge contract unchanged.** The credentialed runner still has no GitHub App
private key, installation token, `contents: write`, ruleset bypass, deployment
or Production authority. Only the separate single-flight controller may mint a
short-lived, repository-scoped App token and exact-SHA squash merge a valid
publisher-bound PASS. The controller recomputes the hash across both prompts.

**Activation.** The currently deployed direct gate is explicitly disabled.
Re-enable neither review nor controller until a fresh two-pass agent shadow
corpus plus stale SHA, draft, fork, candidate-manifest drift, forbidden tool,
invalid/timeout, base drift, duplicate delivery and merge-409 injection records
are accepted. Earlier direct-model receipts are superseded for activation; they
remain historical diagnostic evidence only.

---

## 2026-08-13 — one unambiguous blocker object receives the same strict validation (v12)

**Decision.** When a provider returns a valid outer decision object but encodes
exactly one `blocking_issues` item as an object rather than a one-element array,
the trusted publisher normalizes only that cardinality representation before
full validation. It still requires one `BLOCK` decision, HIGH/CRITICAL severity,
bounded non-empty finding prose, and one exact `(path, line, side)` anchor.
Everything else malformed or ambiguous remains `INVALID`; no review binding,
GitHub authority, controller guard or merge rule changes.

**Why.** V11 focused receipt `31705829341` proved the deleted refresh guard
case, but V11 full receipt `31706132187` was 12/14 because #2574 and #2571
were fail-closed invalids with the safe class `SHAPE`. Raw replies were deleted,
so the class does not prove that cardinality was the cause. It is nevertheless
a common, independently safe-to-normalize compatibility form. Rejecting a
fully proven instance would discard a valid BLOCK, while accepting it without
the existing field and exact-anchor validation would be unsafe.

**Acceptance.** Run the complete approved 14-fixture shadow corpus against V12
and require a fully matching redacted receipt with raw-material cleanup. No V11
result is evidence for the new policy hash.

---

## 2026-08-13 — exact add/delete anchors make removed guards reviewable (v11)

**Decision.** Supersede the added-line-only finding citation contract. The
trusted publisher derives a complete `(path, line, side)` allowlist from the
API patch: `RIGHT` is an added candidate line and `LEFT` is an exact deleted
pre-image guard. A provider BLOCK must use one of those locations; the publisher
stores the side in its immutable record, and the visible projection labels a
`LEFT` finding as a deleted guard. Guessed, context or unanchored locations
remain invalid. This does not delegate review binding, GitHub authority or
merge authority to the provider.

**Why.** Full V10 receipt `31701095615` was 13/14. Its sole miss was #2561:
the reverse candidate deleted the atomic per-JTI refresh-replay claim and its
repeat no-op guard. A reviewer cannot prove a removal if its only admissible
citation must be an added line. Broadening policy wording or adding a
sample-specific deterministic rule would hide that protocol defect and create
noise; exact left-side patch evidence fixes the actual boundary.

**Acceptance.** First replay #2561 against V11, then all 14 approved fixtures.
Only a fully matching redacted receipt with successful raw-material cleanup is
quality evidence; this ADR alone is not calibration.

---

## 2026-08-13 — publisher, not provider text, owns the immutable review binding (v10)

**Decision.** Supersede V9's model-echoed head/base/policy/input binding
contract. The trusted publisher derives and records the exact review tuple from
the API snapshot and trusted-base policy; the merge controller re-reads that
publisher record. Provider JSON may contain only decision/finding data. Any
provider-supplied binding-like keys are ignored and cannot alter the record.
Decision shape, HIGH/CRITICAL severity, exact changed-line citations, bounded
fields, candidate conflicts and provider failure remain fail-closed.

**Why.** Full V9 receipt `31699738145` was 12/14: #2571 had a transient
model-copy `BINDING` invalid despite focused `BLOCK / MATCH` receipt
`31699563779`. The model echo did not authenticate anything—the publisher and
controller were already the only trusted binding owners—but could discard a
proven BLOCK. The same receipt found #2581 a false positive although its CI
selector preserves required contexts and fails open; V10 records its exact
guarded-selection APPROVE boundary. Fresh #2581/#2571 and full-corpus evidence
is required; this is not calibration.

---

## 2026-08-13 — candidate selection follows full exact-bound validation (v9)

**Decision.** Supersede v8's *shape-first* JSON selection rule. The one
credentialed publisher parses every JSON object returned by the provider, then
validates each candidate against the same trusted head/base/policy/input hash,
severity, changed-line and bounded-field contract before it can be selected. A
single valid decision is admissible even with invalid diagnostic JSON around it;
multiple valid decisions are admissible only if their merge-relevant verdict
and normalized findings are canonical-identical. Conflicting valid decisions
and no valid decision remain `INVALID` and cannot merge.

When no parsed candidate validates, publish only an aggregate fixed validation
class (`SHAPE`, `BLOCKER_COUNT`, `CITATION`, `VERDICT` or
`FINDING`) for calibration. It must never contain candidate values, provider
prose or source excerpts and does not alter the non-success merge predicate.

**Why.** Focused V8 shadow receipt `31697910753` recovered #2561, #2500 and
#2517 but left #2574/#2571 `INVALID` for ambiguous JSON. Selecting before full
binding validation could reject a valid BLOCK merely because a provider repeats
or surrounds it with another object. Choosing the first candidate, or simply
relaxing ambiguity, would permit a forged or contradictory PASS. Fresh exact
replays for #2574/#2571 and then all 14 live-replayable fixtures are required;
this decision is not calibration evidence.

---

## 2026-08-13 — shadow quality repair preserves a fail-closed merge predicate

**Decision.** Treat full shadow receipt `31696046120` as a calibration failure,
not an acceptable all-green signal. Keep the one credentialed DeepSeek call and
one required check, but accept one uniquely complete/hash-binding review object
only when other parsed JSON objects lack that complete shape; validate at most
twelve valid changed-line blockers and publish the first five. Zero/multiple
complete responses, more than twelve blockers, or any invalid
field/binding/citation remain INVALID. Add two transparent deterministic
findings only for the two exact historical release regressions:
`cli-release.yml` adding itself to automatic paths (#2500), and reusable
`image-release.yml` gaining `issues: write` (#2517). The #2561 refresh-JTI
account-lockout case receives an explicit model policy requirement, not a broad
static rule.

**Boundary.** This is not a second model or broad Actions linter. It adds no
credential, bypass, merge permission or candidate-code execution. Deterministic
findings cite the exact added line, carry `source=deterministic_regression`, and
are visibly labeled. The controller still accepts a publisher-bound PASS only.

**Why.** The full corpus matched 5/5 ordinary APPROVE and only 4/9 expected
BLOCK cases. #2574/#2571 were protocol INVALIDs, while #2500/#2517 and #2561
were PASS false negatives. The first four have bounded, direct evidence for a
narrow attempt without weakening validation; each must earn a fresh receipt.
#2561 must earn a fresh model result.

**Acceptance.** Replay the five affected fixtures, then all 14 on the new policy.
Do not call the reviewer calibrated unless both receipts match and raw material
cleanup succeeds.

## 2026-08-13 — proven Staging topology omission receives one transparent deterministic preflight

**Decision.** Keep `DeepSeek Review / Required` as one required check and one
credentialed DeepSeek call. Add, inside that same trusted-base publisher, a
small deterministic topology preflight that is explicitly labeled in its
published finding. It blocks only a new Compose deployment path that starts a
backend while exact reviewed-base evidence names `REDIS_URL` but the new Compose
does not declare a Redis service, and/or where the candidate claims to preserve
Caddy but declares no network. It cites the candidate's exact added backend
line. The model remains responsible for all other HIGH/CRITICAL review classes.

**Boundary.** This is not a hidden second model, a ruleset bypass, a merge
credential, or an arbitrary static scan. It reads only the already hash-bound,
redacted fixed-source context and candidate API patch; it does not execute code
or load candidate-selected paths. A finding carries `source=deterministic_topology`
through the immutable record and the timeline rendering marks it as such.
Ordinary code changes and deployments with their own Redis and explicit network
do not trigger it. The controller still merges only a publisher-bound PASS.

**Why.** V7's trusted context did not fix the #2666 real-model recall gap:
[run 31692513464](https://github.com/pingcap-inc/Helix/actions/runs/31692513464)
returned valid `PASS / FALSE_NEGATIVE` even though its input was base-bound and
protocol-valid. The omission is mechanically provable from the same exact
candidate/base evidence, so leaving it to a model-only judgment would keep an
identified merge predicate unreliable.

**Acceptance.** The #2666 replay is `BLOCK / MATCH`; run the full live-replayable
balanced corpus before calling the policy calibrated. A historical sample is
admissible only if its verified GitHub patch and exact-base context fit the live
bounded input contract. #2595 is deliberately excluded: GitHub omits its
3,128-line i18n patch, so it cannot be replayed by the live reviewer and cannot
support an APPROVE-quality claim.

## 2026-08-13 — deployment review gets fixed reviewed-base topology evidence

**Decision.** Keep the single DeepSeek reviewer, prompt-output schema and
exact-SHA merge authority unchanged. For a deployment-sensitive diff only, the
trusted-base publisher may add a compact `runtime_topology` context built from
three fixed source paths at the exact reviewed base: the root Compose reference,
the production Compose reference and the Redis configuration declaration. Each
source has a content hash, the context carries the reviewed-base SHA and the
context is included in the review input hash. Candidate PR data cannot select a
context path; it remains only in the untrusted API snapshot and is never
checked out or executed. Sensitive configuration values are redacted before the
provider request.

**Boundary.** This is evidence for a directly named dependency or declared
external edge/network contract, not a requirement to copy every service from a
different environment. The reviewer must cite the changed candidate line that
creates an unsafe topology, never a base-context line. The historical shadow
materializer reconstructs the same context from the fixture's exact base using
read-only GitHub APIs. Ordinary source changes receive no context.

**Why.** #2666's new Staging files had no same-path base predecessor, so the
diff-only policy could not observe the reviewed-base Redis dependency or the
existing Compose topology. Its valid PASS is a measured false negative; merely
strengthening prose would still ask the model to infer absent facts.

**Acceptance.** This decision is pending fresh real-model evidence after the
implementation merges: replay #2666 first, then the selected balanced corpus.
No calibration, merge-gate relaxation or claim of production deployment follows
from the source change alone.

## 2026-08-13 — DeepSeek Review v6 is challenger-first; runtime topology is a blocking surface

**Decision.** Retain one DeepSeek reviewer and its exact-head, fail-closed
squash contract, but supersede v5's policy wording with a mandatory challenger
pass. Before APPROVE, the reviewer must actively test every changed
state/effect/authority/runtime dependency against duplicate delivery, stale
state, retry failure, rollback, missing dependency and unexpected valid input.
Workflow, deployment, infrastructure, Compose, Docker, Caddy, cloud and
runtime-shell changes are critical paths: a changed configuration reference plus
a missing required network, volume, dependent service, health gate, immutable
target, rollback boundary or fail-closed validation is a BLOCK-level causal path.

**Boundary.** The reviewer does not invent a numeric blocker quota or block
style, documentation, historic debt or unsupported speculation. But it must not
write a non-blocking suggestion for an unguarded critical-path behaviour; it
must promote it to a changed-line BLOCK or omit it when no causal claim can be
made. This changes neither merge authority nor the single-reviewer design.

**Why.** v5 was live for #2666–#2668 under policy hash `70714663…` and returned
only PASS results. #2667 immediately repaired #2666's Redis/Caddy topology
preservation, demonstrating that merely telling the model to be evidence-first
did not make it challenge operational configuration. This is a quality
correction, not an incident stop-the-line decision.

**Acceptance.** v6 requires a fresh real-model shadow run. Its result must bind
the v6 policy hash, and must not inherit v5's uncalibrated PASS record.

## 2026-08-13 — DeepSeek Review policy changes from pass-first leniency to evidence-first invariant recall

**Decision.** Keep one DeepSeek reviewer and the exact-head, fail-closed squash
contract, but replace the prompt's `Be intentionally lenient` posture. A changed
line with a direct causal path is now enough to require `BLOCK`; the reviewer
must not require a production trace, reproduced race, or failing test. Mandatory
BLOCK classes are lifecycle-state misclassification, non-atomic at-least-once
side-effect/notification/receipt handling, failed provider-operation reuse,
unsafe transaction/external-I/O or DB-error isolation, and weakened authority,
migration, reconciliation, or release-control guards.

**Boundary.** This is not a target of one to three findings per PR. Clean PRs
should pass first time. Style, naming, refactoring preference, documentation,
optional coverage, historic debt and risks without a changed-line causal path
remain suggestions. The policy change preserves the reviewer's lack of merge
authority and does not add human review or a second reviewer.

**Acceptance.** A new real-model shadow run is required for this policy hash:
the known BLOCK fixtures must cite their stated invariant and changed candidate
line, and known APPROVE fixtures must remain unblocked. Until recorded, PASS is
only a bound review result, not proof of policy-quality calibration.

**Why.** Several live PASS reviews found concurrency, deduplication and state
risks but relegated them to suggestions; a later offboarding self-review then
confirmed three state/retry/notification regressions. The earlier precision-first
wording was therefore too weak for the actual Helix control plane.

---

## 2026-08-13 — Shared Staging is a daily/manual, digest-bound Alibaba release path

**Decision.** Shared Staging has one independent workflow,
`.github/workflows/staging-deploy.yml`, scheduled at 02:20 Asia/Shanghai and
manually dispatchable for an explicit full `main` SHA. It must bind the source
to an ancestor of current `main`, the exact successful `go-ci / Main` check,
and a source-bound manifest of content-addressed OSS server/web archives before
a narrowly scoped Cloud Assistant command can recreate the Staging runtime. The workflow initially remains
disabled by `HELIX_STAGING_AUTO_DEPLOY_ENABLED=false` until its GitHub Environment,
Alibaba OIDC/RAM roles, private OSS bucket and ECS runtime role are explicitly
bootstrapped and a manual acceptance succeeds.

**Boundaries.** The scheduler runs only backend and frontend application
services plus the existing isolated Staging Redis dependency. It requires the
pre-existing Caddy-attached Docker network and adopted Redis volume, rather than
creating a disconnected default network. `worker`, production AWS
ECR/SSM/compose, production credentials and provider effects are absent.
The current TiDB Staging Branch is a copied Production snapshot, so this
workflow additionally refuses every image replacement until a separate
sanitizer's source-bound receipt proves jobs/integrations/identity targets are
disabled, copied credentials/tokens/provider locators removed, people
pseudonymised and a synthetic cohort seeded. Scheduled runs do not migrate the Staging
database; a reviewed manual run may opt in to `alembic upgrade head`. A failed
runtime replacement may restore the previous receipt's images but does not
undo a database migration and cannot turn the failed workflow green. Public
readiness is service proof, not OAuth/business-effect acceptance.

**Why.** Reusing the production deployment chain would couple an experimental
shared environment to production image, credential and worker authority. A
floating tag would make the deployed code unprovable. OIDC plus a private OSS
prefix permits repeatable daily releases without a long-lived cloud key in
GitHub or the host. The `it-infra` account has no existing ACR instance and ACR
Enterprise is a standing subscription; for one internal host, encrypted,
forbid-overwrite, digest-keyed archives are the smaller-cost transport. This is
not a general replacement for a registry when host count or release volume
grows.

**Alternatives rejected.** Running the production AWS deploy scripts against
the Alibaba host, creating ACR Enterprise solely for one Staging host, copying
a permanent AccessKey to GitHub/ECS, pulling a floating `main` tag, and starting
worker were rejected as cost, authority, or provenance expansion. This decision
does not authorize Production automation.

---

## 2026-08-13 (superseding follow-up) — Post-merge CI retains only release-critical evidence

**Decision.** `go-ci.yml` is reduced to exactly three visible exact-main jobs:
`backend-validate`, `frontend`, and fail-closed terminal `go-ci / Main`.
`backend-validate` proves migration-chain/upgrade, transaction isolation, TiDB
compatibility, lockfile, backend lint/type ratchet and the GitHub Actions trust
boundary. `frontend` performs the dependency security audit and one production
Docker image build; the Dockerfile performs the application TypeScript/Vite
build. The terminal accepts only the same immutable `main` SHA after both
children succeed. Each child is capped at ten minutes; the terminal at two.

The previous five-job topology and scheduled/manual `Backend Regression` are
**superseded**. The repository no longer runs generic backend pytest, Vitest,
frontend lint/typecheck/build duplicates, CLI tests, CI self-tests, or a broad
timed regression lane in GitHub Actions. Existing test source remains available
to developers, but it is not merge, main, or Staging evidence.

**Why.** The current value is release safety, not proving agent-generated mocked
happy paths. The v16.3 exact-main run spent 7m44s in the frontend job, including
1m35s of Vitest and a standalone build duplicated by Docker. The manual full
backend run was cancelled after roughly 65 minutes without a terminal artifact.
Neither class of work is an appropriate default automated gate.

**Staging boundary.** Real Staging verification must run only after deployment
and bind its result to the active commit/image digest and a small real
HTTP/role/route contract. No such workflow exists in this decision; do not call
generic local tests “Staging” or let them become a hidden merge requirement.

**Alternatives.** Retaining the full suite on a schedule was rejected: it still
consumes CI capacity and produces a misleading, non-deployment signal. Moving
mocked tests to a workflow labelled “Staging” was rejected: a name does not bind
tests to a deployed artifact or real route. A later staging profile needs its
own authorized design and acceptance evidence.

## 2026-08-13 — 离职终删必须先证明数据交接;交接人解析不出来就**不删**(推翻 2026-08-04「Zoom 不做转移」)

**Decision**: Google / Zoom 的离职终删(hard delete)前必须拿到**数据交接完成的证据**,
拿不到就地补做转移;补不了(解析不出交接人)就**拒绝删除**、记非终态、单次告警 IT,
下轮自动重试。交接人解析链固定为:流程「默认交接人」→ 直属 lead → lead 的上级链
(上限 5 层、带环检测)→ 平台设置 `offboarding.default_handover_fallback_person_id`;
候选人必须在职、未软删、有 work_email,且**绝不能是离职者本人**。这条同时推翻
2026-08-04「Zoom 账号无 successor / 无 data transfer 硬删」的决定:Zoom 删除**永久
不可恢复**,`delete_user_unconditional` 现在带 `transfer_email` +
`transfer_meeting/webinar/recording`。

**Context**: 生产实证两例被删号时零交接。alison.u@pingcap.com(2026-08-12 删)——
她的 Google Workspace 席位在 07-31 03:30 被 `google.seat_sync` 以「account no longer
active in Google Workspace tenant」自动 sync-revoke,离职流程 08-10 才建,扫描时已无
席位 ⇒ 不生成 blocker ⇒ 唯一会做 Drive/Calendar 转移的 `user_suspend` revoke 从未
执行。hice@pingcap.com(2026-08-02 删)——blocker 生成了但被 `[stale_after_rescan]`
自动跳过,同样结果。两条删号路径的原注释都写着「blocker 级联已经转移过所有资源」,
这个前提在「账号先于 Helix 被人工挂起」这个**常规**场景下不成立。Zoom 更彻底:席位
binding 是 `action_on_offboard=disassociate`(本就不转移任何东西),终删又显式不带
successor,已删的 2 个号(juno / alison)的云录制不可恢复。

**Alternatives**: (a) 在 seat_sync 侧不回收离职者席位,让 blocker 链路继续生效——
只堵一个入口,`stale_after_rescan` 那条仍漏;已作为纵深防御另行实现,不作为主闸。
(b) 删号前查 Zoom 云录制数量,0 条就照删——被否:Zoom `/users/{id}/recordings` 按
自然月分段查询,离职者的历史录制可能远早于查询窗口,「查到 0 条」不等于「没有录制」,
拿一个可能撒谎的信号去授权不可逆删除,正是最该避免的失败模式。改为「无交接人一律
不删」。(c) 超时即视为转移完成继续删——被否:Google transfer 超预算只说明还在跑,
删了就没了;现在超时记 `in_progress` 并**持久化 transfer_id**,下轮续查同一个
transfer(而不是排第二个)。(d) 拒绝删除时把 cron 判为失败——被否:这是**安全的
拒绝**不是故障,计入独立计数器 `blocked_no_successor`,不污染 `errors`,否则真故障
会被淹没。

## 2026-08-13 — Post-merge Helix CI uses five visible checks, with bounded parallel backend execution
## 2026-08-13 (follow-up) — Image publication is a separate, manually dispatched workflow

**Decision.** Keep `go-ci.yml` validation-only exactly as decided above, and give
`image-release.yml` its own `workflow_dispatch` trigger. Publication is therefore
a deliberate step on a chosen main SHA, in the same shape production deployment
already has. `go-ci.yml` gains no publication job, no OIDC credential and no
`workflow_run` hook; the guard test for that stays as written.

**Why.** The decision above removed image publication from `go-ci.yml` but
`image-release.yml` was `workflow_call`-only, so removing its caller left the
repository with **no way to publish an image at all**. Main ran green for three
commits (`6a705c0f6`, `907d84c6f`, …) with nothing in ECR behind them; the last
published tag was `sha-73ae15989` from #2645. A missing release is silent — the
code is merged, every check is green, and the gap only appears when a deploy asks
ECR for a tag that was never pushed. `workflow_dispatch` is input-free on purpose:
it publishes `github.sha`, identical semantics to the `workflow_call` path, with
no tag parameter to get wrong.

**Guard.** Both repositories also receive a floating `:main` tag, so publishing
off main would label a feature build as production. `prepare` now fails closed
unless `GITHUB_REF` is `refs/heads/main` — placed in the job rather than the
trigger so it covers any future `workflow_call` caller too. Pinned by
`test_image_release_is_triggerable_and_only_publishes_from_main`.

---

## 2026-08-13 — Post-merge Helix CI uses five visible checks with fast smoke and scheduled full regression

**Decision.** Preserve the complete backend scope, but collapse `go-ci.yml` to
exactly five post-merge checks: `backend-validate`, one bounded backend
CI-contract smoke `backend-test`, `frontend`, `cli`, and terminal `go-ci / Main`. Each
job checks out and verifies the immutable `github.sha`; the aggregate fails
closed unless the event is `refs/heads/main`, its checkout matches that SHA,
and every child succeeded. There is no PR trigger, path filter, shared
concurrency/cancellation, image publication, deployment, merge credential, or
OIDC credential in this workflow.

**Why.** The prior workflow exposed 22 backend matrix shards plus intermediate
and image-release jobs for every main commit. That amplified repeated bootstrap
and rendered routine main feedback as a large check list. The first v16
implementation kept the manifest but executed 22 former shards sequentially;
its first exact-main run accumulated 50 minutes and was intentionally cancelled.
That serial strategy is superseded. The retained manifest became one 8-worker
`pytest-xdist --dist loadscope` invocation, but the first exact-main attempt
was still running after 20 minutes. It cannot remain on the push path without
breaking the owner SLO. `backend-test` therefore becomes a fixture-free smoke
of the complete-manifest guard, xdist/cleanup limits, hang diagnostics and
workflow privilege contract; the complete MySQL/Redis suite moves to a daily
and manual `Backend Regression` workflow. The prior 45-minute allowance is
superseded: each concurrent push validation job is bounded so `go-ci` has a
15–20-minute upper feedback target; the separate DeepSeek decision is bounded
to 10 minutes.

**Acceptance boundary.** The first live run must be bound to its exact main SHA
and prove the smoke JUnit and terminal aggregate complete within 20 minutes.
The daily/manual full-regression record separately proves collection through its
full JUnit artifact. The unavailable local Docker daemon is not evidence of
either runtime acceptance. A red result remains post-merge feedback for a new
repair PR; it does not retroactively change the reviewed merge decision.

---

## 2026-08-12 — 简化为 direct DeepSeek publisher + exact-SHA GitHub App handoff

> **Implementation status updated 2026-08-13**: the old staging/disabled wording
> in this ADR is superseded. The direct publisher, exact-SHA controller and the
> publisher-bound required check are live for internal PRs. The reviewer now
> passes its result to a separate GitHub-hosted commenter, which has only
> `pull-requests: write` for the PR timeline endpoint and no provider credential
> or `contents: write`. Neither plane has an App key, installation token, merge
> capability, bypass or deployment authority. The active source of truth is
> [`DEEPSEEK_REVIEW.md`](DEEPSEEK_REVIEW.md).

**决定**：以 `DEEPSEEK_REVIEW.md` v3 取代 v2 的 adapter/evaluator daemon、Unix socket、签名
receipt 和它们的 service templates。运行时只保留两个 standard-library scripts：专用 trusted-base
runner 上的 `deepseek_review.py` 直接调用固定 `deepseek-v4-flash` endpoint 并发布绑定
head/base/policy/input 的 check；default-branch `workflow_run` 上的
`deepseek_exact_main_handoff.py` 用短期 GitHub App token live-read check/PR 后以 exact SHA squash
merge。没有 per-PR 人审、thread resolution 或普通 pre-merge CI predicate。

**保留的边界**：review runner 只拥有 provider key、checks write 和读快照；无 App private key、
merge token、ruleset bypass、deployment 或 Production authority。无 provider credential 的 commenter
才拥有 PR timeline 所需的 `pull-requests: write`，但没有 GitHub merge API 所需的 contents write。
controller 不拥有 provider key；
它只接受 expected workflow-run details URL、expected check publisher App ID、canonical PASS record、
current internal head/main base，base drift 则 guarded update branch 后等待新 review。merge request
一律携带 reviewed SHA + `squash`，`409`/stale/invalid/BLOCK/timeout 都不重试旧 head。

**取舍及原始状态（已由上方 2026-08-13 implementation status supersede）**：删除签名 receipt
降低了 host 部署和密钥轮换复杂度，但 dedicated runner compromise 可伪造一个 review check，不能
直接 merge。该风险仅在 runner 只运行 trusted-base review、从不运行 candidate code 的约束下接受。

---

## 2026-08-14 — owner permits the merge App to bypass the main ruleset

**Supersedes only the earlier no-ruleset-bypass assumption.** The owner permits
repository owners/admins (`Repository admin`) and `amber-codex-automation` to
bypass the `main` ruleset. The App uses that admission only to squash merge an
accepted review. This is not a global bypass and does not grant the App
Administration, Production, Environment, deployment, provider, or runner
credentials.

**Invariant unchanged.** Bypass removes GitHub's ruleset admission block only.
Before every merge the single-flight controller still re-reads an open internal
PR, `base=main`, current head equals reviewed head, publisher-bound exact-head
PASS and the current policy/input binding; it calls the REST merge API with
`sha=<reviewed_head_sha>` and `merge_method=squash`. Stale SHA, BLOCK, INVALID,
timeout, fork, base drift, duplicate delivery and `409` fail closed.

**One-time bootstrap.** The PR that installs the workflow runs its
`pull_request_target` definition from the old default branch and therefore
cannot publish its own required check. A repository owner/admin may squash merge that
single PR using the owner bypass, after which a normal internal canary must
demonstrate unattended App merge from an exact-head PASS. No later PR receives
this exception.

---

## 2026-08-12 — 独立 DeepSeek Review adapter 取代 Codex Review provider；不伪装为 Codex

> **Superseded in implementation detail**：本条的 adapter/evaluator daemon、signed receipt 和
> socket custody 已被上方 v3 direct publisher 决定取代；provider identity、one-check merge
> contract、trusted-base and exact-SHA principles remain active.

**决定**：Helix 的 active target provider 改为独立 DeepSeek Review adapter，required check
固定为 `DeepSeek Review / Required`。它通过 trusted-base snapshot 和受限 local adapter 调用
DeepSeek documented Chat Completions API，返回 schema-bound JSON，再由确定性 evaluator 发布
signed exact-head receipt/check。它不运行 `codex exec`，不使用 ChatGPT subscription、`CODEX_HOME`
或 `auth.json`，也绝不将 DeepSeek result 命名成 `Codex Review / Required`。activation-time 固定模型为
`deepseek-v4-flash`；模型/alias/endpoint/behavior 的改变必须进入新的 policy version 并先 shadow。

**取代范围**：`CODEX_REVIEW.md` v4 中 managed Codex auth、device login、`auth.json`、Codex CLI
和 Codex check identity 的合同已 superseded。trusted-base materialization、single blocker-focused
review、reviewed head/base/policy/input binding、block-first evaluator、独立 controller、无人值守
squash merge、Staging 自动进入与 Production 独立授权仍保留，并由
`DEEPSEEK_REVIEW.md` v2 重新定义。此前无法完成的 OpenAI device login 不再是该 review provider
的 implementation blocker。

**安全合同**：DeepSeek key 是独立按量计费 credential，先 revoke 已暴露 key，再仅由
least-privilege secret-manager → root-owned adapter daemon → fixed DeepSeek HTTPS endpoint 使用；
不能进入 GitHub Actions secret/workflow/job environment、repository、artifact、log、prompt、chat、
Obsidian、`auth.json` 或 `CODEX_HOME`。模型无 shell/tool/GitHub/cloud/merge/deploy/network tool
authority。data/security owner 必须在 activation 前批准 PR source/comments/documentation 到
DeepSeek 的 egress、retention/residency/terms 与 incident contract。无效/截断/timeout/provider
error/model drift、secret/adapter 失败均 fail-closed。

**合并合同（v2 superseding clarification）**：controller 只接受指定 publisher/workflow 的 same-head
`DeepSeek Review / Required` + valid signed receipt；其 PASS 是唯一 PR merge trigger，并依旧以
`sha=<reviewed_head_sha>` 和 squash merge。原本“三项 CI 必须在同 head SUCCESS”的 merge predicate
被本条 superseded：三项 CI 仅在 resulting `main` SHA 上进行 post-merge validation。internal behind
PR 使用 `expected_head_sha` guarded update 后退出等待新 head 的 DeepSeek review；fork、`422`、
duplicate、`409`、BLOCK/invalid/timeout/stale 都不 merge。App 没有 ruleset bypass、Administration、
Deployments 或 Environments/Production；review plane 不拥有 App private key、installation token 或
merge-capable token。运行时仍没有 human approval 或 thread resolution；一次性 ruleset bootstrap
仍由 repository admin 完成。

**取舍**：这降低了 PR admission 阻塞时间，但不降低 exact head/base、receipt/publisher、strict-main、
squash-only、fork fail-closed、single-flight 或 GitHub server-side enforcement；main CI 与 Staging 的
失败仍按其独立事实报告，不能追溯性推翻已完成的 squash merge。

**状态**：这是 provider/design 决定，不是上线 receipt。repository 已有默认禁用的
trusted-base publisher、adapter/evaluator/controller source、签名 receipt、runner templates、shadow
fixtures 与 unit/static tests；provider/receipt secret custody、data approval、host service deployment、
live check publisher、App installation、ruleset bootstrap、shadow/failure-injection execution、merge
和 Staging automation 均未配置或执行。Production automation 不在本决定范围内。

---

## 2026-08-12 — 无人值守 PR → CI → Codex → squash merge → Staging；人审假设 superseded

> **Provider identity superseded**：本条及紧随其后 ADR 中的 `Codex Review / Required`、
> Codex credential/runner 文字已由上方 DeepSeek Review ADR 取代；无人值守 exact-SHA controller
> 与人审假设被取代的决定仍继续有效。**本条“三项 PR CI 作为 merge 前提”的全部文字已由
> 上方 DeepSeek Review v2 的 sole-check merge contract superseded；下文不再是当前合并合同。**

**决定**：本条 supersede 紧随其后的 2026-08-12 ADR 中“保留现有人审/unresolved thread
gate”的部分。Helix 的 happy path 是无人接入、无人值守的：内部 PR 的 current head 通过
三项 required CI 与 `Codex Review / Required` 后，独立 controller re-check 并 squash merge，
随后自动进入 Staging。ruleset 的一次性 repository-admin bootstrap 必须把
`required_approving_review_count` 设为 `0`、`require_last_push_approval` 设为 `false`，并不再
要求 conversation/review-thread resolution。运行时没有人工 approval 或 resolve 动作。

**运行时合同**：controller repository-wide single-flight 串行处理 update/merge；只接受
base repository 内部 branch，fork PR fail-closed。若 main 漂移或 PR behind，它以 current
head 的 `expected_head_sha` guarded update-branch；`202` 后立即退出，等待新 head 重跑三项
CI 和 Codex，绝不 merge 旧 review。`422`、冲突、head race、Codex BLOCK/invalid/timeout/stale、
CI failure 均停止不合并。Codex finding 只使用 check output/annotations 与由新 head supersede
的 summary，不创建需人工 resolve 的永久 merge blocker。自动修复是独立 change-agent 边界，
reviewer 无 merge credential。仍使用 `sha=<reviewed_head_sha>` + `merge_method=squash`；无
GHEC-only capability、merge queue、ruleset bypass、Administration 或 Production 权限。

**边界**：本决定覆盖 merge 后自动进入 Staging，不授权 Production 自动化。一次性 ruleset
bootstrap 需 repository admin 并记录 preimage/patch/readback；controller 运行时不需要、也不
获得 Administration 或 ruleset-bypass 权限。当前 runner authentication、workflow、App、
controller、ruleset bootstrap 与 Staging 自动进入均未实现。

**校准边界**：`DeepSeek Review Shadow` 是手动触发、只读的离线评估路径：它允许
`contents: read` 与 `pull-requests: read` 来 materialize 已批准 fixture 的精确快照，但它不
写 check/comment、不持有 App 私钥或 merge token，也不会唤醒 controller。其结果只作为
policy-quality evidence，不是 required check、merge predicate 或 production authorization。

**背景**：把每个 PR 再交给人工批准或解析机器人创建的 thread，会让无人值守 lane 停在一个
没有新增验证信息的人工队列。真正的安全边界是 exact-head CI/Codex、strict-main、App
publisher identity、live readback、single-flight 与 guarded writes，而不是一个可被遗忘的
人工按钮。

---

## 2026-08-12 — Codex PASS 触发独立 controller 的 exact-SHA 自动 squash merge

> **部分 superseded**：上方 DeepSeek Review v2 已取代本条的 provider/check identity、
> 人审、last-push approval、unresolved thread 以及三项 CI merge predicate；reviewer/controller
> 分权、exact-SHA 绑定与 failure-closed 的其余安全边界仍然有效。

**决定**：Helix 的轻量 PR 流程采用一个 exact-head required check，`Codex Review /
Required`。它对当前 PR head 产出 `SUCCESS` 后，**独立的 trusted merge controller** 才可
自动 squash merge。旧的“Codex Review 不自动合并 / auto-merge 关闭”设计在此决定下
**superseded**；GitHub native auto-merge 仍不启用，controller 改为调用 GitHub REST merge
API，且必须携带 `sha=<reviewed_head_sha>` 与 `merge_method=squash`。

**安全合同**：credentialed Codex runner 只可发布绑定 reviewed head/base、policy hash、
input manifest hash 的 review check；它没有 GitHub App private key、installation token 或
merge-capable token。Controller 使用单独、短期、repository-scoped GitHub App installation
token，并在每次 webhook/retry 前重新读取 PR：仍 open/non-draft、base 为 `main`、current
head 等于 reviewed head、三项既有 required CI 在同一 head `SUCCESS`、指定 check name 与
publisher App 的 Codex result `SUCCESS`、以及人审/unresolved threads/strict-main/ruleset
均满足。App 不得有 ruleset bypass、Administration、Deployments 或 Environments/Production 权限。SHA
漂移/API `409` 不重试旧 merge，等待新 head 重新完整 review；duplicate webhook 必须按
durable idempotency key 去重但仍作 live readback。Codex PASS 是最终自动触发器，绝不替代
现有 CI 或人审，除非 owner 将来另行明确。

**背景**：单审已降低 review 成本，但 owner 选择让通过所有既有治理 gate 的 current
head 自动合并，避免把额外人工“点击 merge”保留成无信息步骤。风险不在 Codex verdict
本身，而在把 ChatGPT credential、GitHub merge authority 和 stale webhook 混在同一 runner。
因此控制面拆成 reviewer 与 merge controller 两个最小权限平面；启用前必须完成 shadow
与故障注入（stale SHA、CI fail、draft、base drift、unresolved thread、duplicate delivery、
API `409`）。当前 runner 仍未完成 Codex authentication，controller/App/workflow/ruleset
均未实现或创建，本 ADR 是设计决定而非上线 receipt。

**替代方案（未采纳）**：

1. **保留 v2 的人工 merge**：安全边界较简单，但在 CI、人审、strict rules 与 Codex
   check 都已成功后再等待一次人工点击没有增加新的治理信息，owner 已明确否决。
2. **让 Codex runner 使用同一 App token 直接 merge**：一个 workflow 即可实现，却把
   model credential 和 merge authority 放在同一执行面；PR-code/prompt-injection 或 runner
   compromise 的 blast radius 不可接受。
3. **只依赖同名 check 或 GitHub native auto-merge**：前者可被错误 publisher/same-name
   check 混淆；后者不足以表达 exact reviewed SHA、policy/input binding 和 controller 的
   failure-closed receipt，因此不采用。

---

## 2026-08-09 — 云资源清扫的 drop-guard baseline 改取「近 5 次成功的中位数」

**决定**:`_safe_sweep` 的 drop-ratio 守卫,baseline 从「上一次成功同步的 `upserted`」改为「最近 `_BASELINE_SAMPLE_TARGET=5` 次成功的 per-capability `upserted` 的 `median_low`」(`_previous_successful_cap_upserted` 更名为 `_baseline_cap_upserted`)。全局 20% 容差**不动**,不引入 per-integration / per-capability 的容差覆盖。

**背景**:GCP servers 那条 cron `failure_count` 累计 47,其中一类失败是 `provider persisted 834 unique rows vs 1140 baseline (previous_sync) (>20% drop) — refusing to sweep`。排查确认 driver 完好:近 40 轮 `skipped_leg_count` 恒为 20,且只单向减少过两次(项目陆续开了 Compute API)。真实原因是这支 GKE 机群**按中国业务时段伸缩** —— 按 cron tick 分组,09:55 UTC(北京 17:55)中位数 986 / 峰值 1140,21:55 UTC(北京 05:55)中位数 803 / 谷值 740,**振幅 35% 高于 20% 容差**。sync-to-sync 只看上一次成功,baseline 落在峰值轮时,下一轮谷值必被判成部分失败。这是个防数据丢失的守卫,误报的代价是拒绝 sweep + 告警,不是丢数据 —— 但它会周期性复发并训练人忽略这条 cron。

**替代方案(未采纳)**:

1. **按 integration / capability 覆盖容差** —— 改动面小,但多一个配置维度要维护,且把「机群会伸缩」这个普遍事实编码成了逐租户的例外。下一个弹性机群接进来又要再配一次。
2. **全局容差 20% → 40%** —— 一个数字、无需发版,但所有 capability 的守卫一起变松;真丢了三分之一行也不再拦,而那正是这个守卫存在的唯一理由。
3. **取近 N 次的最大值** —— 比中位数更严,会把谷值轮**必然**判成下跌,等于把问题放大。
4. **不改,只记录 + 触发时人工 force-sweep** —— 把一个可自动判定的常态,变成需要人在场的例行操作。

**为什么是 `median_low` 而不是 `median`**:偶数样本必须落在**云真的返回过的某个计数**上。这个整数要去授权软删,事后复盘得指得出它来自哪一轮;取两轮平均得到的是一个从未发生过的数字。偏低也让守卫略微更宽容,即偏向「不要拒绝一份健康快照」。

**没有被改动的**:零行守卫、`existing` 上限封顶(`previous_sync_capped_by_existing`)、`force_sweep` 管理员覆盖、`extra_active_filter`、`baseline_source` 的取值名(`previous_sync` 保留为「云侧 baseline」的稳定标识符,已落在历史审计行里)。

---

## 2026-08-09 — 收件人语言由 `app.core.locale.resolve_lang` 单点判定；通知模板三语齐全并按域拆分

**决定**：

1. **一个语言解析器**。新增 `server/app/core/locale.py`,导出 `normalize_lang` / `resolve_lang` / `is_en` / `pick_localized`。全平台任何「这个人读哪种语言」的判断都走它,**禁止再手写 `preferred_language.startswith(...)`**。解析链是有文档的四段:显式 `preferred_language` → `country`(ISO-3,`JPN`→ja / `CHN`→zh / 其它非空→en)→ `is_china_employee` → 基线 `zh`。

2. **降级阶梯 `ja → en → zh`**。日文缺文案时退英文(对 JP 读者比中文更近),英文缺文案时退中文(中文是无后缀基线,每个模板都保证有)。由 `pick_localized` 统一实现。

3. **模板三语为硬约束**。`subject` / `body_text` / `body_html` / `card_button_label` 只要存在 `_en` 就必须存在 `_ja`,反之亦然;`server/tests/test_notifications/test_template_locale_parity.py` 在 CI 上卡住。同时**任何被 `template_key=` 发送的 key 必须已注册模板** —— 未注册的 key 会静默回落到调用方硬编码的那一种语言,这正是「通知中心中英混排」的根因。

4. **模板按域拆分**。新增包 `server/app/notifications/templates/`(apikey / cloud / email_group / feed / lifecycle / platform / support),`service.py` 合并进 `_DEFAULT_TEMPLATES`。域内文案与域代码同处,`service.py` 的行内字面量不再继续膨胀。合并只做**新增**,重复 key 在 import 期直接抛错。

5. **`_load_template` 改为 builtin 与 override 逐字段合并**(原先是整字典替换)。

6. **通知中心筛选改按 category,不再按 template_key 前缀**。

**背景**:审计发现 6 处互相矛盾的语言判断。其中两处用 `not preferred_language.startswith("zh")`,而生产库里存着 SuccessFactors `defaultLocale` 的自由文本(`english` / `chinese` / `中文`),于是 **`chinese` 被读成「不是中文」** —— 明确选了中文的员工收到的是**英文的 Google Workspace 临时密码邮件**和英文 Day-90 问卷。同期发现:98 个在用 template_key 里有 70 个没有注册模板(全部单语);28 个已注册模板**一个日文都没有**;迁移 035/043 种下的中文覆盖行因整字典替换**把内置的英文变体整体删掉**,导致 `password_reset` / `transfer_received` / `alias_created` 等对全球员工恒为中文,而完好的英文文案就躺在代码里读不到;站内铃铛只按语言渲染正文、标题直接取库里存的生产者硬编码值,同一张卡中英各半。

**替代方案(未采纳)**:

- *保留各处 `is_en` 布尔,只修 bug* —— 6 处判断对 `None` / 自由文本的默认值本就互相矛盾(两处默认英文、四处默认中文),不收口就是等下一次再分叉。
- *NULL 语言改跟随 `app.default_locale`(ADR 记为 `en_US`)* —— 会把所有没设偏好的人从中文翻成英文,是一次大规模改语言而不是修 bug。基线保持 `zh`(与既有行为一致、也与人员构成一致),真正的改进放在 `country` 那一段。
- *日文缺文案时退中文* —— 对日本读者英文更可读;`FALLBACK_CHAIN` 明确写死 `ja → en → zh`。
- *把 locale fragment(`{level_label_zh}` / `{level_label_en}`)在调用点按收件人语言解析成单值* —— 否决:context 会被冻进 outbox 行,员工之后改语言偏好,铃铛重渲染就会拿到旧语言的片段。多语言片段全部入库、渲染期按语言取,才是可重渲染的。

**Acceptance**:98 个模板 × 三语齐全(`subject_ja` 覆盖率 98/98);`test_template_locale_parity.py` 的四组断言(三语齐全 / placeholder 跨语言一致 / 不跨语言引用片段 / 无未注册 key)全绿;`test_inbox_locale_and_category.py` 覆盖标题按语言渲染、自由文本 `chinese` 归一、无模板行标题原样保留、category 筛选与行标签一致、未知 category 返回空集。

## 2026-08-07 — 资产「流转阶段」词表收口：退役 5 个死状态，写入时强校验，`idle` 为唯一库存状态

**决定**：`asset_items.status` 的合法取值收敛为 14 个（`idle` / `available` / `claimed` / `assigned` / `borrowed` / `held_by_office` / `held_by_leaver` / 5 个 `*_pending` 与 `transferring` / `disposal_pending` / `disposed`），定义在 `models.ASSET_STATUSES` 单一处，`schemas.ALLOWED_STATUSES` 由它派生；**ORM 层 `@validates("status")` 在写入时硬拒词表外取值**。退役 `maintenance` / `retired` / `faulty` / `testing` / `legacy` 五个 token（生产 0 行、0 写入方），并由迁移 454 把遗留 `in_stock` 归一到 `idle`、迁移 455 把列默认值从 `available` 改为 `idle`。前端筛选下拉只列**真实可能出现**的 7 个状态。

**背景**：IT 反馈 `P-JP-NB2302001`（日本，MacBook Air）显示「在库」且无法变更持有。根因是 `in_stock`——2026-04 Excel 导入带进来的 idle 同义词，不在词表内、无任何代码写它，但所有变更持有的闸都按词表白名单判断，于是该行被**冻死**：发不出去、收不回来。2026-05-09 曾发现过一次，只补了 i18n 文案键（让它从"看不懂"变成"看得懂但用不了"），没迁数据。全站仅此 1 行，已修。

**判断**：词表外的 token 要**从数据里迁走**，而不是逐个闸去教它——迁移 188 的 `IDLE_STATUSES = {idle, available, in_stock}` 是全仓唯一知道这个同义词的地方，一处知道等于没人知道。同理，"同一件事两个名字"必须收敛：`unassign_asset` 是 `available` 的最后一个写入方，改写 `idle`；`AssetCreateRequest.status` 默认值 `available` → `idle`（真正在用的 `/assets/intake` 早就强制 `idle`，这是个没人走的后门）；DDL 列默认值同步。

**保留 `*_pending` 与 `transferring` 的理由**：它们当前同样没有写入方，但被 `ASSET_IN_USE_STATUSES`（**离职扫描依赖**）和盘点快照排除列表引用，删除要动跨域代码；且将来做两段式确认（申请→确认领用/归还）就是现成的名字。它们唯一的实际害处——筛选下拉里的空选项——已在前端解决，不必动后端词表。`available` 同样保留在词表内（读侧兼容），只是不再写。

**边界**：这不是给资产加新状态或新流程；不改任何权限；不动 `device_status`（`normal` / `faulty` / `under_repair` / `pending_repair`）——维修状态本来就在那一列，被退役的 `faulty` **流转阶段**恰恰是与它重名的假选项。

**否决方案**：① 只改数据不加写入闸——今天 `in_stock` 写不进来靠的是"没有代码写它"，不是"写不进去"，下次批量导入照样带野词；② 给每个闸补认 `in_stock`——把同义词扩散到 N 处判断，正是这次要根除的模式；③ 连 `*_pending` 一起删——收益是词表短 6 行，代价是动离职域和盘点域，负收益；④ 保留 `maintenance` 作为"维修中"——与 `device_status` 重复表达同一事实，两个真相源迟早互相矛盾。

## 2026-08-04 — 暂缓 AWS 生产 Terraform 纳管 epic（#2515 及其子项），生产部署人工闸落在合并 main 层

**决定**：关闭 #2515 及其全部子 issue（#2538–2546、#2551、#2542–2544），**不执行**那套分批机器护栏纳管流程（`infra/aws/environments/prod/adoption/` 的 `stage01_guard.py` / 逐字段 parity 契约 / epoch-amendment 模型 / Stage02 target-health 封存）。AWS 生产暂不由 Terraform 接管，维持现状。#2418（生产部署人工审批闸）以**补偿控制**方式收口并关闭。

**背景**：#2515 发现 AWS 生产的 Terraform 远程 state 为空，直接 apply 会重建生产。围绕它衍生出 11 个 `code-review`/`security` 子 issue，逐步给一个**尚未真正执行过**的一次性纳管流程叠加硬化(逐字段 live/desired 证据、语句级 IAM 策略等价、target 健康 hash 封存)。其中 #2551 本身就是"前面加的硬化让后面批次够不着,得再建不可变 epoch 模型去救"——过度设计的自证。

**判断**：生产footprint 极小且静态(单台 EC2 + ALB + Redis + ECR + 少量 IAM/SSM),日常变更走推镜像 CI 而非 Terraform。让 IaC 真正接管有中等价值(漂移检测/灾备重建),但需要的是一次由人小心跑一遍 `import` + 验 `plan` 0 变更,**不需要**这一整套航天级护栏。收尾还涉及对**唯一生产 ALB target** 跑 `RegisterTargets`(停机风险),属不可逆生产写,不应自主 fire-and-forget 执行。投入产出严重倒挂。

**#2418 收口方式**:environment 级"必需审批人"是私有库付费方案(Team/Enterprise)功能,当前计费方案不支持(`PUT .../environments/production` 返回 422 "billing plan does not support the required reviewers protection rule")。等价的人工审批闸已由以下**补偿控制**满足并核实:① ruleset `Protect production main`(active)——合并 main 需 PR + ≥1 审批 + 绿 CI + require_last_push_approval + squash-only;② production 环境部署分支**仅 main**(单条 `branch` 策略,无通配);③ PR #2314 的 IAM 信任边界(`environment:production` ∧ `ref:refs/heads/main` ∧ 精确 workflow claim)。三者构成可审计的控制集,记入 `docs/deployment/README.md`。

**边界/可逆性**:这不是删除已有能力。纳管脚手架代码保留在仓库(未删),随时可 reopen 重启;若将来基础设施规模变大或迁 EKS,应以精简 import 流程重做,而非复活这套逐字段护栏。若 org 升级到支持 environment required reviewers 的 GitHub 方案,再补 environment 层第二道闸。

**否决方案**:① 自主跑完整 63 资源生产 import + Stage02 target 写——不可逆生产动作,系统本就设计成人工把关,agent 不 fire-and-forget;② 逐个实现 12 个子 issue 的护栏/测试再合并——为一个不打算执行的流程投入大量工程,尾巴摇狗;③ 保留 issue 开着"以后再说"——违背"要么做要么明确关"的收口纪律,且这些 code-review issue 会持续诱导复杂度螺旋。

## 2026-08-02 — `global_admin` 与 `cloud_admin` 的云访问都永久，不进入续期提醒或到期回收

**决定**：云权限永久人员集合为 `cloud_admin ∪ global_admin`（兼容 legacy `it_admin`）。两类角色的新授权、续期和云端发现权限都写 `expires_at=NULL`；存量 active grant 部署时由迁移 440 清空到期日与提醒阶段；到期 sweep 在归一化、提醒、回收三个阶段全部按同一集合跳过。角色视角切换只是 UI/请求降权，不改变账号的永久事实。

**背景**：2026-07-29 的事故修复建立了 `load_perpetual_person_ids` 单一判定源，但当时政策明确只包含 `cloud_admin`、排除 `global_admin`。2026-08-02 全局管理员收到 4 项腾讯云权限 T-14 聚合提醒后，用户明确要求“全局管理员的也不要移除”。全局管理员同样承担平台和云事故响应；让其进入普通续期时钟会在值班/事故时制造不可接受的锁出风险。

**边界**：这不是新增 RBAC 权限，也不把普通员工、finance、management、auditor 或资产/HR 管理员变成永久；离职回收和管理员主动撤销仍然执行。这里只豁免“按时间到期”的提醒与自动回收。

**否决方案**：① 只静音提醒但保留 T+0 回收——会制造无预警锁出；② 只让当前这 4 条腾讯云权限永久——按账号/厂商打补丁会让其他云继续踩同一策略错；③ 要求全局管理员额外再持有 `cloud_admin`——重复角色是人工数据依赖，且与“全局管理员拥有全部管理职责”的产品语义冲突。

## 2026-07-30 — 保密红线第二批收口:红线按**收件人/读者**判定;脱敏只去「绝对金额与可反推量」;审计写钱动作强制登记;告警齐全度按各自窗口

**背景**:2026-07-29 的审计面补漏(#2097)确认「面」的判据是门禁宽于保密圈,并留下通知面四个候选修法待决。本批(第二批)把通知面、审计读面、成本读并发一起收口。

1. **红线按收件人/读者判定,通知是一个面**。同一张云账单告警卡按**每个已解析收件人**(含 `cc_person_ids`)是否在保密圈分渲染:圈外只拿无量纲比值 / 周期 / 规则**序号**(不再给「预算的 N%」——`GET /cmdb/scopes` 会把 `budget_usd_monthly`+`budget_measure` 发给 scope 成员,pct × budget 可反推净额阈值),圈内照旧完整。管理读面同理:`GET /notifications/outbox`(`require_admin_read` 含 auditor/management,圈外)对 `cmdb_cloud_scope` 行的正文做货币片段脱敏(`redact_money_in_text`),覆盖圈内收件人的行与**存量**行——这是 #2097 表四注 4 的选项 (a)+(d) 组合;(b) 被 (a) 覆盖,(c) 因过渡期静默不告警的代价被否决。根因侧:新建绝对金额规则**显式打口径标记**,非圈作者 → `list`。
   **1a. 一张卡上可以有多个口径,门禁要逐个判**:比较金额与**预算 tile** 是两个独立口径(tile 走 `scope.budget_measure`),所以 list 规则的卡片对圈外收件人仍要单独摘掉 net 预算 —— 而且根因修复(非圈作者的绝对规则标 `list`)会**制造**这个组合,修复不能只看主口径。
   **1b. 受众不可枚举的通道只能走写侧不落**:按收件人分渲染的前提是收件人是解析出来的 Person 行。群 webhook / 邮件组 / 公开看板的受众代码无从枚举,结构上不可能证明是保密圈子集 —— 这类通道**只能**发无量纲量 + 指向有门禁页面的链接(月度账单群 webhook 已按此改),不要试图在收件人侧解决。
2. **脱敏规则的定义**:去掉**绝对金额**与**能反推金额的量**(FX 汇率+原币、百分比+可读预算、派生阈值 `baseline × multiplier`);保留**无量纲同基比值**(actual_multiple、超基线倍数)——同基相除折扣率已消去,这正是取证/告警需要的那个数。残留明示:`spend ≥ threshold` 不等式预言机是阈值告警固有;无 list 列的云(Azure/腾讯/UCloud/金山)金额层面收益≈0(见 sweep 顶部不变量限定)。
3. **审计写钱动作强制登记(NON-NEGOTIABLE,性质同 MCP 工具计数 hint 规则)**:任何往 `audit_logs.detail` 写云成本绝对金额的动作,必须在同一 PR 登记进 `app.core.masking.AUDIT_ACTION_COST_BASIS`(`list` / `invoice` / `per_row` / `non_cloud`);**未登记动作在读侧对圈外读者自动按实付脱敏**(fail-closed)。**这条由测试而不是自觉守住**:`test_invoice_basis_redaction.py::test_every_money_bearing_audit_writer_is_classified` 用 AST 扫全部 `audit.log(action=..., detail={...})` 字面调用点,detail 键命中金额形状而未登记即红。**fail-closed 是正确的默认,但对非云动作它同时是静默回归** —— 首轮就有 10 个动作漏登记,auditor 查采购纠纷时看到的是 `[redacted]` 而不是资产原值,且没有任何东西告诉他这是分类遗漏。形状识别是钝的:`residual_rate`(残值率)、`balance_legal`(年假天数)、`charge_user_id`(UUID)都会被吃掉。读侧脱敏是分类表制,不是 #2097 反对的「共享序列化器按键名猜语义」——资产/采购/财务预算的钱显式分类 `non_cloud` 放行。写侧优先原则不变(scope_alert 行 net 下金额仍不落库),读侧管的是**存量行**与**漏网的未来写入**。
4. **告警输入齐全度按各自窗口判定**:daily 规则只要求**当天行**该口径齐,monthly 规则要求 **MTD 整段**齐。此前共用 MTD 闸,月初一天 list NULL 就把该 scope 全部 list 日阈值哑火 ~28 天(闸刚确认过当天有 list 却因 3 号缺数据整口径抹掉)。

## 2026-07-30 — Zoom 基线账号档位 = `no_meetings`(type 4),不是 Basic(type 1);容量拒绝归 `blocked`;blocked 积压必须有告警

**背景**:入职建 Zoom 号的链路 2026-07-24 上线,到 07-30 只建成 **1 个**账号,由员工反馈发现(新入职 `bin.ye@pingcap.com` 没有 Zoom 账号),而不是由平台发现 —— 所有 run 都是绿的。

1. **基线档位改为 `no_meetings`(Zoom `type=4`)**。原选 Basic(`type=1`)是因为它叫"免费",但它**按租户限量**(本租户 20 个名额),第 21 个建号被 `400/3412` 拒。生产真实分布决定了正确答案:`type=1` 恰好 20 个(满)、`type=2` Licensed 190、**`type=4` 397 个活跃** —— type 4(有账号、无 Meetings 许可,Zoom 2024 年许可模型改造的产物)才是这个租户「普通员工的基线账号」的事实含义,且无上限。`basic` 保留给坐席流的 Licensed→Basic 释放路径(managed-domain 租户唯一的释放方式),**不再**用于全员建号。
   **备注**:type 4 不在 Zoom 公开文档的 user-type 枚举里(文档仍只列 1/2/3/99),我们按**本租户实测分布**采用它;`_TYPE_RANK` 把 4 与 `none` 同列 rank 0(都不能开会),所以坐席流「不许静默降级」的守卫对 Licensed→4 仍然生效。

2. **下游容量拒绝 = `blocked`,不是 `failed`**。3412 这类「租户容量/档位配置」问题重试永远不会自愈,记 `failed` 只会染红 run、把真正的可重试故障淹掉。归入平台已有的 `blocked` 语义(非临时性、数据/配置依赖的拒绝,run 保持绿色、不重复重试、数据修好自动重推),并在**同一次 run 内跳闸**:第一个人撞上后,后续的人直接 blocked、不再发注定被拒的请求。

3. **`blocked` 积压必须有告警**(平台级,不只 Zoom)。`blocked` 保持 run 绿色是对的,但此前**任何目标**的 blocked 行都没有任何信号 —— 这正是「为什么是人发现的」。新增 `maybe_notify_blocked_backlog`(挂 `_finalise_run`,outbox 签名去重,只发目标自己配的收件人,守「不要默认通知管理员」)。**自愈型原因不告警**(`SELF_DRAINING_BLOCKED_REASONS`):天天响的告警等于没有告警。

4. **建号要限速**。建号会让 Zoom 给本人发邀请邮件 → 属于对员工的对外推送。`zoom_max_creates_per_run`(默认 25)让存量补建按天匀速排空,而不是开闸瞬间给几百人发信。呼应既有约定「新增推送先评估存量风暴」。

**考虑过但没选**:① 清 Basic 池腾名额 —— 20 个里只有 3 个是 Helix 不认识的账号,最多腾 3 个,治不了根;② 全员发 Licensed —— 要花钱,且付费坐席应当是申请制权益,不是入职默认;③ 保持 `update_only` 靠人工建号 —— 那等于这个目标不存在。

---

## 2026-07-29 — 双口径保密红线的判定式:「非圈用户不得拿到任何实付数字」;告警口径按规则判定;预算口径写授权「陈述可、移动需 admin」

**背景**:「折前 / 实付不得同框」这个目标在 2026-07-28~29 的三轮审计里**被漏过两次**——第一轮漏了 `/billing/daily`、`/billing/overview`;第二轮把 `/billing/monthly` 翻成折前时又**新造**了两处配对(`/cmdb/analytics`、`/dashboard/insights/cloud` 当时仍是纯实付,受众与粒度恰好相同)。根因是每轮都在修被点名的端点,没人持有全量清单。

1. **不变量改写成更强的一条**:**任何非保密圈用户都不得从任何面拿到任何实付基数字**。比「不得同框」强,也更好核查——只要非圈用户永远只拿到折前,任何两面相除都得不到折扣率,跨面泄漏被结构性排除,不必逐对枚举。全量清单落在 [cost-measure-surface-sweep.md](cost-measure-surface-sweep.md),加改面时核对它。
   **易踩的坑(写进表里)**:`can_view_finance` 含 `auditor` + `management`、`can_read_cloud` 也含、`can_view_cloud_resources` 还含**普通员工**(scope 成员)——这三个都**不是**保密圈(`can_view_invoice_measure` = finance / cloud_admin / global_admin)。
   **推论**:日表的 `cost_by_service` / `owner_breakdown` 只有实付版本,折前口径下**不发**(而不是"从 facts 重算一份折前拆分"——grep 过全部消费方,无面渲染它,重算是纯成本);必须发实付的面(对账、账本)门禁用保密圈**或其子集**,不用 `can_view_finance`。

2. **告警阈值口径按规则判定,不按整组**(取代 2026-07-28 第 2 条的"整组标记"契约)。「输入行必须整组同口径」约束的是**喂给一次比较的数据行**,不是规则集合。评估器为每个被用到的口径各算一份整组同口径聚合,每条规则只跟**自己 `measure` 标记**对应的那份比。未标记 = 实付(保守侧,与其阈值的历史口径一致);标 list 但窗口数据不齐 → **整条跳过并记日志**,绝不退回实付比较(那是把假触发翻成永久假阴性),也不占去重槽位。分层抑制与**跨 run 去重槽位**都按 `(type, 口径)` 分组。
   撤销原「整组闸」的原因:scope owner 通过 `PUT /scopes/{id}/alerts` 加一条未标记规则,就能把同 scope 其余已换算规则全部拖回实付(阈值高约 2.7×、永远打不到)——**非管理员可触发的静默降级**。去重槽位同理:一张实付月卡在 3 号发出会永久压制同月的 list 月卡,而 list 是研发面默认。

3. **预算口径写授权:陈述可、移动需 admin**。`cmdb_cloud_scopes.budget_measure`(迁移 433,NULL ≡ 实付)。scope owner 可写预算**数字**、也可**陈述**该 scope 现有口径(这正是 `CLOUD_BUDGET_MEASURE_REQUIRED` 要的消歧,不移动分母);**改变**口径需 `can_manage_cloud` → 否则 403 `CLOUD_BUDGET_MEASURE_FORBIDDEN`。`cloud.subscriptions:write` 视为 admin 侧(IT 签发的服务主体,不是 scope owner)。
   理由:禁的是移动分母而不是陈述它。把数字也一起锁,会让 list 换算过的 scope 预算对其归属人**永久不可编辑**;而允许 owner 单独翻 basis 则等于让他把 ~2.7× 虚高的预算重贴成实付标签、pct 阈值随之重算成永不触发——`set_scope_alert_rules` 的信任边界注释本就写着"不得静默变成 scope owner 给自己阈值换分母的途径"。

4. **对账端点收窄到保密圈子集**(用户拍板)。`GET /finance/spend/reconcile/internal` + MCP 孪生返回 `SUM(cmdb_cost_facts.amount_usd)`(实付)按「公司×月」,原门禁 `can_view_finance` 含 auditor/management。**对账无法用折前口径重述**(账本只能对上实付),所以不能像其它成本面那样默认发折前,只能收窄授权:改用 `can_operate_finance`(global_admin + finance),它是保密圈**严格子集**、且本来就是同域月结操作的门禁。
   **已考虑并否决的替代**:(a) 发一个抽掉金额的壳给 auditor——对账失去意义;(b) 保留现状并在 ADR 里记为明示例外——红线从此有书面缺口,下一轮复查会再次提出。
   ~~**仍未收紧、留待决定**:`/finance/waterfall` …… 门禁 `can_view_finance` 含 auditor/management。~~
   **2026-07-30 更正:这个待决项不存在。** `/finance/waterfall` 的门禁自 #2065 起就是 `can_view_invoice_measure`(保密圈本身),回读代码即知 —— 上面那句是 2026-07-29 凭记忆写下的错误描述,它凭空制造了一个待决项。该面故意并列两个口径(它就是折扣构成分析视图,每列带口径标注),而它本来就在圈内,所以并列是正确的、无需任何动作。教训见 sweep 表注 1:**改那张表或这条记录时必须回读代码**,一处错误的门禁名比不写更贵。

---

## 2026-07-28 — prod scope 的云账单告警只发 admin-tier,员工一律不发;告警比值/阈值输入必须整组同口径

**决策**(用户拍板,回应「我不是说过 Prod 的不触发告警给员工吗?以后不要给员工发 Prod 的了」+「检查同类问题一起修,不要有打扰用户的 bug」):

1. **prod 收件人门禁**:`environment='prod'` 的 scope,账单类告警卡(日环比尖峰 + 日/月阈值规则)的收件人在 `_resolve_billing_alert_recipients` 出口统一过滤为 admin-tier(`global_admin` / legacy `it_admin` / `cloud_admin`)持有人;scope owner / billing 角色 grant 持有人 / 规则 cc 名单里的普通员工一律剔除(记 log)。全员皆非 admin → 空集静默丢弃(log 可见),**不**回退为向全体云管 fan-out(2026-05 已否决过全量 fan-out,spam)。与既有「prod 云访问不由 Helix 自动化管理(到期/闲置扫描排除 prod)」「新增推送只许 IT-only」同一姿态。
2. **告警输入口径一致性(平台不变量)**:任何「比值型」(尖峰 today/baseline)或「对阈值型」(threshold_usd 是历史口径下物化的绝对值)告警,其输入行必须整组同口径——「今日 + 全部基线/MTD 行」都有 list 才用 list,否则整组回落发票净额。**读侧展示的按行 COALESCE(list→净额)语义禁止进入告警比较**(2026-07-27 假告警事故根因:3 天滚动窗口刚写出 list、基线还是净额,折扣 >33% 的 scope 必然假尖峰)。
3. **口径切换的运维契约**:`convert_cloud_budgets_to_list_measure.py` 同 ratio 换算 `budget_usd_monthly` **和** `alert_rules[*].threshold_usd`;顺序=先回填历史 list、立即跑脚本 --apply,输入口径按「历史行是否全有 list」自动翻转。

**上下文**:2026-07-27 员工蔡洲(employee,因持有 `roles/recommender.billingAccountCudViewer` 被 billing 关键词筛进收件人)收到 `gcp-autonomous-prod` 假「▲65%」卡,对照 GCP 控制台发现账单平稳,信任受损。同窗口共 12+ 张假环比卡、pingcap-gardener 日阈值连续 3 天假触发。

**备选**:(a) prod 告警全部静默 —— 被否,IT/云管仍需要看 prod 花费异常;(b) 给规则 dict 加 `measure` 字段按规则记口径 —— 暂否,全量规则同批换算 + 全组同口径闸已闭环,字段留到真出现「同 scope 混口径规则」需求再说;(c) 告警输入直接回退净额口径、放弃 list —— 被否,研发口径告警是双口径改造的既定决策,问题只在过渡期与阈值校准。

---

## 2026-07-24 — Zoom 账号生命周期进 identity-sync(数据同步)域,而非扩展坐席流

**决策**(用户拍板,回应「Authing 下线,把 Zoom 账号同步改成咱们的系统」):

1. **归属 identity-sync 域**:把 Zoom 的「入职建号 / 离职关号」做成一个 identity-sync 目标(第 7 个 kind `zoom`),和 Feishu/NetSuite/AWS IdC/Navan/汇联易 并列,在同步中心 `/admin/identity-sync` 统一管理。**不**扩展已有的 software-provisioning 坐席流。
2. **与坐席流互补分层**:身份基线账号(全员 Basic,免费)属于身份生命周期,走 identity-sync;付费 Licensed 坐席是按申请授予的权益,继续走 `/my/seat-requests` 坐席流。两条线操作同一 Zoom 用户但**永不互相覆盖**——本目标 `upsert` 对已有用户绝不改档位,坐席流的 Licensed 在同步时被保留。
3. **离职 = 停用,不删除**:`on_offboard=deactivate`(`PUT /users/{id}/status deactivate`),`supports_delete=False`。停用保留账号/录制、释放坐席、SSO 不会静默重建、managed-domain 也可停;需内容转移的彻底删除仍是坐席流 `revoke_seat action=delete` 的职责。与 NetSuite/汇联易 的「停用而非硬删」离职契约一致。
4. **与 Authing 脱钩,不碰 Google SSO**:Authing(2026-05-07 弃用)此前承载的 Zoom 账号供给由 Helix 直接接管;**Zoom 登录继续走 Google SSO,本目标绝不修改 Zoom 的 SAML/SSO 配置**。
5. **seed 但默认关**:migration 428 幂等 seed 目标行,`enabled=0`。首次全量会给存量在职员工批量建 Basic 号(免费但是可见的批量动作),由管理员在 UI 里确认后开启,可先收窄 filter。
6. **离职坐席回收复用既有对账机,不写一次性脚本**:用户要求「把已离职的人的 Zoom 号关掉、释放坐席」。只加一个 `_collect_zoom_records` 厂商快照采集器,让 Zoom 接入既有的 vendor-snapshot + `leaver_revocation_reconcile`(读只读孪生 `leaver_assurance` / 写 `revoke_leavers_on_target`),而**不是**写一次性清理脚本。因为对账机已有全部护栏(仅 `left`、保留名单豁免、快照晚于离职日、按 vendor external_key 收敛、逐行提交、批量上限),且 `revoke_leavers_on_target(dry_run=…)` 既能预览又能执行、且不看 enabled——历史欠账可在不触发建号 sweep 的前提下先清,目标启用后每日 cron 持续兜底。**只关 Helix 明确已离职者,查无匹配的号(服务号/外部访客/保留号)不动**。

**上下文**:Zoom 之前只以 software-provisioning 驱动接入(付费坐席 + LRU 挤出 + `sso_cascade` 被动离职),不主动给全员建号、也不主动关号——不满足「入职建号 / 离职关号」。复用现有 `zoom-primary` S2S OAuth 连接器(同 token 缓存),runner 仅承载 create-if-missing / 不降档 / 回归者激活的编排,HTTP 全部委托给 `ZoomSoftwareDriver`(一处对接 Zoom API)。

**备选**:(a) 扩展坐席流并把 `offboarding_policy` 改 `auto_revoke` —— 被否,坐席流是「按需付费权益」语义,和「全员基线账号」不同层,且用户明确要求「在数据同步里加」;(b) 离职硬删 —— 被否,破坏录制/会议且需 successor,违反「停用优先」离职契约;(c) 新建独立 Zoom 连接器 —— 被否,凭据/token 已有,重复接入无收益。

---

## 2026-07-22 — 云效率二轮:自助可见性=订阅归集口径(三页统一)/ 触达=站内+飞书 owner digest(默认关)/ 记账=同步侧检测含降配 / 不做代执行降配

**决策**(用户四点拍板,回应「员工打开效率页全 0 + 优化建议如何触达/闭环」):

1. **可见性口径**:自助(员工/经理)可见机器 = **server 级 `owner_person_id`(本人+下属)∪ 本人(+下属)拥有的云订阅归集的全部机器**(归因链=cost-fact `scope_id` 任意云 + GCP `raw_data._project_id`→scope `external_id`,与 `resolve_inherited_server_owners` 一致)。**统一套用**到效率页、我的云服务器列表、服务器详情三处——任何列表可见的机器点详情永不 404。共享原语在 `app/domains/cmdb/visibility.py`。
2. **触达**:站内通知 + 飞书按 owner 聚合 digest **都做**(每周一张卡片/人,只发有待处理 actionable 建议的 owner)。**默认关**,管理员控制台开关 + 发送前收件人预览,由用户评估存量(当时 pending 3,584 条)后手动开启——兼容「新增推送只许 IT-only」红线的逐案确认。已有的 `cmdb.efficiency_digest`(IT-only 周报)保留不动,owner digest 是独立新 handler。
3. **已优化成本记账**:从「只认 Helix 内 stop/destroy audit hook」扩成**同步侧检测**——被标 idle/oversized 的机器在后续同步/重算中 (a) 消失→`destroy`、(b) 变 stopped→`stop`、(c) 机型变小→新增 **`downsize`** method,事件标 `source=console`(Helix 内动作 `source=helix`)。用户在云控制台处理也能进「已优化成本」。
4. **不做 Helix 代执行降配**(resize 云写操作)——保持效率面纯只读,降配去云控制台做,靠同步侧检测记账闭环。

**背景/根因**:效率页顶层只认 server 级 owner(生产覆盖率极低,如 shuai.feng 名下仅 1 台 UCloud 且 UCloud 无 metrics 能力永远没有画像行)→ 几乎所有员工看到全 0;订阅业主要钻进订阅详情 pane 才能看到归集机器。

**替代方案(未采纳)**:保持 server owner 口径只改空态文案(治标);触达只做站内或不做(用户要双通道);记账只认 Helix 内动作(控制台侧优化全部漏记);Helix 代执行 resize(各云 API 面广、受 prod 写锁约束、风险/收益不成比例)。

**执行模式**:用户授权三 PR 全自主交付(每 PR 过 CI 自行合并),完成后整体 code review + 安全/逻辑扫描循环,最后部署生产;owner digest 上线保持关闭待用户手动开。

---

## 2026-07-20 — 提前 24h 建号需要 effective-dated HRIS 数据:SF 按入职日拉未来切片 + 入职日兜底补发

**决策**（用户确认，回应 yihan.mo P10014 三处入职缺口:飞书无部门/未进群、飞书没收到欢迎卡片、欢迎信没抄送当地 Ops）：**两者都做**——① 从 SF **源头**按入职日拉取未来生效数据,让建号当场数据就位;② 无论 HR 是否提前录,都有**入职日兜底补发**,保证不出错。

**背景/根因**:账号 + 双邮件按已批准的「提前 24h」(见 2026-07-06「入职建号提前 24 小时」)触发,但员工的**部门(EmpJob)、HRBP(SF 2448)、HR Ops(SF 2450)**都是 effective-dated,要到入职当天才成为 SF 当前生效切片。Helix 三个查询(`list_current_jobs` 的 `effectiveLatestChange eq true`、`get_jobs_for_users`/`list_job_relationships` 无日期参数=默认 as-of-today)在提前 24h 建号那一刻都拉不到未来生效行 → 飞书回落根部门 `"0"`(#1987 已能在入职日自愈)、欢迎邮件 CC 名单为空(当地 Ops 收不到)。飞书卡片则被 30 分钟补发器在账号一建好(提前 24h)就 DM 给尚未激活的账号,员工入职日看不到旧 DM 且幂等标记已打死不再补。

**决定要点**：
1. **SF 支持按日期拉未来切片,且 Helix 从未用过**。EmpJob/EmpEmployment 的未来生效行在入职前已存在于 SF(代码多处证实);SF EC OData 对 effective-dated 实体支持 `fromDate`/`toDate` 导航参数。为 `get_jobs_for_users` / `list_job_relationships` 增 `effective_as_of`,建号路径对**未入职者**按 `hire_date` 拉未来切片。**仅对未入职者启用**(其无当前生效行,只会填空、绝不把在职员工的未来调动提前生效)。
2. **不动员工欢迎邮件的提前 24h**(2026-07-06 已批准,且「入职当天 0 点建」备选当时被否决=无缓冲)。CC 靠源头拉取在同一封邮件生效;拉不到则走兜底。
3. **兜底优先于源头依赖**:HR 是否提前录未来生效行属**外部依赖**(尤其 2448/2450 能否在入职前录入 SF 未经查证)。因此设计成:源头拉到 → 建号当场齐全;拉不到 → 入职日关系落库后由补发器补部门(#1987 自愈)+ 补飞书卡片(改由入职日发给已激活账号)+ 补 HR 抄送(给 HRBP+HR Ops 补送同样的欢迎信)。**即使源头一直拉不到也不出错**,这样也免除了为上线去跑生产 SF 探针的前置阻塞。
4. **字段契约**:HR 需在入职前把 SF 的 EmpJob(`department`/`managerId`)+ 工作关系(2448/2450)录好并让生效日覆盖入职日,契约文档 `docs/integrations/successfactors-future-hire-fields.md` 供 HR/SF 侧对接。「当地 Ops」= 按人的 HR Ops(2450),无独立「按办公室」概念。

**替代方案(未采纳)**：① 只做下游兜底、不碰 SF —— 可行但建号当场仍缺数据、CC 仍落到入职当天而非原邮件,用户明确要源头修;② 只做 SF 源头拉取、不做兜底 —— 依赖 HR 提前录 2448/2450(未经查证),失败即回归原 bug;③ 把欢迎邮件也延到入职当天 —— 违反 2026-07-06 已批准决策。

**上线纪律**:`effective_as_of` 的 SF 日期参数未在生产 SF 上验证(用户「先不动生产」),故设计为 best-effort + 兜底兜住:参数即便被 SF 忽略/报错,`apply_hris_role_relationships` 吞异常返回 0、兜底补发照常在入职日生效,零回归风险。yihan 个例的生产补救(强制重推飞书 + 补发卡片 + 补抄送)按用户要求**暂不执行**,仅交付代码。

---

## 2026-07-18 — 2061(不推送)不豁免离职回收 + 零信任对账要有「写侧孪生」主动收敛

**决策**（用户确认，回应 `leaver_assurance` 首次在 aws/alicloud 目标上告警 58→101 条真阳性）：

1. **`sf_downstream_push_code=2061`(SF `customString24` 不推送)只压制「在职员工的下游 provisioning」,不豁免「离职者的账号回收」。** 一个已离职的 2061 员工若厂商侧账号仍活跃,必须回收——「不推送」不能演变成「离职后账号永久在线」。这**不**改动 `_load_candidates` 的 2061 排除(它对在职 provisioning 仍正确);回收由下述快照驱动的对账负责,它绕过 `_load_candidates`。理由:安全不变量优先;2061 的语义是「Helix 不主动往下游写属性」,不是「离职豁免」。

2. **零信任对账从「只读告警」升级为「读+写双闭环」。** 新增 `identity_sync.leaver_revocation_reconcile`(只读 `leaver_assurance` 的**写侧孪生**),两者共用同一 Signal-B 人群(`IdentitySyncService.active_leaver_vendor_records`),因此告警必被回收清零、读写两侧不可能对人群判断不一致。回收按厂商自己的 `external_key` 直接停用/删除,**无需 Helix 侧既有链接**,故能收掉 sweep 结构性收不到的两类:(a) 2061 离职者;(b) **孤儿**——账号早于 Helix 接管该目标就存在、离职时无事件下发(如 alicloud-sso 2026-07-09 接入前离职的 94 人)。新目标接入的遗留账号由每日全目标扫描在 ≤24h 内自动收敛(即「目标接入对账」,target-agnostic,不引入脆弱的 HTTP 建目标钩子)。

**上线纪律**:存量 101 个账号的实际 prod 回收**须用户明确放行后**才执行;`revoke_leavers_on_target(dry_run=True)` 先产出清单预演。护栏与 sweep 一致:registry 保留豁免 + 快照新鲜度闸 + `left` 门 + runner 能力位。

**考虑过但否决**:①把 2061 离职者像 registry-known 一样从 assurance 豁免——等于静音真安全信号,与零信任相悖;②只做一次性人工清理 94 个——不防复发(下个新目标接入又会遗留)。

**实现**:[`active_leaver_vendor_records`/`revoke_leavers_on_target`/`_mark_leaver_state_terminal`](../../server/app/domains/identity_sync/service.py)、[`run_identity_sync_leaver_revocation_reconcile` + assurance Signal B 复用共享 helper](../../server/app/domains/identity_sync/scheduler_handler.py) + 迁移 410。测试 [`test_leaver_revocation_reconcile_2026_07_18.py`](../../server/tests/test_identity_sync/test_leaver_revocation_reconcile_2026_07_18.py)。承接 2026-07-17「离职回收零信任」ADR(#4)。

---

## 2026-07-17 — 测试工号段按数值区间 9900–9999(双宽度)判定 + SCIM PUT-merge 默认关

> **状态: 用户批准(7-PR 重构第 7 个;测试段收口此前对话已一并认可「P099 号段扩正则我已核实现存行全是软删测试行」)。**

**背景**:HR 的测试工号保留段是 **9900–9999**,但 SF 会写成两种宽度:4 位 `P9900`–`P9999` 和补零 5 位 `P09900`–`P09999`。旧闸 `P99\d{2}` 只匹配 4 位,`P09966`(test.test)等 5 位测试行逃逸、被推到全部 target。另外 SCIM full-replace PUT 会清掉 body 未含的属性,瞬时空字段会擦除厂商值。

**决定**:
1. **测试段按数值区间判定,不按字符串形状**:去掉 `P` 前缀、若余部全为数字则判 `9900 <= n <= 9999`。覆盖两种宽度,且**真实工号数值在区间外永不误伤**(PingCAP ~500 人,P0 段真实号远不到 9900;9900–9999 是刻意预留在真实号之上的测试段)。已核实 prod:9900–9999 段现存行全是软删测试行,无真人。旧测试里把 `P09999` 当「真实五位号」的断言反映的是旧 bug 的误解,已改为真实号(`P01572`/`P10018`)。**理由**:比"逐个加号段黑名单"或"只认 4 位"都更稳(数值语义唯一),且与 HR 的保留区间定义一致。
2. **SCIM PUT 的 GET-merge 默认关(opt-in `put_merge`)**:full-replace PUT 清场是真问题,但**强制**每次 PUT 先 GET 会给线上 navan/aws-sso/aws-ai(各 ~430 人)的每次推送加一次读放大,且合并回厂商只读字段有未经线上验证的厂商特定风险。改为 opt-in:能力就位(AWS IdC 可开),但不默认改变线上行为。**理由**:PR2 的必填预检 + value_map 未命中信号已覆盖"瞬时空"的主要子类,#1965 已保护 manager 引用槽,残余风险低;宁可把有读放大 + 厂商风险的行为做成按目标验证后开启,而非默认强加。

**备选(否决)**:① 只把 4 位扩成"P + 可选 0 + 99 + 2 位"正则 —— 不如数值区间通用(还能覆盖未来其它零填充形态);② 维护显式工号黑名单 —— 一次性、易漏,数值区间是规则化的根治;③ PUT GET-merge 默认开 —— 读放大 + 未验证厂商风险强加到线上,违背"确认后再动高风险外向行为"。

**实现**:[`is_test_employee_no` 数值区间 + `CandidatePerson.hris_person_id` + `matches()` 双查](../../server/app/core/person_filters.py)、[`_validate_runner_options` 未知键拒绝 + `put_merge`/`schemas_user_create` 接线](../../server/app/domains/identity_sync/service.py)、[`ScimRunner` opt-in GET-merge](../../server/app/domains/identity_sync/runners/scim.py)、[`google_schema` 缺失缓存](../../server/app/domains/identity_sync/runners/google_schema.py)。测试 [`test_mapping_config_safety_2026_07_17.py`](../../server/tests/test_identity_sync/test_mapping_config_safety_2026_07_17.py)。**这是 7-PR 数据同步重构的收官 PR。**

---

## 2026-07-17 — 事件推送必达:扇出不误伤 + 撞锁必重排

> **状态: 用户批准(7-PR 重构第 4 个;"执行必达"不变量在事件路的落地)。**

**背景**:生命周期事件无视各目标 filter 扇出到每个 event-aware 目标。两个缺陷:①对"在职、范围外、从未链接"的人仍走 deactivate —— 给该目标从没同步过的人伪造回收台账,且在按邮箱寻址的厂商上可能误停一个同邮箱的无关账号(入职事件误回收);②撞到目标锁就丢弃 —— event_only 无 sweep 兜底=永久丢失。

**决定**:
1. **范围外未链接的在职人是 no-op,不是回收**。回收(deactivate)只对"离职者"或"曾链接过的人"有意义;对一个该目标从未同步、且仍在职的人,唯一正确动作是什么都不做。理由:扇出是"广播给所有目标各自判断",不是"让每个目标去回收范围外的人";在按邮箱寻址的厂商上尤其危险(会动别人的账号)。
2. **离职者豁免该 no-op 闸**:offboard 事件即便无历史链接也要走 deactivate —— 该路径按邮箱解析,能抓到 Helix 从未登记、但离职仍需关停的线下建的账号。理由:回收宁可多查一次邮箱,不可漏关一个离职者的账号(与 PR3 零信任一致)。
3. **撞锁不丢弃,有上限地延迟重排直至落地**。理由:"必达"要求推送要么落地、要么留下可告警痕迹,不能静默蒸发;in-memory 延迟重排在无新基建(单 TiDB)约束下,覆盖"in-flight sweep 数十秒到分钟级完成"的绝大多数情形,耗尽上限才回落到"cron 靠下次 sweep / event_only 告警 IT"。

**备选(否决)**:① 在 events.py 扇出层按 filter 预筛 —— 不如放在单人 chokepoint(所有调用者统一覆盖,且能区分离职者豁免);② 对所有范围外的人都 no-op(含离职者)—— 会漏关离职者线下账号;③ 撞锁落 DB 队列做持久重排 —— 需新基建(broker/state store),违背单 TiDB 约束,且 in-memory 重排 + cron/告警兜底已足够。

**实现**:[`_push_one_person` no-op 闸 + `_push_person_to_target`/`sync_person_to_target` 撞锁重排 + `_retry_person_push_after_delay`](../../server/app/domains/identity_sync/service.py)。测试 [`test_event_delivery_2026_07_17.py`](../../server/tests/test_identity_sync/test_event_delivery_2026_07_17.py)。

---

## 2026-07-17 — 离职回收零信任:写路径的"已停用"必须被独立反向核对

> **状态: 用户批准(7-PR 重构第 3 个;"回收零信任"是四不变量之四)。**

**背景**:离职回收此前整条链路 assumption-based —— runner 的 `deactivate` 返回"已停用"就当真。复核发现多处"绿灯但账号还活着":①SCIM 对陈旧 external_id 的 404 直接当成功,但同一邮箱可能挂在另一个 id 下(联邦重建/厂商重建);②NetSuite 对 `eid:{工号}` 的 404 当幂等成功,但多数 NS 记录是 Authing 时代按内部 id、externalId 空的,`eid:` 必然 404 而真实记录仍 active;③auto-included 主管无条件豁免出范围回收,离职后账号永久敞开。这些都是安全假阴性:离职者仍能登录厂商系统,而看板显示已回收。

**决定**:
1. **停用/删除对 404 必须"验证后再声明"**:404 只证明"这个 id 没了",不证明"这个人在厂商侧没有活跃账号"。SCIM 用邮箱、NetSuite 用工号(SuiteQL)重新解析,发现另一个活跃账号就回收它,仅当二次查找也为空才算幂等成功。理由:回收是安全关键动作,不能信任一个可能陈旧的 id 的 404。
2. **复核查找失败必须容错降级**,不能把陈旧-id 404 变成每 sweep 硬失败(否则"零信任"引入新的天天红)。找不到=按"已不在"处理(下个 sweep 查找成功时仍会抓到真账号)。
3. **auto-included 豁免仅限在职**:一个人不在职了,就不该再享受"永久在范围内"的豁免——必须回收。理由:豁免的初衷是"别因为 HR 把外籍下属调走就停掉在职主管的账号",与"离职后也不回收"是两回事。
4. **回收要有独立于写路径的反向对账闭环**(`identity_sync.leaver_assurance` cron):写路径声称回收成功是一面之词;每日按 Helix 状态 + 厂商快照两路反向核验离职者是否仍有活跃账号,命中只推 IT。理由:安全不变量不能只靠"生产者自证",要有"消费者独立复核"。这是 Feishu account_backstop(仅飞书)向全 target 的推广。

**备选(否决)**:① 继续信任 404=已停用——就是被否的假阴性现状;② 反向对账实时调厂商 API 核验——每日全量厂商调用太重,改用已有 vendor 快照(过期只少报不误报)+ Helix 状态双信号,纯 DB;③ auto-included 离职也豁免、只靠事件回收——事件丢一次账号就永久敞开,正是要消除的单点。

**实现**:[`ScimRunner._patch_deactivate`/`_safe_find_user_id_by_email`](../../server/app/domains/identity_sync/runners/scim.py)、[`NetSuiteRunner._resolve_internal_id_by_work_num`](../../server/app/domains/identity_sync/runners/netsuite.py)、[`_sweep` auto-included 仅在职豁免 + `_push_one_person` leaver 守卫](../../server/app/domains/identity_sync/service.py)、[`run_identity_sync_leaver_assurance`](../../server/app/domains/identity_sync/scheduler_handler.py) + 迁移 405。测试 [`test_offboarding_zerotrust_2026_07_17.py`](../../server/tests/test_identity_sync/test_offboarding_zerotrust_2026_07_17.py)。**飞书 freeze 路径的邮箱/工号 fallback 在 PR5 做(避免两 PR 同改 feishu_contact.py)。**

---

## 2026-07-17 — 数据同步结果分类学:`blocked` 终态区分"数据依赖拒绝"与"瞬时失败"

> **状态: 用户批准(7-PR 重构第 2 个;"结果诚实、失败可分类"是四不变量之二/之三)。**

**背景**:此前每个 (人,target) 只有 `success/skipped/excluded/failed`。`failed` 把两类根本不同的东西混在一起——**瞬时**故障(网络/5xx,该自动重试)与**数据依赖**的厂商拒绝(缺必填字段、value_map 没有该新编码、飞书账号已离职)。后者每个 sweep 都重推、每次烧一个必然失败的厂商调用、每次把 run 染红,导致"同一批人天天红"淹没真失败(NetSuite 20% 成功率的重要成因)。另有两处"绿灯造假":STRICT value_map 未命中静默丢字段冻结下游属性、slot 降级记 success 但把丢掉的字段冻结至多 10 天。

**决定**:
1. **新增 `blocked` 人员终态**,语义是"数据依赖的厂商拒绝,重试无用、但数据修好会自愈":存 hash(下个 sweep 廉价复核不重推)、retry_count 不涨、带可操作原因、计 `summary.blocked` 而非 `failed`、**run 保持绿色**、失败告警不触发;payload 变化(数据修好)自动重新同步。理由:一撮待补数据的人是**待办的数据任务**,不是同步失败;把它算成 failed 既误导又淹没真问题。
2. **必填由映射声明(`MappingRule.required`),预检在渲染期**:必填字段渲染为空 → 调厂商**之前**置 blocked。理由:与其让每个 vendor 各写一遍"缺子公司就 400"的知识,不如把"必填"放在映射配置(数据)里,渲染期统一拦截,零厂商调用浪费,且天然多语言/多 vendor 通用。
3. **value_map「新码」只提醒一次**(outbox 集合签名去重),而非静默丢字段或每 sweep 重复告警。理由:新法人主体/新用工类型是**一次性配置任务**,该被看见一次并处理,不该无声冻结数据、也不该刷屏。
4. **槽降级不刷新 `last_verified_at`**(强制下个 sweep 重试被丢的槽,而非 hash 冻结 7-10 天)+ 保留降级原因。理由:部分成功不是完全成功,"绿灯 + 冻结错值 10 天"违背"结果诚实"。
5. **失败告警去重改滑窗**(回溯最近 25 个 finished run 找上一个真正告警过的 run 比签名),而非只比紧邻上一个 run。理由:高频单人事件 run 夹在中间会让常驻夜间 partial 每天重复告警。

**备选(否决)**:① 继续只用 `failed` + 靠人肉分辨——就是"天天红"现状;② 用 per-runner 的 `required_fields()` 方法声明必填——不如放在映射配置通用、且要每个 runner 改代码;③ blocked 也算进失败率/触发告警——那 blocked 就没意义了,仍会淹没真失败;④ 每 sweep 都对 blocked 行重推以"探测是否恢复"——正是要消除的 churn,靠 payload-hash 变化触发重试即可,零浪费。

**实现**:[`RunSummary.blocked`/`value_map_misses`](../../server/app/domains/identity_sync/runners/base.py)、[`MappingRule.required` + `collect_render_diagnostics`](../../server/app/domains/identity_sync/mapping.py)、`_sweep` + `_push_one_person` 的 blocked 分支与预检、[`maybe_notify_value_map_misses` + `_should_notify` 滑窗](../../server/app/domains/identity_sync/alert_notifier.py)、迁移 404(`blocked_count`)。测试 [`test_outcome_taxonomy_2026_07_17.py`](../../server/tests/test_identity_sync/test_outcome_taxonomy_2026_07_17.py)。**PR5/PR6 会把飞书 42006/41010、NetSuite 子公司/货币/实体名、汇联易 companyCode 等具体 vendor 拒绝路由进 `blocked`。**

---

## 2026-07-16 — 数据同步执行面四不变量:执行必达 / `cancelled` 独立态 / 失败当天退避重试 / 手动同步后台化

> **状态: 用户批准(本轮对话,"不是修修补补,从设计上、逻辑上完善";"一个 PR 合并好就自己做下一个"的 7-PR 重构第 1 个)。**

**背景**:数据同步"天天报错、数据天天不准"的执行面根因不是单点 bug,而是缺一条**执行必达**不变量。`_finalise_run` 过去在任何定时 run 收尾时都把 cron 游标推进到**下一个 cron 槽**——于是:①每次合并自动部署重启容器,优雅停机取消了正在跑的定时 sweep,被记 `failed` 且游标推到明天,**当天全量对账(含离职回收)整个丢失**、无当天重试(讽刺的是硬 SIGKILL 因为 `_finalise_run` 根本没跑到、游标不动反而自愈——优雅停机严格比崩溃更糟);②厂商一次瞬时抖动 = 24h 数据滞后;③手动「立即同步」在 HTTP 请求内同步跑完整 sweep,断连即取消半途夭折。同时 `cancelled`(基础设施事件)和 `failed`(数据失败)被混为一谈,把 NetSuite 成功率压到 20%、真失败被淹。

**决定**:
1. **`cancelled` 是一等运行状态,与 `failed` 分开**。worker 停机 / 部署 / task cancel 杀掉的 sweep 记 `cancelled`;监控看板独立成桶,**不并入 failed、不进 `success_rate` 分母、不触发失败告警**。理由:部署把 sweep 打断不是数据质量问题,把它算进失败率会让"天天红"淹掉真信号。
2. **cron 游标按 run 结果重排(执行必达)**:`ok`/`partial`→下一 cron 槽;`cancelled`→**now+60s**(部署重启后下一 tick 补跑,当天对账绝不丢);`failed`→**当天指数退避重试** 5/10/20/40/60m 封顶,随 `sched_failure_streak` 升级,超 5 次回落每日槽。理由:一次瞬时失败 / 一次部署都不该让"这一天"凭空消失;但也不能猛捶一个真的挂了的厂商,故有次数上限 + 沉默漏跑看门狗兜底。
3. **手动/MCP 触发的 sweep 一律后台执行**:请求内建 run 行、立即返回 `run_id`,真正 sweep 交常驻后台任务在请求生命周期外跑,前端轮询 `GET /runs/{id}`。理由:管理员的连接寿命不该成为 sweep 的时限;NetSuite 一次全量数分钟,断连即取消是数据不准的独立来源。
4. **幽灵运行行由定时 cron 清理**(每 15min),不再只靠 web 启动看门狗(仅开机扫、30min 下限)。硬退出留下的 `running` 行收尾为 `cancelled`,目标 `next_run_at` 不动(硬退出未推游标、下一 tick 自愈)。

**备选(否决)**:① 只把 `CancelledError` 从 `failed` 改记 `cancelled` 但仍推游标到明天——治标,当天对账照丢;② 把 worker `stop_grace_period` 拉到能跑完整个 sweep(数分钟)——部署会被一个慢 sweep 卡住数分钟,且 553s 级 NetSuite sweep 根本不现实;改为"取消后靠 60s 重排补跑"更稳,`stop_grace_period` 只需覆盖"排水 + 取消后 finalise run 行"(设 30s);③ 手动同步保持同步、加个更长的 nginx 超时——治标,大 target 仍会超;④ 失败无限当天重试——会猛捶挂掉的厂商,故设 5 次上限 + 回落每日槽。

**实现**:[`_finalise_run` / `run_target` / `start_manual_run_background` / `cleanup_stale_runs`](../../server/app/domains/identity_sync/service.py)、[`scheduler_handler.py`](../../server/app/domains/identity_sync/scheduler_handler.py)、迁移 402(`sched_failure_streak` 列 + `identity_sync.cleanup_stale_runs` cron)、worker `stop_grace_period: 30s`。测试 [`test_run_rearm_policy_2026_07_16.py`](../../server/tests/test_identity_sync/test_run_rearm_policy_2026_07_16.py)。**这是"数据同步全面重构"7-PR 的第 1 个(执行面);后续 PR 覆盖 outcome 分类学/阻塞态、离职回收零信任对账、事件推送必达、飞书/NetSuite/汇联易写正确性、映射与配置安全。**

---

## 2026-07-16 — 建号时活体 SF 存在性复核(第四道入职建号闸,fail-closed/fail-open 语义)

> **状态: 用户指定实现(本轮任务:为未来新人在 mint 时做 live SF presence re-check,并与 #1767/#1905/#1953 三闸保持一致)**

**背景**:每日 `hris.absent_person_check` 对未入职者刻意豁免(未来生效 EmpJob 结构性缺席于当日 roster,缺席≠被删),而 24h-lead 建号路径只查 purged 坟头。HR 在入职前 1-2 天删掉 SF 报错条目(roger.zhou02 形态)时,人保持 `present` 穿过整个建号窗口,#1905 闸永不触发。

**决定**:
1. **复核位置在建号驱动层**(`hire_day_service`,两条 mint 路径共用 per-run memo),不放进 coordinator 钩子——钩子被多路径高频调用,不应各自发 SF HTTP。
2. **判定实体用 `User`(`get_users`,含全部已知 status)**,不是 roster diff:对 pre-start 者,「查无 User 行」才等于「源头已删」。
3. **范围边界与 #1953 分类器 rule-1 一致**:`hire_date >= as_of`(上海日期)。已入职者归每日对账管。
4. **坟头完全复用每日对账语义**(同字段/同 lifecycle 事件/同 audit action),仅 `hris_purged_reason='absent_at_hire_day_recheck'` 区分来源——下游闸、徽章、登录封锁、un-purge 逆操作零改动。
5. **失败语义**:SF 查询失败 = fail-closed(顺延本 tick,不打坟头;小时 cron 在 24h lead 内自愈);实例不可解析/非 SF 源 = fail-open(遗留行为不回归);单 tick 缺席 > 10 触发刹车全部顺延(防新 status 值不可见被误判成批量删除——`active_external_suite` 陷阱形态)。

**备选(否决)**:① 把每日 absent check 的豁免改成「未来者也对账」——roster 对未来者结构性缺席,会重演静文/亦寒误坟头;② 在 coordinator 每个钩子里加 live check——重复 HTTP、多路径漂移,正是 #1953 治理掉的形态;③ 只 skip 不打坟头——永不收敛,入职日当天小时 cron 仍会抢在每日对账前建号。

**实现**:[`_filter_by_live_sf_presence`](../../server/app/hr_lifecycle/hire_day_service.py);回归 [`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)。

---

## 2026-07-16 — HRIS 生命周期判定收敛为单一分类器 + 不可能状态不变量(治理「未来新人↔离职者」误判整类 bug)

> **状态: 用户批准(本轮对话,"从设计上来优化不出现这些漏洞";复核门策略选「分级自动确认」、落地选「一个 PR 全做」)。**

**背景**:同步误判反复发生——Camila P10009(多实例乱序,未来新人被当离职)、Julia P01572(迟到入库,老员工当新人)、roger.zhou02(SF 删的未来入职被建号)、Robert Wood P10013(全量 `>` / 增量 `>=` 漂移 + 上海时区边界,且无真实 endDate 时用 `lastModifiedDateTime` 伪造离职日)。根因是**设计层**的三个问题:① 「未来新人 vs 离职者」判定在 `preview_full_employee_sync` 与 `preview_incremental_sync` 两条路径**各写一遍并漂移**;② 数据缺失时**伪造离职信号**;③ 允许表示**逻辑不可能的状态**(离职日 < 入职日)。此前每次都是补一个边界,换条路径/换个时区再漏。

**决定**:
1. **单一事实来源分类器** `classify_inactive_sf_user(hire_date, as_of, termination_end_date)`(+ `sf_status_is_active`/`SF_ACTIVE_STATUSES`)是判断「SF inactive 用户是不是离职者」的**唯一**函数,full + incremental 两路径都调它。规则:未到岗(`hire_date >= as_of`)→ 永不判离职;有真实 `endDate` 且 `>= hire_date` → offboard;其余(无真实 endDate 或 endDate<hire)→ 不判离职、不伪造。各路径仍自行决定对「非离职者」怎么处理(全量跳过、增量建号),但**离职与否的裁决集中在一处**。
2. **时区**:`as_of` 用 HQ 上海日期即可,不需要 per-person 时区。上海是我们最东时区、日历日最先翻页,`>=` 能在**任意时区**覆盖新人整个到岗前窗口(上海翻到 hireDate+1 时地球最西端也已进入 hireDate)。刻意不引入 per-person tz 到分类器——那会增加「取不到 location→错 tz」的新失败面。
3. **不可能状态不变量守卫(双闸)**:语义不变量 `termination_date < hire_date` 一律拒绝——**preview 时**在 `_stage_event` 丢弃 staged 事件(覆盖 full/incremental/window-import),**apply 时**在 `_upsert_person` offboard UPDATE 分支忽略写入(覆盖 **webhook 推送**——它直连 `_apply_event`、绕过 staging,单靠 `_stage_event` 够不着;此漏由本轮 code review 抓出并补)。与路径/时区无关,是防「未来任何分类器 bug」的最终底。
4. **不伪造**:删除 full-sync 的 `lastModifiedDateTime` 兜底离职日;无真实终止记录 = 不判离职。
5. **置信度分级发生在分类层**:破坏性 offboard 必须有真实终止记录(高置信)才产生;无根据的 inactive 降级为 skip+`hris_status_divergence`(可见待复核),不再自动执行破坏性动作。因此**不新建** per-event 复核队列/置信度列(冗余 + 需迁移)。

**备选(否决)**:① 继续按路径/边界逐个补 —— 就是被否的「补窟窿」;② 保留伪造 + 加复核队列 hold 低置信 offboard —— 需 schema 迁移且本质是「先造错再拦」,不如「不产生无根据的破坏性动作」干净;③ 分类器引入 per-person 时区 —— 收益为零(`>=`+上海已在所有时区安全)、新增失败面;④ 一步步分 PR —— 用户明确要一个 PR 到位。

**实现**:[`classify_inactive_sf_user`](../../server/app/domains/hr/service.py) + `_stage_event` 不变量守卫;测试 [`test_lifecycle_classifier.py`](../../server/tests/test_hr/test_lifecycle_classifier.py) 与 `test_full_sync_forfeited.py` 新增用例。**未来改任何「离职/未来入职/`self.today` 比较」逻辑,改分类器一处即可,不要再在某条路径内联判定。**

---

## 2026-07-15 — 入离职「任务队列」补齐对 hr 只读(入离职管理菜单四项对 hr 全开)

> **状态: 用户批准(本轮对话,"一起对HR开吧")。**

**背景**:离职 SLA 卡片的 counts-only 死胡同排查中,确认入离职管理菜单的前三项(总览/入职管理/离职管理,含流程详情页)自 2026-07-08 已对 `hr` 只读放行(`require_lifecycle_read`),唯独第四项 **provisioning 任务队列**仍 global_admin-only。用户要求"整个入离职菜单都给 HR 开,让他们随便看"。

**决定**:把 `/admin/provisioning/tasks` 的**读取面**对 `hr` 开(与 global_admin/management 并列于 `GET /provisioning/tasks` 及 `/tasks/export.xlsx` 的「看全量」判定),前端路由从 global_admin-only 移入 `['global_admin','hr']` 并加入 HR 侧边栏。**每个任务变更动作(execute / cancel / fail / bulk)保持 `require_admin`** —— hr 只读、不可操作。沿用已对 hr 开的 `/admin/offboarding` 既定模式:hr 看得到按钮但点击 403(不额外做前端按钮隐藏,server-side gate 为唯一强制点)。

**边界**:任务队列虽物理上是跨域下发队列(含软件/云任务),但它是入离职管理菜单的一员、且已有 `require_lifecycle_read` 同级读面(overview/analytics/policies)对 hr 开,故读取面对 hr 开属一致收口,非新增跨域读授权面。`HR_ROLE` 未加入任何**变更**闸。

**备选(否决)**:① 保持 global_admin-only —— 与用户"整个菜单全开"诉求相悖;② 前端为 hr 隐藏所有变更按钮 —— 与同菜单 `/admin/offboarding` 的既定"按钮可见+点击 403"模式不一致,徒增分叉;③ 用 `require_lifecycle_read` 一并纳入 auditor/management 读全量 —— 超出"对 HR 开"范围,auditor 有独立菜单不经此入口,management 已在该 view-all 判定内。

---

## 2026-07-14 — 汇联易部门 HRBP(6105)按整棵子树取众数(父/BU 节点不再被 leadership 直属成员带偏)

> **状态: 用户批准(本轮对话,"按生产标准修复")。**

**背景**:汇联易的团建等审批解析**一级部门**的 HRBP 作为审批人。孙欢(Finance/Accounting)的团建走到了唐小丽(P0100)而非其部门 HRBP Sally Hua(P01269)。用户让**先对比 Authing 旧做法**再定方案。查证(浏览器看 Authing 同步中心 + 生产只读实调汇联易 open API):① Authing 旧链路是 **北森→Authing→下游(AWS/飞书)**,**从不同步汇联易**(仅 OIDC 登录);汇联易部门 HRBP 历史上由北森/HR **按部门权威维护**,非派生。② Helix 接管后,`_huilianyi_dept_attributes` 把每个部门的单一 HRBP 取成**该节点直属成员**的 `hris_hrbp_person_id` 众数——父/BU 节点的直属成员是 leadership 小圈子(CFO 个人挂资深 HRBP 唐小丽),与下面业务团队(Sally)不同,于是 Finance BU 被盖成唐小丽,而整棵 Finance 子树是 Sally 16:1。

**决定**:**HRBP(6105)改为在整棵子树(自身+所有后代)上取众数**;**leader(6103)/ Budget Owner(6102)/ 成本中心仍按直属成员**——它们是 leadership 锚定字段(组织负责人、预算负责人、法人成本中心),本就该来自直属节点,不下沉子树。叶子部门子树==自身,行为不变。理由:把「按人 HRBP(SF 2448)」压成「按部门一个 HRBP」本质有损;既然审批在 BU 级解析,BU 的单一 HRBP 应代表其**覆盖的人群**(子树多数),而非恰好坐在父节点上的 leadership。

**影响面(生产只读灰度)**:94 个在册部门仅 5 个父/聚合节点 HRBP 变化,全部从「空/leadership 偏差」纠正为运营多数。实现见 [`_huilianyi_dept_attributes`](../../server/app/domains/identity_sync/service.py) 的 `_subtree_member_ids`;测试 [`test_huilianyi_hrbp_subtree.py`](../../server/tests/test_identity_sync/test_huilianyi_hrbp_subtree.py)。

**备选(否决)**:(a) 直属众数 + 手工校正 —— 否决:又回到人工维护,且 leadership 偏差是系统性的;(b) HRBP 也按人下推到叶子、审批改按叶子解析 —— 汇联易审批流配置侧的事,不在 Helix 控制内,列为对 HR/汇联易的补充建议;(c) budget owner 也改子树 —— 否决:预算负责人是 BU head,直属 leadership 恰恰是对的,子树多数反而会取到中层。

---

## 2026-07-14 — 归档云租户(金山云)退出半年盘点 + 历史数据软删

> **状态: 用户批准(本轮对话)。**

**背景**:半年盘点(云服务器 owner 确认)持续把**金山云**机器推给 owner 确认,骚扰 13 名在职员工。但金山云早已是**只读历史归档租户**(见 2026-04-23 决定,PingCAP 不再在金山云上跑任何东西),让 owner 确认这些机器"是否仍在使用"毫无意义。用户明确:"不要再管任何金山云的机器",并选择"软删全部 + 代码护栏"。

**决定**:① **归档租户概念上升为驱动一等标记** —— `IntegrationDriver.archival: bool`(`KSYunDriver.archival=True`),`registry.archival_providers()` 暴露归档 provider 集合;所有**主动管理面**(当前是半年 owner 确认 `list_due_owners`)据此排除归档租户下的资源。将来任何被停用的云被标 `archival=True` 即自动退出主动管理,**不得**在各面硬编码 `ksyun`。② **金山云历史数据软删** —— 生产 541 台服务器 + 13 LB 全部 `deleted_at=NOW()`(可回滚);整合租户行 `金山云 (历史)` 与历史成本数据**保留**(仍可做历史成本报表)。

**替代方案(未采纳)**:① **只静音不删数据**——机器仍留在 CMDB 服务器列表里可查,但用户要"眼不见";② **彻底硬删**——见下方 follow-up,同日即被采纳。

**Follow-up(同日,用户改主意"历史的也清理、咱们不管这个了")**:软删后用户指示彻底物理删。只读盘查确认金山云**无任何成本数据**(billing/cost_facts 全 0),原顾虑"硬删丢历史成本"不成立。遂在生产**物理删除**:`cmdb_servers` 541 + `cmdb_load_balancers` 13 + `cmdb_network_owner_history` 150 + `integrations`(`金山云 (历史)`)1,**永久不可逆**。**未删** `cmdb_server_owner_confirmations`(跨云历史确认快照,删之破坏审计链)。**保留** `KSYunDriver` + `archival` 护栏(通用归档租户排除机制,与金山云数据是否存在解耦)。至此金山云在 Helix 数据层零残留。

---

## 2026-07-11 — 系统手册(handbook)刻意 zh-only,ToC 标题豁免 i18n 硬规

> **状态: 记录既有设计(code-review issue #1879 触发确认)。**

**背景**:`web/src/pages/handbook/docRegistry.ts` 的 `HANDBOOK_GROUPS` / `ALL_DOCS` 里 nav / ToC 的 `title` / `summary` 是硬编码中文,表面上违反「`web/src/` 无硬编码中文」硬规。code review 标记后确认这是**刻意设计**,非疏漏。

**决定**:handbook 内容(`web/src/content/handbook/*.md`)本就是**纯中文**,`.md` 也在 i18n ESLint glob 之外(见 `docRegistry.ts` 文件头注释)。ToC 元数据(标题/摘要)与其所指的中文正文一一对应,单独把标题三语化而正文仍是中文只会造成语言割裂。故 handbook 的 registry 元数据**豁免** i18n 硬规,保持 zh-only,与内容一致。

**替代方案(未采纳)**:① 把 ToC 标题/摘要三语化——正文仍中文,标题是英文/日文只会更割裂,且要为每篇文档维护三语标题却指向中文正文;② 整套 handbook 内容三语化——超出当前范围,且中文是这批运维手册的目标读者语言。若将来 handbook 要国际化,应连同 `.md` 正文一起做,届时再新开 ADR。

---

## 2026-07-08 — 离职 Google 账号挂起 2 天后自动删除(终态处置,不做交接)

> **状态: 已批准并实施(PR「offboarding-google-account-delete」)。**

**背景**:2026-07-01 翻案后,离职者 Google 账号在末日次日被自动**挂起**(见下条 2026-07-02 挂起 ADR),但此前永久停在挂起态,没有删除或数据交接动作。用户希望收口为终态。

**决定**(用户 2026-07-08):
1. **挂起后 2 天删除**。离职后账号先挂起(末日 +1 本地日),再在 **`leaver_date + 2` 本地日**由每日定时任务硬删除已挂起的账号。删除量 `delete_lead_days` 可配(默认 2)。
2. **暂不做交接**。删除不带继任者、不做 Drive/邮件数据转移 —— 资源在离职 blocker 级联里早已转交/回收,账号自挂起起即暗号。
3. **靠 Google ~20 天恢复窗口做安全网**。Google 删除用户后保留约 20 天可 `undelete`,加上挂起→删除的 1 天间隔,足以覆盖误离职的发现窗口,所以删除可自动化、无需人工确认每一次。误删的补救 = 到 Google Admin 控制台在恢复窗口内 undelete(不建 Helix 侧 undelete 路径)。

**护栏**:① 只删**确认挂起**(`session_enforcement.google_account=='suspended'`)的号,删除永不跑在它依赖的挂起之前;② 删除时**再查一次**保留分类,`external_account_registry` 里服务/共享/外部号永不删;③ 返聘(`employment_status='active'`)跳过;④ 幂等键 `session_enforcement.google_account_deletion`(deleted/already_absent/retained 为终态)。

**替代方案(未采纳)**:① 永久挂起不删(账号无限堆积、license/审计噪音、用户明确要删);② 删除前做数据交接(用户明确「暂时没有交接动作」,且资源已在 blocker 级联处置);③ 建 Helix 侧 undelete/软删缓冲(Google 自带 20 天恢复窗口已是缓冲,再叠一层是重复造轮子);④ 删除也要人工逐个确认(有恢复窗口兜底,自动化即可,人工确认反而拖成又一个永久挂起态)。

---

## 2026-07-06 — HR Ops 纳入建号推送 + 飞书按 .com/.com.cn 同族解析(HRIS 仍为源)

> **状态: 已批准并实施(PR「hr-ops-sync-and-feishu-resolve-fix」)。**

**背景**:新员工建号成功卡片原发给 HRBP + IT 联系人;用户希望把 SF「工作关系」里的 **HR Ops** 也纳入收件人。排查中还发现飞书 HRBP 字段长期不更新。

**决定**(用户 2026-07-06):
1. **HR Ops 也要同步 + 收建号卡**。SF `EmpJobRelationships` 的 **`2450`=HR Ops**(生产核实:P0989→P0067 肖静 Jenny Xiao;46 人指向 P0067)同步进新列 `hris_hrops_person_id`;建号卡收件人 = **HRBP + HR Ops + IT 联系人**。
2. **HRIS→飞书是源,推送要真的成功,不加「防覆盖」护栏**。飞书 HRBP 是人员引用字段,解析失败即静默丢槽;因 Helix 存 `@pingcap.com`、飞书号常在 `@pingcap.com.cn` 而漏解析。改为按 **local-part 在 .com/.com.cn 同族域**兜底解析(不跨无关域);解析不到时记 WARN。
3. **HRBP 数据对错归 HR 在 SF 定,不在 Helix 改映射**。生产核实 2448 对 CN 正确(Claire Zhu 是真 HRBP),美国把 HR Generalist(Zedan Liu)填进了 2448 槽。**给 HR 一张 413 人影响表**在 SF 预审「要不要改」(也可能 SF 才对);**上线时序**:HR 先在 SF 校正,再部署飞书解析修复,以免把个别正确的飞书 HRBP 覆盖成错值。

**替代方案(未采纳)**:① 在 Helix 端「源值可信度低时不覆盖飞书」的护栏——用户否决(HRIS 是源,该以推成功为准,数据对错在 SF 治理);② 猜 HR Ops 的 relationshipType 码——拒绝(猜错=推给错的人),改为只读查生产 SF 核实为 2450。

---

## 2026-07-06 — 入职建号「提前 24 小时」+ 全程按员工本地时区

> **状态: 已批准并实施(PR #1688)。**

**决定**(用户 + SF 2026-07-06):新员工的 Google 账号 + Day-1 欢迎/密码信在其**本地入职日前约 24 小时**创建就绪,而不是入职当天;若 SF 在这 24h 窗口内(或之后)才把新人推给 Helix,则**立刻**创建。所有「到点没到点」的判断一律按**员工本地时区**计算。

**落地**:`working_hours.is_future_hire` 加入 `DEFAULT_PROVISION_LEAD_HOURS=24` 提前量(`lead_hours=0` 可退回严格「入职日」语义);建号/欢迎信共用这一个闸(`hr_sync_coordinator` 事件路 + `account_provisioner` 内部门 + `hr.hire_day_it_notify` 每小时兜底 cron + identity_sync 出站)。时区解析补齐 ISO-3 国家码 + 美/加城市级细化(HRIS 存 ISO-3,旧 map 只有 ISO-2/国名 → 非美国全落上海)。

**边界/权衡**:
- **只有账号 + 双邮件提前 24h**;软件坐席/权限的 provisioning-task `not_before` **不变**(仍入职日本地 0 点)—— 付费坐席在真正到岗日才生效,账号提前一天只为「能登录 + 收到密码」。
- 提前 24h 仍远离原本要防的「提前数周建号」失败模式(欢迎信携明文密码);安全。
- SF 侧契约:`startDate` 请按**纯日期(`YYYY-MM-DD`)或 UTC 0 点**传,勿传带本地偏移的 epoch —— Helix 把 `/Date(ms)/` 按 UTC 取 `.date()`,带本地偏移的 epoch 会让东八区日期 -1 天漂移。事件路当前传纯日期字符串(安全)。

**替代方案(未采纳)**:① 入职当天 0 点建(员工第一天早上账号可能还没就绪,无缓冲);② 提前数天/数周建(license 成本 + 明文密码信过早 + access-review 噪音)。24h 是「到岗前就绪」与「不过早」的平衡点。

---

## 2026-07-01 — 厂商账号生命周期收归入离职事件(事件+配置为主、数据同步为常开兜底);离职自动挂起 Google 号(翻案)

> **状态: 已批准方向的目标架构(ADR),分步实施中。** 每步独立可上、可回退;实施前逐条对照本条。分 5 个 PR:PR0 本 ADR / PR1 账号管家单一入口(零行为变化)/ PR2 同步降兜底+删仲裁开关 / PR3 Google 离职翻案 / PR4 页面·说明·图归一。

**决定**(用户 2026-07-01 拍板:「应该没有孤立的代码;厂商账号生命周期是正常入离职的工作;数据同步做为最后的兜底;厂商账号生命周期需要事件和配置;兜底的不用单独的开关,配置着同步就是开着」):

Google / 飞书 **账号本身**的 create / suspend / freeze / delete 生命周期,从现在散在**四处**(① `account_provisioner` 直连代码建号挂号 ② 坐席 provisioning task 顺手动账号 ③ identity_sync 出站 `deactivate` ④ 外部飞书人事/HRIS)收敛为**单一事件驱动模型**:

1. **账号生命周期 = 入离职事件的本职**。新增单一入口 `app/core/coordinators/account_lifecycle_coordinator.py`,是**唯一**调用厂商驱动做账号 create/suspend/freeze/delete 的地方。`account_provisioner` 的建号/挂号能力**折叠**进来(不再是自己一套孤立代码);坐席 provisioning task **只管坐席**,账号处置委托给该 coordinator。**连接器/驱动层(google_workspace / feishu_software)不动** —— 它是对厂商 API 的正确抽象,乱的是它之上的编排。

2. **由事件 + 配置驱动**。入职事件 → 按配置决定「是否建号 / 建到哪个域·OU / 是否发欢迎信」;离职事件 → 按配置决定「挂起 / 冻结 / 删除 / 保留」。时序统一走已有的本地时区门控(`_onboarding_not_before` / `_leaver_revoke_not_before` 收敛为 coordinator 内一处 `resolve_account_action_time(person, event)`,不再各写各的)。

3. **数据同步 = 常开兜底(reconciler),不是主力**。identity_sync 出站(`feishu_contact` 及未来 Google 出站)降级为「收敛事件路漏掉的账号状态」的安全网:定期扫「应处置但厂商侧状态不符」的人并补齐。事件为主、同步兜底,**两者天然不撞**(补齐动作是幂等的 freeze/suspend),因此:
   - ~~删除 `offboarding_policy='identity_sync_managed'` 仲裁枚举值~~ —— **复评后保留**(见下 Step 3b:它还兼作 provisioning `manual_notify` 安全路由,删除有实时数据行为风险且零功能收益);
   - **兜底不设独立开关** —— reconciler **骑既有 identity_sync target 的启用状态**(见下「兜底门槛」)。

4. **离职自动挂起 Google 号(翻案 B)**。离职事件路**默认自动挂起** Google 账号,不再「只入孤儿面板等人工」。**本条推翻**既往「绝不自动挂起离职 Google 号、孤儿面板保留是权威/人工决定」的立场(此前记录于开发记忆 + `test_google_seat_sync_identity_leaver_2026_06_26.py` 断言)。保留例外:被 `external_account_registry` 标为**应保留**类(`shared_service` 等)或显式 keep 的邮箱**豁免**、不自动挂。孤儿面板保留其 **presence 清单 + 保留分类**职责,但「一个正常离职者的 Google 号」的处置从人工改为自动。**待实施细节**:现有 `user_suspend` 模式硬要求继任者做数据转交;账号级自动挂起需要一条「先挂起、数据转交作可选后续」的路径,不能因缺继任者而卡住离职挂号。

5. **飞书离职**天然契合:事件路冻结(`user_suspend` 默认)/ 删除(opt-in),`sso_cascade` 那种「什么都不做」由显式配置取代;`feishu_contact` identity_sync 变兜底。既有 **2026-06-30 决定**(飞书坐席「读」与离职「写」解耦)与 **2026-06-26 决定**(HRIS→Helix→飞书 投影字段)**不变** —— 本条只动「账号生命周期的编排归属」,不动坐席对账读法、也不动属性投影映射。

6. **开关归一**:入离职自动化的所有配置(建号开关、离职处置、时序)收进**一个**「入离职自动化」设置面。已上线的入职总览页「账号创建(建号)」区是第一块拼图;PR4 扩为可编辑 + 补离职侧。取代「开关散在平台设置 / 集成中心 / 软件详情三个页面」的现状。

**兜底门槛(既满足「兜底不设独立开关」又不误激活未就绪的同步)**:PR2 实施时采用了比原计划(`cutover_ready` 新列)**更简单、更少 churn** 的解法——兜底 reconciler **直接骑既有 `feishu_contact` target 的 `enabled`**(+ `on_offboard≠keep`):target 启用即兜底运行,未启用即整体 no-op。这样当前**故意关着**的 `feishu_contact`(HRIS→飞书切换尚未上线,见 2026-06-26 ADR)不会被兜底激活,契合「配置着同步就是开着」——兜底没有自己的开关,完全跟随主同步的启用。放弃 `enabled→cutover_ready` 重命名(15+ 读点 + UI + 迁移的 churn 换纯语义收益,不值,且骑 `enabled` 已达成同一目标)。

**分步迁移(每步独立可上、可回退)**:
- **Step 1 / PR1**:建 `account_lifecycle_coordinator` 单一入口,把现有三条路改成调它。**零行为变化**(Google 仍不自动挂,直到 PR3 翻配置)—— 纯收编,blast radius 最小,先做。
- **Step 2 / PR2 (已交付)**:coordinator 新增 `deactivate_account` 单一入口 + 新 cron `identity_sync.account_backstop`(迁移 373 seed,每日 02:20 UTC)——扫离职者飞书侧残留账号幂等冻结,骑 `feishu_contact` 启用、无独立开关、批量护栏、失败 raise。`identity_sync_managed` 删除 + `cutover_ready` 均**未做**:前者挪 PR3(兜底就位已可安全删),后者放弃(骑 `enabled` 更简单)。
- **Step 3 / PR3 (已交付 2026-07-02)**:coordinator `suspend_google_account`(账号级、无继任者、幂等)挂进 `OffboardingService._push_force_logout`(与强制登出同一 `local_today>leaver_date` 门控)——离职**默认自动挂起** Google 号(翻案),`external_account_registry` KNOWN 类**保留豁免**。**纯代码激活、无迁移**(挂起无条件而非 per-software 配置,故不需改 `offboarding_policy` 默认)。seat_sync 路径正交、断言不变。`identity_sync_managed` 删除**拆到 PR3b**(独立小 PR:该值因兜底就位已 inert,不与此 incident-sensitive 挂起变更混提;backfill 现存行→`sso_cascade`)。
- **Step 3b / PR3b —— 取消,决定保留 `identity_sync_managed`(2026-07-02 复评)**:原计划删该仲裁枚举,深入核查后**改为保留**。理由:① 它不止是防 409 仲裁——[`provisioning/service.py::_resolve_execution_mode`](../../server/app/domains/provisioning/service.py) 用它把该类软件的任务强制成 `manual_notify`,是一条**有用的安全路由**(避免坐席驱动重复 identity_sync 的冻结、或撞 `tenant_users` binding 的 grant/revoke 拒绝),删掉会改变这类软件的**入职执行模式**;② 安全删除需 backfill **两张表**(`asset_software_catalog.offboarding_policy` + 历史 `offboarding_blockers.resolution`,后者被 reopen 逻辑 [`service.py:434`](../../server/app/domains/offboarding/service.py) 复位读取);③ 兜底就位后它对「防双重处理」已 inert,但保留**零功能损失**。在**最 incident-sensitive** 的离职链路上,为纯装饰性去重而改动实时数据行为,风险/收益不划算——**保留**。原 ADR「删除 `identity_sync_managed`」条目据此作废。
- **PR4**:页面/说明/图归一(入职策略页改新模型 + 补离职侧「账号处置」+ 开关归一到一个设置面)。

**风险 / 验证**:离职是**上线前反复加固、且出过生产事故**的链路(2026-05-29 未来离职误挂、5-29 飞书坐席面板静默冻结),必须小步。每步:先写关键回归测(账号状态机、幂等、not_before 本地 0 点门控保持、保留豁免、兜底不双挂),coordinator smoke-import,provisioning/offboarding 既有测试全绿再上。

**否决的替代**:① 维持三条路并存 + `identity_sync_managed` 仲裁(现状)—— 否决:同一厂商账号无单一 owner,靠配置值防撞车是设计 smell。② `account_provisioner` 保持独立子系统 —— 否决:违背「无孤立代码」。③ 全量改成纯 desired-state 声明式 reconciler(每 (person,provider) 目标状态表 + 收敛循环)—— 暂不:过重、冲击驱动层;「事件为主 + 同步兜底」已达成目标,不引入新表/新循环基座(符合单 TiDB、不加基础设施)。④ Google 离职维持人工(方案 A)—— 用户选 B,否决。

---

## 2026-07-01 — 飞书同步供给策略对齐 Authing:reopen(打开)默认关+管理员门控,跳过名单逐人排除;不盲目 reopen

**决定**: `feishu_contact` target 新增两个 `runner_options`,补齐与 Authing 出站流的功能对齐:
- **`reopen_frozen`(bool,默认 `false`)**:开启后,在职且在范围内的人在 PATCH 时附带 `status.is_frozen=false`,把飞书上被冻结的账号**重新打开**(幂等)。
- **`skip_provision_person_ids`(person UUID 列表)**:管理员跳过名单,名单上的人**永不创建、永不 reopen**(已存在且在职的仍正常更新);缺失者返回 `skipped`(台账留痕、hash 落库不 churn)。

**背景**: 要把 Authing→飞书 完整替换,「他有的我们都要有」。Chrome 复核 Authing 控制台:出站流「Authing在职用户创建更新-飞书」= 拉取→数据过滤×N→转换→对比 diff→**批量创建+批量更新**;离职删除是**独立**工作流。Helix 已有 创建/更新/离职停用/范围过滤,但**从不 `is_frozen=false`**(无法 reopen)且**无逐人跳过**(挡不住 `1111@pingcap.com` 占位邮箱、`nina.lim168@gmail.com` 个人邮箱、离职残留这类会被误建的脏数据)。

**为何 reopen 默认关 + 门控,而非盲目对齐 Authing 的"更新即断言在职"**: 有些冻结是 IT **故意**的(安全封禁、离职残留 Feishu 已关户但 HRIS 未回写)。若每次 sweep 无条件把在职人断言为活跃,会把这些该关的账号打开 —— 与既有红线一致(账号启用/停用是**权威人工决定**)。故 reopen 是**显式 target 开关(默认关)**,且**跳过名单**能对个别应保持冻结的人 opt-out。跳过名单同时是"逐人 review 再建/开"的落点(比 Authing 的纯条件过滤更细)。

**为何存 `runner_options` 而非新列/新表**: 纯 target 级配置,`runner_options` JSON 天然承载(单 TiDB、无 schema churn);`_validate_runner_options` 的 feishu 分支从"返回 `{}` 丢弃"改为校验并保留这两键。改动局限 feishu runner + service,不动通用 sweep/其它 runner;默认值 = **部署后行为零变化**,须管理员显式开启。

**备选(否决)**: (A) reopen 默认开/无条件断言在职 —— 会误开故意冻结的号,否决;(B) 跳过名单按邮箱存 —— 邮箱会变,person_id 稳定,故存 person_id、runner 侧解析成 email;(C) 加数据库列/表 —— 过重,target 级配置无需 schema 变更。

## 2026-07-01 — 跨厂商「外部账号」标注用独立 `external_account_registry`(按邮箱);Google 孤儿表保留为 Google presence + 离职 blocker 源

**决定**: 软件坐席对账里"匹配不上在职员工的账号"的人工标注,建成**独立的、按邮箱的跨厂商登记表 `external_account_registry`**(email UNIQUE + classification 枚举 + note + 可选 owner_person_id),各厂商对账**读时 JOIN** 复用。把原 Google-only「共用账号」菜单升级为这个统一页(单菜单)。**已与用户确认两点**:(1) 合并成一个菜单;(2) 被标成"已知/应保留"(`service_account`/`shared_mailbox`/`external_guest`/`contractor`)的邮箱从各面板「外部未匹配」计数里**减掉**(原始数 `external_unmatched_raw` + `external_known` 仍可见);`former_employee`/`unknown` 仍计入。

**背景**: 同一邮箱跨厂商重复严重(生产:290 外部行 / 187 唯一邮箱,58 个跨 2+ 厂商,服务号/离职号横跨 4–7 个厂商)。`software_external_holder` 按 `(plan_id, holder_key)` 一行行存、且 `category` 全是同步时系统判定,**没有可人工标注的位**,逐厂商标注是 N 倍重复工。

**为何独立表而非扩 `software_external_holder` / 复用 identity_persons**:
- 扩 holder 表 → 标注会按 (厂商×plan×email) **去规范化重复**,跨厂商根本不共享,违背"一个备注"初衷。
- 建成 identity_persons 特殊类型 → 影响面大(HRIS 同步、离职、各处 Person 假设),重且危险。
- 独立 registry 按 email 单键,读时 JOIN,**0 改 seat-sync handler**,最小侵入。

**为何 Google 孤儿表(`google_orphan_accounts`)保留不合并**: 它是 Google 账号**presence** 的权威来源 + 承载离职 blocker `RES_GOOGLE_ORPHAN_OWNER`(owner 在该表)。registry 只承载**跨厂商的 classification + note**;Google 邮箱的 owner 仍写 `google_orphan_accounts`(统一页对 Google 行复用既有 owner 端点),**离职拦截逻辑一字不动**,零回归风险。presence("出现在哪些厂商")不进 registry,读时由 holder 表 + 孤儿表 union 派生。

**备选(否决)**: (B) 给 holder 表加共享备注列 —— 跨厂商不共享,见上;(C) 第二张 Google-并存菜单 —— 用户明确要合并;(D) registry 也接管 Google owner + 离职 blocker —— 改动大、动线上拦截,本期不做(owner 留在孤儿表)。

## 2026-07-01 — GitHub ID 推翻「无源不映射」:源已在 SF `cust_Other.cust_id` 回填,接入飞书同步

**决定**: 把 GitHub ID 接入飞书通讯录同步。源 = SF 自定义 MDF 实体 **`cust_Other`**(以 `externalCode`=工号为键)的 **`cust_id`** 字段;新增 `identity_persons.github_id`(迁移 369)+ `github_id` 映射源字段 + preset 规则 `{github_id} → custom_attrs.github_id`(飞书侧 schema「GitHub ID」`C-6934211695879389211`,按归一化 i18n_name 实时解析,不硬编码 id)。

**为何推翻 [2026-06-26 决定](#2026-06-26)**: 那条 ADR 把 GitHub ID 列为「无干净 SF 源、不映射」,依据是当时实测 `cust_Other.cust_id` **为空**。2026-07-01 用户指出 SF 上确有该字段;对生产 SF 只读复核(`cust_Other` $top=600)证实:**该字段已被回填** —— 取到 GitHub handle 如 `KennethLeeJE8`(正好对上同人 `cust_priorworkdecl` 里的 `github.com/KennethLeeJE8`)、`dpavitra-cell`、`Perth00` 等。即旧结论的前提(空)已不成立。

**数据现实(write-when-present)**: `cust_Other` 目前只有约 23 条记录(填了入职「其他声明」表单的那批),约 10 人有真 handle。故采 **write-when-present、绝不 NULL 化**:有 handle 的同步过去,其余人飞书里 Authing 时代的旧值原样保留;junk 占位(`无`/`none`/`N/A`/空)在 HR 同步层过滤,不落库为伪 handle。HR 后续在 SF 补录会自动流过去。

**否决的替代**: (a) 维持不映射 —— 否决:源已存在,用户明确要;(b) 从 GitHub 组织成员反查 handle 回填 —— 否决(本 PR):是独立链路、覆盖与 SF 自报不一定一致,SF 自报是权威且零额外集成。**反向守卫**:`test_feishu_preset_omits_no_source_and_feishu_managed_fields` 移除 github 断言,新增 `test_feishu_preset_maps_github_id` 钉死「已映射」;Job Function/Job Family/enterprise_email 仍维持不映射不变。

---

## 2026-06-30 — 飞书坐席「读」与离职「写」解耦:`user_suspend`/`user_create` 绑定也走全租户花名册对账

**决定**: 飞书坐席对账的**花名册读取**与离职**写操作模式**解耦。新增 `_TENANT_ROSTER_OPERATIONS = {tenant_users, user_suspend, user_create}`(在 `feishu_seat_coordinator.py`),三种模式**都**用全租户花名册(`list_all_tenant_users_with_emails`)喂坐席对账;只有 `group_member` 用群成员名单。快照 `product_key` 对三种全租户模式统一归一为 `tenant_users`(配合 `software_seat_sync_stat.uq_..._plan` 单列唯一键 → 每 plan 一行 stat,翻模式不产生重复行)。

**背景/为何推翻旧决定**: [2026-05-30 决定](#2026-05-30) 把飞书绑定默认从 `tenant_users`(只读镜像)翻成 `user_suspend`(真冻结),是对的 —— 离职要能真停号。但当时 `feishu_seat_coordinator` 把 `user_suspend`/`user_create` **静默排除**在可对账模式外(理由:「这些绑定是离职硬切,不是花名册;HRIS 同步才是『谁有飞书账号』的权威,Helix 不双重镜像」)。该理由忽略了一个事实:**坐席花名册的「读」是 operation-无关的** —— 不管离职走冻结/创建/无操作,飞书的「坐席宇宙」都是「全部活跃租户账号」。代价是一次生产事故:2026-05-29 prod 唯一一条飞书 connector 翻成 `user_suspend` 后,`feishu.seat_sync` 每晚空转、坐席对账面板**静默冻结在 5-29 快照整整一个月**,且 cron 显示 `status='ok'`(用户报「面板数对不上飞书后台」时才发现)。解耦后:翻成 `user_suspend` 既能真冻结离职账号,又保持对账面板实时。

**配套监控(防复发)**: (1) 面板 API 返回 `sync_stale`/`sync_stale_days`(snapshot 超 48h),前端 `SeatReconciliationDrawer` 顶部红色 Alert 告警「同步已停摆 N 天」—— 覆盖**一切**冻结成因(绑定翻转、misconfig、connector 停用、厂商接口挂)的常开兜底,因为它们都表现为「无新快照→synced_at 变陈旧」。(2) `feishu.seat_sync` handler 的 0-plans 早返回:当**有活跃 connector 指向已启用 feishu_group 集成、却 0 可同步 + 0 misconfig**(plan/软件被置非 active 的纯隐形冻结)时 **raise**(→ failure-notifier 触达);misconfig 仍**不** raise(沿用 [2026-05-30 决定](#2026-05-30) 的「静态配置错不应每 tick 告警」,改由面板陈旧标记兜底)。

**备选**: (B) 给飞书加第二条 `tenant_users` connector 与 `user_suspend` 并存 —— 否决:离职取数走 `get_plan_binding().first()` 假设单条 connector,两条会非确定地选错绑定,脆弱。(C) 仅修面板陈旧告警、不解耦 —— 否决:面板会一直陈旧,治标不治本,用户要的是面板实时 + 离职可停号二者兼得。

**自愈**: 代码上线后无需补数据 —— 次日 03:00 `feishu.seat_sync` 即以 `user_suspend` 模式拉到 1 eligible plan、覆盖写新鲜快照,面板自动恢复。

---

## 2026-06-27 — 软件「一次性增值项」是独立概念,不复用坐席;钱落 `software_monthly_bills` 同一脊梁

**决定**:软件的一次性增值消费(AI Credits、厂商人天等)建为**独立**的 `software_addon_items`(管理员配置:单位+单价+数量信封)+ `software_addon_charges`(员工申请),**不**用「`billing_cycle=one_time` 的 license plan + SoftwareSeatAllocation」来表达。员工自助申请复用现有 **申请→审批** 轨道(`request_type=software_addon`,飞书审批,无模板回退站内 admin);approve 时把 charge 钉到当期账期,`close_month` 把它**物化进 `software_monthly_bills`** 作 `line_type='addon'` 行,从而软件成本/部门成本/BU 财务视图零改动自动纳入。

**金额形态**:管理员配「单位 + 单价 + min/step/max」,员工选数量,`amount=unit_price×quantity` 下单快照。比固定档位灵活(覆盖 credits 与人天),比任意填金额可治理(max 卡上限)。

**上下文**:用户要求「像坐席一样申请、能复用就复用、钱别丢、算到软件也算到部门、一次性只算一次」。核实发现 `software_monthly_bills` 是所有软件成本汇总(`finance_spend_coordinator` 等)的唯一冻结脊梁——把钱落这张表是「不丢」最稳的方式(独立表则每个汇总 SQL 都要记得 UNION,漏一个就丢钱)。

**否决的替代**:① 用 `one_time` license plan 表达——价格固定在 plan 上、无「数量」、且会创建 SoftwareSeatAllocation,使消费者被当成永久坐席持有者,污染「生效坐席==厂商真值」对账 + 闲置回收 + 离职回收(增值消费无对应厂商 seat、无可回收物)。② 独立成本表 + 各汇总 UNION——丢钱风险高,否决。③ 自助任意填金额——不可治理,否决。**边界**:增值 charge 不进坐席对账/闲置/离职流;离职已花的钱按归属保留(不冲正);`approved` 不可自助撤销(只 `pending` 可撤)。一个 PR 交付(迁移 348,惰性增量、向后兼容)。

---

## 2026-06-26 — HRIS→Helix→飞书:Helix 接替 Authing 当飞书上游(三项调研结论:实体分区/enterprise_email/职能字段均不做)

**Decision**: 替代旧链路 **北森→Authing→飞书**,改为 **SuccessFactors→Helix→飞书**(Authing 唯一下游就是飞书)。`feishu_contact` identity-sync target 是投影面。共 3 个 PR:**PR1**(迁移 344)把飞书实际消费、Helix 缺源的 SF 字段灌进 golden record(`gender`/`employee_type_code`/`office_code`,存原始码);**PR2** 把它们投影到飞书(gender/employee_type 枚举、Location 改用 `customString7` 办公室码表、Contact Email),并**修正**两个一直会推错值的旧映射(Location 误用 work_location_name、job_function 误用 business_unit)。**PR3 = 收口**:经对生产 SF + 飞书只读核实,Authing 同步文档(《PingCAP数据中台字段》)里剩余的同步逻辑**在 Helix 架构下均无需写代码**:

1. **EmpJob eventReason D06/B01 离职过滤 → 不做**。Authing 在 SF 拉取层用事件原因码剔除离职记录;Helix 用 `employment_status` 分类等效达成——`feishu_contact` 过滤器 `include_offboarded=False` + `employment_statuses=["active","on_leave"]` 已把离职者挡在推送外(已有 `test_filter_include_offboarded.py` 钉死)。
2. **company DP00001-04 / 中国-海外实体分区 → 不适用**。这是 Authing **双 SF 实例**(北森 CN + DE)各管一区、互相反向排除以防**重复同步**的产物。Helix 从**单租户** `api30.sapsf.cn` 一次拉全员(实测 shuai CN 与 akshata US 同一 `hris_instance_id`),不存在重复;DP00001-04 是海外法人(US/JP/SG/MY)的真实员工,本就该进飞书。`^P\d+$` 工号 + 在职态过滤已是正确闸门。
3. **enterprise_email(.com.cn)→ 不同步**。实测 5 国抽样:绝大多数飞书用户 `enterprise_email` 为 **None**,仅极少数有值——说明它是**飞书邮箱侧零散开通**、非 Authing 系统性从 SF 同步;且动邮箱身份风险高、SF 无干净源。Helix 不写,留飞书自管。
4. **Job Function / Job Family / GitHub ID → 不映射**(PR2 已落)。实测飞书「IT」/「Cybersecurity」对不上 SF customString19/21 的「IT Service」/「IT Management」;GitHub 的 `cust_Other.cust_id` 在租户内为空。无干净 SF 源,留飞书现值,不回退。

PR3 因此**不含代码改动**,只加一条**反向回归守卫**(`test_feishu_preset_omits_no_source_and_feishu_managed_fields` 钉死上述 enterprise_email/job_function/job_family/github 不被映射)+ 本 ADR + 文档。**同步开关由用户在确认就绪后手动开启**(当前关闭)。

**Context**: 用户提供 Authing 时代《PingCAP数据中台字段》文档,要求 Helix 接替 Authing 给飞书供数。逐字段对照后,真正缺的源字段是 gender/员工类型/办公室(PR1/2 已补);文档里另有一段「同步逻辑」(每日全量 + eventReason 过滤 + 实体分区)。**用生产凭证只读核实**后发现:过滤已等效覆盖、分区是双实例架构产物、enterprise_email 飞书自管——盲目照搬会造重复/错误代码。秉持「不造空壳 PR / 不破坏权威值」,PR3 落为决策记录 + 守卫。

**Alternatives considered**: (a) 照文档加 eventReason D06/B01 过滤 —— 否决:与 `employment_status` 过滤重复,徒增复杂。(b) 照文档加 DP00001-04 实体排除 —— 否决:Helix 单租户无重复问题,排除反而会漏掉海外真实员工。(c) 同步 enterprise_email(work_email 域名 .com→.com.cn)—— 否决:实测它在飞书侧本就稀疏/自管,SF 无源,误写会破坏邮箱身份。(d) 用 business_unit / 部门名近似 Job Function —— 否决:实测值对不上,会把「IT」覆盖成「Business Operations」。

## 2026-06-26 — 考勤通知发 HRBP(不发 admin);月历「实到」时间暂不接打卡流水

**Decision**: ① 考勤报告的**完成 / 退回通知一律发实习生的 HRBP**(`resolve_hrbp_for_person`,SF `hris_hrbp_person_id` → 飞书缓存 → 飞书 custom_attr),**不发周期生成者 / 管理员**;无 HRBP 可解析时**跳过推送**(状态在 `/admin/attendance` 仍可见),绝不回退到 admin 池。② 月历「实到」时间:`user_tasks/query` 只返回结果 + **应到**排班,**不含实际打卡时刻**;UI 诚实地仅在有实到时显示、否则显示应到 / 类别(不显示误导性的「—」)。真实打卡时刻需另接飞书**打卡流水** `user_flows/query`,但**暂不接**——当前实习生数据实到全空(疑似考勤组不打实际卡),盲接可能再上一个空功能,待确认确有打卡再补。

**Context**: 用户实测:① 最终确认通知发给了 admin(应发对应 HRBP);② 「缺下班卡」弹层里上班实到也是「—」,质疑是否有打卡时间概念。连生产核实 Kevin Xu v2:13 个工作段实到 0 个有值,只有应到。

**Alternatives**: (a) 无 HRBP 时回退通知生成者 —— 否决:生成者常是 admin,违背「不发管理员」;状态在操作台可见,跳过推送可接受。(b) 盲接 `user_flows` 填实到 —— 否决:接口文档前端渲染无法离线核对,且数据强烈提示无实际打卡,先诚实显示、待确认再接。

## 2026-06-26 — 实习生考勤判定口径:缺卡拆细 + 请假以飞书假勤审批为准(法定并入「休」)

**Decision**: 考勤月历每天用**单一权威 `category`**。① **缺卡按缺哪侧拆分**:`lack_in`(缺上班卡)/`lack_out`(缺下班卡)/`absent`(全天旷工 = 两头都缺且未打卡),不再笼统「缺卡」。② **请假/出差/外出以飞书假勤审批 `user_approvals/query` 为权威来源**,按日期 overlay 覆盖打卡结果(含假别 i18n);打卡结果里的 `supplement` 仅在审批拉取失败时降级使用。③ **法定节假日不单独区分**,与周末同归「休」—— 飞书在此数据里对两者都返回 `NoNeedCheck`、无可靠 holiday 标记,且**不靠星期几推断**(会误判实习生的非固定休息日);用户已确认「休」标注正确。④ 审批是 **enrichment**,拉取失败 degrade、**绝不阻断生成**。`user_approvals` 与 `user_tasks` 同 scope(`attendance:task:readonly`),无需新增飞书权限。

**Context**: PR-A~D 上线后 HR 用真实数据复核,指出原判定「太简单、像拼凑」,并明确要分清 法定/请假/全天没打/少一次卡。实测李炯整月 `NoNeedCheck` + `supplement=Leave`(飞书真这么标),暴露「只靠单个 supplement 字段推断请假」既不权威(整天假可能无 supplement)、也分不清缺卡细类。遂引入审批 overlay 做权威请假 + 缺卡按侧拆分。

**Alternatives**: (a) 靠星期几把工作日的 `NoNeedCheck` 当法定节假日 —— 否决:纯猜测会误判;(b) 仅用打卡结果 supplement 判请假 —— 否决:整天假可能无 supplement、且无假别;(c) join Helix 的 SF 假期台账(`hr_leave_accounts`)—— 暂不:飞书假勤审批与考勤同源、口径一致,SF 台账是另一套口径。

## 2026-06-25 — NetSuite 员工同步:写 子公司/部门/工号,**不写**部门角色字段(leader/budget/hrbp)

**Decision**: Helix 的 NetSuite 员工同步只写**可靠的身份基础字段** —— externalId=工号、`custentity_swc_work_num`=工号、中文名、email、**subsidiary**(全量 `company_code→NS子公司id` 映射)、**department**(`department.externalId`=org_node_source_id,**仅当该部门已采纳**才发)、supervisor(直属经理)、title、hireDate、mobilePhone。**部门角色字段 depart_leader / budget_owner / hrbp / FP&A / 采购小组 一律不写,保留 NS 现值。** 撤掉 PR #1398 给员工写这三个角色引用的规则(只留 supervisor)。子公司映射:海外 US→6 / 新加坡→28 / 日本→9 / 马来→25;**中国大陆全部并入 32 北京星云深图**(口径确认,旧 平凯星辰/上海楷浦/星数云集 等实体均 `_Discontinued`)+ `netsuite_default_subsidiary_id` 兜底。

**Context**: 用浏览器读 NS 文件柜脚本源码 + 实测线上记录定夺。① 真正的员工/部门/子公司同步在 **HitPointCloud 托管包(源码锁定)**,从 Authing/飞书/汇联易拉;**SWC 包是财务自动化**,不碰身份。② `subsidiary-restlet.js` 只读暴露子公司列表 → **子公司是 NS 财务主数据、非程序创建**,外部系统按它映射。③ 角色字段当年是 **Authing 逐部门显式指派**;Helix 黄金记录无"部门负责人"显式字段,只能启发式派生,实测 `depart_leader` 4 部门只对 1 个(二级=上级 lead、一级=自身 head、三级另指,无干净规则),budget/hrbp ~3/4 —— 用它覆盖权威值=破坏数据,违反"不破坏"底线。④ HRBP 在**部门**记录(`custrecord_swc_hrbp`),员工表无此字段,#1398 的 `custentity_swc_hrbp` 规则在写不存在的字段。⑤ 员工→部门引用必须门控:未采纳的部门会让 `department.externalId` 悬空 → 400 整条 upsert,故新增 `ns_adopted_dept_codes`(SuiteQL `department` externalId 集合)只在已采纳时注入 `ns_department_code`。⑥ `SWC_MR_UpdateEmpByDept`(NS 内部、不依赖 Authing)继续把部门字段传导到员工,不冲突。

**Alternatives considered**: (a) Helix 直接派生并写角色字段 —— 否决,派生不可靠会覆盖坏权威值;(b) 写到部门记录再靠 NS 脚本传导 —— 暂不做,Helix 无可靠 leader 源;(c) 把 FP&A/采购小组也接管 —— 否决,Helix 无此数据源、部门记录上本就稀疏。后续若要接管角色字段,需 HR 侧给出权威"部门负责人"源。

## 2026-06-25 — 实习生考勤月报口径修正:roster 改飞书通讯录部门直读(推翻同日「Helix 部门成员」)+ 月历 UI 升级

**Decision**: 实习生 roster 的来源从「Helix `identity_org_memberships` 里名为 `PingCAP Intern` 的部门」改为**飞书通讯录该部门的实时成员**(`FeishuAttendanceClient.list_intern_members`:`/contact/v3/departments` 按名解析 `open_department_id` → `find_by_department` 列成员 → 按 email 映射 Helix 在职 person)。飞书有、但 Helix 无在职档案的成员列**「待补」**(持久化 `attendance_cycles.pending_not_in_helix_json`,迁移 342),先覆盖能映射的、其余提醒 HR(用户选择,非自动建 person)。leader 仍取 Helix `manager_person_id`。配置项 `attendance.intern_department`(默认 `PingCAP Intern`)从「Helix 部门名」语义变为「飞书部门名」语义。同时把考勤明细 UI 升级为**平台级月历**(`AttendanceCalendar`,实到/应到 + 色彩编码 + 点格详情),HR 操作台重做,新增桌面自助页 `/me/attendance`,并把 HR 菜单入口从「身份源数据与认证」移到「HR 系统」组。

**Context**: PR-A/B/C 上线并部署生产后,用 ljq key 连生产 TiDB 复核,发现 `org_node_name='PingCAP Intern'` 在 Helix 解析 **0 人**——Helix 的部门树 100% 来自 SuccessFactors,而「PingCAP Intern」只是飞书通讯录里的分组;9 名实习生在 Helix 里各自挂在真实业务部门(Global Control / Observability Service…),其中 4 人(许晴 / Qiyuan Xu / Zhixian Chen / 彭文欣)根本没进 SF、Helix 无 person。故原「部门派生」口径对真实数据完全不命中。用户确认改为「飞书部门直读 + 未入库者列待补」。另外用户反馈考勤明细「太简单、不是平台级」,要求 HR 能用月历方式看每天细节并更漂亮——遂做 `AttendanceCalendar` 月历 + 操作台/自助页重做。HR 还反馈在「HR 系统」菜单找不到入口(原错放在身份源组),一并归位。

**Alternatives**: (a) 把飞书实习生部门同步进 Helix org tree(扩 `identity_sync_feishu` 写 membership + 为缺失者建 person)—— 被否(本 PR):动身份同步、为非 SF 人在 HRIS 之外凭空造 person,blast radius 大;待补清单已满足「不静默丢」。(b) HR 手工维护实习生名单/标签 —— 被否:要人工维护且只覆盖已在 Helix 的人,不如飞书部门权威。(c) 为未入库实习生自动建最小 person —— 被否(用户选「先覆盖能映射的,其余待补」)。

## 2026-06-25 — 退役 `/dashboard`,登录按角色落各自「本域首页」;默认视角随之翻转为「按主角色」(推翻 2026-05-07)

**Decision**: 通用 `/dashboard` 仪表盘退役。登录后每个角色落到**本域首页**,而非统一落 `/dashboard`:员工/经理→IT 宇宙(`/me`)、`global_admin`/`management`→运营洞察(`/admin/insights`)、`finance`→财务总览、`auditor`→合规总览(`/compliance`)、`asset_admin`(+`asset_global_admin` 共生)→资产工作台(`/admin/assets`)、`cloud_admin`→云总览、`hr`→人员目录(`/admin/users`)。落地映射 + 主角色优先级集中在 [`web/src/lib/roleLanding.ts`](web/src/lib/roleLanding.ts);`RootLanding`(`/`)与角色 chip 切换都走它,`postLoginTarget` 非移动端改返 `/` 由其派发。`DashboardPage.tsx`/`.module.css` 删除、`/dashboard` 改 `Navigate→/`、导航去掉「仪表盘」项;**保留** `pages/dashboard/reportsShared.tsx`(35 处图表原语依赖)与后端 `GET /dashboard/summary`(移动端 `MMyBillsPage` 仍用)。

**为何同时翻转默认视角(本设计成立的关键)**: 视角 chip 是真实鉴权收窄(`X-View-Role` 头 + `effectiveUser`,见 2026-05-29 ADR)。若中间角色仍按 2026-05-07「非 `global_admin` 一律默认 `employee` 视角」,则把他们落到本域首页时 `RoleRoute` 与后端都会因「停在员工视角」403 / 返空。故 `deriveDefaultViewRole` 由「只有 `global_admin` 进 admin 视角、其余 employee」**翻转为按主角色优先级**(global_admin > management > asset_admin > auditor > hr > finance > cloud_admin > employee,镜像 chip 顺序)。登录落地与默认视角因此是同一份契约的两半,合放一处。每域首页的 `RoleRoute` 已逐项核过:收窄后的 `effectiveUser` 均放行(insights 门 global_admin + management 自动提升;finance/overview 含 finance;compliance 含 auditor;/admin/assets 含 asset_admin;/admin/cloud/overview 含 cloud_admin;/admin/users 含 hr)。

**Context**: 用户反馈「普通员工的仪表盘对他们没用,首页就该是 IT 宇宙;管理员的仪表盘用运营洞察显示」。经澄清,中间角色取「各自落到本域首页」、`DashboardPage` 取「彻底删除」。这直接与 2026-05-07「默认员工视角(让混合角色的人登录先看到自己的东西)」相悖——用户在知情下选择翻转。

**Alternatives**: (a) 中间角色也落 `/me`、保留默认员工视角(改动最小、不动安全模型)——被否:不满足用户「本域首页」诉求。(b) 仅改导航/落地、不动默认视角——技术上不可行:不翻视角则本域首页会被收窄 403。(c) 保留 `DashboardPage` 仅移出导航——被否:用户明确要删。`reportsShared` + 后端 summary 端点因仍有依赖而保留,不在删除范围。

## 2026-06-23 — 移动端 `manager` 不是可切换视角,而是派生事实 `is_manager`(对齐桌面)

**Decision**: 移动端 `/m/*` 工作台的角色模型与桌面对齐——`manager` **不是** RoleSwitcher 里可主动切入的视角角色,而是从组织结构派生的事实 `is_manager`(有 ≥1 名 live 下属)。`GET /m/aggregate/home` 的 `available_roles` 只列**可切换视角**(调用者的 DB 角色 + 隐式 `employee`),不再注入合成 `manager`;新增并行字段 `is_manager`。团队入口(主页 我的团队 卡 + 员工底栏的 team 页签)由 `is_manager` 门控,与当前视角**正交**——任何视角下,管理者都能看到团队卡(镜像桌面常驻 我的团队 菜单组)。

**Context**: 桌面端在 2026-06-15(commit `2059fa52`)已把 `manager` 从 view-role 切换器移除,改为 `user.is_manager` 布尔事实 + `requireManager` 路由门控;`manager` 在 RBAC 里本就「无 DB 角色,请求时由 `manager_person_id` 派生」(见 ROADMAP「9 canonical roles + manager-derived scope」)。但 2026-05-22 的移动端重做 ADR(见下,"5-tab workspace layout / RoleSwitcher in top bar")把 `manager` 当成 `available_roles` 里的合成角色 + 可切换视角,移动端就此与桌面口径分裂并落后至今。本次升级把移动端拉齐。

**为何团队入口由事实门控、而非视角**:管理者常常同时持有 admin + employee,且团队视图与「以什么身份浏览」无关——桌面正是无论当前 view-role 都常驻 我的团队 组。移动端底栏只有 5 槽,故团队页签仅注入到员工底栏(纯团队负责人的常见情形),admin/auditor 满栏的管理者经主页团队卡 + 应用宫格触达,既守拇指预算又不丢入口。

**Alternatives considered**:
- **保留 `manager` 为可切换视角(仅移动端)** — 否决:与桌面 + RBAC 口径分裂是本次要消除的债,继续分叉只会让下一次会话重新推导。
- **给所有视角底栏都加 team 页签** — 否决:admin/auditor 满栏会撑到 6 槽,破坏 Feishu webview 拇指/安全区预算。
- **从 `perspective.py` 删除 `manager` 这个已知视角** — 否决:旧客户端在 Feishu webview 里缓存的 JS 可能仍发 `as_role=manager`,删掉会让它落入「未知角色 → 回退全角色集」从而泄漏 admin 项;保留其塌缩到 `[]` 的 fail-closed 分支更安全。

**Implementation summary**: 后端 `mobile/aggregate.py` + `mobile/schemas.py`(+`is_manager`)+ `test_aggregate_home.py`;前端 `mobileWorkspace.ts`(PRIORITY 去 `manager`/`it_admin`)+ `mobileMenu.tsx`(`buildTabsForRole` 加 `isManager`)+ `MobileLayout.tsx`(传 `is_manager` + 过期 `activeRole` 自愈)+ `MHomePage.tsx`(团队卡按事实渲染 + 接 `/m/team`、`/m/team/leavers` 真页面)+ `RoleSwitcher.tsx`(仅注释)。0 迁移、0 cron、0 新 MCP、0 新 RBAC。属「移动端整体升级」多 PR 主题第 1 步(用户 2026-06-23 指示「整体升级一次」)。

## 2026-06-19 — 数据同步失败告警:per-target 开关 + 取消「默认通知管理员」兜底

**Decision**: 身份源出站数据同步(`identity_sync` 目标)的运行失败告警改为**纯显式**模型:

1. 新增 per-target 主开关 `identity_sync_targets.alert_enabled`(NOT NULL,server_default true,迁移 309)。关闭即该目标失败时一律不告警 —— 即使配了接收人;接收人配置保留,便于临时静音一个噪声目标。
2. **取消 `alert_notifier.py` 的 IT-admin 兜底广播**。此前 `alert_recipient_ids` 为空时会回退到「通知所有 IT 管理员」(列表页显示「默认通知管理员」);现在为空 ⇒ **不通知任何人**。一个目标只通知管理员明确挑选的人,绝不默认全管理员组扇出。

**Context**: 用户(IT 负责人)明确要求「失败告警做一个开关,开了再通知人,关了或者不选人,不要默认通知管理员」。`alert_recipient_ids` 是 2026-06-19 当天(迁移 308)刚落地的特性,兜底广播随之引入;同日按本决策收口为显式模型,无存量误配。降噪(去掉广播),无新增打扰风险。

**保留**:cron 主调度 `identity_sync.dispatch` 在「到点目标失败」时仍 `raise` → 通用 scheduled-job 失败通知器仍按去重给 IT 管理员推一张「定时任务失败」卡片(沿用既有 leaver-lockout backstop 决策)。这是**独立于** per-target 告警的基础设施健康信号,经用户确认保留。两条路径互不影响:per-target 告警走配置的接收人 / 开关,cron backstop 走调度框架。

**Alternatives considered**: (a) 全局开关 + 全局接收人列表(platform_settings)—— 否决:已是 per-target 模型,全局开关粒度太粗,无法按目标静音。(b) 一并静音 cron backstop —— 用户选择保留(基础设施健康仍需兜底)。

---

## 2026-06-18 — 官方 `helix` CLI 拆为独立分发包(后端不再内置)

**Decision**: 把内嵌在后端 `server/app/cli/` 的官方 `helix` CLI 整树 `git mv` 到仓库根新目录 `cli/`,成为一个独立的 Python 分发包(包名 `helix_cli`,自带 `cli/pyproject.toml`,**只依赖 httpx**,入口 `helix = "helix_cli.main:main"` + `python -m helix_cli`)。imports 由 `from app.cli.*` 机械改为 `from helix_cli.*`;测试随包搬到 `cli/tests/`。**后端不再以 `[project.scripts]` 注册 `helix`,后端镜像也不再带这个命令。** CI 新增一个无数据库、几秒级的独立 `cli` job(ruff + smoke `--help` + pytest);原 `identity-cli` 分片去掉已移走的 `tests/test_cli`(更名 `identity-sso`)。`_DESTRUCTIVE_MCP_TOOLS ⊆ 注册 MCP 工具` 的 typo-proof 回归测试改读 sibling `server/app/mcp/tools.py`(全仓 checkout 时仍强制;若 CLI 包被单独 checkout 而无后端源,优雅 `pytest.skip` 而非失败,保持包真正独立可测)。

**Context**: 帮助中心给员工写了「`pip install -e server`(源码安装)」才能用 CLI,但员工既看不到源码也进不了后端容器,这条路对员工根本走不通。要让员工自助使用,必须能把 CLI 作为 **wheel(`pipx install`)+ 各 OS 免-Python 单文件二进制** 独立发出去——而这要求 CLI 不拖后端那一长串重依赖(FastAPI/SQLAlchemy/boto3/google-sdk…)。排查确认 CLI 早已是 MCP+探针之上的瘦客户端、零 `app.*` 耦合、仅依赖 httpx,所以「拆成独立薄包」是水到渠成的最成熟做法(后续 PR2 建发布管线把产物传 S3,PR3 在网页做鉴权自助下载)。

**Alternatives**: (a) **引入 uv workspace**,`server` 与 `cli` 互为成员、后端仍通过 workspace 依赖保留容器内 `helix` —— 被否(本 PR):要改 `server/uv.lock`、Dockerfile、镜像 build context(现为 `server/`),blast radius 远大于收益;员工拿不到容器,「容器内保留 helix」对目标无帮助。若日后运维确需容器内 `helix`,再起一个 workspace/context 变更的后续 PR。(b) **保持 CLI 内嵌、另起一个只打包 `app.cli` 子集的影子 pyproject** —— 被否:绝对 import `from app.cli` 在 `app` 命名空间外无法解析,需构建期改写 import,脆弱。(c) **PyPI / 内网 index 直接 `pip install`** —— 被否(分发渠道层面,见后续 PR):仓库私有、员工多无 GitHub/PyPI 凭据,且只解决 wheel 不解决免-Python 二进制;改用 S3 + Helix 网页鉴权下载(所有员工都有 Helix 账号)。

---

## 2026-06-18 — Finance Controller 权限:`finance` 角色可月结关账/重开 + 治理发现 triage(`can_operate_finance`)

**Decision**(财务全生命周期闭环 PR3):月结 close/reopen 与成本治理发现的 triage(acknowledge/snooze/resolve)从 `global_admin`-only 放宽到 **`finance` + `global_admin`**,经新门控 `can_operate_finance`。关账是财务 controller 的本职决定、治理发现的处置是财务分析师的日常,不该卡在全局管理员。

**关键边界**:`can_operate_finance` ≠ `can_manage_finance`。后者仍是 **global-admin-only**,继续守主数据/不可逆操作——FX 月度 pin、统一账 rollup 刷新、预算 CRUD、供应商/采购单/付款编辑。即 finance 能"运营"(关账、处置发现)但不能改"主数据/钱的定义"。前端 Closeout 页据此拆分:rollup 刷新仍 `global_admin` 守门,close/reopen 放给 `['global_admin','finance']`。

**发现变动作**:新增通用表 `finance_finding_acks`(`(kind, ref)` 唯一,status=acknowledged/snoozed/resolved + snooze_until + note + actor)。治理读(先接入 savings 浪费/闲置坐席)left-join 它注解每条发现,resolved/未到期 snoozed 的发现从活跃列表过滤(返回 `suppressed_count`),到期自动重现。让只读的"发现墙"变成可管理工作队列,不再每次重算都重复刷屏。generic by kind/ref,其余发现族(合同续约/异常增长/孤儿/报销治理)可后续复用同一原语。

**Alternatives considered**:(a) 把 close 也留给 global-admin-only——拒,财务团队连自己的月都关不了,违背"让现有功能真正可用"。(b) 给发现处置直接触发 idle_reclaim 回收——本 PR 先做记录/抑制(低风险闭环),真正的跨域回收触发留后续(idle_reclaim 已是独立 cron 原语)。(c) 用一次性 dismiss 而非 snooze——拒,snooze 到期重现才符合"待复核"语义,resolved 才是永久关闭。

## 2026-06-17 — 部门→平台角色自动派生(Finance⇒finance、HR⇒hr),provenance 护栏保护手动授权

**Decision**(用户 2026-06-17:「属于 Finance 这个 BU 的就给他财务的角色,HR 的就给他 HR 的角色,这个变成自动的,现在没有的人也给他们加上」):`finance` / `hr` 角色对组织内 Finance / HR 部门成员**自动派生**——HRIS 同步落库即实时派生 + 每日 cron `identity.department_role_reconcile` 兜底 + 部署时 migration 一次性回填存量。匹配按主部门 `org_path` 段(剥离租户根)大小写不敏感等于配置部门名,默认 Finance/HR 中英文,经 `identity.department_role_map` 平台设置**扩展不替换**。

**关键设计 — provenance 护栏**:新增 `auth_user_roles.grant_source`(`manual` 默认 / `derived`)。派生对账**只增删 `derived` 行**,管理员手动授予的 finance/hr(`manual`)永不被覆盖或撤销。理由:finance/hr 至今可手动授予(RBAC 页),若让对账"完全拥有"这两个角色会把非部门成员的合法手动授权(如给某分析师/IT 的财务只读)误撤——生产事故级风险。镜像 `asset_admin` 的「派生」精神但用 provenance 区分,而非把整个角色变成纯派生。

**Alternatives considered**:(a) 纯派生(对账拥有整个 finance/hr 集合)——拒,会撤销手动授权。(b) 只增不撤(additive-only,永不撤销)——拒,转岗出部门的人会留着陈旧角色直到离职;provenance 让转岗撤销变安全。(c) 用 `granted_by IS NULL` 当派生标记——拒,系统/HRIS 创建的历史行 granted_by 也可能为 NULL,语义不明确;专用列无歧义。离职清理仍由既有 `LEAVER_REVOCABLE_ROLES` + leaver 角色 cron 兜底(finance/hr 在其中)。
## 2026-06-17 — 汇联易作为财务对账中心的外部实际账来源(Phase 2);非 IT 报销补全口径 + IT 重叠只对账(双算护栏)

**决策**:财务对账中心的 Phase 2「外部账单比对」来源 = **汇联易**(公司费控/报销/差旅/发票/银企系统)。汇联易实际账 read-only 拉入新表 `finance_external_statements`(独立于内部 `finance_spend_facts`),两用:① **对账**——按 期间×成本中心/部门×供应商 比对内部应花 vs 外部实际;② **补全口径**——把汇联易里 Helix 没有来源的非 IT 钱(差旅/餐饮/办公…)提升进统一账层,新 `cost_type='expense'`。

**核心不变式(双算护栏)**:`finance_spend_facts` 里每块钱只出现一次。只有「明确非 IT」的费用类型(平台设置 `finance.huilianyi.promote_categories` 白名单,可调,默认保守非 IT 集)`promote_to_ledger=True` 进总账;IT 重叠类(SaaS/云/采购发票走汇联易报销付款)`promote=False`,**只对账不入账**(Helix 已从源系统算过)。

**集成**:复用现有 `identity_sync_huilianyi` 连接器 + `HuilianyiClient`(身份是「推」,财务是「拉」,同一 app/凭证;write-scoped token 已能读 roster,expense 读权限不足则 4xx 优雅降级)。跨域读走 `app/core/coordinators/finance_huilianyi_coordinator.py`(finance 域不 import identity_sync)。币种复用 `finance.fx` 月度冻结换算。

**Live 验证诚实性**:汇联易 expense API 无法在 CI 真调、字段名离线不可定。故连接器**防御式**(端点路径/参数 config 可覆盖、字段多名 tolerant getter、原始留 `raw`、无连接器/无读权限优雅跳过不崩),拉取 cron **默认禁用**;启用 = 在汇联易开放平台授读权限 + 跑一次 dry-run 校验字段映射(见 `docs/ops/finance-reconciliation-ops.md`)后开启。

**不自动建凭证**:在生产费控系统建 API key / 改 scope 属安全操作,由人在开放平台执行,Helix 侧 scope-ready。

**备选(否决)**:NetSuite / 银行流水 / 发票 OCR 直连作为首选外部源——汇联易已聚合发票+成本中心分摊+付款,是最完整的单一外部源,其余作未来补充。

**PR 切分**:PR-A 读地基(连接器读+落地表+成本中心主数据+禁用 cron+测试);PR-B 对账+补全口径(external-vs-internal+分类提升+UI+MCP);PR-C 成本中心权威化+报销治理。

## 2026-06-14 — 移动端升级为一等操作面:废除「mobile = read + single-record only」,全角色全流程闭环(supersedes 2026-05-22 mobile-RBAC 限制)

**Decision**(用户 2026-06-14 拍板:「认真做移动端到 80 分以上,闭环、生产级、全角色,考虑全生命周期,不拿工作量当借口选小的」):移动端(Feishu H5)不再是只读 + 单条动作的受限面,而是与桌面端对等的**一等操作面**。员工/经理/管理员/审计的**申请发起 + 流程动作 + 对象全生命周期操作**都要能在手机上闭环完成。**本条取代 2026-05-22「mobile RBAC = read + single-record only」决定中"发起申请 / 编辑 / 批量留桌面"的限制部分**。

仍然保留的护栏(不是限制移动端能力,而是平台级不变量,桌面端同样适用):
- 不可逆/高破坏操作(scope 退役、连接器凭证编辑)仍 `global_admin` only,且继续走 `ensure_prod_scope_writable` 生产写锁——"全做"≠"绕过 RBAC / 写锁"。
- 所有写操作走 `useMobileMutation`(Idempotency-Key + 防重入 + 本地化 toast),消解一拇指环境的误触/双提交。

**结构决策**:Feishu 审批模板驱动的申请表单逻辑(radioV2 选项守卫 / NFKC 控件名归一 / dateInterval·amount·upload 值整形 / primary-reason 别名解析)从桌面 [`EmployeeRequestPage`](../../web/src/pages/asset/EmployeeRequestPage.tsx) 内联提升到框架无关的单一真源 [`components/approvalWidgetEngine.ts`](../../web/src/components/approvalWidgetEngine.ts);桌面与移动 [`MRequestForm`](../../web/src/mobile/components/MRequestForm.tsx) 共用之。**禁止在移动端复制一份并行的 widget 逻辑**——那正是历史上一连串"飞书改名→静默丢值"bug 的根因。

**Context**:2026-05-22 的限制在当时是合理的渐进式收口,但它把"能看不能办、一办甩桌面"固化成了设计,导致员工在手机上无法发起任何申请(~17 种)、经理纯只读、管理员/审计的真实写操作全部回退桌面——用户实测打 40 分。用户明确要求按全闭环、全生命周期、生产级重做,分 PR 系统推进(PR1=表单引擎 + 资产模板流;后续覆盖非模板申请 / 待办闭环 / 经理 / 管理员 / 审计 / 收口)。

**Alternatives**:① 维持只读 + 单条动作,仅补几个高频发起——被否,与"全闭环"直接冲突,且 escape-to-desktop 在手机场景体验断裂;② 移动端各页自带表单逻辑——被否,必然与桌面 widget 引擎漂移(rename-trap 类 bug 会复发);③ 用 MHandoff 继续把复杂表单(上传/回购/付款信息)推桌面——被否,上传/回购/付款信息正是要在手机上闭环的核心。

**Acceptance**:`pnpm exec tsc --noEmit` / `pnpm lint` / `pnpm build` 三关全绿;桌面 `EmployeeRequestPage` 改用共享引擎后行为不变;移动 `/m/requests/new` 可发起全部资产模板申请(选资产→填控件→上传证明→回购报价→提交),i18n 三语(zh-CN/en-US/ja-JP)齐备。

## 2026-06-12 — Data-access Feishu approval definitions are repo-stored ops artifacts, not runtime-created

**Decision**: Helix does not create, update, or delete Feishu approval
definitions for data-access requests at runtime. The canonical sample definition
for `data_access_group_grant` lives under
[`deploy/feishu/approval-definitions/data_access_group_grant`](../../deploy/feishu/approval-definitions/data_access_group_grant).
Feishu admins manually deploy that definition in Feishu, then configure the
published approval code and `payload_schema` widget mapping in Helix's
`integration_approval_flows` row.

Helix runtime remains responsible for semantic validation, request creation,
Feishu approval-instance submission, callback state transitions, and storing
the mapping to custom Feishu widget ids. The BigQuery reconciler remains the
only provider mutation owner. Request submission and Feishu callbacks still
must not call BigQuery, and approval-definition deployment remains an explicit
ops action outside the app runtime.

**Context**: While wiring real DAX approval support, the user rejected creating
approval definitions from Helix runtime and asked for a repo directory that
stores approval definitions for manual deployment. This mirrors existing Feishu
ownership boundaries: Feishu admins own template authoring and approver-chain
configuration, while Helix owns only the mapped, Helix-originated request
flow.

**Alternatives considered**:

- *Create/update Feishu approval definitions from Helix runtime* - rejected:
  template authoring becomes a second deployment engine with unclear
  permissions, drift handling, and production blast radius.
- *Keep the definition only in Feishu admin console* - rejected: Helix reviewers
  cannot diff or test the widget ids and sample form payload before a rollout.
- *Allow direct Feishu-originated data-access submissions* - rejected: DAX
  requests must originate in Helix after resource, Google Group, requester, and
  entitlement validation.

## 2026-06-12 — 生产写锁新增第二个豁免口:管理员转让 owner(owner 本人自转让仍锁挡)

**Decision**:`PATCH /cmdb/scopes/{id}` 与 MCP `transfer_cloud_scope_owner` 中,当调用者为 `can_manage_cloud`(cloud_admin / global_admin)且(HTTP 侧)请求不带其它可锁字段(budget / status / notes / ttl / auto-suspend)时,owner 转让**豁免**生产云账号写锁。owner 本人(非管理员)的自转让在锁定的 prod scope 上**仍被 403 PROD_CLOUD_LOCKED 拦截**;捆绑任何其它配置字段照旧触锁。

**Context**:`assign_scope_owner` 是纯 Helix 元数据操作(DB rebind + 审计 + 治理时间线),从不触达云厂商 IAM——与 2026-06-04 的「environment-only 豁免」同一类。管理员在锁定期需要为生产订阅重新落 owner(典型:owner 离职交接),为一次纯元数据 rebind break-glass 开全局锁反而扩大风险面。用户 2026-06-12 拍板「允许管理员和云管理员转让订阅的 owner」。

**Alternatives**:① 维持全锁、要求 break-glass 开 `cmdb.prod_cloud_writes_enabled` 再转让——被否,开关窗口内所有 prod 写全放开,比定向豁免更危险;② 连 owner 自转让也放开——未采纳,kill-switch 的初衷就是拦非管理员的自助生产操作,豁免面收窄到 `can_manage_cloud`。

## 2026-06-11 — 身份分发补全 pull 方向:双协议面(REST Directory + 只读 SCIM Server)+ 静态 service token,凭证层独立于员工 PAT

**Decision**(用户四点拍板,2026-06-11):Helix 作为公司身份权威源,对外开放**拉取**面,与既有 Identity Sync Center(push)并行:

1. **消费方画像 = 两者都有 → 双协议面**:REST Directory API(内部自研系统,keyset 增量游标)+ 只读 SCIM 2.0 Server(标准 SaaS/IdP),共享同一套 consumer 凭证/scope/filter/审计底座。
2. **数据边界 = 人员 + 组织树,PII 分级**:`directory.persons:read`(PII 默认脱敏)/ `directory.org:read` / `directory.pii:read`(明文 PII 显式授予)。账号/权限数据不在本期范围。
3. **实时性 = 增量轮询即可**:不做 webhook 变更通知;游标增量(updated_at+id keyset)为主路。
4. **认证 = 静态 service token**(`helix_svc_` 前缀,复用 PAT 哈希基建),**不自建 OAuth2 授权服务器**——500 人规模、消费方有限,GitHub-PAT 式长期密钥 + 重叠轮换是务实成熟解。凭证绑定「系统」而非员工(PAT 形态权责不分、会随员工离职失效),每个 consumer 必须有在职负责人。

衍生结构决策:filter 引擎从 `app/domains/identity_sync/filters.py` **提升到 `app/core/person_filters.py`**(identity_sync 留 re-export shim)——`app/identity`(core 层)不得 import domain 模块,而 push/pull 两面必须共用同一套人群过滤语义;slug 创建后不可变且软删后永久保留(审计行/日志中的 slug 永远无歧义);SCIM 响应体豁免 Helix 标准信封(RFC 7644 协议要求,豁免仅限 `/scim/v2/*`,PR3 落地时生效)。

**Context**:所有下游同步均为 Helix 推送,每接一个下游写一个 runner;用户要求把「身份源数据与认证」做成可被外部系统拉取的生产级开放面,手册进 /help。

**Alternatives**:(a) 让外部系统用员工 PAT 走 MCP — 拒绝:凭证绑人、权责不分、随离职失效;(b) OAuth2 client credentials 授权服务器 — 拒绝:本规模下复杂度不成比例,无消费方硬性要求;(c) webhook 推送变更通知 — 拒绝(本期):增量轮询满足时效要求,避免推送一致性负担;(d) 只做 SCIM 不做 REST — 拒绝:SCIM 装不下成本中心/HRBP/组织树等内部消费方需要的字段。

## 2026-06-11 — `asset_global_admin` 成为第 9 个规范 RBAC 角色(手动授予 + 共生派生 `asset_admin`),而非数据侧 scope 标记

**Decision**: 用户明确要求新增「全局资产管理员」:和 IT 管理员一样可操作**所有**办公室的资产,但权限不出资产域。实现为新的规范角色 `asset_global_admin`(migration 275),带两条不变量:

1. **共生派生**:持有者由 `office_admin_sync` 派生规则(+ 每日 reconciler)自动同时持有 `asset_admin`。所有既有入口 gate(`require_asset_admin`、FE 路由/菜单/viewRole 检查、移动端 admin-tier 集合)零枚举新角色名直接放行;角色被移除/离职剥离时共生派生同步消失。
2. **唯一新增权限 = 区域旁路**:`rbac.has_global_asset_scope()`(global_admin ∪ asset_global_admin)统一收口全部 7 处办公室区域短路(`person_can_manage_asset` + asset router 4 gate + MCP 2 孪生)。除此之外不进任何 `can_*` helper 的放行集。

**视图收窄语义**(本设计成立的关键):`apply_view_role_narrow` 对持有 asset_global_admin 的人,把收窄到 `asset_admin` 或 `asset_global_admin` 的请求统一折叠为 `[asset_admin, asset_global_admin]`。否则持有者点「资产管理员」chip(FE 非 global_admin 默认 employee 视图,这是他们的日常入口)后,后端收窄集丢掉 global scope、而他们又没有任何办公室行,会被区域 gate 全拦。这仍是严格收窄——资产域之外什么都不剩。

**Alternatives considered**:
- *数据侧 scope 标记(如 `asset_global_admins` 表 / office 行特殊语义),不动规范角色模型* — 否决:授予入口会落到资产设置页而非 RBAC 页,与用户「加一个角色」的心智模型相悖;特权治理面(特权角色盘点、access certification)也需要按角色名枚举它。
- *给新角色独立 FE 视图 chip + ViewRole union* — 否决:持有者的菜单面与 asset_admin 完全一致,独立 chip 只会让 5+ 个页面的 `viewRole === 'asset_admin'` 检查全部加或运算,FE 血缘大、信息量为零。共生派生 + 收窄折叠让 FE 仅需展示层补丁(RbacPage blueprint / roleColor / i18n)。
- *把新角色加进 `require_asset_admin` 与所有 `can_*` helper,不做共生派生* — 否决:FE 入口检查(App.tsx 路由、MainLayout 菜单、移动端集合)全部按 `asset_admin` 字符串匹配,不派生就要全前端枚举第二个角色名(~15 文件),且未来每个新资产入口都要记得两个名字。

**与 2026-06-09「网络负责人不新增第 9 角色」ADR 的关系**:不冲突。那条否决的是为「通知兜底指定人」造角色(一个人、无权限语义、platform setting 足够);本条是用户明确要求的**写权限 tier**,需要授予/撤销生命周期、特权盘点、离职剥离——这些正是角色原语提供的东西。判据:要的是「权限语义」用角色,要的是「指定某个人」用 platform setting。

**杂项约定**:`/auth/me` 给持有者物化返回全部 office id(不复用 global_admin 的 `[]` 哨兵——FE 的无限制语义只认 global_admin 角色);离职 flip 在 office-admin 清扫块同时删 `asset_global_admin` 行(防复职静默恢复全局权限)。

## 2026-08-10 — Data-access principal identity expands from Group-only to trusted GCP Service Accounts

**Decision.** Keep the existing BigQuery dataset `read:v1` entitlement, but
make the DAX principal a first-class Helix identity. `google_group` and
`gcp_service_account` are its two supported types; the entitlement does not
encode principal type or BigQuery access-entry syntax.

1. A Service Account is admitted only through the trusted, versioned workload
   catalog. Its immutable Google `unique_id` is the semantic subject, while
   email is a compiled snapshot. Project IAM membership, labels, and an email
   typed by a requester are not identity/owner sources.
2. `data_access_principals` holds identity/provenance and
   `data_access_service_account_profiles` holds catalog/workload/environment
   and active technical owner. Legacy Groups are projected/backfilled during
   migration 397; `email_groups` still owns Group facts.
3. A deleted/recreated Service Account with the same email is a new identity:
   the old identity must be revoked/absent before the new identity can apply.
   Catalog omission suspends and blocks new authoring but does not change
   provider state; only `deleted_confirmed` or explicit revoke compiles absent
   effects. Owner departure blocks new requests, not existing access.
4. Group compilation remains `groupByEmail`; Service Account compilation is
   `userByEmail`, both fixed to dataset `READER` / `dataViewer`. Reconciliation
   touches only exact Helix-owned selectors and preserves all conditional or
   unmanaged ACL entries.
5. The requester must be the technical owner. A global admin must explicitly
   submit on behalf of that owner and cannot skip the owner approval chain.
   The service-account Feishu definition has ordered owner-manager, technical
   owner, then dataset-owner review. Its callback revalidates all snapshots.
6. SA provider writes require the existing integration apply gate **and** an
   SA gate, principal allowlist, and dataset allowlist. Absence of any gate is
   observe-only. Production enablement remains a separate approval after
   non-production canaries.

   **Superseded 2026-08-24:** the dataset-allowlist clause is replaced by the
   current active/requestable/owner-resolved resource-row gate in the ADR at
   the top of this file. The integration, SA, and principal gates remain.

**Supersedes the principal-only portion** of the 2026-06-09 Group-only ADR
below. Its provider/resource/entitlement scope and all non-goals remain in
force: no single user access, table/project IAM, jobUser, arbitrary roles, or
broad scans.

## 2026-08-16 — DAX Service Account authoring is typed self-service input, not a catalog admission gate

**Decision.** This supersedes the 2026-08-10 Service Account **authoring**
rules (catalog admission, immutable Google `unique_id` as the request subject,
technical-owner requester gate, and the three-stage owner chain). It does not
weaken the provider mutation boundary or dataset-owner authority.

1. An employee enters a GCP Service Account email as free text. Helix trims,
   lowercases, bounds, and validates the canonical
   `name@project.iam.gserviceaccount.com` syntax, then transactionally records
   a `gcp_service_account` principal. There is no pre-registration lookup,
   Service Account selector API, technical-owner requirement, or GCP API call
   on this authoring path.
2. The normalised email is the self-service selector/provenance for new
   records. A legacy catalog row with the same email is converted/reused so it
   cannot block authoring. An observed catalog may retain workload metadata for
   lifecycle visibility, but its owner, omission, and status never gate a
   typed request or its callback.
3. Group and Service Account requests use the same
   `data_access_resources` catalog and the same canonical BigQuery dataset
   `read:v1` entitlement. The only provider difference is compiler output:
   Group uses `group:<email>` and Service Account uses exact
   `serviceAccount:<email>` / `userByEmail` entries.
4. The Service Account Feishu definition is requester line-manager review,
   followed by dynamic owners of every selected dataset. It deliberately has
   no workload-manager or Service Account technical-owner node. Callback
   validation still checks the typed-principal record, immutable request
   snapshots, resource owner/version/etag snapshots, and the approved flow.
5. Submission and callback continue to create only semantic request/binding/
   effect state. The reconciler remains the sole BigQuery writer, and Service
   Account effects still require integration apply mode, SA apply mode, exact
   principal allowlist, and exact dataset allowlist before any provider write.

   **Superseded 2026-08-24:** the exact dataset allowlist is removed; present
   writes instead require the current resource row to remain active,
   requestable, and owner-resolved. See the current ADR at the top of this file.

The prior catalog/profile feed remains a legacy observation path only; it must
not reintroduce a requestability or authorization decision.

## 2026-08-19 — DAX Group new-request intake is retired without mutating legacy grants

**Decision.** Retire only the public/new-authoring portion of Google Group DAX
access: remove the request-page Group option, the Group selector used by that
option, and `POST /api/v1/data-access/requests/group-grants`. New DAX requests
are now either self-only `google_user` requests or typed Service Account
requests.

This is not a destructive Group entitlement retirement. Existing Group
requests and their Feishu callbacks, bindings, compiled effects, reconciler
operations, semantic revokes, and read-only Group-access history remain live.
They are necessary to converge in-flight approvals, revoke an existing exact
`group:<email>` ACL, and retain audit evidence. No data migration, automatic
revoke, Feishu definition deletion, or provider IAM mutation is part of this
decision.

**Navigation follow-up (2026-08-24).** The employee data-warehouse sidebar no
longer exposes a standalone Group-access tab. Keeping that first-class entry
made a legacy compatibility surface look like a current request workflow after
Group authoring had already been retired. The existing
`/my/data-access/group-access` route remains deep-link-only for historical
audit and revoke operations; removing navigation must not delete the route,
read model, callbacks, bindings, effects, or revocation support.

## 2026-08-19 — DAX personal access is self-only, identity-derived, and dataset-scoped

**Decision.** Add a third DAX principal type, `google_user`, so an employee can
request BigQuery dataset `read:v1` for their own work account. This supersedes
the earlier Group-only/single-user non-goal only for this constrained path; it
does not create arbitrary-person, on-behalf, project, table, job, credential,
or direct-provider access.

1. The request API accepts no target person or email. It derives the principal
   from the authenticated active `identity_persons` UUID and normalized current
   `work_email`, storing the UUID as `external_subject_id` and the email only as
   a compiled snapshot.
2. Personal and typed Service Account requests select from the same
   `data_access_resources` dataset catalog and use the same `read:v1`
   entitlement. Legacy Group records keep their historical entitlement
   snapshots. The only personal provider output is exact
   `user:<work_email>` / BigQuery `userByEmail` with
   `roles/bigquery.dataViewer`.
3. The personal Feishu definition is requester-line-manager review followed by
   dynamic selected-dataset owners. Submission and callback never call
   BigQuery. Callback revalidates active employee, UUID, email snapshot,
   entitlement, resource snapshots, and approved flow before creating binding
   and effect state.

   **Superseded 2026-08-24 for the approver chain:** personal requests now go
   directly to the dynamic selected-resource-owner node. The Service Account
   manager-first chain is unchanged; see the current ADR at the top.
4. Dataset browsing is available to active employees because it is the shared
   selector for personal access. It does not reintroduce Group new-request
   authoring.
5. The scheduled DAX identity sweep marks an active personal binding
   `revoked_pending` and compiles only its exact old member absent effect if the
   person leaves, disappears, or their work email changes. The existing
   reconciler remains the sole provider write path; its default-closed
   `personal_apply_enabled` gate requires an explicit post-staging enablement.

No new schema is required: the existing principal/request/binding/effect rows
already persist a principal type, immutable external subject, and email/type
snapshots. The runtime deployment still requires publication of the dedicated
Feishu definition and normal staging/production gates.

## 2026-06-09 — Data access management V1 is BigQuery dataset read access for Google Groups through semantic Helix bindings

**Decision.** Helix data-access management V1 is intentionally narrow:

1. Provider: `bigquery`.
2. Resource: `dataset`.
3. Principal: `google_group`.
4. Entitlement: `read`.
5. Provider effect: `group:<email>` gets `roles/bigquery.dataViewer` on the exact BigQuery dataset.
6. Approval: one Feishu approval for one group, one entitlement, many datasets.
7. Execution: Helix stores semantic bindings and compiled effects; the data-access reconciler is the only component that mutates BigQuery IAM.

The implementation contract lives in [`DATA_ACCESS.md`](DATA_ACCESS.md), with merge sequencing in [`DATA_ACCESS_EXECUTION_ORDER.md`](DATA_ACCESS_EXECUTION_ORDER.md). Issues under [#809](https://github.com/pingcap-inc/Helix/issues/809) are executable slices of that contract, not competing authorities.

**Owner split.**

- Catalog federation or ops-only bootstrap owns requestable dataset identity, locator, owner context, requestability, version, and etag. `data_access_resources.owner_person_id` is the only runtime resource-owner authorization source; raw catalog/bootstrap reads must not become a second owner authority.
- The email group directory owns Google Group existence, deletion state, membership, and ownership facts. Data access validates those facts but does not reconcile group membership. `email_groups.id` is the semantic principal key, `google_group_id` is a continuity/rename anchor, and `group:<primary_email_snapshot>` is compiled provider output. A deleted or invalid Google Group is a data-access system revocation trigger for active bindings unless a same-`google_group_id` primary-email rename bridge is resolved first; cleanup compiles absent effects without Feishu reapproval.
- The approval/Feishu integration owns transport, callback authenticity, and Feishu instance state. Data access owns the semantic request transition.
- The data-access domain owns requests, request items, bindings, compiled effects, staleness, audit, and run/apply visibility.
- The reconciler owns provider mutation. Request submission code and Feishu callback handlers must never call BigQuery directly.
- A dedicated `kind='data_access'`, `provider='bigquery'` driver will own locator-scoped dataset IAM operations. This path must not route through generic CMDB/GCP cloud grants.

**Gates.**

- No model/API work until canonical names are fixed.
- No request/binding model work until principal identity is fixed: `email_groups.id` for Helix state, `google_group_id` for rename continuity, provider email snapshots for compiled effects, aliases as resolution aids only.
- No resource-owner authorization work until `data_access_resources.owner_person_id` is the single runtime owner authority, with owner-mismatch and unresolved bootstrap-owner/fallback cases failing closed.
- No Feishu-backed request submission until resource validation, Google Group validation, requester authorization, and dry-run compile exist.
- No Feishu-backed request submission or provider mutation path until the security planning bar in `DATA_ACCESS.md` is satisfied for touched assets/actors/trust boundaries, including negative tests for forged or stale callbacks, unauthorized requesters, deleted groups, forbidden principals/resources, raw provider roles, `jobUser`, and broad scan attempts.
- No BigQuery mutation until observe mode proves bounded deltas and canary prerequisites pass. "Bounded" means the reconcile report is scoped to selected compiled effects and dataset locators, with explicit add/remove/unchanged/unmanaged/error counts and no project IAM, `bigquery.jobUser`, or broad dataset enumeration.
- No catalog cutover until catalog ingestion, deterministic bootstrap replacement, and a cutover cleanup report/apply pass exist. The cleanup may suspend unmatched bootstrap resources from new authoring, must suspend new authoring on owner mismatch, must fail closed on unresolved bootstrap-owner/fallback cases, and must not revoke provider grants solely because a bootstrap row failed to match catalog.
- Apply/cutover words are operational: canary apply changes only `group:<email>` + `roles/bigquery.dataViewer` on allowlisted dataset locators; "bootstrap creation closed" means new requestable bootstrap rows are rejected while historical bootstrap provenance remains readable.

**Rejected alternatives.**

- **Table-level access in V1.** Rejected because table-level semantics, catalog shape, and BigQuery ACL/IAM behavior need a separate design.
- **Single-user data access.** Rejected because V1 is group-owner delegated access; user principals would require a different approval and lifecycle model.
- **Project IAM or `bigquery.jobUser`.** Rejected because V1 grants data visibility only, not query-execution authority or project-wide privilege.
- **Broad BigQuery scanning.** Rejected because Helix should mutate only catalog-fed or bootstrap-declared resources; broad scans create blast radius and ownership ambiguity.
- **Terraform runtime apply.** Rejected because Helix already has idempotent reconciler patterns; runtime Terraform would add another desired-state engine.
- **Feishu template authoring in Helix.** Rejected because Helix only maps to the configured `data_access_group_grant` approval flow.
- **Declarative Google Group membership reconciliation.** Rejected as a separate email-group capability; data access consumes group facts rather than owning membership.

**Anti-recurrence rule.** Provider role strings are compile output, not request input. A request that asks for `roles/bigquery.dataViewer`, `viewer`, `user`, `table`, `project`, `gcp`, or `jobUser` is not "close enough"; it is outside the V1 contract and must fail closed.

---

## 2026-06-09 — 手动「完成」离职自己驱动 flip-to-left,与 T+0 cron 对称(不再把 PERSON_NOT_LEFT 当前置条件)

**Decision**: `OffboardingService.complete_process` 在 person 仍为 `leaver_pending` 且前置条件全满足(状态非 BLOCKED/SCANNING/CANCELLED + `sso_suspended_at` 已盖章 + `leaver_date <= 本地今天`)时,**自己**经 `LeaverBlockGuard.flip_to_left`(`via=admin_api`,cron 路径仍 `process_advance`)把人翻到 `left`,再置 COMPLETED——和自动 T+0 advance cron(`_advance_single`:force-transfer → suspend_sso → flip_to_left → complete_process)完全对称。原先 complete 把 `employment_status=='left'` 当**硬前置**(SEC-002 加的 `PERSON_NOT_LEFT` 拒绝),只有 cron / HR 域 endpoint 会 flip。新增 `leaver_date` 未到的拒绝码 `LEAVER_DATE_NOT_REACHED`,防止管理员提前终结仍在职的人。

**Context**: PR4「落地页 cockpit」(#870)的「完成」按钮调 gate-only 的 `POST /offboarding/processes/{id}/complete`,但 cockpit 手动动作里有 force-transfer、suspend-sso、**唯独没有 flip-to-left**。管理员手动驱动完离职步骤后点「完成」必撞 `PERSON_NOT_LEFT`,且 cockpit 上无任何动作能清掉它——只能干等下一个 15 分钟 cron tick(单人 IT 实测踩到:郭江涛 SSO 已停、资源已转,卡 leaver_pending)。`completed ⟹ left` 这条硬不变量仍保留——只是从「assert」变成「ensure」:flip_to_left 自己会复检 pending blocker,有则抛 `LeaverBlockedError`,不变量绝不会被跳过。

**Alternatives**:
- *保持 gate-only,改前端在 `employment_status != 'left'` 时禁用「完成」并提示等 cron* — 否决:把一个本该即时的管理动作变成「干等定时任务」,IT 平台体感差;且手动授权面不扩大反而留下死按钮。
- *cockpit「完成」改调 HR 域 `complete_offboarding`(它本就 flip + complete)* — 否决:跨域换 endpoint、两套校验,不如让 offboarding 自己的 complete 与其唯一的自动调用方(cron)语义对齐——cron 仍先 flip,complete 见已 left 即跳过 flip,幂等无双翻。

---

## 2026-06-09 — 「网络负责人」做成可配置指定人(platform setting),不新增第 9 个 RBAC 角色

**Decision**: 证书/域名/DNS 等网络资源「无主对象」的兜底负责人 + 到期告警兜底接收人,落地为单个可配置的**指定人** —— platform setting `network.owner_person_id`(存 JSON 字符串 person_id,由管理后台设置页可改),migration 266 best-effort 把初值播种为孟凡生。解析器 `resolve_network_owner_id` 会校验该人仍存在且 `employment_status='active'`,否则返回 None,调用方据此优雅降级(台账仍展示无主行,绝不静默丢)。

**Context**: 用户要求无主证书的到期提醒兜底发给「网络负责人,就是孟凡生」,并问「要不就加一个网络负责人的角色」。功能真正需要的只是「一个兜底接收人」,即一个具体的人,而不是一类用户。把它做成指定人和现有 `asset_office_admins`(asset_admin 由其派生)是同源的成熟做法。

**Alternatives**:
- *新增 `network_admin` 规范 RBAC 角色* — 否决:为「一个人=网络负责人」去动 `rbac.py` 规范化、能力矩阵、前端 view role、以及所有 guard,血缘半径过大,属过度设计。若将来要给孟凡生网络域**写权限**,那是另一个独立 RBAC 决策,届时再单独评估。
- *硬编码孟凡生 person_id* — 否决:人会变岗/离职,硬编码迟早过时且不可在 UI 改。

---

## 2026-06-09 — NS 记录纳入 DNS 自助申请白名单(子域委派开放自助,MX/SRV/CAA 仍管理员专属)

**Decision**: `DNS_REQ_SELF_SERVICE_TYPES` 从 `{A, AAAA, CNAME, TXT}` 扩为 `{A, AAAA, CNAME, TXT, NS}`。NS 的「解析目标值」按 FQDN 校验(与 CNAME 同形),前后端双校验对齐(`_validate_dns_value` in schemas.py + `_validate_value_for_type` in dns_request_service.py + 前端 `validateValueByType`),走与其它自助类型完全相同的 Feishu 审批 + 云端下发路径。MX / SRV / CAA 继续保持管理员直写专属。

**Context**: 此前 NS 被刻意排除在自助之外(models.py 注释:NS 误改会波及整个域)。但实际的常规自助需求是**子域委派**(`sub.example.com NS ns1.vendor.com`),把一个子域交给外部权威 nameserver —— 这既不提权也不影响父域其余记录,没理由每次都开工单找管理员。用户明确要求让 NS「真的用起来」,即端到端可自助(校验 + 下发),不是只在下拉里摆一个选项。zone 顶点(`@`)的权威 NS 由云厂商自管、其记录 API 一般拒绝覆写,这条天然护栏使自助 NS 实质只对子域委派生效。

**Alternatives**:
- *只在前端下拉加 NS* — 否决:后端 literal / 白名单 / service 校验仍会 422 拒绝,是半成品(违背「绝不出半成品」)。
- *连同 MX/SRV/CAA 一起开放自助* — 否决:这三类误改 blast radius 大(邮件投递 / 服务发现 / CA 签发授权),与既有「高危走管理员」治理基调一致,保持专属。
- *给自助 NS 加「禁止顶点 @ 委派」显式校验* — 否决(本期):云厂商记录 API 已天然拒绝覆写自管 NS,再加一层 Helix 侧 apex 拦截属过度设计;若未来某厂商允许 apex NS 覆写再按需补。

---

## 2026-06-08 — Jira / Atlassian 集成模型:双 key 读写分离 + 按 conferring-group 撤产品访问 + 离职只撤访问不停号

**Decision**: Atlassian(Jira)走完整真 driver([`jira_software.py`](../../server/app/integrations/drivers/jira_software.py)),端到端自动化,固化四个结构性选择:

1. **三类凭据、读写严格分离,无 fallback**:
   - 用户/seat 级 token(`id.atlassian.com`,Basic)——走 Jira REST v3 做 group 增删(`POST`/`DELETE /rest/api/3/group/user`)。
   - `org_admin_api_key`(`admin.atlassian.com/o/{org}/api-keys` 生成、**带 scope**,Bearer 走 `api.atlassian.com/admin/v1/`)——**仅只读**离职对账(拉 org 托管名册 + product_access)。
   - `org_unscoped_api_key`(同页生成但**创建时不选任何 scope** 的全权 org key,加密入库)——**仅**撤访问写(remove-access / suspend-access)。带 scope 的 key 调撤访问端点一律 401 `INSUFFICIENT_SCOPE`(官方:无 scope 的 key 才可访问全部 Admin API)。两把 org key **故意无 fallback**,防 scoped key 被误用于写。

2. **撤权按 conferring-group 取交集,不是撤 seat**(`supports_product_access_revoke = True`):只移出绑定的那一个 group 仍会留下 `product_access`(用户经其它 conferring group 如 `jira-administrators` 仍持有产品)。revoke 改为「用户所属 groups ∩ 该产品全部 conferring groups」再逐个 DELETE。

3. **离职策略 = 撤产品访问、保留账号**:`DELETE /admin/v1/orgs/{org}/directory/users/{acct}` 撤掉产品访问但**不停用、不删号**(保留 issue 归属与历史)。明确**不**走 suspend-access(v2 directory suspend 一律 403 `ADMIN-UAM-403-1`)。

4. **404 消歧,杜绝假成功**:资源 404 = 幂等已撤(成功);路由 404(「match any route」)= 硬错误,绝不当成功;401/403 = partial-success deferred(不重试,带可执行 hint);5xx/网络错 = 有界重试 → dead-letter。

对账由两个 cron 兜底:`jira.seat_sync`(以 product_access 为真相导入/自动回收 seat)+ `jira.leaver_account_audit`(自动清「非 group 残留」离职访问,仅「未配 unscoped key / API 拒绝」才告警管理员,全自动撤成功的 run 静默)。

**Context**: IT 反复收到「N 名离职员工 Atlassian 仍报产品访问、不由任何 group 授予、Helix 清不掉」的周期告警。对着 prod API 实测定位到两层根因(撤访问端点不在 granular-scope 体系 + 旧 `revoke_seat` 打错 lifecycle URL 且把 404 当成功),已分别修复。这些决策此前只在 CHANGELOG(2026-06-08 条)有叙事,DECISIONS 无沉淀,后续易被重新质疑「为什么要两把 key」「离职为什么不停 Atlassian 账号」,故补此 ADR。

**Alternatives**:
- *单把 org key 兼读写* — 否决:scoped key 撑不起撤访问写(401),unscoped key 又过度授权不该用于日常对账;读写分离 + 无 fallback 是最小权限的正解。
- *撤权只移出绑定 group* — 否决:留 `product_access` 残留 = 那一整类周期告警的根因,必须按 conferring-group 交集清。
- *离职 suspend / 删号* — 否决:v2 directory suspend 403,且删号会丢 issue 归属;只撤产品访问符合「保留历史」策略。

---

## 2026-06-06 — 统一云角色治理框架(四档分级 + B 类仅 can_manage_cloud 审批 + 强制在 service 层)

**Decision**: 所有可申请云角色统一分入四档治理模型,贯穿「显示 / 申请 / 审批 / 驱动」四个卡点:

- `normal` — 只读/低危。显示、自助申请、scope owner 可审批。
- `elevated` — 高危但合法可申请(Contributor / PowerUser / GCP editor / 非 "admin" 名的 IAM 写)。显示并自助申请,但**审批仅 `can_manage_cloud`(global_admin / cloud_admin)**,scope owner 单独无权批。
- `restricted` — owner 等价 / 全 IAM admin / billing-admin。员工不显示、不可自助申请,仅管理员经 owner-add/admin 通道授;审批同样仅 `can_manage_cloud`。
- `ungrantable` — 该 scope 授不了(资源层级错配如 GCP billing.* 授在 project / 形态错)。任何人都看不到、不可申请,驱动 fail-fast 返回可操作错误。

分类由中央分类器 [`app/integrations/cloud_role_governance.py`](../../server/app/integrations/cloud_role_governance.py) 完成:每个驱动声明 provider 显式集(`restricted_role_keys`/`elevated_role_keys`/`ungrantable_role_keys(scope_type)`,`owner_role_keys` 自动并入 restricted),中央做合成 + 关键词兜底(供自定义角色)。**纵深三层防御**:① 角色同步把 `governance_tier` 落 `cmdb_cloud_roles`(管理员可在目录覆盖,同步不回写其改动);② 申请/审批的 **service 层**用分类器实时复核(不盲信缓存行);③ 驱动 `grant_access` 对 ungrantable 永远 fail-fast。

**强制点一律下沉 service 层(`submit_access_request`/`approve_grant`),不在 router** —— 因为 MCP 工具走 service 不走 router,既有 `is_admin_role` 拦截只在 router(`lifecycle_router.py`),存在 MCP 绕过隐患;下沉后 HTTP + MCP 同时生效。

**Context**: 王聪审批 GCP `roles/billing.viewer` 报 `400 not supported for this resource`(账单账号级角色授在 project 上)。深挖发现这是系统性缺陷:(A) 资源层级错配会授时报错,各云覆盖参差;(B) **当前完全没有「敏感角色需更高审批权」机制** —— `is_admin_role` 只匹配 "admin"/"管理员",于是 GCP `roles/owner`/`roles/editor`、Azure `Owner`/`Contributor`、AWS `PowerUserAccess` 这些 owner 等价/IAM 写角色既能员工自助申请又能普通 scope owner 一点就批,且审批环节对角色敏感度零重检;(C) 可见性只靠 `is_promoted` + admin 关键词,挡不住上述高危非 admin 角色。用户要求生产级、全面、可拆 PR、绝不出半成品(申请时还报错)。

**Alternatives**:
- *实时计算 tier 不落库* — 否决:管理员需可逐角色覆盖纠偏 + 下拉过滤要走 SQL,持久化更合适(覆盖标志 `governance_overridden_by_admin` 控制同步不回写)。
- *沿用 `is_admin_role` 扩关键词* — 否决:关键词一刀切既漏(owner/editor/contributor 无 "admin" 字样)又粗,且无法表达「ungrantable / elevated」之分;需要 provider 显式集 + 四档语义。
- *双签(cloud_admin + auditor)审批敏感角色* — 否决(本期):改动与流程成本最高,先用「仅 can_manage_cloud」收口最大缺口,双签可作未来 ADR。
- *把分类器放 `domains/cmdb`* — 否决:驱动(integrations 层)要调它自分类,放 `app/integrations/` 才符合 domain→integrations 单向分层。

**实现拆分(每个独立可上线,任何阶段都不让用户撞错)**: PR0 GCP billing 项目侧 400 修复(已上线 #757);PR1 治理内核(分类落库,零行为);PR2 可见性+申请拦截(消灭「申请即报错」,拦截下沉 service 堵 MCP 绕过);PR3 升级审批(elevated/restricted 仅 can_manage_cloud);PR4 驱动 fail-fast 清洁错误;PR5 GCP 账单账号 scope 能力(billing 诉求正路);PR6 前端角标/隐藏/目录 tier 管理。

---

## 2026-06-04 — 「安全合规」重设计为标准 GRC 模块:Control 为一等实体 + 多框架 crosswalk

**Decision.** The 安全合规 menu (never used in production, so a clean rebuild was authorized) is redesigned around a first-class **Control** entity — the model every GRC platform (Vanta / Drata / SailPoint) is built on — rather than a flat list of independent detective pages. Five new tables (`compliance_frameworks` / `compliance_requirements` / `compliance_controls` / `compliance_control_requirements` / `compliance_control_status`) make a control a first-class row with a status, a backing signal, and N framework-requirement mappings. A framework's readiness is a **projection** of its requirements' controls' statuses — never stored. SOC2 / ISO27001 / (later PCI / 等保) reuse the SAME underlying controls via the crosswalk.

**Delivered in phases (one PR each):** PR1 = GRC spine (control catalog + crosswalk + posture, SOC2 + ISO27001, registers the 8 existing detective controls). PR2 = access-certification engine (generalizes privileged review into a unified campaign engine; privileged review becomes `campaign_type='privileged_role'`, old standalone tables retired since there's no production data). PR3 = SoD detection + violation attestation. PR4 = PCI + 等保 catalogs. PR5+ = hardening.

**Why inside the `auditor` domain, not a new `compliance` domain.** The spine reads auditor data (MFA coverage, exposure counts, …) + `scheduled_jobs` freshness to evaluate controls. A separate domain would have to import from `auditor`, violating the "domains don't import each other" rule. The auditor domain is already the cross-cutting governance lane, so the spine lives there and reuses `can_view_security` / `can_manage_security` (no new RBAC role) and the control-freshness mechanism.

**Why a curated requirement subset, not the full standard.** Each framework seeds only the IT-relevant clauses Helix can actually evidence (access control, identity & auth, cryptography/key mgmt, logging & monitoring, vulnerability/exposure). Clauses with no Helix signal (physical security, HR policy, BCP) are NOT enumerated, and mapped requirements with no passing control show `not_covered` honestly. Claiming coverage we can't back is the opposite of audit-ready.

**Why catalog data lives in `compliance_catalog.py` (not inline in the migration).** A single pure-data module is the source of truth for both the runtime `sync_compliance_catalog` reconcile and the migration; editing the catalog (adding a clause, a framework) propagates on the next recompute without a migration, and the two never drift.

**Why CCM stores a latest-status row** (vs computing live every request): the recompute cron evaluates once and upserts, keeping the posture dashboard fast and giving an idempotent write target; history/trend is a deliberate later add.

**Rejected alternatives.**
* "Bolt access-cert + SoD + framework mapping onto the existing flat pages." — Leaves the no-unified-view root cause; the menu stays a pile of disconnected pages.
* "Full standard requirement sets (all 93 ISO controls, all PCI sub-reqs)." — Most clauses are outside an IT platform's evidence scope; hand-seeding fakes coverage.
* "Separate `compliance` domain." — Cross-domain import of `auditor` violates the domain-boundary rule.
* "Compute framework readiness live on every request." — Wasted work; CCM with a stored snapshot is the mature pattern and gives history later.

---

## 2026-05-30 — Managed lookup columns (`asset.location` first) gate every write through a service-layer resolver; no DB FK

**Decision.** For every column that is supposed to be a "managed lookup" (its valid set lives in a `*_locations`/`*_brands`/`*_categories` master table) we will:

1. Keep the column free-text on disk (`String(N)`) — **no foreign key**, no `CHECK`, no triggers.
2. Run every write through a **service-layer resolver** that normalises the input against the master table (+ an explicit normalisation map for known-dirty inputs) and either canonicalises it OR returns `None`.
3. Surface `None` per-path:
   * `create`/`intake`/inventory move — drop the field; the row lands without a location, dropdown forces a real value.
   * `claim`/`return`/`borrow`/`transfer` — `asset.location` only writes when the resolver returns non-null; the prior value survives. The operation row carries both `detail.location` (canonical) and `detail.location_raw` (what the operator submitted) when they differ.
   * `update` PATCH — unresolvable non-empty input is **dropped from the patch** (typo can't blank the prior correct value); explicit `None`/`""` clears the column.

**First application** = `asset_items.location` (resolver = [`server/app/domains/asset/location_resolver.py`](../../server/app/domains/asset/location_resolver.py); master = `asset_locations`).

**Why no FK / no DB CHECK.**
* TiDB has documented strictness quirks on CHAR↔VARCHAR FKs (memory: `tidb_fk_strictness`); the existing `asset_locations.name` is `VARCHAR(128)`, `asset_items.location` is `VARCHAR(256)` — bringing them into compliance for a real FK would require a destructive ALTER + a deployment-coupled data backfill.
* An FK would also reject every prod legacy row that still uses a name not in master, blocking the migration even after the data backfill on a transient race.
* A CHECK constraint can't enforce "must exist in another table" portably (no MySQL/TiDB CHECK against subquery).
* The resolver pattern matches the existing `AssetBrandModelService` (`brand_id`/`model_id` are real FKs but the canonical-name reconciliation also happens in the service), so this isn't novel.

**Why a built-in normalization map instead of pure strict matching.**
* HR's `identity_persons.work_location_name` comes from SuccessFactors at city granularity worldwide (`Santa Clara`, `北京市`, `Würzburg`, …). Strict matching would force admins to handle every claim's `location` field manually.
* The map is documented + tested (70 entries, all rooted in real prod values), and re-validated against the master each call — deactivating a managed row instantly breaks the corresponding map entries without code change.
* The map is **closed**: we only add entries for values that have actually shown up in `asset_items.location` or `identity_persons.work_location_name`. No aspirational world-city oracle.

**Why service-layer, not Pydantic validator.**
* Pydantic field validators are synchronous and can't query the live master state.
* A schema-layer "reject unknown location" would 422 the request — too noisy for the auto-fill path where the operator never typed the value (HR sync did). Service-layer drop-or-canonicalise is silent for the operator, audited via `detail.location_raw`, and reversible (admin can set the field manually from the dropdown after the fact).

**Where the contract lives.** Every new managed-lookup column (future: `usage_company`, `purchase_entity` cleanup, etc.) gets a similar resolver in the same module pattern. The boundary list — claim / return / borrow / transfer / create / intake / update / inventory-move — is the canonical "write boundary set" any new feature must consider when it adds to `asset_items`.

**Rejected alternatives.**
* "Add a real FK and CHECK." — TiDB FK strictness + the legacy-row migration cost don't pay back; see above.
* "Only validate at the schema layer, return 422." — Auto-fill path doesn't have a human operator to interpret the 422; the request would silently fail in MCP / Feishu approval / HR sync paths and confuse downstream debugging.
* "Build a city → country oracle from a world-city library." — Maintenance liability, and we don't actually need full coverage; the map only needs to cover values our HR data carries.
* "Do nothing, run a daily backfill cron." — Already tried this pattern for `location` over the last week (3 manual SQL backfills in 7 days). Treats the symptom, not the root cause. The user explicitly asked for root cause.

---

## 2026-05-29 — CMDB domains are company-public; sibling cloud resources stay owner-scoped

**Decision**: `GET /api/v1/cmdb/domains` (HTTP) and the MCP `list_cloud_domains` / `get_cloud_domain` tools no longer auto-demote non-admin callers to `owner_person_id=self`. Any `can_view_cloud_resources` user sees every domain in every enabled cloud tenant. The PATCH path (`can_manage_cloud`) is unchanged — RBAC relaxation is read-only.

**Why**: Domain names ARE company-public identifiers. `pingcap.com` / `tidb-community.com` / `pingkai.cn` are printed on business cards, the marketing site, every product page. They carry zero reconnaissance value beyond what a public `dig` already exposes. The expiry date / auto-renew flag / registrar are operational facts every employee can legitimately need to know ("is pingkai.cn going to expire? is auto-renew on?" — the 2026-05-29 ticket that motivated this PR). The pre-existing owner demote was copy-pasted from `list_servers` without revisiting the threat model for this resource.

**Sibling cloud resources KEEP the owner demote.** The split is deliberate. Each of these leaks something a `can_view_cloud_resources` employee should NOT see at the fleet level:

- **servers** — hostnames + private IPs (recon: lateral movement targets).
- **DNS records** — internal A/AAAA private-IP rows + DKIM/SPF TXT contents (recon: phishing + private-network mapping).
- **CDN domains** — origin host/port pairs (recon: origin shielding bypass).
- **load balancers** — internal listener ports + endpoint IPs (recon: backplane mapping).
- **IAM users** — ARNs + attached-policy fingerprints (recon: privilege-escalation paths).
- **object storage** — bucket names + ACL state (recon: data-exfil targets).

Only domains lose the demote. The rule is "domain != DNS record" — a domain is a published identifier, a DNS record is what's at it.

**Alternatives rejected**:

- **Drop owner demote everywhere**: rejected — leaks the recon material above. Owner-scoped is correct for sibling resources.
- **Keep owner demote on domains, add a separate public-listing endpoint**: rejected — two endpoints for the same logical query, hard to keep in sync. The current endpoint already returns the right shape; the only change needed is "who gets to call it."
- **Per-domain `public_visibility: bool` admin toggle**: rejected — every existing domain is public, no admin would ever flip it; the toggle is dead weight that future admins would have to explain.
- **Filter visibility by domain TLD (`.com` public, `.internal` private)**: rejected — Helix doesn't host private TLDs today; the rule is precisely "every domain in `cmdb_domains` is public", and the trigger for the PR was about visibility of `pingkai.cn` / `tidb-community.com` which are by-construction public.

**Anti-pretext / abuse model**: a leaver-pending or compromised employee account that gains read access to the domain list gets... a list of every domain PingCAP owns. That list is already public knowledge (WHOIS, the marketing site, the linked footers). No new attack surface vs. the status quo where every employee already knew most of these names.

---

## 2026-05-29 — CMDB domain provenance = `data_source` enum, sync is authoritative, sweep excludes non-synced

**Decision**: `cmdb_domains.data_source VARCHAR(16) NOT NULL DEFAULT 'synced'` tracks whether the row's expiry / auto_renew / registrar values came from a live cloud driver, a legacy CMDB import, or an admin manual override:

- `synced` — most recent write came from a cloud driver upsert.
- `migrated` — row landed via `scripts/migrate_cmdb.py` (one-shot legacy GCP CloudSQL → Helix import). Expiry / auto_renew / registrar are frozen at import time.
- `manual` — admin explicitly set expiry / auto_renew / registrar via `PATCH /cmdb/domains/{id}`.

**Sync is authoritative when it speaks**: `_sync_domains` re-stamps `data_source='synced'` on every upsert (both INSERT and ON DUPLICATE KEY UPDATE). A `manual` row whose `domain_name` is later returned by a real driver call flips back to `synced` — the cloud's value wins. `manual` only persists for as long as no driver speaks (off-platform registrars like GoDaddy / Markmonitor, or domains tagged under integrations without a domain driver like UCloud).

**Sweep excludes `manual` and `migrated`**: `_safe_sweep` for domains gets `extra_active_filter=[CmdbDomain.data_source == 'synced']`. Only synced rows are eligible for soft-delete when the cloud's authoritative list doesn't include them. Without this, the first sync after enabling a previously-disabled Aliyun integration would soft-delete every UCloud-tagged migrated row (UCloud's domain driver doesn't exist, so the rows can't appear in any cloud's `list_domains` output). Same pattern as `CmdbIamUser.revoked_at IS NOT NULL` — Helix legitimately retains rows the cloud doesn't.

**Alternatives rejected**:

- **Split into `registrant` (company name) + `registrar` (cloud slug) columns**: rejected — would force every existing query / schema / driver to be updated; adds a column without clarifying the actual question (is this value live?). `data_source` answers the live-or-not question more directly; the registrar field stays a single column with two namespaces disambiguated by `data_source`.
- **Boolean `is_stale: bool`**: rejected — three-state is the actual model (a manual edit IS NOT stale, just non-synced). A bool would conflate `manual` with `migrated` and the UI affordances differ (warning banner only for migrated; manual rows are admin-asserted and trusted).
- **Drop the migration data instead of tracking it**: rejected — the migrated rows ARE the source of truth for many domains Helix has no other connection to. Soft-deleting them would lose 50+ domains with no recovery path. The right answer is "label them honestly so admins know to verify."
- **TiDB ENUM type**: rejected — TiDB's online schema change on ENUM is materially slower than on VARCHAR; membership invariant enforced at the Pydantic layer (`Literal["synced", "migrated", "manual"]` on the response schema, schema-validated on PATCH).

**`auto_renew=null` explicitly rejected**: the column is NOT NULL on the model. A buggy client sending `{"auto_renew": null}` would otherwise coerce to False (Python's `bool(None) == False`) — silently flipping every legacy-imported row to "no auto-renew" if the admin meant "leave unchanged". Now returns 422 with a precise message ("send true / false, or omit the field"). Omit-vs-null contract: omit = no change, null = REJECTED, value = set.

---

## 2026-05-29 — SSO identity resolution = subject-first, email-fallback with anti-takeover guard

**Decision**: `AuthService.login_via_google` and `login_via_feishu` resolve a Helix Person in this order:

1. **Subject** — `(provider, provider_subject)` against `identity_external_identities` filtered on `deleted_at IS NULL` + `identity_status='active'` + `is_login_identity=True` + Person `deleted_at IS NULL`. Feishu probes union_id then open_id **sequentially** so the documented "union_id preferred" contract is deterministic; ties between historic rows for different persons can't silently flip.
2. **Email** (first-time binding only) — `enterprise_email` first (admin-bound, harder to forge), then the self-editable `email` field. The `email` fallback is gated by a **local-part match** against `enterprise_email`: same local-part required, both addresses must contain `@`, non-empty local-part required.

**Why**: 2026-05-29 prod incident. Feishu tenant admin rotated PingCAP's `enterprise_email` field from `@pingcap.com` → `@pingcap.com.cn` for the whole tenant. Helix `work_email` (HRIS-sourced via SuccessFactors) stayed `.com`. Previous email-only matcher locked out every already-bound user (audit confirmed: `shuai.feng`, `fansheng.meng`, `cody.maschinot`, `xiaomei.gan` etc. all 401'd against the new domain). Subject (Feishu `union_id` / Google `sub`) is the durable anchor — it survives provider-side field renames, tenant migrations, and admin error.

**Anti-takeover rationale**: a Feishu user can self-edit their `email` field. Without the guard, attacker A with their own admin-bound `enterprise_email=a@evil.com` and a self-edited `email=victim@pingcap.com` would first-time-bind to Victim's Helix Person on email-fallback (enterprise miss → email hit). Local-part equality (`a` vs `victim`) defeats this without breaking the legitimate PingCAP `@pingcap.com.cn` ↔ `@pingcap.com` mismatch case. **When `enterprise_email` is empty**, the local-part guard does NOT fire — legacy behaviour preserved for external Feishu accounts that have no admin-bound email; risk documented but kept (existing test codifies the behaviour).

**Five additional hardenings** (each closed an angle-of-attack a multi-agent review surfaced):

- `is_login_identity=False` on a live row is now an actual admin kill-switch — filtered out by subject lookup, rejected in email-fallback upsert, preserved across tombstone revival. Was previously cosmetic (always overwritten to True by next login).
- `identity_status != 'active'` on a live row is rejected in email-fallback upsert — was silently re-bound.
- `(provider, provider_subject)` UNIQUE collisions during subject rotation surface as clean 401 (pre-check via `_safe_swap_subject`), not as MySQL 1062 → 500.
- Tombstone revival: subject swap pre-check BEFORE clearing `deleted_at` — keeps SQLAlchemy session pristine on rejection.
- `is_login_identity=False` on a tombstoned row (admin's double kill-switch) survives revival and still rejects login.

**Information disclosure**: all `LOGIN_FAILED` 401s from `login_via_*` paths return the same opaque message string client-side; detailed reason (suspended / disabled / rotation collision / no user found) stays server-side in `audit_logs.detail.reason`. Account-state enumeration via response-body diff blocked; SOC visibility preserved.

**Alternatives considered**:
- **Force enterprise_email-only matching, drop email fallback entirely** — rejected: would break external Feishu users with no admin-bound email and was a behaviour change beyond the incident scope.
- **Hard-code PingCAP-specific `@pingcap.com.cn` → `@pingcap.com` domain alias** — rejected: solves only this incident, doesn't generalise, ugly per-tenant carve-out in shared auth code.
- **Require Feishu `tenant_key` validation** — deferred: real defence-in-depth but not the root cause of this incident; tracked as a follow-up.
- **Require Google `hd` (hosted-domain) claim allowlist** — deferred: same reasoning.

**Out of scope (this PR / tracked for follow-up)**:
- AUTH_NO_REFRESH_PREFIXES on `/auth/feishu/verify` (frontend axios refresh-on-401 loop on a real auth failure).
- H5 endpoint (`/auth/feishu/h5-login`) shares `login_via_feishu` so it inherits all fixes; dedicated H5 regression tests are a follow-up.
- Concurrent first-time-bind race (two simultaneous SSO logins for the same Person) — 1 of 2 will 500 on UNIQUE flush; rare in practice, deferred (would need ON DUPLICATE KEY or savepoint+retry).

**Implementation summary**: pure service-layer refactor — no schema changes, no new tables, no migrations, no new cron, no new MCP tool. New helpers in `server/app/auth/service.py`: `find_user_by_external_subject`, `_pick_provider_ext`, `_safe_swap_subject`, `_should_replace_subject`, `_local_parts_match`. `pick_match_email` (returned single value) renamed to `pick_match_emails` (returns tuple). 13 new regression tests cover the prod incident scenario + every anti-takeover / admin-kill-switch / tombstone / cross-subject-collision edge case. 93 auth tests pass.

---

## 2026-05-29 — View-role switcher chip = real backend narrowing (`X-View-Role` header), not cosmetic UI swap

**Decision**: The header role-switcher chip in `MainLayout` is now an authority-narrowing primitive. When the user picks a strict-downgrade view (e.g. global_admin → "员工"), the frontend axios interceptor stamps every authenticated request with `X-View-Role: <chosen-role>`; the backend `get_current_user` validates and applies it via a request-scoped `_role_names_override` on the `IdentityPerson` instance, and every downstream RBAC helper (`can_manage_cloud`, `can_view_finance`, etc.) sees the narrowed set transparently. Frontend mirrors via a derived `effectiveUser` Zustand selector; permission-gate call sites (`canX(user)`) read `s.effectiveUser` while display surfaces (avatar, name, the role-switcher menu itself) keep `s.user`.

**Context**: User reported a real-world incident: an admin (or an IT admin demoing "as employee") on the cloud-server detail page could see other people's servers AND the "更多" dropdown with 开关机 / 重启 / 删除. Before this fix the chip changed the left nav (cosmetic) but the JWT still carried `global_admin` and `canManageCloud(user)` returned true. The chip was lying — to the admin, to the auditor, and to anyone trying to verify "what would my employees see?". User said: "执行合并并发布到生产" once it lands cleanly.

**Alternatives considered + rejected**:
- **Per-page viewRole check** (each page reads `useAuthStore(s => s.viewRole)` and `&&`s into the canX gate). Rejected — explosion of N call sites, each must remember to do the AND, easy to miss in new code (the `SoftwareCatalogPage` pattern is the existence proof: that page already did this but no one else copied it).
- **A second JWT scope minted at view-role switch time.** Rejected — every chip click round-trips to backend for a new token; refresh flow becomes triple-token; auditor / management roles with read-only menus see no benefit; over-engineered.
- **Add a `viewRole` field to JWT claims.** Rejected — JWT is signed at login time; the chip must work at runtime without re-login. A claim ages out with the JWT.
- **Pure frontend gating.** Rejected — backend still serves admin-scope data because it doesn't know the user is "in employee view". The list-servers endpoint would still return every UCloud / AWS / Aliyun server. User-facing fix means data leak persists.

**Defense-in-depth rules baked in**:
- Header can ONLY narrow, never widen. Three checks in `apply_view_role_narrow`: canonical-set membership, user-actually-holds-role, and the `employee` universal carve-out also requires the user to truly hold `employee` (so a zero-grant contractor can't self-elevate via header).
- PAT-authenticated requests skip narrowing entirely. MCP / automation clients have no UI chip; a PAT-bearing request carrying `X-View-Role` is suspicious and the header is dropped.
- `/auth/me` and `/auth/me/profile` return `actual_role_names` (real, un-narrowed) so the role-switcher menu stays populated under narrowed sessions — otherwise an admin switching to 员工 would only see "员工" in the menu and have no way back.
- Header length capped at 64 chars to defuse CRLF / log-poisoning vectors.
- Defaults to no-op (zero header → existing behaviour unchanged); PAT/MCP/cron unaffected.

**Process lesson**: when a UI affordance has a security implication ("switch perspective" suggests the perspective will be enforced), the absence of backend enforcement is the bug. The pre-fix chip silently violated user trust — admins testing "as employee" got false confidence. Future audit: every UI element that names a permission boundary must have a corresponding backend assertion, or the element should be reworded to "preview navigation" (not "switch role").

**Implementation summary**: 4 backend files (`dependencies.py` + `rbac.py` + `person_models.py` + `auth/router.py`), 28 frontend files (Zustand store + axios interceptor + 27 page/component gate migrations), 10 new tests (`test_view_role_header.py`), 0 migration. Two code-review passes + one dedicated security/logic scan landed 5 findings (1 P1 PAT short-circuit order-of-ops, 1 P1 ProfileBasicTab missed migration, 3 P2 hygiene/edge) all fixed same PR. Memory: future "header switcher" or "preview as X" features must compose with this seam (X-View-Role header + effectiveUser store derivation) — don't roll a parallel mechanism.

---

## 2026-05-29 — Feishu Contact sync — add office dimension; reject sync_timestamp / Last Updated rule (hash-skip wins)

**Decision (2-part)**:

**(a) Office dimension is a first-class Helix-managed dept axis**, not a passive merge survivor. Users picked up this design gap with "除了同步标准的组 还有根据办公室同步 这个逻辑怎么没有体现出来". Implementation: a second mapping rule renders `{location}` → Feishu office-dept name via admin-curated `value_map` (defaults `OFFICE_LOCATION_TO_FEISHU_DEPT` covering Tokyo / Singapore / Sunnyvale / San Mateo / Kuala Lumpur / Selangor). `service._build_office_name_index` resolves the curated names to Feishu `od-XXX` via the live catalog. The user runner appends the resolved od-XXX into `department_ids` alongside the HRIS BU dept; both od-XXX sets are recorded in `_helix_external_set` so the slice-replace merge moves office assignments on relocation while preserving non-Helix-managed (Authing / manual) depts. The FlowDiagram on `/admin/identity-sync` flow tab renders "BU 维度" + "办公地维度" as parallel sub-nodes feeding into the Feishu target so admins can see the two-axis design directly.

**(b) NO `sync_timestamp` / "Last Updated" mapping rule** (added briefly, reverted after admin testing). The natural fit for Feishu's `Last Updated` custom_attr would be a `{sync_timestamp}` source field — but injecting a fresh ISO timestamp into the payload makes the SHA256 payload hash change on every render, which forfeits the `IdentitySyncState.last_payload_hash` skip optimisation. Admin tested both shapes and chose hash-skip > timestamp freshness — for 500 employees × 1 PATCH/day the skip-vs-write delta is small in absolute terms, but the "Last Updated" semantic is admin-derived from "did Helix touch this user" not from a per-field timestamp, so loss of skip is real cost for low signal value. Feishu's `Last Updated` field stays whatever Authing / the previous sync source last wrote.

**Context**:
- Live-tenant probe (verified 2026-05-28) showed Feishu's 20 top-level depts split into 13 HRIS BUs (Engineering / HR / Finance / Legal / etc.) + 7 non-HRIS depts (Tokyo Office / SG Office / SV Office / MY Office / China Business Group / PingCAP Intern / Consultants).
- The 4 office Offices map 1:1 from HRIS `work_location_name`. Round-3 design treated them as "Authing-managed, preserve via merge". Admin pointed out this means a RELOCATION (Tokyo → Singapore) silently keeps both od-tokyo and od-sg on the user — wrong behavior. Promoting the office axis to Helix-managed fixes it.
- `Last Updated` custom_attr exists on Feishu (id `C-7459311619677814787`, TEXT) — Authing populated it back in 2026-03-13. Tested with timestamp rule, observed cost vs value, decided against.

**Alternatives considered**:
- Hard-code office_mapping in the runner (rejected — per-tenant office sets vary; admin needs the value_map editor).
- Sync office names to Feishu (rejected — Feishu admin owns these depts; Helix only sets membership, never creates / renames them; this matches the 7-extra depts handling in Round 3).
- Compute payload hash WITHOUT timestamp field then push WITH timestamp (rejected as over-engineered; if admin ever wants Last Updated they add the rule and accept the trade-off).
- Generic "sync_timestamp" source field kept in mapping engine but unused by preset (rejected — dead source field surface adds parser-validator complexity without payoff).

**Implementation summary**: presets.py adds `OFFICE_LOCATION_TO_FEISHU_DEPT` constant + one mapping rule (`{location}` → `office_dept_name` with the default value_map). `FeishuContactRunner.__init__` accepts `office_name_to_external` (Feishu name → od-XXX from live catalog), uses it in `upsert` for the office translation, includes its values in `_helix_external_set` for the merge. `service._build_office_name_index` re-primes the Feishu catalog via a transient `FeishuDepartmentRunner` (read-only, ~2s cost) and filters to the curated office-name set. Single-person event-hook path also runs this so a hire / leave event picks up office assignment immediately. 8 new tests pin the office flow (append / replace on relocation / miss tolerance / in-Helix-managed universe / default-map sanity).

---

## 2026-05-29 — Feishu Contact downstream sync — collapse to ONE target / ONE switch

**Decision**: Merge ``feishu_contact`` + ``feishu_department`` into ONE admin-facing target kind ``feishu_contact``. Internally the sweep runs **Phase A (department reconciliation)** then **Phase B (user reconciliation)** in a single execution, but admins see one preset, one row in identity_sync_targets, and one enable switch.

**Context**: User asked "为什么部门和员工要分开同步 如果有一天先同步了员工没有部门怎么办 不会主动建部门吗 这个不是一个整体的事情吗" (Why are dept and user sync separate? What if one day user sync runs first without depts — won't it just create depts? Why split? Isn't this one whole thing?). One-line strike. Original 2026-05-28 design separated for INTERNAL implementation reasons (two state tables, two mass-action brakes, two runner shapes) — none admin-facing. That separation is now correctly classified as a leak from internal complexity into admin UX.

**What changed**:
- `feishu_department` kind removed from `SUPPORTED_KINDS` / `RUNNER_REGISTRY` / `_KIND_DISPATCH_PRIORITY`. `DEPT_KINDS` constant deleted entirely.
- `FeishuDepartmentRunner` class kept as service-layer internal helper (importable but not registered).
- `feishu_contact` is now the single admin-facing Feishu kind. Its `run_target` does Phase A (`_run_feishu_dept_phase`) then `_build_runner` + `_sweep`. Phase A writes `identity_sync_department_state` rows; Phase B picks up the fresh adoption map.
- Per-person event-hook path (`sync_person_to_target`) skips Phase A by design — single-person triggers shouldn't pay for a full dept reconciliation.
- `RunSummary` gains 5 dept counters so one run row carries both halves.
- `DeptSyncRunner.upsert_dept` interface changed from `(payload: dict)` to explicit kwargs (`dept_code, dept_name, parent_dept_code, leader_email, dept_order`) — dept fields are admin-invariant; no mapping engine round-trip needed.

**Alternatives considered + rejected**:
- Keep both kinds for "rare use case of dept-only sync" — admin can disable mapping rules. Rejected: there is no real "dept-only" use case that justifies admin-facing duality.
- Combine state into a single table (one row per HRIS code, person or dept) — rejected, awkward keying, breaks existing per-person event-hook code paths.
- Make dept phase a hook ON the user sweep instead of a sibling phase — rejected, less clear in logs / debug.

**Migration**: `migration 215` seeds a single `feishu-contact-primary` integration + a single `feishu-contact` target (enabled=0) from the preset, with credentials decrypted from `feishu-primary` and re-encrypted under the new row's AAD. Local + prod both get configured automatically the moment 215 runs. (`migration 214` creates the `identity_sync_department_state` table 215 depends on.)

**Process lesson** recorded for future agents: when a design separates concerns by implementation shape (state table A vs B / runner A vs B), pressure-test whether that separation is actually visible to the user. If admin doesn't know two implementations exist, having two switches is the bug, not the feature. Same lesson as the prior 2026-05-28 entry (probe-the-live-state-first); both came from being told "from the admin's view, this is one thing".

---

## 2026-05-28 — Feishu Contact downstream sync (identity_sync feishu_contact / feishu_department kinds) [REVISED — see post-mortem below, and superseded again by 2026-05-29 above]

**Decision**: Helix gains an outbound sync channel into Feishu's `/contact/v3` directory under the existing Identity Sync Center framework. **Two new kinds**, one new integration kind, one new state table:

1. `feishu_department` — dept-keyed (new `DeptSyncRunner` base class in parallel with `SyncRunner`); writes `identity_sync_department_state` (migration 214); pushed in parent-before-child topological order; soft-deletes via `PATCH status.is_deleted=true` (never hard-delete).
2. `feishu_contact` — person-keyed (subclass of existing `SyncRunner`); writes `identity_sync_state` (same as scim/navan/google_schema); `supports_delete=False` (offboard freeze only); coexists non-destructively with whatever currently writes Feishu accounts (Authing or otherwise).
3. New integration kind `identity_sync_feishu` (driver `FeishuContactDriver`) — credential row holding `app_id` + `app_secret` (typically reused from the existing `feishu_chat_approval` self-built app). Both new sync target kinds share this one integration row.
4. Dispatch ordering: dept kind sorts before user kind in `run_due_targets` so the user runner's `department_ids` push has the dept present (`_KIND_DISPATCH_PRIORITY`: feishu_department=10 < feishu_contact=30).
5. Default cron: `0 2 * * *` Asia/Shanghai. Hire/leave/transfer event hooks attach automatically via existing `events.py::enqueue_person_sync` (with new `t.kind not in DEPT_KINDS` guard so per-person events don't fire dept sweeps).

**Post-mortem — initial ADR was wrong, replaced after probing the live tenant**:

The first draft of this ADR claimed **"use HRIS dept_code as Feishu open_department_id"** — that was based on the assumption that Feishu was effectively empty and we could pick the IDs. Reality (verified 2026-05-28 by running `FeishuApprovalClient.get_tenant_access_token()` + `GET /contact/v3/departments` + `GET /contact/v3/custom_attrs` + sample `GET /contact/v3/users/{id}` against the live PingCAP tenant):

* Feishu has **20 top-level departments + 477 users** (Authing-managed).
* `open_department_id` is auto-generated by Feishu (`od-581b2e24...`) — admins cannot pre-pick it via the API.
* `code` field on every dept is **NULL** (Authing didn't populate).
* HRIS top-13 BUs match Feishu's top-13 by **exact name 1:1**; Feishu also has 7 extras (Tokyo Office / SG Office / SV Office / MY Office / China Business Group / PingCAP Intern / Consultants) — geographic-location / intern / contractor groupings outside HRIS authority.
* Each user's `department_ids` is a **multi-dept list** carrying both HRIS-BU and geographic-office assignments.
* `employee_no` on user records is HRIS `employee_id` (P0001) — perfect natural key.
* `custom_attrs` schemas exist (13 of them) — but `key` field is NULL, `id` is opaque `C-XXX`, `i18n_name` is the human-readable key. The 4 schemas the first draft mapped (`cost_center / business_unit / subsidiary / hire_date`) **don't exist** on the live tenant; the 4 that DO exist + map cleanly to HRIS are `PinYin / Company Entity / Location / Job Function`.

**Revised design (the one that actually ships)**:

**(a) Adopt-by-name for departments** — Feishu auto-assigns `open_department_id`; we don't fight that. Runner at build time calls `prime_catalog()` which BFS walks the Feishu dept tree and builds `name → od-XXX` index. On first sweep, HRIS dept matches Feishu existing dept by name → PATCH that `od-XXX` directly (no duplicate creation). Adopted `od-XXX` lands in `identity_sync_department_state.external_id`; steady-state PATCH-by-prior-external-id afterwards. HRIS `dept_code` written to Feishu's `code` field (Helix one-way contribution, useful for admin cross-ref). The 7 non-HRIS depts never enter Helix's state table → never modified, never soft-deleted.

**(b) Slice-replace merge for user `department_ids`** — runner GETs the user's current `department_ids` before PATCH, identifies which slice is Helix-managed (membership in `helix_dept_to_external` values), replaces only that slice with the fresh HRIS calculation, keeps everything else verbatim. Geographic-office assignments survive every sync. GET failure → omit `department_ids` slot entirely (Feishu leaves it untouched).

**(c) Custom_attrs catalog translation** — runner at build time calls `prime_custom_attrs()` reading `/contact/v3/custom_attrs` and building `normalised_name → {id, type}` from each schema's `i18n_name` locale values. Payload's `custom_attrs.pinyin` etc. translates to Feishu wire format `[{type, id, value: {text}}]`. Schemas not in the catalog are silently dropped (info-log) — admin creates missing schema in Feishu admin console + next sync picks it up automatically, zero ops overhead.

**(d) Helix never hard-deletes on Feishu — only freezes (users) / soft-deletes (depts)**. Real account deletion lives in `feishu_software._user_delete` (offboarding flow with SOC2 / leaver_blocked / file-ownership-transfer awareness). The identity_sync sweep is a continuous projection of HRIS state; cascading file ownership from a sweep is the wrong abstraction. Pathways coexist idempotently:
  - identity_sync freeze + offboarding freeze on same leaver — Feishu returns 230002 (already_frozen) which we treat as success.
  - User runner `supports_delete=False`; dept runner exposes `soft_delete` only.

**Context**: User asked (2026-05-28) to add 飞书 as another downstream sync target like Navan, run daily, hire-day instant create, abstract for future systems. Critical clarifying decisions:
- "What about 飞书人事 / Authing already pushing into Feishu?" — User: "先不关 Authing 我们稳定了才会关 Authing" (don't disable Authing; Helix's lookup-first / non-destructive update path keeps both running until admin chooses to retire Authing).
- "Phases?" — User: "没有什么第一期第二期 就是基于最成熟的生产目标一直做" (no phasing; production-grade everything in one PR).
- After the prod-probe revelation: "现在的飞书是生产环境 你不要做任何的危险的操作 不要做删除 清理 修改之类的事情 开关关上 等我一个一个的测试好了再说" (production Feishu — no destructive ops; default-disabled; admin tests one by one).
- "飞书多出来的 7 个部门是根据员工所在职场弄的 不具备实际上下级关系" → don't manage them (geographic groupings outside HRIS authority).

**Alternatives considered + rejected**:
- **"Use HRIS dept_code as Feishu open_department_id"** (the first-draft proposal) — would have created **duplicate depts** beside Authing's existing tree. Killed after probing the live tenant.
- **Two-way mapping table** (Helix dept_code ↔ Feishu od-XXX in a side table) — rejected; `identity_sync_department_state.external_id` already serves that purpose for the rows Helix has adopted; no separate table needed.
- **Reuse `ScimRunner` for Feishu** — rejected, Feishu is not SCIM (auth, field names, ID space, error vocabulary all diverge).
- **Single runner for both users + depts** — rejected; different keying (person_id vs dept_code), different state table, different mass-action brake. Separate `SyncRunner` vs `DeptSyncRunner` shapes preserve the existing person-keyed orchestrator unchanged.
- **Hard-delete dead depts/users on Feishu** — rejected; offboarding flow + admin re-org are the authoritative drivers; sweep doing it would race + cascade file ownership outside the controlled flow.
- **Full-overwrite `department_ids`** (no merge) — rejected; would wipe non-HRIS geographic dept assignments on every sweep.
- **Pre-create UNASSIGNED placeholder dept** (first-draft hire-day fallback) — rejected after probing: 13/13 HRIS top BUs already exist on Feishu by name, so realistic hire-day scenarios always have a real dept to land in. Edge case (HRIS adds a brand-new BU that dept sync hasn't reached) falls back to Feishu root `"0"` (always exists, zero admin pre-work).
- **Defer dept sync to a P2 PR** — rejected by user explicitly.

**Docs**: PLATFORM.md §10.5 Identity Sync Center extended (two new kinds documented with corrected adopt-by-name + slice-replace merge + catalog-aware custom_attrs); ROADMAP.md 2026-05-28 entry with the full design + post-mortem; this DECISIONS.md entry.

**Lesson recorded for future agents**: when designing against an external system that's "already running", **probe the live state first** before writing the ADR. The original "open_department_id = dept_code" ADR was internally consistent and would have looked fine in code review — but it would have created 13 duplicate top-level departments on day 1 of production deployment. The 4-API read-only probe (`GET /contact/v3/departments?parent_department_id=0` + `GET /contact/v3/custom_attrs` + sample `GET /contact/v3/users/{id}`) took 4 seconds and saved a P0 incident. Bake this discipline into the workflow.

---

## 2026-05-28 — Navigation restructure (4 sequenced PRs) + per-section Insights RBAC + Ops Cockpit / Procurement top-level menus

**Decision**: A user-driven 7-point menu reorganisation, shipped as four stacked PRs (#540 / #548 / #546 / #547) plus a follow-up (office-info → HR Tools + `/help` refresh):
- New top-level **采购管理 (Procurement)** menu — 采购 / 业务付款 / 供应商 split out of 软件管理 (not software-specific; hardware + services flow through them too).
- New top-level **运管驾驶舱 (Ops Cockpit)** menu — groups 运营洞察 (Insights) + AI 支出.
- **API Key 管理** moved 软件管理 → 云资产 (keys are cloud-IAM-bound); stripped from the `cloud_admin` menu copy (route stays global_admin + asset_admin). Employee 我的 API Key likewise → 我的云资产.
- **软件坐席/费用** gains a hierarchical department `TreeSelect` (filter by ANY department subtree, not just a top-level BU); reuses the existing `/cost-stats/department` subtree rollup + a new `require_software_read`-gated department-list endpoint (`GET /assets/cost-stats/departments`).
- **运营洞察 modules → standalone per-domain pages**, opened to the owning domain role; the cockpit keeps ALL modules for admin/management. Backend read gate moved from blanket `_require_admin` to `_require_insights_section(user, section)`: people/lifecycle→hr, assets→asset_admin, software(+catalog)→asset_admin+finance, cloud→cloud_admin+finance, security→auditor; overview/operations stay admin/management-only. The two software modules merge into one **软件总览** page.
- **HRIS 触发源** removed from the 入离职 overview and homed under **身份源数据与认证** (it's the inbound identity data source).
- **公司办公室及主体信息维护** (`/admin/assets/office-addresses`) moved from 后台管理 / 基础数据 into **HR 工具** (admin + HR views).

**Context**: User asked (2026-05-27) for these 7 changes citing poor menu grouping + admins unable to view software cost by their real department. Approved the full-split scope + the per-domain→role mapping + the 4-PR sequencing via in-session questions. Insights read-gate widening is READ-only (no insights write endpoints exist); write surface unchanged. All four PRs deployed to prod (final SHA `f0ccef86`; image-release + deploy success; prod healthy).

**Alternatives considered**:
- Keep insights only in the cockpit (rejected — HR / asset_admin / cloud_admin can't reach the cockpit; they need their domain slice).
- Duplicate each insight module into both cockpit AND domain menus as separate components (rejected — modules self-fetch on a `range` prop, so the same lazy component renders standalone; no duplication).
- Department filter as a flat multi-select (rejected — hierarchical `TreeSelect` matches the org tree the user navigates; backend already rolls subtrees up).
- One big PR (rejected — user chose 4 sequenced PRs each passing CI for reviewability; landed via a rebase merge-train onto a main that had moved 5 commits).

**Docs**: `/help` permission matrix gained `procurement` + `insights` rows + a per-domain-insights footnote; HR / finance role-scope text updated; insights help section notes the cockpit location + domain split. zh/en/ja. **Process lesson**: never `--delete-branch` when squash-merging a PR that is the base of a stacked child — GitHub closes the child and it cannot be reopened (its base branch is gone). Retarget the child to main first, or recreate it (#543 → #548).

---

## 2026-05-22 — Mobile rebuild PR2: 5-tab workspace layout, notifications relocate to top-bar bell, RoleSwitcher in top bar

**Decision** (refinement on top of the 2026-05-22 full-rebuild ADR below): the bottom tab footprint becomes **工作台 / 待办 / 应用 / 报表 / 我**. Notifications no longer occupy a bottom tab; the unread inbox is reachable through a top-bar bell icon with an unread badge, mirroring Feishu's own client. The top-bar also carries the RoleSwitcher chip on the left so multi-role users can switch perspective without leaving the current page. M1.4 (antd-mobile rewrite of legacy 4 pages) and M1.5 (IM deep-link routes) are deferred into PR3 alongside M2/M3 — they fit naturally with the new pages M2/M3 are already building.

**Context**: After M1.1 backend foundation landed, building the M1 frontend shell forced a navigation-architecture decision: with 77 pages on the in-scope list, 5 bottom tabs can't represent them. The choice between (a) tabs-as-pages (which the M1.0 ship did with 4 specific destinations) vs. (b) tabs-as-workspace-anchors (workspace home, todos aggregate, apps grid, reports, me) became binary at shell time.

**Why workspace anchors win**:
1. **Discoverability scales.** Tabs-as-pages caps the platform at 4–5 mobile entries forever. Tabs-as-workspace-anchors lets the apps grid carry the long tail without restructuring the bottom nav every milestone.
2. **Pattern familiarity.** Feishu's own client uses workspace + apps grid + 我; users already know it. Lark Workspace is the dominant mental model.
3. **Role coverage in one place.** The workspace home renders Employee / Manager / Admin cards driven by `available_roles`, so the same bottom nav serves an admin, a manager, and an employee without role-conditional tabs.
4. **Reports tab footprint stable before M5.** Putting the reports tab in the bottom nav from PR2 means users learn its location before the KPI cards land, instead of relearning the nav at M5.

**Why bell goes to top bar, not bottom**:
- Notifications is bell-icon-frequency for an employee but configuration-frequency for an Admin viewing other people's work. Keeping it as a bottom tab penalises 80% of viewers for the 20% who consume it constantly.
- The top-bar bell with an unread badge is the universal IM pattern (Feishu, WeChat, Slack, Lark). Users find it by instinct.
- This frees the 5th bottom slot for "报表" (reports) — which the user explicitly asked for in the rebuild brief ("管理层要看的报表之类的这些都能在移动端看到").

**Why RoleSwitcher chip in top bar (not a dedicated screen, not a settings flip)**:
- One person can hold IT Admin + Manager + Employee simultaneously. They want to switch perspective casually, often per glance — e.g. "open Helix, look at admin queue, switch to employee, file a request, switch back". A settings page flip is friction.
- ActionSheet over a chip mirrors the desktop role chip in the side nav, so the UX pattern is consistent across device classes.

**Why `useMobileWorkspaceStore` doesn't use `zustand/persist` middleware** even though it persists to localStorage: every other store in `web/src/stores/` (auth.ts) hand-rolls persistence with `localStorage.{get,set}Item`. Adding the persist middleware just for the workspace store would split the codebase between two persistence patterns. The hand-rolled path is ~20 lines, well-tested by the auth flow.

**Why M1.4 (antd-mobile rewrite of legacy 4 pages) and M1.5 (IM deep-link routes) move into PR3**: the legacy 4 pages (Todos / Assets / Notifications / Me) already work and render inside the new MobileLayout. Rewriting them now would balloon PR2 without UX visible to the user — the shell is the visible delta. M1.5 IM deep-links want detail-page templates that don't exist yet — those templates land naturally as M2 builds 23 employee self-service pages with detail views. Pairing M1.4+M1.5 with M2+M3 keeps each PR coherent.

**Alternatives rejected**:
1. **Keep 4-tab + add nav drawer for the rest** — drawers are a desktop pattern; on mobile they're discoverable only if the user knows to swipe. The apps grid is more familiar to Feishu users.
2. **Put notifications in `/m/me`** — buries them. Bell in top bar wins on discoverability.
3. **No RoleSwitcher; users go to `/m/me` to flip perspective** — too many round-trips for a per-glance action. The top-bar chip is one tap.
4. **Persist `activeRole` to the backend (per-user setting)** — over-engineering for a UI preference. localStorage is fine and lossless across normal use; explicit `reset()` on logout clears it.

**Acceptance**: `pnpm build` produces an `antd-mobile-*.js` chunk (~143KB / 49KB gzipped); desktop bundle unchanged. `pnpm lint` clean (one pre-existing unrelated warning). The 5-tab MobileLayout mounts in the Feishu webview; RoleSwitcher only appears when ≥2 roles available; workspace home cards mount per role; apps grid surfaces empty-state with the "coming soon" copy; reports tab shows placeholder. i18n parity (zh-CN + en-US + ja-JP) maintained on every new key.

---

## 2026-05-22 — Mobile (Feishu H5) full rebuild: 77 pages on mobile, `antd-mobile@5` alongside `antd@6`, dedicated `/api/v1/m/*` aggregate surface, mobile RBAC = read + single-record only

**Decision**: Rebuild the Feishu H5 mobile experience from the current 5-page minimal shell into a 77-page surface that covers ~85.7% of daily/weekly user interactions across all 8 roles. Adopt `antd-mobile@5` as the mobile component library while keeping `antd@6` on desktop — both live in the same SPA, separated by lazy chunks. Add a `/api/v1/m/*` aggregate surface so first-screen views (workspace home, KPI dashboard) collapse 5-6 parallel calls into one round-trip. Restrict mobile UI to **read + single-record actions only** — batch operations, destructive operations, and configuration pages (RBAC / integrations / SSO Apps / Identity Sync / system settings / email templates / scheduled jobs / audit logs) stay desktop-only.

**Context**: User direction 2026-05-22: 「现在的飞书小程序一塌糊涂 功能又不行 跳转也乱糟糟的 我准备重新做一下这个 做出来80%以上的功能 还有管理层要看的报表之类的这些都能在移动端看到」. The 2026-05-21 first-version mobile ([ROADMAP](../../ROADMAP.md) "移动端入口(飞书 H5)首版") delivered Boot / Home / Todos / Assets / Notifications / Me — ~10% of the platform's daily-use surface, no Manager view, no admin operations, no management reports. User wants the rebuild to (a) cover 80%+ of features including management dashboards, (b) reach mobile-native UX quality, (c) ship incrementally one PR at a time.

**Page-count math (sized before any code)**:

- Total desktop routable pages: 119 (counted from [`web/src/App.tsx`](../../web/src/App.tsx) `<Route>` declarations).
- **77 ship on mobile**: 63 direct ports + 14 mobile-redesigns (dashboards / reports / dense-table density that doesn't shrink). Split across milestones: M2 employee self-service 23, M3 manager + 12 high-freq admin, M4 admin remainder + 9 auditor, M5 reports + redesigns + 2 auditor redesigns.
- **42 stay desktop-only** (Bucket D: configuration / rare): all of RBAC, integrations, SSO Apps, Identity Sync, system settings, email templates, scheduled jobs, audit logs, org chart, user CRUD, HR sync, security tips, request-help-urls, evidence packs, supplier registry, role catalog, etc.

**Why `antd-mobile@5` instead of writing mobile-native UI on Antd, or going Taro / React Native**:

1. **Hand-rolled CSS modules don't scale to 77 pages.** The first-version 5 pages were doable hand-rolled, but 77 pages × (SwipeAction / ActionSheet / Picker / Stepper / PullToRefresh / Form / DatePicker / Selector / NumberKeyboard / SearchBar / Cascader / TextArea / ImageUploader / Toast) = several engineer-months of throwaway work that antd-mobile already ships, tested.
2. **antd-mobile@5 declares React 19 support** in `peerDependencies` (`^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0`). The 5.42.3 release confirmed working at install time. `@react-spring/animated@9.6.1` (transitive) carries a stale `react@^16-18` peer range that pnpm warns about; runtime works fine — same noise level as several other deps already in the lockfile.
3. **Bundle co-existence works via Vite chunk splitting.** `web/vite.config.ts` `manualChunks` filter routes `antd-mobile` + its exclusive transitive graph (`@react-spring/*` / `@use-gesture/core` / `@use-gesture/react` / `staged-components`) into a dedicated chunk. The desktop bundle has zero references to antd-mobile (frontend convention: nothing under `web/src/pages/`, `web/src/layouts/`, `web/src/components/` imports from `web/src/mobile/`). When a desktop user loads the site, the `antd-mobile` chunk does not download.
4. **Taro / React Native = rewriting the entire frontend** for one feature surface. Out of scope. The desktop SPA + mobile H5 model is already proven (PR 2026-05-21).

**Why `/api/v1/m/*` aggregate endpoints, NOT a BFF layer / GraphQL**:

The first-version `MHomePage` issued 5 parallel HTTP calls on mount (`Promise.allSettled` over transferApi + provisioningApi + assetApi + 2 notifications calls). Feishu's iOS in-app webview adds 200-500ms RTT per fetch on cellular — first paint sat at 2-3 seconds in user-reported testing. The aggregate endpoint (`GET /m/aggregate/home`) computes the same payload server-side in one round-trip with parallel DB queries internally. Trade-off: backend now owns the cross-domain composition logic for mobile-first pages. Alternative considered and rejected:

- **GraphQL / dedicated BFF process** — violates the user's "single TiDB, no extra infra" principle ([feedback_single_tidb_only](../../.claude/projects/-Users-ucjmh-Desktop-Helix/memory/feedback_single_tidb_only.md)). One more deploy unit + one more codegen pipeline for a benefit (typed federation) that FastAPI + Pydantic already gives us.
- **Wider reuse of `/dashboard/summary`** — that endpoint returns too much for mobile (cost snapshots, expiring keys, expiring warranty, recent activity). Mobile home needs counts + 3-row inbox, not a 50-field payload.

**Why mobile RBAC = read + single-record only (NOT 1:1 desktop parity)**:

Mobile is a one-thumb interface in a noisy environment (commute / meetings / outside). Mis-taps are real. The platform has dozens of unrecoverable operations (delete asset, force-transfer, bypass 4-eyes DNS, override leaver-blocked) — every one of those is a real recovery cost when triggered by accident. Locked rules:

- **READ + single-record approve / reject / mark-complete / accept / view** — surfaced.
- **Single-record edit with non-trivial change** — surfaced with twice-confirm modal (`Modal.confirm` with explicit re-typed reason).
- **Batch operations / bulk actions** — UI hidden on mobile. Same actions still work on desktop with the existing controls.
- **Delete / decommission / force-transfer / 4-eyes bypass / leaver-blocked override / scope decommission** — UI hidden on mobile. No exceptions, even for global_admin.
- **Bucket D config pages** (RBAC, integrations, SSO Apps, Identity Sync, system settings, email templates, scheduled jobs, audit logs, security tips, request-help-urls, evidence packs) — entire pages not ported. Configuration work belongs on desktop with full-width tables and proper review surface.

Backend gates stay unchanged — this is purely a frontend surface policy. A determined user can still hit `/api/v1/...` from the mobile JS console; backend RBAC catches them. The policy is about UX safety, not authz.

**Why workspace home + 5 bottom tabs + RoleSwitcher + apps grid (NOT 5-tab + drill-down or sidebar)**:

5 tabs can't carry 77 features. Workspace pattern is what Feishu itself uses internally (Lark Workspace, WeChat Discover) so users have muscle memory. Layout:

- Bottom 5 tabs: 工作台 / 待办 / 应用 / 报表 / 我 (workspace / todos / apps / reports / me).
- 工作台: greeting + role-aware cards (Employee: my assets/account/requests; Manager: team SLA / leaver predictions; Admin: pending approvals / queue anomalies) + pinned shortcuts.
- 应用: full grid of accessible apps, server-filtered by role gates, search bar at top, long-press to pin/unpin to workspace. Categories: my / team / asset / approval / cloud / software / audit / report.
- 报表: management KPIs (M5 ships 5-8 cards: AI monthly cost + Top-10 spenders, cloud monthly cost by account + Top-10).
- RoleSwitcher in header — same person can hold IT Admin + Manager + Employee roles; the chip switches which workspace cards mount, which apps grid groups expand, and which KPI cards show on Reports.

**Phased delivery — 11 weeks, one PR (or 2-3 small PRs) per milestone**:

- **M1.1 (this PR, 2026-05-22)** — Foundation: antd-mobile installed, Vite chunk rule, mobile/ scaffolding, backend `/m/aggregate/home` + `/m/apps/catalog` with empty APPS list (subsequent milestones append), API client `web/src/api/mobile.ts`, 12 pytest. No UX change yet; existing 5 mobile pages keep working at current paths.
- **M1.2** — Workspace home + RoleSwitcher (replaces existing MHomePage).
- **M1.3** — Apps grid + search + pin.
- **M1.4** — Rewrite Todos/Assets/Notifications/Me with antd-mobile (replaces existing pages).
- **M1.5** — IM deep-link routes (Feishu bot cards open `/m/<page>/<id>` directly).
- **M2** — Employee self-service: 23 Bucket A pages.
- **M3** — Manager + 12 high-frequency admin (approvals queue, provisioning queue, offboarding pipeline, notification outbox, asset workbench/list/detail/employee-requests/software-requests/api-keys).
- **M4** — Admin remainder (25 Bucket C pages) + 7 Auditor port pages.
- **M5** — Reports tab + KPI cards + dashboard redesigns (AI spending / cloud overview / network overview / insights / auditor dashboard / risk score).
- **M6** — Perf budget (<1.5s first paint), in-memory SWR cache, regression sweep, prod deploy.

**Alternatives rejected**:

1. **Antd 6 mobile-responsive** — desktop pages don't use Antd `Grid` xs/sm breakpoints today, and the table-density widgets don't compress well to 375px wide. Cost of retrofitting > cost of separate mobile component tree.
2. **Mobile-only fork / separate repo** — doubles the dev surface. Single SPA with lazy chunks does the bundle-size job without forking.
3. **Skip mobile reports tab; push management to desktop** — user explicitly asked for "管理层要看的报表之类的这些都能在移动端看到" because executives view data on phones during travel. Skipping defeats the stated goal.
4. **Service Worker / PWA / offline mode** — out of scope. Feishu webview is online-only in practice (offline behavior unspecified).
5. **Migrate to Taro for native Feishu mini-program** — `~3 months` rewrite cost, complete divergence from the desktop SPA. ROI doesn't pencil.
6. **Skip the synthetic `"manager"` role**, require an explicit role row — manager is derived from org structure (`IdentityPerson.manager_person_id`) by every dashboard already. Adding a stored role would be a schema change that contradicts the existing semantic.

**Acceptance (M1.1 only)**: Backend `/api/v1/m/aggregate/home` returns role-aware payload with all 8 counts in the correct privacy posture. `/api/v1/m/apps/catalog` returns the 8 stable categories and an empty `apps` array. 12 pytest pass. Frontend `web/src/mobile/` directory exists with README + locale helper; `web/src/api/mobile.ts` exposes typed client. Vite build emits no `antd-mobile` chunk yet (nothing imports it from app routes); when M1.2 mounts the first antd-mobile component, the chunk materialises. Zero migration, zero MCP tool change, zero cron change, zero RBAC change, zero existing-page UX change.

---

## 2026-05-20 — Role × menu matrix realignment: `cloud_admin` becomes a real write authority, `management` becomes a universal read-only view, `/help` carries the matrix table

**Decision**: Promote `cloud_admin` from "perpetual-grant marker" to a real cloud + network domain write authority by adding it to `can_manage_cloud` (backend) + `canManageCloud` (frontend), making it a first-class `ViewRole` with its own admin menu slice (cloud-mgmt + network-mgmt), and opt-ing it into every `/admin/cloud/*` and `/admin/network/*` RoleRoute. Broaden `RoleRoute`'s `management` auto-promotion 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 the domain-admin pages. Backend `can_manage_*` helpers stay closed for `management` (writes 403). Remove the dynamic "AI Spending wedge" that injected `/admin/ai-spending` into the menu for `finance` users in non-finance views — finance users go through the role chip like every other specialist role. Render the resulting role × menu matrix as a color-coded table on `/help`'s Quick Start tab.

**Context**: User audited the 8-role × menu reality and found three implementation gaps that contradicted the role definitions captured in CLAUDE.md / earlier ADRs:

1. `cloud_admin` was defined as "manage cloud accounts and trigger syncs" but every cloud admin page route in `App.tsx` was `RoleRoute roles={['global_admin']}` and backend `can_manage_cloud` returned `False` for cloud_admin — the role couldn't actually do anything.
2. `management` was defined as "read everything, write nothing" but `RoleRoute`'s auto-promotion only added management to routes that explicitly listed `global_admin` — asset_admin / hr / finance / cloud_admin specialist routes left management out.
3. `finance` users in employee view got a `/admin/ai-spending` link injected into the menu after computation, which produced a menu that mutated based on hidden state and made the "where does AI Spending live?" question unanswerable.

User direction was "云管理员能看到云相关的东西 财务能看到各种帐单的东西 HR能看到他们的小工具还有人员的数据 管理层能看到各种数据 但是不能操作 admin有所有的权限 — 都听你的 一个PR来解决这些问题 并且把上面那个图表更新到 /help".

**Why the perpetual-grant marker semantic survives via a separate helper, not a flag**: the 2026-04-29 brief that revived `cloud_admin` (after migration 040 deleted it) hinged on "SREs / DevOps need long-lived cloud access that the 90-day expiry sweep doesn't kick out". That's an `is_cloud_admin` predicate the lifecycle service consults when issuing grants (`expires_at = NULL`) and the expiry sweeper consults when filtering candidates. It's orthogonal to "is this person allowed to mutate cloud resources?" — many SRE / DevOps tenants want BOTH (long-lived access AND the right to operate the cloud), and a few orgs might want a long-lived grant on a non-operator (rare). We preserve both axes: `can_manage_cloud(role)` is the authority predicate (now admits cloud_admin), `is_cloud_admin(role)` is the perpetual-grant marker (unchanged). Same role, two predicates.

**Why `management` auto-promotes onto every admin-tier route but `cloud_admin` does NOT**: management's brief is universal read; bounding it to global_admin routes contradicted that. cloud_admin's brief is bounded to cloud + network; auto-promoting onto every global_admin route would leak access to `/admin/users` / `/admin/integrations` / `/admin/insights` / config-center / security pages where cloud_admin has no business. We opt-in explicitly per cloud/network RoleRoute instead — slightly more wiring, but the access boundary is local to the route declaration and grep-discoverable.

**Why irreversible cloud operations stay global_admin-only**: `can_manage_cloud_scope_lifecycle` (scope decommission, account close) and the `/admin/integrations?kind=cloud` route (connector credential edits) deliberately stay restricted to IT admin. Decommission is a one-way door (you can't unclose an AWS sub-account), and connector credentials are the platform's keys to the tenant — a separate authority dimension from operating individual cloud resources. The cloud_admin promotion explicitly preserves these two boundaries.

**Why `/help` carries the matrix**: every non-trivial role gate change in the last year (HR + Finance roles 2026-04-21, management 2026-05-02, cloud_admin revival 2026-04-29) had the same downstream cost — users + new admins emailing "can role X do Y?". A single source-of-truth table on the user manual that mirrors `lib/rbac.ts` + `RoleRoute.tsx` + `App.tsx` + `core/rbac.py` answers the question authoritatively and is bilingual (zh + en).

**Alternatives rejected**:

1. **Two separate cloud_admin-like roles** (`cloud_operator` for write authority + `cloud_admin` retained as the perpetual marker) — doubles role-count, doesn't match the population shape (the same SRE / DevOps people need both), and forces grant lifecycle to know about two roles instead of one. The two-predicate split inside one role is simpler and matches the same-people-need-both reality.
2. **Make `cloud_admin` a superset that also gets `asset_admin` and `auditor`** — explicitly out of scope. Cloud admin should not approve hardware requests or close out audit findings; mixing scopes is exactly the wrong direction.
3. **Make `management` auto-promote onto every authenticated route** — too broad. Management has no business on `/me/api-keys/:assignmentId` (which is owner-keyed self-service) or the `/my/*` employee pages of other people. The "admin-tier role appears in the allowlist" gate is the right boundary — it captures "is this an admin-facing page".
4. **Move `/admin/ai-spending` link into the role chip dropdown instead of the menu** — discovery is worse. The role chip is for switching, not for cross-perspective nav. Users want a stable menu in their current perspective and a clean switcher to another perspective.

**Acceptance**: One PR touching `server/app/core/rbac.py` (one helper updated), `web/src/lib/rbac.ts` (one helper), `web/src/stores/auth.ts` (`ViewRole` enum + comment), `web/src/components/RoleRoute.tsx` (auto-promotion broadened), `web/src/App.tsx` (cloud/network routes + cleanup of duplicate role-catalog/scope-requests block), `web/src/layouts/MainLayout.tsx` (`isCloudAdminView` + `cloudAdminItems` + `contextualItems` + `roleColors`/`roleLabels` + AI Spending wedge removal), `web/src/pages/help/HelpCenterPage.tsx` (matrix table + management card), `web/src/pages/help/HelpCenterPage.module.css` (matrix styles), zh-CN/en-US i18n bundles (23+ pairs), ja-JP bundle (`role.cloudAdmin` only). Zero migrations, zero MCP tool changes, zero cron changes.

---

## 2026-05-10 — APIKey monthly-budget alerts: per-(assignment, month, threshold) dedup + dual-recipient notification, NO standalone alert channel

**Decision**: Implement `apikey.budget_check` as a daily cron in the existing scheduled-jobs framework that sums per-applicant cost from `cmdb_cost_facts.cost_owner_person_id` (denormalised at ingest by the allocation rule ladder), compares to `api_key_assignments.monthly_budget_usd`, and dispatches 80% / 100% alerts via the existing `NotificationService` to BOTH the applicant AND their direct manager. Idempotency table `apikey_budget_alerts_dedup` UNIQUE(assignment_id, billing_month, threshold_pct). **Do NOT** build a standalone alerting channel; **do NOT** dispatch via direct Feishu webhook (skip the `cmdb_billing_alerts` cooldown / materiality logic).

**Context**: User direction during scoping was "把这个事情按生产标准完成 — 现在 monthly_budget_usd 是个摆设没人验证". The schema field has existed since M0; PR #240 closed the per-IAM cost attribution gap so MTD spend per applicant is now reliably queryable from `cmdb_cost_facts.cost_owner_person_id`. The missing piece was the verification loop.

**Why two thresholds (80% + 100%) with separate dedup rows**:
- **80% (warning)** lets the applicant + manager react before usage is throttled. Most useful early-month signal.
- **100% (over-budget)** is the chargeback / freeze decision point.
- Each threshold is monotonic — once 80% fires for May, the next interesting signal is 100% (a different `threshold_pct` → a different dedup row). When 100% fires we ALSO insert the 80% dedup row even though we don't dispatch it (the `notify_dispatched` short-circuit suppresses), so a refund-driven dip back below 100% mid-month doesn't re-fire 80% the next day. Pre-records both thresholds' dedup state in a single sweep.

**Why NotificationService (not direct Feishu webhook like `cmdb.billing_sync`)**:
- The cmdb daily sync alerts on TENANT-LEVEL budget breaches — one webhook to a finance channel is the right shape.
- This one alerts on PER-APPLICANT breaches — needs to land in the applicant's individual inbox + their manager's inbox. NotificationService resolves work_email → Feishu open_id, dispatches in-app row + Feishu card + email driver per recipient. Much less duplicate plumbing than building a per-recipient Feishu sender from scratch.
- The notification template (`apikey.budget_alert`) is bilingual (zh + en) following the platform convention, with the `[r]…[/r]` red-emphasis marker for money fields. Card header tinted red via `_CARD_HEADER_COLOR_BY_TEMPLATE`.

**Why dispatch BEFORE dedup write (and dedup rolls back on dispatch failure)**:
- Cmdb billing_alerts learned this in its 2026-05-05 round-2 audit: writing dedup BEFORE dispatch and then having the webhook 5xx leaves a 24h "cooldown active" silence baked in while the alert never reached anyone.
- Same posture here: SELECT to check dedup, dispatch via NotificationService, IF dispatch raised THEN log + still write the dedup row anyway (audit detail captures `notified=False`). The rationale: a notification-channel outage that re-fires the same alert tomorrow is louder than letting one user's alert drop. The audit row is the truth ledger; the dedup row is the rate-limiter.

**Sanitisation (added in security review pass 2)**: `assignment.project_name`, `applicant.display_name`, `applicant.work_email` are user-controlled values. Without sanitisation a determined applicant could inject a CR/LF + fake `— Helix IT 平台` signature + `[r]…[/r]` red-emphasis markers and the next budget alert would phish their MANAGER via Helix's trusted notification channel. The first incarnation of the `_safe_text` helper lived inline in `azure_openai_autogrant.py` (PR #239 round-2); promoted here to [`app/notifications/text_safety.py:safe_envelope_text`](../../server/app/notifications/text_safety.py) so every notification call site applies identical sanitisation. Strips CR/LF + `[r]/[/r]/[b]/[/b]` markup + length-bounded.

**Single-flight is at the handler_key level today**, sufficient because there's no manual-trigger entry point. A future `POST /admin/apikey/{id}/force-budget-check` (or MCP equivalent) would race the cron tick; the cron-handler doc-comment makes the inner-lock requirement explicit ahead of that. Without it, two runs racing past the dedup SELECT both DISPATCH before either INSERTs — UNIQUE catches the WRITE race but not the dispatch fan-out.

**Alternatives rejected**:
1. **Reuse `cmdb_billing_alerts_dedup`** — wrong granularity. That table dedupes by (integration_id, month) — a tenant-level signal. Per-applicant needs (assignment_id, month, threshold_pct).
2. **Single threshold (100% only)** — an applicant whose key is suddenly burning $80 on a $100/mo budget and accelerating gets no early warning. The 80% pass costs one extra dedup row per applicant per month, well worth the early-warning value.
3. **Dispatch only on 100%** — same problem as #2.
4. **Block over-budget keys at the IAM level** — out of scope and conflates an admin policy decision (freeze the key) with the cron's job (notify the responsible parties). The applicant + manager decide whether to pause / extend the budget; the cron just surfaces the signal.

## 2026-05-10 — Bedrock per-IAM-user cost attribution via AWS IAM principal tags (NOT Application Inference Profiles)

**Decision**: For the AWS SG / Bedrock account, attribute Bedrock spend per applicant by relying on AWS's **IAM principal tag cost allocation** (the dimension Cost Explorer surfaces as `aws:PrincipalTag/<Key>`). The IAM user provisioned by `helix_autogrant.bedrock` carries `Owner=<applicant_work_email>`; the cost-ingestion service runs the AWS driver in `principal_tag_keys=["Owner"]` mode (gated by `platform_settings.cost_facts.aws_principal_tag.<slug>`) and emits one `cmdb_cost_facts` row per (date × service × Owner) cell with `tags.Owner=<email>`; a priority-10 `CmdbCostAllocationRule` written at auto-grant time matches `(match_tag_key="Owner", match_tag_value=<email>) → assign_person_id=<applicant>` so the ingest stamps `cost_owner_person_id` + `cost_owner_department` + `cost_center` automatically. **Do NOT** mint per-applicant Application Inference Profiles + force-route IAM policies for this purpose.

**Context**: Bedrock on-demand `InvokeModel` has no taggable billable resource — the foundation model is shared across customers, so resource-level user-defined tags (the regular `TAG:<Key>` Cost Explorer dimension) don't reach the bill. The pre-existing Bedrock auto-grant tagged the IAM user with `Owner=<project_name>`, expecting it to slice cost; it didn't, because (a) IAM-user tags don't propagate to `TAG:<Key>` cost rows by default, and (b) project-level grouping collapsed multiple applicants on the same project into one bucket regardless. User direction was "我开了IAM tag的 也有Owner这个Tag 都可以分到IAM的" with a screenshot of the working `iamPrincipal/Owner` breakdown showing per-employee rows (`chensiyu@pingcap.com` $448.59, `ran.xu@pingcap.com` $16.57, etc.) — i.e. the IAM-principal-tag cost-allocation feature is already activated tenant-side, the gap is purely on Helix's side.

**Why the principal-tag path beats Application Inference Profiles**:
- Cost Explorer's `aws:PrincipalTag/<Key>` dimension is **flat** — no per-resource provisioning, no per-user inference profile, no IAM-policy gymnastics. Tagging the IAM user once at auto-grant time is sufficient.
- Inference profiles would require Helix to (a) call `bedrock:CreateInferenceProfile` per applicant with the right base-model ARNs, (b) keep the profile catalog in sync as Anthropic / Amazon ships new models, (c) ship an IAM policy that denies direct `bedrock:InvokeModel` on foundation-model ARNs and only permits invocation through the per-user profile ARN, (d) handle profile lifecycle on offboarding (delete profile + revoke policy). Each item is a real ops cost; together they multiply review surface for marginal benefit.
- The user's existing Cost Explorer setup proves the principal-tag path **already works in production** at PingCAP — Helix just needs to query the matching dimension and write the matching allocation rule.

**Alternatives rejected**:
1. **Application Inference Profile per applicant** — see "Why" above. Worth revisiting only if AWS deprecates IAM principal-tag cost allocation, OR if a future workload (e.g. provisioned-throughput billing) ships and we genuinely need per-resource attribution.
2. **Parse CloudTrail `InvokeModel` events + meter ourselves** — calculation drift vs. actual bill is the failure mode (Bedrock pricing changes, regional surcharges, request-failure billing semantics). Memory feedback: production-grade discipline forbids meter-yourself approaches when the billing system already exposes the dimension we need.
3. **Tag values = project name (status quo)** — collapses multiple applicants on the same project; breaks per-employee chargeback; `cmdb_cost_allocation_rules.match_tag_value` becomes ambiguous. Email is deterministically per-applicant and matches `IdentityPerson.work_email` directly.

**Implementation footprint** (one PR):
- `CloudDriver.list_cost_facts(..., principal_tag_keys=None)` kwarg added to the base; AWS implements `_list_cost_facts_principal_tag_sync`; Aliyun / Azure / GCP / Tencent accept-and-`del`. Symmetric to how `cny_per_usd` is plumbed.
- `cost_service.sync_cost_facts_for_integration` resolves `cost_facts.aws_principal_tag.<integration_slug>` once per run.
- `bedrock_autogrant` swaps `Owner=<project>` → `Owner=<email>` and writes the priority-10 allocation rule in the same commit as `granted=True`.
- Migration 142 seeds the `aws-sg` slug. Other AWS accounts stay on the per-resource path (kept, because main AWS spend is EC2 / S3 / RDS where resource-level granularity matters for drilldown).

**Synthetic `resource_id` shape** for principal-tag mode: `aws-principal:<key>=<owner>|service=<svc>`. Encodes (key, owner, service) so `compute_fact_key`'s natural-key hash stays unique per cell — service must be in the resource_id because `compute_fact_key` is keyed on `(date, integration, scope_external, resource_id, usage_type)` and same-day same-owner cross-service rows would otherwise collide. Untagged spend folds onto `(unallocated)` so chargeback reports surface it as unallocated rather than dropping it silently.

## 2026-05-10 — Bailian per-applicant attribution deferred (Aliyun BSS lacks per-RAM-user dimension)

**Decision**: Bailian cost continues to attribute at **member-account granularity** via the existing Aliyun `DescribeInstanceBill` line-item ingest. **Do NOT** ingest `bailian_usage_daily` into `cmdb_cost_facts` at this time, **do NOT** write per-API-key `CmdbCostAllocationRule` rows in `helix_autogrant.bailian`, and **do NOT** provision one Aliyun member account per applicant.

**Context**: User direction during scoping was "AliCloud … 这里会有百炼 ，百练的申请都是走的API KEY 要么是IAM，要么是百炼Key也要找到人去分帐". Investigation revealed Aliyun BSS only exposes Bailian cost at `(date × OwnerID)` member-account granularity — per-(workspace × api_key × model) granularity is not available on any public Aliyun billing API. The applicant's RAM user / Workspace API Key dimension does not appear on `QueryAccountBill`, `DescribeInstanceBill`, or DashScope usage endpoints. User response: "到不了申请人就到帐号吧 我再想想怎么分 不耽误整体的这个事情".

**What works today (no Helix change needed)**: `AliyunCloudDriver.list_cost_facts` already covers Bailian as a `ProductName` line item under `DescribeInstanceBill`, attributed to the matching `cmdb_cloud_scopes(scope_type='aliyun_account')` row whose `owner_person_id` is the member-account owner. The cost-allocation ladder denormalises `cost_owner_department` + `cost_center` from that person at ingest time. Result: department-level chargeback for Bailian works, applicant-level does not.

**Two paths considered**:
1. **Per-applicant Aliyun member account** (mirrors the Azure OpenAI RG-per-request pattern). Realistic but invasive — would require a Bailian-side autogrant flow that calls `ResourceManagement` OpenAPI to spin up a new RD member account per applicant, then bind credentials and quotas. ~Same complexity as the Azure OpenAI auto-grant we already shipped.
2. **Estimate per-applicant via DashScope per-request token logs** — rejected. Memory feedback: production-grade discipline forbids meter-yourself approaches when finance numbers must reconcile to vendor bill. Estimation drift vs. actual BSS cost is the failure mode.

**Decision**: **defer**. User is thinking about whether option 1 is worth the lift. Recording the constraint here so future agents don't re-derive that "per-API-key attribution is structurally impossible via current Aliyun APIs" — it is, on `QueryAccountBill` + `DescribeInstanceBill`. Member-account-level attribution is the production-grade ceiling absent option 1.

## 2026-05-09 — Production-grade FinOps surface: line-item fact table on TiDB, no separate OLAP store

**Decision**: Add `cmdb_cost_facts` (line-item: one row per date × resource × usage_type) and `cmdb_cost_allocation_rules` (priority-ordered chargeback ladder) directly on the existing TiDB cluster (migration 139). Store **denormalised** owner / department / cost_center columns on each fact row, populated at ingest by walking the rule ladder. Build the analytics layer as direct SQL against the indexed fact table. Do NOT introduce ClickHouse / Druid / Snowflake.

**Context**: User feedback "现在的整体帐单能力太小儿科了 没有什么作用 / 我想看每一个资源花了多少钱". The legacy schema (`cmdb_billing_monthly` + `cmdb_cloud_billing_daily`) only captured tenant-month + scope-day granularity, throwing away the line-item detail every cloud's billing API actually exposes.

**Why direct on TiDB rather than a dedicated OLAP store**:
1. ~500-employee scale: 5 clouds × ~5k resources × ~3 billing items/resource/day ≈ 27M rows/year. TiDB IndexLookup on the composite indexes keeps dashboard queries under ~200ms.
2. **Single-TiDB invariant** — standing user instruction is one TiDB cluster only, never propose adding a second DB / broker / state store.
3. No MV maintenance traps. We can add MVs later if a specific query crosses a real latency threshold.
4. Idempotent re-ingestion via `INSERT ... ON DUPLICATE KEY UPDATE` keyed on `fact_key` (sha256 with U+0000 NULL marker — collision-proof against literal provider values like `~`).

**Why denormalise allocation onto the fact row**:
- Walking the rule ladder per fact at query time would mean per-row Python evaluation against ~10-100 rules. A "last-90-days breakdown by department" query scanning ~6.7M rows is a non-starter.
- Denormalising at ingest keeps analytics queries pure SQL. Cost: a `recompute_allocation` after a rule edit (acceptable). Consistent with `cmdb_ai_resources.owner_department` (migration 079).

**Why a single `list_cost_facts` driver method** rather than reusing `list_billing_by_dimension`:
- `list_billing_by_dimension` is the ad-hoc admin query path (90-day cap, AWS 2-dim limit, hits cloud API live). Stays for that use case.
- `list_cost_facts` is the bulk ingestion path — driver-internal orchestration of whatever combination produces full line-item data.

**Why FX rate raises rather than silent default** (Aliyun + Tencent):
- A bogus default (e.g. 7.20 CNY/USD) silently produces wrong USD aggregates that finance later relies on. Blast radius of "we under-reported $X" is much worse than "the cron failed loudly until you set the rate." Operators MUST configure `cny_per_usd`. Same posture as the existing `cmdb_billing_monthly` skip-on-unknown-FX convention.

**Why backend keeps user-facing strings out of API responses** (`is_unallocated` flag instead of `label="未分摊"`):
- Helix serves zh-CN + en-US; embedding Chinese in the API forces every client to post-process. Returning a structured boolean lets the frontend i18n the label.

**Out of scope (deferred to follow-up PRs)**: AWS CUR + Athena (>14-day historical), multi-currency presentation, RI / Savings-Plan utilization, finance-system reconciliation (Netsuite / SAP).

**Alternatives considered**:
- **ClickHouse** — handles 100× the volume but violates single-TiDB invariant.
- **Read-time rule evaluation (no denormalisation)** — admin rule edit reflects immediately. Rejected because per-query cost makes the dashboard unusable past ~1M rows.
- **Reuse `cmdb_ai_resource_cost_daily` shape** (one row per resource × day) without line-item breakdown. Rejected because the user explicitly wants per-RG / per-tag / per-resource-type drilldown.
- **Materialized rollups** — deferred until a specific query crosses a real latency threshold.

---

## 2026-05-07 — Cloud-side orphan-principal cleanup (admin-confirmed sweep)

**Decision**: Helix surfaces and removes cloud-side orphan principal
assignments (AWS Identity Center "User not found" rows) through a
two-step admin workflow — never auto-deletes — and persists each
swept assignment as a `cmdb_cloud_access_grants` history row with
`person_id IS NULL`, `revoke_reason='orphan_principal_swept'`,
`external_principal_id`, and a best-effort recovered email in
`external_principal_email`.

**Context**: AWS Identity Center keeps an `AccountAssignment` row
indefinitely after the SCIM-synced user is deleted from the Identity
Store. The console renders these as "User not found (UUID)" attached
to a permission set on a sub-account. `list_scope_grants`
(`aws_cloud._list_scope_grants_sso_sync`) silently dropped them —
`_identity_store_email` returns `None`, the row gets skipped — so the
existing drift-removal mark-and-sweep was blind to them: drift only
fires for grants that were once successfully discovered. Result:
months-old leavers still showed `DBaaS-Prod-Admin-Role` on
`tidbcloud-marketplace` long after Helix had fully offboarded them
(observed 2026-05-07 from the user's screenshot of the AWS console).

**Why a two-step admin flow rather than auto-delete in sync**:
1. Mis-classified "orphans" (driver bug, transient throttling, AWS
   regional outage) would otherwise silently mass-delete live access.
   The orphan path uses `describe_user → ResourceNotFoundException`
   ONLY as the orphan signal; throttling / 5xx / auth errors are
   treated as alive (err on the side of safety), but a real bug in
   that classification would still bypass any review if we
   auto-deleted.
2. The sweep crosses into prod scopes (e.g. `pingcap-public`,
   `aws-root-public`). One-click bulk delete with no confirm is the
   class of action we forced an explicit confirm on for everything
   else (decommission scope, force drift-sweep) — orphan cleanup gets
   the same treatment.
3. The continuous path (`sync_grants_for_integration` runs the scan
   read-only at the end, surfaces the count in
   `orphan_assignments_detected`) keeps admins aware without forcing
   a decision on the cron's clock.

**Why insert a history row even though `person_id IS NULL`**:
The user explicitly asked for "在新加的这个功能上记录上这些" — the
sweep must be visible in the admin "已结束" tab, not only in audit
logs. Audit-only would mean every cleanup run vanishes from the
visual scope view; admins would have to grep `audit_logs` to know
what happened. Inserting a grant row keeps the historical record
co-located with the rest of that scope's revoked grants.

**Why migration 131 instead of a sidecar table**: orphan history is
semantically the same shape as a revoked grant — same scope, same
role, same revoke timestamp, same audit trail. A sidecar table would
have duplicated five columns and required a UNION on every "show me
the history of this scope" query. The cost was making `person_id`
nullable, which the existing service paths already tolerate (every
caller filtering by `status IN (active, pending)` short-circuits
before touching a NULL `person_id`).

**Recovery semantics for the email column**: when sweeping, look up
the most recent past grant whose `external_ref` ends in `::<principal_id>`
— the AWS driver writes `external_ref` as
`account_id::permission_set_arn::principal_id`, so any past
helix-issued or discovered grant for the same principal has the
suffix. If no past grant exists (principal was never SCIM-synced into
Helix), the email column stays NULL — the principal_id itself is the
attribution.

**Provider scope (v1)**: AWS only. Azure / GCP / Aliyun / Tencent
drivers raise `NotImplementedError` from
`list_scope_orphan_assignments` and the admin UI hides the "扫描云端
孤儿" button on those providers. Adding more providers is mechanical
(implement the two driver methods, remove the provider whitelist on
the frontend's `orphanScanSupported`). The AWS driver also short-
circuits when `scope.scope_type != "aws_account"` so a
`standalone_iam` scope falls into the same "not applicable" bucket
without surfacing a confusing AWS ValidationException.

**RBAC**: gated to `can_manage_cloud` (`global_admin` + `cloud_admin`).
> **Updated 2026-05-20**: this paragraph originally read "`global_admin`
> only … same gate as `decommission_scope`". That is no longer true.
> The 2026-05-20 cloud_admin promotion (see the "Role × menu matrix
> realignment" entry) made `cloud_admin` a real cloud write authority,
> and `POST /cloud-grants/orphan-cleanup` gates on `can_manage_cloud`,
> which now admits `cloud_admin`. It is NOT the same gate as
> `decommission_scope`, which stays `can_manage_cloud_scope_lifecycle`
> (`global_admin` only).

**PII redaction**: The history rows expose
`external_principal_email` (a leaver's recovered work email) and
`external_principal_id` (their AWS Identity Store UUID). These are
PII — a current scope member listing
`?scope_id=X&status_in=revoked` would otherwise enumerate every
ex-member who ever held cloud access on their scope. `_serialize_grants`
gates the fields on `can_read_cloud(viewer.role_names)` and renders
`person_label` as a generic `(orphan principal)` placeholder for
non-privileged readers. Privileged readers (admins, auditors,
cloud_admin) see the full attribution for forensic purposes.

**Per-target commit semantics**: each target is processed
independently. Cloud-side `DeleteAccountAssignment` succeeded means
the row is gone from AWS forever. To prevent the situation where the
50th target's history-row insert raises a DB error and rolls back
all 49 prior history rows (cloud state ahead of Helix bookkeeping),
each target's history row + audit row are committed BEFORE the next
target's driver call. On a per-target Helix-side write failure
after a successful cloud delete, the helper logs a `CRITICAL` line
naming the orphan + trace_id and returns `ok=False` for that target;
SRE hand-reconciliation is the recovery path. The alternative
(refuse to call cloud-side delete inside one big transaction) means
a single bad target blocks every other cleanup the admin selected,
which is worse for the common-case "30 leavers, all stale" sweep.

**Idempotency belt-and-braces**: `(scope_id, role_key,
external_principal_id)` is a second unique key on
`cmdb_cloud_access_grants` (added in migration 131) — NULL
`external_principal_id` makes it a no-op for normal helix-issued /
discovered rows. Catches the bug-window where a driver glitch fails
to return `already_absent` on a re-cleanup; the existing
`(scope_id, person_id, role_key)` UQ doesn't catch it because
NULL `person_id` is distinct under MySQL/TiDB unique semantics.

**Paired offboarding hook**: `LeaverBlockedGuard.flip_to_left`
calls `strip_person_from_alert_cc` after the existing orphan-seat
sweep, removing the leaver's UUID from every
`cmdb_cloud_scopes.alert_rules[*].cc_person_ids`. Without this hook,
leavers' UUIDs accumulated as `未知用户` tags in the alert config
panel because `/scopes/{id}/pickable-people` filters out
`status='left'` and the frontend can't resolve the id to a name.
Same root-cause class as the AWS Identity Center "User not found"
residue — the platform mutated the principal's lifecycle but didn't
sweep the references that pointed at it. Failure is swallowed +
audited as `cloud.alert.cc_leaver_strip_failed` (status='failed')
because cc hygiene is not a correctness invariant — runtime alert
routing already de-duplicates against active employees only via the
IdentityPerson row's existence + `work_email` non-null check in
`_resolve_billing_alert_recipients`.

**Paired: cloud-console pivot button**. Scope detail pages expose a
"打开云 Console" button resolving via
`Integration.config_public.console_url` →
`platform_settings.cloud.console_url.<provider>` → hardcoded baseline
in `console_urls._BUILTIN_DEFAULTS`. The button is the deep-link
admins use to verify the sweep removed the rows they expected. Same
resolution path on both `CloudScopeDetailPage` (admin) and
`MyCloudScopeDetailPage` (employee/owner) — the URL is shared, the
authorization isn't (only the sweep button hides on the employee
page).

**Alternatives rejected**:
- **Sync-time auto-delete with per-orphan confirm token** — a
  middle-ground where each orphan would surface as a Feishu approval
  ping. Rejected: ~30 orphans on the first cleanup would generate 30
  pings; admins prefer a single bulk-confirm modal.
- **Offboarding-time cascade** — extend the existing offboarding flow
  to also delete cloud-side principal assignments. Rejected: the
  orphans are months-old leavers whose offboarding already ran. The
  cleanup is a separate one-shot reconciliation, not a flow change.

---

## 2026-05-06 — Cloud scope owner self-transfer (policy reversal)

**Decision**: a scope owner can hand off ownership of their cloud
sub-account / subscription / project to a successor **without IT
intervention**. Backend `update_scope` accepts `owner_person_id` from
the current owner; `_require_scope_owner_or_admin` is the only RBAC
gate. UI surfaces this as a danger button + Popconfirm + Modal in the
`MyCloudScopeDetailPage` Settings tab, with copy spelling out the
loss-of-management consequence.

**Reverses the prior decision**: from launch through 2026-05-05 the
docstring on `MyCloudScopeDetailPage.tsx` listed Transfer Owner under
"Hidden from owners — admin-only on this page" with the rationale "org
policy: only IT/cloud admin reassigns". The HTTP route hard-gated
`owner_person_id` writes to `can_manage_cloud()`. Owners had no
self-service path; every transfer went through IT.

**Why now (2026-05-06)**: user feedback. The original "IT as
middleman" model added coordination cost without governance value —
the current owner is the right party to handle peer-to-peer handoff.
IT already trusts the owner to grant + revoke + approve everyone
else's access on the scope; trusting them to pick the next owner is
a strictly smaller delegation.

**What stays admin-only** (deliberately not loosened):
* `environment` toggle — opting a scope OUT of the T+0 expiry sweep
  is a tenant-policy decision.
* `status` toggle — the "real close" path goes through
  `/scopes/{id}/decommission` which calls the provider's CloseAccount
  API. Letting an owner flip status directly would skip that and let
  them resurrect a closed scope.
* `decommission` itself — irreversible, calls a provider API.
* `force-sync-now` — provider rate-limits.

**Bounded escalation**: once owner A transfers to B, only B (or an
admin) can transfer again. A cannot reclaim self-service. A stolen
owner session cannot be used to dump every owned scope onto an
attacker account and then re-acquire — once transferred out, the
attacker also loses the authority that made the transfer possible.

**Defense-in-depth retained**:
* `assign_scope_owner` does `SELECT ... FOR UPDATE` on the scope row
  so concurrent rebinds serialize on the row instead of
  last-writer-wins.
* Leaver / pending-leaver targets rejected with HTTP 409
  `SCOPE_OWNER_LEAVER_FORBIDDEN` (offboarding invariant).
* Closed-scope rebind rejected with `SCOPE_OWNER_REBIND_CLOSED_FORBIDDEN`.
* Self-rebind (owner_person_id = current) is a no-op + no audit row
  — prevents an attacker from spamming the audit table with
  self-rebinds to bury a real attack.
* Every successful rebind audited via `cloud.scope.assign_owner`.

**MCP parity** (per CLAUDE.md MCP-sync rule): new tool
`transfer_cloud_scope_owner` ships in the same PR, gated by the same
owner-or-cloud-admin check; tool count hint 107 → 108.

**Alternatives considered**:
* Keep admin-only — rejected, IT-as-middleman has no governance
  value here and adds latency.
* Require Feishu approval — rejected, the approver would be the
  owner themselves; no second signature meaningfully constrains the
  action.
* Restrict target to existing scope members — rejected, too
  restrictive (natural successor often doesn't yet hold a grant).

---

## 2026-05-05 — Cloud grant lifecycle: `revoked` vs `disabled` semantics

**Decision**: `cmdb_cloud_access_grants.status` carries two terminal-ish
states that are **not interchangeable**. Codify the boundary so future
changes don't blur it again.

* **`revoked`** — terminal. Active human decision: an admin / owner
  pressed Revoke, an offboarding workflow fired, drift removal saw
  the cloud-side ACL gone, an IAM disown landed. Stamps
  `revoked_at` / `revoked_by_person_id` / `revoke_reason`. Audit:
  `cloud.grant.revoke`. The user does **not** automatically come
  back; reinstating requires the standard request → approval flow
  for a brand-new grant. The revoke reasons enumerate the human-
  decision space: `user_released`, `expired`, `offboarding`,
  `owner_removed`, `admin_removed`, `drift_removed`,
  `iam_user_disowned`.
* **`disabled`** — reversible policy stop. The current sole entry
  point is the inactivity sweep's Stage 2: a (scope, person) group
  has been silent for `cloud.inactivity_disable_days` past Stage 1
  (default 60+14+30 = 104 days, all configurable). Stamps
  `detail.disable_result` (driver result + reason + ISO timestamp);
  leaves the `revoked_*` columns NULL. Audit: `cloud.grant.disable`
  with `reason="inactive"`. The person can come back, ask the scope
  owner for a re-grant, and resume work.

The cloud driver call is the same in both cases (`driver.revoke_access`
strips the IAM permission). Helix-side bookkeeping is what differs —
which is exactly the surface auditors care about (who pressed the
button, when, why, can they get it back).

**Why the explicit ADR**: pre-2026-05-05, the inactivity sweep called
`revoke_grant` and then overwrote `status` to `disabled`. The audit
log said `cloud.grant.revoke / reason=inactive`; the row said
`status=disabled` with `revoked_at` filled in; the column comments
implied disabled was reversible and revoked was terminal. Three
sources of truth in disagreement. A new helper
`disable_grant_due_to_inactivity` is now the only path to `disabled`,
keyed off the inactivity sweep, and writes the audit row + row state
consistently.

**Alternatives considered**:
* **Collapse to one state** — rejected. Auditors and admins
  legitimately want to distinguish "we stopped this access because
  the human walked away from it" (Helix policy) from "the human
  decided to stop this" (revoke). The flexibility-of-restart matters
  for end users too: a `disabled` grant can come back through the
  normal request flow without re-justifying first principles, while
  a `revoked` grant typically wants a fresh approval narrative.
* **Add `disabled_at` / `disabled_reason` columns** — rejected for
  now. The detail JSON carries the timestamp + reason
  (`detail.disable_result.{at,reason}`) and audit captures the
  same. Adding columns now would cost a migration without unlocking
  a query pattern we don't yet need. Revisit if a UI filter or
  metrics dashboard requires column-level access.
* **Add new revoke reasons (`inactive_owner_stage`,
  `inactive_disabled`) and stay on the revoke path** — rejected.
  Conflates "we ended this" with "policy paused this"; auditors then
  have to second-guess every `revoke_reason=inactive*` row to know
  whether the user can come back. Keeping the state-machine bifurcated
  is the cheaper invariant.

**Rule for future entry points**: a new auto-disable codepath
(license expiry inside the cloud drift, scope-owner-changed
re-evaluation, …) should land on `disabled` with its own dedicated
helper that mirrors `disable_grant_due_to_inactivity` (lock + driver
call + status set + detail stash + audit `cloud.grant.disable` with a
specific `reason`). A new auto-revoke codepath stays on `revoked` and
uses `revoke_grant` with a new `GRANT_REVOKE_REASON_*` constant.

---

## 2026-05-05 — Pre-launch hardening: seat / billing / software-integration surface

**Decision**: Five user-approved policy changes shipped as a single
pre-launch hardening PR before opening to ~500 employees. Each one
overrides historical "import-only" / "default-allow" behaviour with
a stricter, audit-traceable default.

### Sub-decisions

1. **Feishu seat sync gains drift cleanup with a 30-day grace
   window** (default; configurable via platform_setting
   `feishu.seat_sync.drift_grace_days`, set to 0 to disable). Was
   strictly import-only — Helix-active seats whose Feishu
   membership disappeared were left alone, which silently
   overcharged for off-boarded seats indefinitely. Drift cleanup
   now stamps `software_seat_allocations.drift_observed_at` on
   first observed absence and revokes after the grace window. Re-
   appearance clears the stamp so a temporary HR move doesn't
   accumulate.
   Alternatives considered: (a) keep import-only — rejected, real
   billing leakage; (b) revoke immediately like Slack — rejected,
   Feishu HR processes legitimately yank+reinsert accounts; the
   30-day window catches genuine drift while honouring the
   cooling-off pattern.

2. **`SoftwareLicensePlan.currency` column added; cross-currency
   aggregation refused at the service layer**. Was implicit-CNY
   for every plan, with `compute_billing` summing across plans
   without currency awareness — a `120 USD/year` plan and a
   `120 CNY/month` plan landed in the same total. Each plan now
   declares `currency` (default `'CNY'`); `compute_billing`
   returns `by_currency: {CNY: ..., USD: ...}` and refuses to emit
   a single `total_amount` when multiple currencies are present.
   Alternatives considered: (a) keep CNY-implicit — rejected, US
   tooling vendors are USD-priced and arrived in the catalog
   already; (b) FX-convert at compute time — rejected, finance
   owns the exchange rate; reporting must be currency-true.

3. **Zoom driver refuses silent licensed→basic demote on the
   `grant_seat` path**. Was: a 409-on-create triggered a PATCH
   user.type that silently demoted a paid Licensed seat to Basic
   when a binding pointed at the wrong tier — paid seat torn
   down, no `software_seat_allocations` row to record it, finance
   has no way to spot it. Now: the driver compares `_TYPE_RANK`
   and refuses the PATCH unless `ctx.extra["allow_demote"] is
   True`, set ONLY by the new admin
   `/seat-allocations/{id}/change-plan` endpoint (which writes a
   paired `software_seat.plan_changed` audit row).
   Alternatives considered: (a) refuse all PATCHes on conflict —
   rejected, breaks the legitimate basic→licensed upgrade path;
   (b) audit-log every demote without refusing — rejected, the
   goal is to catch the misconfiguration before a paid seat dies.

4. **`software_billing/close` and `software_billing/reopen` gated
   to `global_admin` only** (was `asset_admin`). Closing a period
   freezes the company's monthly software spend; the broader
   asset_admin scope is correct for inventory / seat operations
   but too wide for a finance-bookkeeping action. Reopen also
   gains a snapshot pattern: the rows being cleared are first
   copied into `software_monthly_bill_history` (with
   `supersedes_seq`, `superseded_at`, `superseded_by`) so any
   prior version of any period stays reconstructible — no silent
   rewrite of frozen ledgers. Alternatives considered: (a) keep
   asset_admin — rejected, per finance audit requirement;
   (b) move close/reopen to a new `finance_admin` role —
   rejected, no demand for a dedicated role yet.

5. **Permission catalog `delete_permission` becomes a soft delete**
   (`deleted_at` stamped, `is_active=False`). Was hard
   `db.delete()` — orphaned FK references on every revoked
   `UserPermission` row whose history pointed at the catalog id.
   Soft delete preserves history while hiding the row from the
   catalog UI. Alternatives considered: (a) cascade delete on
   permission removal — rejected, audit trail loss; (b) refuse
   delete entirely — rejected, admins need the "retire this
   permission" lever.

### Cross-cutting invariants this round established

- **`safe_to_drift_revoke` percentage floor (default 50%)** is the
  uniform safety guard across every seat-sync handler. The prior
  binary "0 vs N" guard caught empty-tenant outages but missed
  paginator truncation; the ratio guard holds the line until two
  consecutive runs agree.
- **One `audit_logs` row per sync run** plus **per-seat row on every
  drift-driven revoke**. CLAUDE.md non-negotiable #6 ("audit by
  design") was effectively bypassed by the seat-sync surface
  pre-this-PR; restored now via `_seat_sync_helpers.write_sync_audit`
  + `write_drift_event_audit`.
- **Zoom `max_seats` auto-tune capped at 25% drop per tick.** The
  prior auto-tune blindly accepted whatever Zoom returned; a
  transient API misread could shrink the plan and trigger an
  eviction loop. Larger drops are refused with an error surfaced
  in the run summary; admin can adjust manually if the change is
  intentional.
- **Append-only / soft-delete is the default for any catalog or
  ledger table going forward.** Hard-delete requires an ADR
  override.

### How to apply
- Future seat-sync handlers MUST honour the 30-day-grace pattern
  via `_seat_sync_helpers.safe_to_drift_revoke` +
  `drift_observed_at` (or use the per-handler default that
  matches the vendor's HR semantics).
- New plans MUST declare `currency` explicitly when not CNY.
- Drivers MUST refuse silent tier downgrades unless caller passes
  an explicit `allow_demote` flag in `ctx.extra`.
- Tier-change is a first-class admin action via
  `/seat-allocations/{id}/change-plan`; **do not** add a
  silent-PATCH path in any new driver.

---

## 2026-04-27 — Single-PR UX overhaul: language pref + Feishu cards + scope-creation auto + role catalog v2

**Decision**: Ship a single end-to-end PR (per the standing
"don't split" preference) covering seven user-flagged issues —
unifying scope-creation auto-flow for the clouds where vendor APIs
allow it, fixing the role-catalog UX, persisting user language
preference everywhere, redesigning Feishu DMs as interactive cards
with absolute URL action buttons, merging admin scope-request
approval into the scopes page, applying PersonTag to every remaining
"name-only" person column, and sweeping every stat-card row for
consistent heights.

### Sub-decisions

1. **GCP + Aliyun gain `create_scope`. Azure / Tencent / UCloud
   stay manual.** Rationale: GCP `cloudresourcemanager.projects.create`
   is fully API-driven once a parent organization/folder + billing
   account are bound; Aliyun ResourceManager `CreateResourceAccount`
   is API-driven once a Resource Directory folder id is configured.
   Azure subscriptions require Microsoft Customer Agreement billing
   scope (the API is gated behind paperwork); Tencent and UCloud
   sub-accounts are commercial billing entities that have no
   programmatic create. The frontend's "自动 / IT 手动" tag remains
   capability-driven (`getattr(driver_cls, "create_scope", None)
   is not CloudDriver.create_scope`) so flipping a driver later is
   one-line.

2. **`is_promoted` reconciliation, not refactor.** The column has
   existed since migration 068 with `default=True`. The bug user
   reported on Azure (≫5 entries in the primary dropdown) is stale
   data — every row synced before the driver was updated kept
   `is_promoted=True` because the daily sync's UPSERT deliberately
   skips `is_promoted` on UPDATE so admin manual flips persist
   across syncs. Migration 071 does a one-shot RESET based on
   `(is_custom OR role_key ∈ platform_settings.cloud.<provider>.<key>)`
   per integration. Same logic exposed at runtime via the new
   `POST /cmdb/role-catalog/reset-to-default` admin endpoint so
   later experimentation can be wiped without a redeploy.

3. **Aliyun curated five.** `AdministratorAccess` / `ReadOnlyAccess` /
   `AliyunRAMFullAccess` / `AliyunResourceManagerFullAccess` /
   `AliyunBSSFullAccess`. Mirrors the spirit of Azure's "Privileged
   administrator roles" tab — admin / read-only / IAM admin / RD
   admin / billing admin. Replaces the prior 8-entry ECS+RDS+OSS
   sample that gave ECS/RDS-flavoured employees the wrong defaults.
   Driver fallback updated to match. Migration 071 only overwrites
   the `platform_settings` row when it's still the pristine prior
   8-entry seed (admin customisations are detected and preserved
   with a printed notice).

4. **Per-integration role catalog (replaces per-scope).** Old UX:
   admin picks a scope → 380 rows → promote/demote affects only
   that scope. New UX: admin picks a tenant → deduped role list →
   one promote/demote click flips every same-keyed row across all
   scopes under that tenant. New endpoints
   `GET /cmdb/role-catalog/by-integration?integration_id=`,
   `POST /cmdb/role-catalog/promote-by-key`, and
   `POST /cmdb/role-catalog/reset-to-default` carry the bulk
   semantics. Description column is rendered now (was fetched but
   discarded). [curated] tag flags rows whose role_key is in the
   platform_settings curated list, so the visual matches the
   documented default.

5. **Admin scope-requests merged into scopes page tabs.** The old
   `/admin/cloud/scope-requests` standalone page is now a
   `?tab=requests` pane on `/admin/cloud/scopes`. The legacy URL
   redirects (App.tsx `Navigate replace`). Mirrors the employee
   side's existing tab structure on `MyCloudGrantsPage`.
   `CloudScopeRequestsPage` gained an `embedded?: boolean` prop so
   it can render without its own PageHeader when nested.

6. **Personal language preference persists end-to-end.** Source of
   truth is `identity_persons.preferred_language` (existed; now
   actually used). New `PATCH /api/v1/auth/me/profile` accepts
   `zh_CN` / `en_US` / empty (clear). `MeResponse` carries the
   value so `/auth/me` is the read path. Frontend auth store calls
   `applyUserLanguage()` on `setUser` / `setAuth` / `initialize`,
   which writes the localStorage cache so the next boot's pre-auth
   paint (login screen) renders in the user's last-known language.
   Default is **English** per user preference (2026-04-27) — seeded
   as `app.default_locale = en_US` and used as the i18n fallback
   when no cache exists. Existing users with non-null
   `preferred_language` are unaffected — the default only governs
   "no preference".

7. **PAT cannot patch profile preferences.** Same posture as
   `/me/tokens` (ADR 2026-04-27 PAT). A stolen PAT must not be
   able to flip the human's locale to silently degrade their
   notification stream. The PATCH endpoint 403s when
   `request.state.auth_via == "pat"`.

8. **Feishu notifications are interactive cards, not plain text.**
   Old DMs were `send_text_to_user(text=body)`; raw paths like
   `/my/cloud/scopes/<uuid>` rendered as unclickable text and
   looked like phishing. New DMs are `send_card_to_user(card=…)`
   with a header, markdown body, and a primary action button whose
   URL is absolute (resolved via `app.frontend_base_url`). When
   the platform setting is empty, the body is prefixed with a
   `[Config missing — ask IT to set app.frontend_base_url]`
   notice in the recipient's language so the misconfig is visible
   to anyone receiving notifications, not buried in admin logs.

9. **Bilingual templates with `_en` suffix convention.** Every
   `email_template.<key>` platform_setting now optionally carries
   `subject_en` / `body_text_en` / `body_html_en` /
   `card_button_label_en` alongside the Chinese baseline.
   `NotificationService._select_template_field` picks the en
   variant when `recipient.preferred_language` starts with "en";
   missing en variants fall through to the Chinese baseline (no
   silent drop). Migration 071 seeds the four scope-request
   templates this way; existing templates keep working unchanged
   and get bilingual upgrades opportunistically.

10. **Stat-card heights via `align="stretch"` + `height: 100%`.**
    Antd `<Row>` defaults to `align="top"`, so a card with a
    subtitle hangs taller than its plain neighbours. Fixed
    consistently in `CloudAnalyticsDashboard`,
    `NetworkAnalyticsDashboard`, `SoftwareCostPage`, and
    `BillingPage`. The `HeroCard` component carries the inner
    `height: 100%` so callers don't need to know the trick.

11. **PersonTag in `SoftwareCostPage` + `BillingPage`.** The two
    "user_name" columns missed by the 4c3b85c sweep (commit
    "feat(ui): unify person display") get the same avatar +
    locale-aware short name + tooltip treatment as the rest of
    the platform. No backend change — PersonTag's permissive
    props pull display_name + department directly from the row.

**Where this lives**:

* DB: `alembic/versions/071_role_reconcile_aliyun_curated_frontend_url.py`
* Backend: `app/integrations/drivers/{aliyun,gcp}_cloud.py`
  (`create_scope` + new config fields), `app/notifications/service.py`
  (language-aware + cards), `app/auth/{router,schemas}.py` (PATCH +
  MeResponse), `app/domains/cmdb/{lifecycle_router,scope_request_service,
  lifecycle_service}.py` (catalog endpoints + bilingual templates +
  absolute URLs)
* Frontend: `web/src/pages/my/MySettingsPage.tsx` (new),
  `web/src/pages/cloud/CloudRoleCatalogPage.tsx` (rebuild),
  `web/src/pages/cloud/CloudScopesPage.tsx` (tabs),
  `web/src/pages/cloud/CloudScopeRequestsPage.tsx` (embedded prop),
  `web/src/pages/cloud/{Cloud,Network}AnalyticsDashboard.tsx`,
  `web/src/pages/asset/SoftwareCostPage.tsx`,
  `web/src/pages/provisioning/BillingPage.tsx`,
  `web/src/stores/auth.ts` (`applyUserLanguage`),
  `web/src/i18n/index.ts` (cached boot language),
  `web/src/api/{auth,cloudLifecycle}.ts`
* Tests:
  `tests/test_core/test_notification_language.py` (12 unit tests),
  `tests/test_auth/test_profile_patch.py` (5 integration tests)

**Alternatives considered**:

* **Auto-create on every cloud regardless of vendor support** —
  rejected: Azure / Tencent / UCloud have no programmatic create
  path that we can call without a contract change. Faking it would
  put requests in `failed` state with cryptic upstream errors.
* **Replace the per-scope role catalog with a single
  platform_settings JSON edit** — rejected: admins want to see
  what a tenant actually has (live ARM / RAM catalog) before
  deciding what to promote, not type ARNs into a settings field.
  The new per-tenant view + reset button gets us most of that
  ergonomic gain without losing the live-data context.
* **Build language preference per-channel (web vs email vs
  Feishu)** — rejected: nobody actually wants their email in
  Chinese and their web UI in English. One column, one truth, all
  channels respect it.
* **Send Feishu plain text with auto-linkified URLs** — rejected:
  Feishu plain-text mode does NOT auto-linkify, and even when it
  did, the visual was inferior to a card with an explicit action
  button. The card schema is also extensible (we can add cost /
  expiry chips later without touching transport code).
* **Split into 7 PRs (one per item)** — explicitly rejected by
  user 2026-04-27: "你拆了 PR 写的代码就很差 你放在一起写 一起 review".

**Known trade-offs**:

* GCP project IDs are derived from the user's name slug + 4 hex
  chars. With ~65k combinations and our request volume,
  collisions are essentially zero. If GCP rejects a generated id
  (rare per Microsoft / Google blocklists), the request lands in
  `failed` and the admin retries — no auto-retry loop because the
  collision space implies a different problem.
* Aliyun `CreateResourceAccount` is synchronous; the AccountId
  is returned in the response. No polling needed — unlike AWS
  Organizations.
* `app.frontend_base_url` is empty by default and admins must
  fill it before notifications render fully clickable. The
  Feishu card prefixes a `[Config missing]` notice in the
  recipient's language until they do.
* The role catalog reset endpoint is destructive of admin
  customisations; the page guards it with a Popconfirm explaining
  exactly what will be lost.

**Review outcome**: Two-pass self-review completed. First pass
verified every endpoint roundtrips (TypeScript clean, ruff clean,
17/17 new pytest cases passing). Second pass swept for fallbacks /
mocks / stubs / empty methods — none introduced. The known
"empty default" on `app.frontend_base_url` is intentional and
surfaced to the admin in every Feishu card body until they fill
it in (not a silent fallback).

---

## 2026-04-27 — Personal access tokens + MCP server for AI assistant integration

**Context**: User wants AI tools (Claude Desktop, Cursor, Claude Code,
custom scripts) to be able to read and act on Helix data on behalf of
each employee — typified by the question "我有几台电脑?" returning a
list of the user's hardware assets and software seats. Helix only had
JWT auth from Google SSO + dev-mode password login, neither of which is
practical for non-browser AI clients (Google SSO requires a browser
consent screen; JWT access tokens are 30-min and refresh requires a
cookie). There was no MCP server, no service-account model, no API key
mechanism for the platform itself.

**Decision**: Ship a **single end-to-end PR** (no splits, per the
user's standing preference "你拆了 PR 写的代码就很差") containing:

1. **Personal access tokens (PAT) only.** Each employee can mint up to
   **4** PATs from `/me/api-tokens` with selectable expiry (30 / 90 /
   365 days, or never). Plaintext format `helix_pat_<32 base32 chars>`
   (160 bits of entropy). The plaintext is **shown once on creation
   and never persisted** — only SHA-256(plaintext) lives in the DB.
   The first 12 chars (prefix) are kept for UI display so the user can
   recognize their own tokens.
2. **No service accounts / AK-SK** in this iteration. Per-user PAT
   covers the announced use case; we can add shared service accounts
   later without breaking what's here. ADR will be appended when /if
   that lands.
3. **No scope flag** on a PAT — it carries the same permissions as
   its owner does in the web UI. Read-only / write-only modes were
   considered but ruled out for v1: the user explicitly chose
   simplicity ("和本人完全一致") and there is no realistic threat that
   a stricter scope mitigates without also blocking legitimate AI use.
4. **MCP Streamable HTTP** mounted at `/api/v1/mcp/`, using the
   official `mcp` Python SDK's `FastMCP`. ~35 tools exposed covering
   the full employee surface — profile, assets, software seats,
   approvals, API keys, permissions, email groups, CMDB (servers,
   domains, DNS), provisioning tasks, dashboard summary, compliance
   incidents, notifications inbox. **All tools call existing domain
   services**, so the MCP path is functionally identical to the web
   UI; we never reimplement business logic in the MCP layer.
5. **Write tools go through the existing approval chain.** A
   `request_software_seat` MCP call lands in the same Feishu approval
   queue a UI click does. There is no MCP-specific bypass and no
   dry-run / confirm middle layer (the user explicitly chose "给用户不
   添麻烦"). File-upload-bearing endpoints (asset approval form
   uploads) are **not** exposed via MCP — MCP is JSON-RPC and binary
   payloads don't fit cleanly; users do those in the web UI.
6. **Auth path:** `get_current_user` now accepts both JWT and PAT
   bearers. PAT detection is by the `helix_pat_` prefix; SHA-256
   lookup is one indexed query. `last_used_at` / `last_used_ip` /
   `last_used_user_agent` updates commit in an **independent session**
   so read-only requests still get accurate stolen-token forensics.
7. **PAT cannot manage other PATs.** The three PAT-management endpoints
   (`GET/POST/DELETE /api/v1/auth/me/tokens`) reject requests where
   `request.state.auth_via == "pat"` with 403. Rationale: a stolen
   PAT must not be able to self-perpetuate (mint replacement tokens
   to outlive revocation) or revoke the user's other legitimate
   tokens to lock them out. The user must come through the web /
   Google SSO path to manage tokens. Same posture as
   GitHub / GitLab take for their PAT settings page.
8. **Audit:** every PAT-authenticated request gets `via=pat` and
   `pat_token_id` merged into `audit_logs.detail` automatically via
   contextvars set in `get_current_user` and read in
   `AuditService.log` — no domain code change required. MCP tool
   calls additionally write `action="mcp.<tool_name>"` rows.
9. **Expiry warning:** scheduled job `auth.pat_expiry_warning` runs
   daily 09:00 Asia/Shanghai (cron seeded in migration 069), pushes
   one inbox notification per token expiring within 7 days, sets
   `expiry_notified_at` to dedupe. Departed-owner tokens are silently
   marked notified (won't re-scan, and won't authenticate anyway).
10. **Help center:** new bilingual "AI Assistant Integration" section
    with Claude Desktop / Cursor / Claude Code config examples + tool
    inventory + security best practices. Linked from the new
    `/me/api-tokens` page.

**Alternatives considered**:

* **Session-token re-use**: have the AI client paste the JWT
  access_token. Rejected — 30 min lifetime is unusable, and the
  refresh flow is browser-cookie-bound.
* **OAuth client_credentials grant**: too heavy for a 500-employee
  org, and AI clients don't have a per-tool registration story yet.
* **Service accounts on day 1**: deferred. Use case isn't there yet
  (no team-shared bot), would have doubled the API surface, and a
  later addition is non-breaking — service accounts get their own
  table, their own admin UI, and PAT semantics stay unchanged.
* **Separate read-only / read-write PAT scopes**: rejected for v1.
  All write actions are gated by approval anyway, and the simpler
  model matches user intent.

**Where this lives**:

* DB: migration `069_personal_access_tokens.py`
* Backend: `server/app/auth/tokens/` (PAT module),
  `server/app/auth/auth_context.py` (audit contextvars),
  `server/app/mcp/` (MCP server)
* Frontend: `web/src/pages/my/MySettingsPage.tsx` (tokens tab:
  `web/src/pages/my/settings/ApiTokensTab.tsx`; the old `/me/api-tokens`
  route now redirects to `/me/settings?tab=tokens`),
  `web/src/api/tokens.ts`, AI Integration section in
  `web/src/pages/help/HelpCenterPage.tsx`
* Scheduled job: `auth.pat_expiry_warning` in
  `server/app/scheduled_jobs/handlers.py`

---

## 2026-04-23 — Slack end-to-end: manual_notify on Pro plan, SCIM-ready when upgraded

**Context**: User asked for Slack seat management to mirror Zoom's
self-service flow. Probing the workspace revealed two hard
constraints:

1. `pingcap.slack.com` is **not** Enterprise Grid
   (`is_enterprise_install: false`), so `admin.users.invite` —
   Slack's only Grid-level user-lifecycle API — is unavailable.
2. Slack's SCIM v1 endpoint returns `403 plus_teams_only` for this
   workspace, meaning the Pro plan doesn't expose it either. SCIM
   requires Business+ ($15/user/month, roughly $6/user above Pro).
3. Slack has no SCIM v2 (`unsupported_version`).
4. The legacy `users.admin.invite` endpoint requires the `client`
   scope which is reserved for deprecated classic apps and can't be
   granted to new apps anymore.

i.e. **no Slack API whatsoever allows programmatic user invite /
deactivate from a Pro-plan workspace**. This is Slack's commercial
gating, not a Helix bug.

**Decision**: Ship a **single end-to-end PR** (no splits per user
preference "你拆了 PR 写的代码就很差 你放在一起写 一起 review")
implementing a **degraded-but-complete** flow that:

1. Fully syncs Slack membership via bot token (always available).
2. Uses a new generic `binding_config.execution_mode` override to
   steer all Slack grant/revoke tasks through `STATUS_NOTIFIED`
   instead of calling SCIM.
3. Fires a Feishu DM (+ email fallback) to IT admins when a task
   enters NOTIFIED, re-using the existing
   `NotificationService` + outbox infrastructure.
4. IT admin completes the invite in Slack's admin console and
   clicks "Complete" in Helix's existing `/admin/provisioning`
   page (no new UI page needed).
5. Ships a complete SCIM v1 implementation in the driver so that
   when the workspace eventually upgrades to Business+, flipping
   the binding to `execution_mode=auto_api` in the UI is the only
   change needed — no redeploy, no migration.

**Offboarding choice**: Slack catalog `offboarding_policy = auto_revoke`
(NOT `sso_cascade` like Zoom). Slack caches SSO sessions for up to
14 days after Google SSO revocation, so lazy SSO cascade is
insufficient — we must actively deactivate. On Pro plan the revoke
task goes through the same manual_notify flow (DM to IT admin).

**Seat caps**: `max_seats = NULL`, `over_quota_policy = 'reject'`
per explicit user decision ("这个不用 LRU 这个没坐席了可以开通的
提醒 IT 管理员 手动去开坐席", 2026-04-23). Slack seat cost is tracked
at the finance level, not gated in Helix.

**Guest filtering**: `list_full_members` drops `is_restricted` +
`is_ultra_restricted` (single/multi-channel guests) + bots +
`USLACKBOT` + `deleted`. Guests are typically free/low-cost;
tracking them as seats adds noise without IT value. Decision by
Claude, user delegated: "你看着办 我不懂".

**Generic improvements (benefits future plan-gated drivers too)**:

- `SoftwareConnector.binding_config["execution_mode"]` string field
  that `enqueue_grant_for_seat` / `enqueue_revoke_for_seat` honour.
  Unknown values ignored (typo-safe).
- `ProvisioningService._notify_manual_assignees` — DM on
  `STATUS_NOTIFIED` transition. Resolves recipient via:
  assignee_id if set, else `it_admin` + `global_admin` role fan-out
  (same shape as offboarding escalation's `_list_it_admins`).
- Platform-setting default `email_template.provisioning.manual_notify`
  in migration 056 — can be overridden per install without a
  redeploy.

**Alternatives considered**:

- (a) **Upgrade workspace to Business+** for ~$6/user/month × 500
  users ≈ $3k/month extra. Rejected as out of scope — business
  decision, not an engineering one; the code is ready the day
  finance signs off.
- (b) **Invent a new seat status `pending_manual`** and bypass the
  existing provisioning-task infrastructure. Rejected:
  `ProvisioningTask` already has `execution_mode=manual_notify` +
  `status=notified` + a generic `complete_manual_task` API +
  admin UI. Reusing is cheaper.
- (c) **Notify via Slack bot DM** (since we have `chat:write` on
  the bot token). Rejected per user ("通知走飞书吧"): the IT team
  lives in Feishu, Slack DMs get lost among channel noise.
- (d) **Per-person static assignee only, no role fan-out**.
  Rejected: software owner isn't always set; if the owner leaves
  or isn't in `asset_software_catalog.owner_person_id` the task
  would silently pile up. Role fan-out guarantees coverage.
- (e) **Notify on every NOTIFIED task regardless of driver**.
  Implemented (it's generic). No driver other than Slack uses
  manual_notify today, so zero blast radius; when it becomes
  common we have a single behaviour instead of per-driver copies.

**Known trade-offs**:

- IT admin must remember to click "Complete" after inviting in
  Slack. If they forget, `slack.seat_sync` will still mirror the
  new member into `software_seat_allocations` next morning, so
  the seat ledger stays accurate — the provisioning_task just
  sits in NOTIFIED until someone closes it (audit noise, not
  functional harm).
- SCIM v1 uses `PUT` (full resource replace), not `PATCH`, because
  Slack's implementation of PATCH on SCIM v1 is inconsistent
  between Grid types. PUT always works and is idempotent.
- The xoxp- User Token defaults to non-rotating; we explicitly
  tell admins not to enable Token Rotation in the app config
  (if they do, tokens expire every 12h and Helix has no refresh
  logic wired up yet).

---

## 2026-04-23 — Zoom end-to-end: seat sync + LRU eviction + lazy offboarding

**Decision**: Ship the Zoom integration end-to-end in a single PR —
driver additions + new `over_quota_policy` column on
`SoftwareLicensePlan` + `zoom.seat_sync` scheduled job + grant-time
LRU eviction coordinator + migration 051 that seeds integration
row, software catalog entry, license plan, connector binding,
scheduled job, and platform_settings defaults.

**Offboarding choice**: Zoom software's `offboarding_policy =
sso_cascade`. Google SSO revocation locks the user out; the paid
seat is reclaimed *lazily* by the LRU eviction when the next
request needs a slot. Not `auto_revoke`, explicitly per user
("sso_cascade", 2026-04-23 dialog).

**Protection whitelist**: `platform_settings.zoom.seat_eviction.
whitelist_emails` (explicit, admin-editable, default `[]`) UNION
the dynamic set of work emails for roles listed in
`platform_settings.zoom.seat_eviction.protected_roles` (default
`[global_admin, asset_admin]`). The requester whose grant is
running is always added to the protected set for that invocation.

**Sync cadence**: daily cron `0 3 * * *` UTC. Notification
channel: email only (per user — Feishu would wake up global team
members across time zones).

**Alternatives considered**:
- (a) Auto-revoke on offboarding — rejected; introduces a cross-call
  race between Google SSO suspend + Zoom disassociate and needs
  handling of the "user leaves on Friday, someone needs the seat
  Monday" case anyway (which is exactly what LRU handles). Lazy
  reclaim is simpler and already covers the gap.
- (b) Reject-on-over-quota like every other plan — rejected; Zoom
  licenses are expensive enough that "please wait for a free seat"
  is an unacceptable UX and the admin would end up manually juggling
  seats anyway.
- (c) Make the admin confirm each eviction via Feishu — rejected
  ("全自动不需要二次确认", 2026-04-23).
- (d) Ship as multiple PRs (driver → migration → coordinator →
  scheduler → tests → docs) — rejected by user: "你拆了 PR 写的代码
  就很差 你放在一起写 一起 review".

**Known trade-offs**:
- Seat count on the Helix side can drift from Zoom if the admin
  manually toggles a user's type in the Zoom console; `zoom.seat_sync`
  catches this within 24h and reconciles (2h freshness guard prevents
  snatching back a seat that was just granted but whose user hasn't
  shown up in `list_users` yet).
- `extract_licensed_seats` reads `plan_base.hosts` from Zoom; if Zoom
  renames the field we fall back to `None` and leave `max_seats`
  untouched rather than zeroing it out.
- Eviction email uses `template_key='zoom.seat_evicted'` in
  `platform_settings`. If the template is missing the built-in
  inline copy in `zoom_seat_coordinator.ensure_seat_capacity` still
  renders a usable message — the template just lets admin edit the
  copy without a redeploy.
- `ProvisioningTask._post_success_side_effects` for seat grants
  continues to look up allocations by `(user_id, plan_id)` where
  callers set `target_id=seat.id`, so post-success seat-status
  updates for seat grants are effectively no-ops today. That's
  pre-existing behaviour, not a regression — the approval callback
  already flips the allocation to `active` before the task runs.

---

## 2026-04-23 — Drop legacy ``cmdb_cloud_accounts``; single scope/grant model

**Decision**: Delete the ``cmdb_cloud_accounts`` table, its admin
router (``/cmdb/cloud-accounts``), the admin page
(``/admin/cloud/per-user-accounts``) and the employee page
(``/my/cloud/accounts``). All per-person cloud access is now
exclusively modeled as ``cmdb_cloud_access_grants`` under the scope/
grant lifecycle introduced in migration 052. Remove the
``RES_CLOUD_ACCOUNT`` offboarding blocker type (replaced by
``RES_CLOUD_SCOPE_OWNER`` + ``RES_CLOUD_ACCESS_GRANT``). The
``/admin/cloud/per-user-accounts`` and ``/my/cloud/accounts`` routes
client-side redirect (`Navigate replace`) to the new pages for
bookmark compatibility.

**Scope breakdown** (migration 053, same PR follow-up):
- Drop ``cmdb_cloud_accounts`` with a safety rail: refuse upgrade
  when the table has active rows (would force manual migration).
- Remove ``CmdbCloudAccount`` model, ``cloud_accounts_router``,
  ``RES_CLOUD_ACCOUNT`` constant.
- Rewrite callers:
  - ``offboarding/service.py`` — drop the ``CmdbCloudAccount`` scan
    + ``_apply_cloud_account_policy`` (grants now handled by
    ``RES_CLOUD_ACCESS_GRANT`` blockers; scope ownership by
    ``RES_CLOUD_SCOPE_OWNER``).
  - ``advance_service.py`` — drop the ``RES_CLOUD_ACCOUNT`` branch
    in ``_force_transfer_all`` (scope-owner branch remains).
  - ``post_leaver_service.py`` — residual-scan replaced with
    ``CmdbCloudScope`` (scope owner) + ``CmdbCloudAccessGrant``
    (active grants held).
  - ``offboarding/escalation.py`` — manager-reminder cascade now
    includes ``RES_CLOUD_SCOPE_OWNER``, no longer includes
    the cloud-account policy filter (scope is always must_transfer).
  - ``provisioning/service.py`` — remove
    ``_invoke_cloud_account_revoke`` + the ``cloud_account``
    target_type branch. Grants never flow through ProvisioningTask;
    they're driven directly by the lifecycle service.
- Frontend: delete ``PerUserCloudAccountsPage.tsx``,
  ``MyCloudAccountsPage.tsx``, ``web/src/api/cloudAccounts.ts``.
  ``OffboardingConfigOverviewPage`` no longer lists cloud-account
  rows (all routing via ``/admin/cloud/scopes``).
- Menu entries under ``/admin/cloud/*`` and ``/my/cloud/*`` removed.
- Legacy label strings (``offboardingAdmin.resource.cloudAccount``
  and friends) kept as dead fallbacks — i18n keys don't hurt, and
  cleaning them up would risk breaking an obscure reference.

**Context**: User review after the 052 ship: "为什么会并存这些东西
我理解应该没有这么多的". The overlap was deliberate transitional
state (Bedrock kept writing the old table) but the user wants the
final clean model, not a carry-over. Production ``cmdb_cloud_accounts``
was empty (Bedrock actually stores AK/SK on ``api_key_assignments``,
not ``cmdb_cloud_accounts`` — the docs were misleading), so no data
migration is needed.

**Alternatives**:
- **a) Keep the table around, hide the menu entry** — rejected: carries
  the same cognitive load forever and drifts out of sync.
- **b) Migrate existing Bedrock users into grants as part of the drop** —
  n/a, Bedrock never wrote the table.

**Known trade-offs**:
- ``CloudAccountRevokeContext`` + ``CloudDriver.revoke_account`` are
  leaf primitives on the driver interface (AWS implementation = full
  IAM user delete: password, keys, MFA, policies, user). The "delete
  leftover IAM user" action anticipated here has since been wired up:
  live callers exist in ``app/domains/cmdb/router.py`` (IAM-user
  cleanup endpoint) and ``app/domains/apikey/service.py``.
- Downgrade recreates the table shape so a rollback is safe; any
  rows written post-rollback would be blank fresh state.

---

## 2026-04-23 — Multi-cloud account lifecycle (tenant → scope → grant)

**Decision**: Add a three-layer abstraction (tenant → scope → grant)
with per-scope owner, self-service request flow (Helix-internal
approval — no Feishu template), 90-day TTL + self-renewal, automatic
expiry revoke, inactivity detection (60 → 74 → 90-day cascade),
scope-level daily billing + spike detection, and offboarding
integration. Implement real SDK/REST grant/revoke for all five
clouds (AWS Org+Identity Center, AWS SG IAM, Azure, GCP, Alicloud,
Tencent IAM) in a single PR.

**Scope breakdown** (all in migration 052, one PR):
- Four new tables: `cmdb_cloud_scopes`, `cmdb_cloud_access_grants`,
  `cmdb_cloud_roles`, `cmdb_cloud_billing_daily`.
- `CloudDriver` mixin extended with `list_scopes` / `list_scope_roles`
  / `grant_access` / `revoke_access` / `list_billing_daily` /
  `get_last_login` + dataclasses (`CloudScopeInfo`, `CloudRoleInfo`,
  `CloudAccessContext`, `CloudGrantResult`, `CloudDailyCost`) +
  `CloudPrincipalNotFoundError`.
- Driver `account_mode` field (`sso` vs `iam`) — SSO expects principals
  to already exist in the IdP (Helix does NOT do SCIM); IAM creates a
  shared-per-person IAM user and attaches policies.
- `app/domains/cmdb/lifecycle_service.py` — domain-facing service for
  request / approve / reject / owner-add / renew / revoke + three
  sweeps + scope/role/billing syncs. Never raises on driver failure —
  `suppress_driver_error` flag for offboarding + expiry paths.
- Five scheduled handlers: `cmdb.scopes_sync` / `cmdb.roles_sync` /
  `cmdb.billing_daily_sync` (per-integration) + `cmdb.access_expiry_sweep`
  / `cmdb.scope_idle_sweep` (org-wide; renamed from
  `cmdb.scope_inactivity_sweep`, which migration 138 removed).
- `scheduled_jobs_provisioning.py` extended to seed the three
  per-integration lifecycle rows on create/update.
- `is_cloud_scope_owner_of` / `person_owns_any_cloud_scope` RBAC helpers;
  derived `scope_owner` NOT a DB role.
- Offboarding blockers `RES_CLOUD_SCOPE_OWNER` (force-transfer) +
  `RES_CLOUD_ACCESS_GRANT` (always revoke).
- New lifecycle router at `/api/v1/cmdb/scopes` + `/grants` +
  `/my-grants` + `/billing/daily`.
- Frontend: `CloudScopesPage` (admin list + owner console drawer) +
  `MyCloudGrantsPage` (employee view + request modal) + nav + full
  zh-CN/en-US i18n.
- Ops doc `docs/ops/cloud-lifecycle-ops.md`.

**Context**: The existing `CmdbCloudAccount` table modeled "one IAM
user per person per tenant" — no concept of sub-accounts, no
membership, no expiry, no self-service. User wanted the full
employee lifecycle: apply → own-account-add → 90-day renewal →
inactivity reclaim → offboarding revoke, across AWS / Azure / GCP /
Alicloud / Tencent / AWS SG. Bedrock auto-grant (the pre-existing
specialized path) remains untouched — it writes `CmdbCloudAccount`;
new lifecycle writes `CmdbCloudAccessGrant`.

**Alternatives**:
- **a) Extend `CmdbCloudAccount` with a `scope_type` discriminator** —
  rejected: column semantics would diverge (account_id means
  "IAM username" in old rows, "account GUID" in new rows); data
  migration risk for Bedrock.
- **b) Feishu-template-based approval** — rejected: scope owner
  approval is a 1-1 decision that belongs in Helix UI; Feishu
  templates are for cross-team multi-step flows. User explicitly
  chose "Helix 内部审批".
- **c) Helix does SCIM provisioning into each cloud IdP** — rejected:
  2× the driver work, conflicts with existing Google-Workspace→cloud
  federation. User confirmed "假设人已在各云 IdP".
- **d) Split into N PRs (framework, then each driver)** — rejected
  by user: "你拆了 PR 写的代码就很差 你放在一起写 一起 review".
  Honored per the 2026-04-21 review protocol ADR.

**Known trade-offs**:
- **Inactivity signal fidelity**: IAM mode uses `PasswordLastUsed` +
  `AccessKeyLastUsed` (free AWS API). SSO mode: no last-login signal
  from Azure (requires AAD P1 + sign-in log fetch), GCP (requires BQ
  audit log export). Those return `None` and the sweep SKIPS — we
  will not auto-disable those grants on inactivity. Acceptable
  because: expiry path (90-day TTL) catches them; Alicloud IAM does
  surface it.
- **GCP `setIamPolicy` is RMW**: etag collisions cause 3-retry loop;
  for hot projects this could theoretically thrash, but the grant
  volume we see (handful/day) makes this a non-issue.
- **Alicloud billing is CNY**: `CmdbCloudBillingDaily.currency="CNY"`;
  cross-cloud totals need conversion downstream. Frontend cards note
  the currency; aggregate totals filter by USD for now.
- **IAM-mode AK/SK delivery**: when `ctx.extra.create_access_key` is
  set (caller-opt-in), driver returns AK/SK in `result.detail`;
  service scrubs everything except `access_key_id` before persisting.
  Plaintext secret is delivered once in the HTTP response body +
  notification, then lost. Bedrock flow keeps its existing separate
  AK/SK storage in `api_key_assignments.key_value` (platform-wide
  convention, not a regression).
- **Unowned scope blocks grants**: deliberate. A scope without an
  owner has no one to approve; letting grants through would lose
  accountability. The `cloud_scope_unowned` compliance_incident
  surfaces on `/admin/compliance-incidents` as a work item.
- **Driver dependency weight**: Azure + Alicloud use REST via httpx
  (ACS3-HMAC-SHA256 signer written inline for Alicloud — ~40 lines
  in `aliyun_cloud.py`). No new Python dependencies added beyond
  what's already in pyproject (boto3, tencentcloud-sdk-python,
  google-api-python-client). GCP BigQuery billing sync uses
  `google-cloud-bigquery` which is NOT in pyproject — the code does
  a lazy import and returns empty billing if missing; admin must
  install it manually if they want GCP daily billing.

**Review outcome**: two-pass self-review completed (correctness +
security / concurrency / idempotency). Findings logged inline (see
code comments prefixed with "Review finding"). No new architectural
gaps opened.

---

## 2026-04-22 — Per-(cloud × capability) sync, IAM + billing, AWS Bedrock auto-grant

**Decision**: Replace the global hourly `cmdb.cloud_sync` handler with a
generic `cmdb.resource_sync` handler + one `scheduled_jobs` row per
(cloud integration × capability); add `cmdb.billing_sync` (monthly) and
a dedicated `iam` capability with table `cmdb_iam_users`; auto-grant
AWS IAM users for approved API-key requests when provider name matches
"AWS Bedrock"; remove the `/admin/cloud/sync-jobs` menu.

**Scope breakdown** (all in migration 047, one PR):
- Backend `ProviderCapability.IAM` enum + `list_iam_users` abstract
  method. AWS driver implements it via `iam.list_users` + enriched per-user
  lookups (policies, groups, MFA, tags). Other providers raise `CapabilityNotSupported`.
- `sync.py` adds `_sync_iam_users` using the same mark-and-sweep pattern
  as other resource types.
- New tables `cmdb_iam_users` (unique on integration_id+user_name+sub_account_id) and
  `cmdb_billing_monthly` (unique on integration_id+month, total + per-service
  JSON).
- New scheduled handlers `cmdb.resource_sync` (config-driven per capability)
  and `cmdb.billing_sync` (AWS Cost Explorer). `run_cloud_sync` removed.
- `scheduled_jobs_provisioning.py` owns the auto-create/diff/delete of
  scheduled_jobs rows; invoked from `IntegrationService.create/update/delete`.
  Admin cadence + enabled overrides preserved across integration updates.
- New endpoints: `POST /cmdb/sync/{id}/trigger-all`, `GET /cmdb/iam-users`,
  `GET /cmdb/billing/monthly`, `GET /cmdb/billing/summary`. Old
  `/cmdb/sync/{id}/trigger` still works.
- Frontend: `/admin/cloud/sync-jobs` deleted (route 302 to scheduled-jobs);
  new `/admin/cloud/iam-users` and `/admin/cloud/billing` pages;
  `/admin/settings` rewritten with namespace grouping + typed editors +
  search.
- `scripts/seed_aws_sg.py` — env-driven, idempotent, works local+prod.
- `bedrock_autogrant.py` hooked into `ApiKeyService.handle_feishu_callback`
  on the approved transition: resolves AWS SG integration (slug `aws-sg`
  or `bedrock.provisioning_integration_slug` override), mints IAM user
  `bedrock-<slug>-<YYYYMMDD>-<XXXX>`, attaches inline `bedrock:InvokeModel`
  policy, writes AK/SK into `assignment.key_value`, status → active.
  Any failure leaves assignment in `approved` with detail.bedrock.error
  so IT admin can manually fill.
- `platform_settings` seeds `billing.budget_usd.default = 1000`; override
  per-account via `billing.budget_usd.<slug>`. Feishu budget alerts fire
  server-side inside `cmdb.billing_sync`.
- AWS Bedrock added as `ApiKeyProvider` row (name="AWS Bedrock").

**Context**: Three related asks from the user — (1) delete the
redundant sync-history menu (scheduled_job_runs already tracks history),
(2) finer-grained sync cadence (different cloud × capability should be
schedulable independently; some tenants only need IAM snapshots, others
billing-only), (3) a low-friction way to provision Bedrock API keys
since Claude API access is becoming a frequent employee request.

**Alternatives**: (a) Keep the single global cron but expose a per-integration
`capabilities` subset — rejected, the global job is opaque (one failure
masks the others) and doesn't give per-capability history. (b) Build a
new provisioning framework for Bedrock (`ProvisioningTask` subclass) —
rejected, too much scaffolding; the Feishu-callback hook is direct and
keeps the Bedrock-specific logic localized. (c) Split into multiple PRs
(refactor first, then IAM, then billing, then Bedrock) — **rejected by
user**: "你拆了 PR 写的代码就很差，你放在一起写，一起 review". Honored.

**Known trade-offs**:
- The "sync all capabilities" button at `/admin/integrations?kind=cloud`
  creates one atomic `CmdbSyncJob` that runs in the request process
  (BackgroundTask). That duplicates work the scheduled_jobs cron would
  eventually do; the duplication is intentional so admins don't need
  to jump to `/admin/scheduled-jobs` just to force a refresh.
- Bedrock auto-grant is a direct driver call, not a `ProvisioningTask`.
  If we outgrow this (batching, retries, drift correction) we'll refactor
  to tasks — but two separate places that can create IAM users is
  tolerable for now.
- **Leak-safety**: before the AWS `create_user`/`create_access_key` call,
  `maybe_auto_grant_bedrock` commits a `detail.bedrock.in_flight=true`
  tombstone. On callback retry after a mid-callback crash, the helper
  returns `in-flight-needs-manual-recovery` instead of re-minting a
  second IAM user. Admin clears the tombstone from `/admin/api-keys`
  after manual AWS reconciliation.
- **AK/SK at rest**: Bedrock AK/SK lands in `api_key_assignments.key_value`,
  the same column that stores OpenAI / Anthropic keys. **Resolved
  (later PR)**: this entry originally noted `key_value` was stored
  unencrypted with a "future migration" TODO. That migration shipped —
  `key_value` is now AES-GCM encrypted platform-wide with per-row AAD
  binding (`_encrypt_key_value` / `_decrypt_key_value` in
  `app/domains/apikey/service.py`; migration 125 re-encrypted existing
  rows). Bedrock auto-grant, the standard apikey path, Azure OpenAI,
  and Bailian auto-grant all write encrypted. `reveal_key` stays
  audit-logged.
- **Bedrock policy** is region-scoped to `ap-southeast-1` via a
  `aws:RequestedRegion` condition; actions are limited to `InvokeModel`
  / `InvokeModelWithResponseStream` / `List*` / `Get*` (no model-training,
  no billing-info, no cross-region). If the AWS SG tenant moves, update
  the condition in `_BEDROCK_POLICY` at the top of `bedrock_autogrant.py`.
- AK/SK for the AWS SG tenant goes through `scripts/seed_aws_sg.py`
  environment variables; git history stays clean. Production admin must
  re-run it with rotated keys manually — this is explicit, not automated.

---

## 2026-04-21 — HR + Finance roles, Google self-service, mail-group mirror

**Decision**: Add two canonical roles (`hr`, `finance`) and three new
`EmployeeRequest` types (`google_mail_group`, `google_email_alias`,
`google_password_reset`) that execute directly against Google
Workspace without an approval step. Mirror every Google mail group
in Helix via `email_groups` + `email_group_owners` so offboarding
can enumerate "groups owned by leaver" in one SQL query.

**Scope breakdown**:
- `hr` role: read-only on org tree / HRIS sync / directory /
  lifecycle events; elevated alias-for-others + delete-any-mail-group.
- `finance` role: read-only on every billing-adjacent screen
  (procurement, suppliers, business payments, license plans,
  software catalog, seat allocations, billing dashboard). All
  monetary mutations remain with `global_admin`.
- Mail group creation: Google-direct (no approval). Requester is
  always added as an owner. Suffix whitelist lives in
  `platform_settings.google.mail_group.suffix_whitelist`. Rate
  limit 2h per requester (configurable).
- Email alias: Google-direct. Employees may alias only their own
  mailbox or a group they own. HR may alias any target.
- Password reset: self-service only (separate admin endpoint for
  "help a user who lost 2FA"). System generates the password; it is
  delivered via Feishu + email and scrubbed from the outbox after
  dispatch. Rate limit 2h per user.
- Offboarding: new `RES_EMAIL_GROUP` blocker type. Transfer flow
  calls Google to swap the OWNER role; T+0 force-transfer also
  invokes Google. At-least-one-owner invariant protected by
  add-new-before-remove-old ordering + local `begin_nested()`.
- `/approval/asset/templates` now caches the Feishu payload in
  `platform_settings` (default 6h TTL) with a resilient stale-fallback
  when Feishu is unreachable. Admin "立即刷新" endpoint invalidates.

**Context**: Solo-operator shop — two personas (HR, Finance) needed
scoped read-only access, and Google self-service was the biggest
manual-ticket sink (group create / alias / password reset accounted
for most IT requests).

**Alternatives**: (a) Run Google self-service through Feishu approval
— rejected, user explicitly said 「无需审批」. (b) Keep mail group
ownership on Google only, query via API on every offboarding scan —
rejected, too slow at scale and breaks the 「一次查询就能列出全部」
transfer guarantee. (c) Wait until Temporal (M3) to get transactional
guarantees for Google + DB writes — rejected, email group feature
needs to ship before then. Current service uses compensation +
`begin_nested()` savepoints to approximate the invariant.

---

## 2026-04-21 — `cloud_admin` role deprecated; merged into `it_admin`

> **⚠️ SUPERSEDED (2026-04-29, hardened 2026-05-20).** This decision no
> longer reflects reality. `cloud_admin` was **revived** on 2026-04-29
> (SREs / DevOps need long-lived cloud access the 90-day expiry sweep
> doesn't evict) and **promoted to a real write authority** on 2026-05-20
> — see the `2026-05-20 — Role × menu matrix realignment` entry above.
> It is a live canonical role today (`CLOUD_ADMIN_ROLE` in
> `server/app/core/rbac.py`, `can_manage_cloud` admits it; irreversible
> scope decommission + connector-credential edits stay `global_admin`
> only). Kept here as history, not current state.

**Decision**: Remove the `cloud_admin` role from the codebase. All
existing holders are upgraded to `it_admin`.

**Context**: We have a solo operator; a separate cloud admin role
created code complexity (per-page role guards, partial view scopes)
without business value. The unified `/admin/integrations` page means
cloud accounts are no longer a separately-governed feature.

**Alternatives**: (a) keep role but scope to `kind='cloud'` integrations
only — rejected, same complexity without payoff. (b) downgrade existing
holders to `employee` — rejected, would remove admin capability they
rely on.

---

## 2026-04-21 — Seat quota lives on `SoftwareLicensePlan.max_seats`

**Decision**: Each `SoftwareLicensePlan` has a `max_seats: int | null`
column (NULL = unlimited). Seat requests that would exceed the quota
route through an "over-quota" approval path; approval UI shows
`used/total`.

**Context**: Zoom / Slack / GWS all have hard seat limits. Blindly
dispatching a driver call on quota exhaustion results in 402/403 from
the vendor, cryptic errors, and a compliance mess. Quota belongs on
the plan (one software, many plans, each plan has its own seat count).

**Alternatives**: (a) no quota — rejected, unacceptable for production.
(b) quota on `AssetSoftwareCatalog` instead of `SoftwareLicensePlan` —
rejected, a software can have multiple plans with independent limits.

---

## 2026-04-21 — `cmdb_applications` table + frontend pages to be deleted

**Decision**: Remove `cmdb_applications` (CMDB-synced cloud application
inventory) from data model and UI. Delete `/cloud/applications` and
`/cloud/my-applications` pages.

**Context**: The "cloud applications" concept as implemented (auto-sync
of deployed apps from AWS CodeCommit / Git metadata) has no user-facing
purpose and overlaps conceptually with software management.

**Alternatives**: (a) keep but hide in UI — rejected, leaves unused
code paths. (b) convert into employee SaaS tracking — rejected, that's
already `asset_software_catalog`.

---

## 2026-04-21 — Network-asset offboarding transfer scope

**Decision**: Offboarding force-transfer covers `CmdbDomain`,
`CmdbDnsRecord`, `CmdbServer`.

> **Extended 2026-05-18**: `CmdbLoadBalancer` and `CmdbCdnDomain` were
> later brought IN scope (both gained `owner_person_id` — LB via
> migration 173, CDN via migration 160 — and the offboarding scanner
> now walks them; see `app/domains/offboarding/service.py`). Only
> `CmdbObjectStorage` remains out of scope (still `owner_raw`-only, no
> `owner_person_id`). The original "Does NOT cover LB/CDN/OSS" below is
> superseded for LB + CDN.

**Context**: Domain / DNS / Server have clear per-person ownership
(registrant, responsible admin). OSS is typically team-owned and
doesn't map to a single person.

**For each of the in-scope three**: convert `owner_raw: String` to
`owner_person_id: UUID FK → identity_persons`, backfill by matching
the raw string against `work_email` / `display_name`.

---

## 2026-04-21 — Large refactors ship as ONE PR with TWO code reviews

**Decision**: Architecture-level refactors (like unified connectors +
provisioning end-to-end) ship as a single PR. Before merging, the
author self-reviews TWICE from different angles.

**Context**: User emphasized "不希望这个发动不连贯" — wants coherent
review, single revert unit, all-or-nothing semantics on rollback.
Production system, not demo.

**Review protocol**:
1. First pass: correctness + completeness (does every feature work, is
   every file consistent, do all tests pass?)
2. Second pass: security + edge cases + idempotency + concurrency +
   data-migration rollback path

**Alternatives**: (a) split into N small PRs — rejected by user on
explicit coherence grounds. (b) one PR, one review — rejected, too
easy to miss bugs on a 70+ file change.

---

## 2026-04-21 — Cross-domain coordination via `app/core/coordinators/`

**Decision**: When domain A needs to trigger domain B (e.g. offboarding
enqueuing a provisioning task, CMDB enriching with asset data), extract
the coordination into `app/core/coordinators/<name>.py`. Domains never
import each other directly.

**Context**: Existing violations (`offboarding → provisioning`, `cmdb →
asset`, `hr → asset`) break the CLAUDE.md "Domain modules do NOT
import from each other" rule. Full event-bus/Temporal is over-kill
pre-M3; coordinators are the lightweight stepping stone.

**Pattern**: coordinator imports from both domains (it sits above them
in the dep graph), exposes a narrow function-level API, depends on
nothing else.

**Alternatives**: (a) async event bus (DB-backed) — rejected, too much
infra for M2. (b) Temporal workflows — waiting for M3. (c) leave as-is
— rejected, violation of a non-negotiable platform rule.

---

## 2026-04-21 — Driver first-wave: Zoom, Slack, GitHub + extend Feishu/GWS

**Decision**: Ship five `software_provisioning` drivers in this PR:
- NEW: `zoom_software.py` (seat grant/revoke, plan-type aware via binding)
- NEW: `slack_software.py` (workspace member add/remove)
- NEW: `github_software.py` (organization membership only; not repo or team level)
- EXTEND: `feishu_software.py` — add `operation=user_suspend` mode
  (disable/reactivate Feishu user account for full leaver lockout)
- EXTEND (conditional): `google_workspace.py` already has `user_suspend`;
  verify coverage

**Context**: User said "要解决IT的所有问题和痛点". Waiting for a
later PR to cover anything but HRIS+assets means the provisioning
framework sits idle with only 3 drivers. Production readiness needs
real coverage of the main vendors.

**GitHub scope**: organization-level only. Not handling per-repo
grants or team membership in this round (rejected for scope).

**Alternatives**: (a) one driver per PR — rejected by user ("这些都应
该做 现在不做以后也要是要做的"). (b) Workday / Jira / Notion / 1Password
included — deferred to next PR.

---

## 2026-04-21 — i18n legacy debt cleared in same PR

**Decision**: The 62 legacy files (~1,072 violations) grandfathered by
PR #241 get fully migrated through `t()` in this PR. `.eslint-i18n-legacy.js`
ends up empty (or file deleted).

**Context**: Several of these files are being rewritten anyway
(`HrisSyncPage`, `CloudAccountsPage`, `CloudApplicationsPage`,
`SoftwareCatalogPage`, `LicensePlanPage`, `MainLayout`…). Touching the
others for i18n-only is strictly additive and ESLint already blocks
regressions. Clear it out.

**Alternatives**: (a) leave the list — rejected by user
("PR #241 还有一个剩余 legacy 债 你也一起弄了吧"). (b) keep the file
but auto-generate from empty list — rejected, simpler to delete.

---

## 2026-04-21 — Driver integration testing: respx mocks + optional live smoke

**Decision**: All drivers ship with respx-mocked unit tests covering
happy / failure / rate-limit / idempotent-retry paths. Each driver also
has a `@pytest.mark.live` smoke test that requires real credentials in
env vars; CI skips them, operator runs locally against sandbox.

**Context**: Real API calls in CI = flaky + expensive + credential
leak risk. Pure mocks = miss real vendor quirks. The hybrid gives
deterministic CI + a low-friction real-API sanity check.

**Alternatives**: (a) mocks only — rejected, too risky for production
claims. (b) real API in CI with sandbox accounts — rejected,
credential management overhead.

---

## 2026-04-21 — Data-migration strategy: one-shot (not live yet)

> **Two parts now superseded.** (1) The `cloud_admin → it_admin` rename
> was reversed: `cloud_admin` was revived 2026-04-29 with new semantics
> and is a live canonical role today (see `server/app/core/rbac.py` and
> the "Role × menu matrix realignment" entry). (2) The "(not live yet)"
> framing is historical — the system is in production and the CMDB
> migration has shipped (`helix-cmdb-migrate` skill). The one-shot
> migration *strategy* (039–045, no dual-write) remains accurate history.

**Decision**: This PR's data migrations (CMDB owner_raw → FK, cloud
provider accounts → integrations table, drop cmdb_applications, role
rename cloud_admin → it_admin) are done **one-shot** per table in
dedicated Alembic migrations (039 ~ 045). No dual-write / gradual cut.

**Context**: Production has HRIS integrations configured but no
user-impacting data in the tables being changed yet (no one has
onboarded through the new system; no IAM users for AWS configured yet).
Safe to cut cleanly.

**Safety net**: each migration has a thorough `downgrade()` that
restores prior schema. Each migration wraps DDL in savepoints where
possible, logs row counts, and is idempotent on re-run.

**Alternatives**: (a) double-write period + feature flag cutover —
rejected by user ("一把迁完 现在还没上线呢"). (b) migrate-as-you-go
scripts run manually — rejected, less auditable.

---

## 2026-04-28 — AI Spending dashboard, dedup priority, "use one day = one month" parity

> **⚠️ Core mechanism SUPERSEDED by PR #253 (the AI Spending Phase-2
> refactor).** The "four upstream sources in strict priority order /
> minus-already-attributed" dedup ladder described below no longer
> exists. It was replaced by a **4-bucket, source-disjoint model**
> (`seats` / `bailian` / `foundry`=Azure / `bedrock`=AWS, + optional
> `vertex`), each backed by exactly one authoritative source table, so
> no dedup ladder is needed — see `server/app/domains/ai_spending/`.
> Specifically: `is_ai_dedicated` on `cmdb_cloud_scopes` was **removed
> in migration 152** (replaced by service-name classification against
> `platform_settings.ai_spending.service_whitelist`); the `is_ai_tool`
> boolean on `asset_software_catalog` was **dropped in migration 082**
> (replaced by a free-text `category` column, value `ai`); and the
> `cmdb_ai_resources` ledger was **deleted in migration 151**. Still
> accurate below: the `cloud_scope_id` FK on `api_key_assignments`, the
> "use one day = one month" seat rule via `software_monthly_bills`, and
> the one-row-per-`(period, language='bilingual')` LLM summary cache.

**Decision**: Build the AI Spending dashboard as a coordinator-class
reporting domain (`server/app/domains/ai_spending/`) that reads from
asset / bailian / cmdb / identity. Aggregate cost across four
upstream sources in strict priority order so each dollar is counted
exactly once: (1) Bailian per-API-key usage, (2) per-Azure-resource
chargeback, (3) is_ai_dedicated cloud scope full billing minus
already-attributed resources, (4) keyword-matched
`cost_by_service` excluding already-counted scopes / Bailian
equivalents. SaaS seat half follows the existing "use one day = one
month" rule via `software_monthly_bills`. LLM summary + suggestions
generated by Bailian DashScope (qwen-max-latest by default) and
cached one row per `(period, language='bilingual')`. A new
`is_ai_dedicated` flag on `cmdb_cloud_scopes` lets admins mark
"100 % AI" subscriptions / accounts (AWS SG, the PingCAP Azure
OpenAI sub `7bc8ab17-...`) so the entire bill counts. A new
`is_ai_tool` flag on `asset_software_catalog` filters which seats
contribute. A new `cloud_scope_id` FK on `api_key_assignments`
allows binding a key to a subscription, not just an IAM user.

**Context**: Leadership asked for a single view of AI spending
across SaaS seats (Cursor / ChatGPT Team / Claude.ai Team /
Copilot), Bailian token usage, dedicated cloud workloads (AWS SG
Bedrock, Azure OpenAI sub), and miscellaneous AI services in other
clouds. Without explicit dedup the same dollar would land in two
buckets (e.g. Bailian usage AND Aliyun's `cost_by_service` for the
"DashScope" service). Without per-resource attribution, Azure
OpenAI cost couldn't be charged back to specific employees /
departments.

**Alternatives**:

(a) Embed AI Spending as a tab inside the existing `/admin/insights`
page — rejected because Insights is engineering-ops-facing
(daily monitoring of asset / lifecycle / security health) while AI
Spending is finance-facing (monthly review by IT director +
finance). Different audience, different refresh cadence, different
RBAC (finance has read-only access to AI Spending but no Insights
exposure today). Standalone page is clearer.

(b) Sum every cloud bill that touches an AI service via keyword
match alone — rejected because (i) not every AI service has an
obvious keyword (custom Vertex AI deployments, internal model
inference services), (ii) Bailian's DashScope cost would be
double-counted from BOTH the Bailian usage rows and the parent
Aliyun integration's `cost_by_service` rows. The priority-order
dedup model lets us claim authoritative attribution where we have
it (Bailian, per-Azure-resource) and fall back to keyword matching
elsewhere.

(c) Add per-resource Azure OpenAI sync as a separate driver method
that emits rows into `cmdb_servers` — rejected because Cognitive
Services accounts aren't servers; they're a different shape. Added
a dedicated `cmdb_ai_resources` ledger so future GCP Vertex AI /
AWS Bedrock workspace attribution slots in without overloading
existing CMDB tables.

(d) Don't generate an LLM summary; render the raw aggregates only —
rejected because the user explicitly asked for an executive
summary + next-month optimization suggestions ("我可以用这个情况说
明去研究下个月的 general 的优化建议"). Bailian was already in the
codebase as a managed target; adding the LLM "use" capability
(BailianLLMClient → DashScope native) was a clean extension that
also gave us a reusable sk-bearer client for future features.

(e) Auto-attribute Azure OpenAI cost via SCIM-style per-deployment
metering — rejected because Azure doesn't surface per-deployment
per-user usage at the resource scope. The achievable best is
per-(resource, day) cost from Cost Management's
`group_by=ResourceId`, then attribute the resource to a person via
Azure tag → email match → fallback to scope owner. Manual
assignment is the override of last resort and is preserved across
syncs (`auto_owner_source='manual'`).

(f) Use the OpenAI / Anthropic admin usage APIs to fetch direct-API
spend — explicitly deferred (user direction Q4): "OpenAI / Claude
this two就用坐席的钱 — these are SaaS seats not API keys". The
ChatGPT Team and claude.ai Team subscriptions are in
`asset_software_catalog`; their seats land in the SaaS-seat half
of the dashboard. Direct-API cost integrations are a future PR if
ever needed.

(g) Charge LLM summary tokens via the same Aliyun integration's RAM
credentials (auto-grant style) — rejected because the LLM use case
is a separate Bailian sk-xxx key issued from the Bailian console,
distinct from the Aliyun Org-level AK/SK that backs CMDB management
ops. Mixing them would tangle two security postures (the RAM key
can't restrict to qwen invoke; the sk key can't manage the
workspace). Keep them separate.

**Naming**: the existing API-key-provider seed
"Aliyun Bailian (DashScope)" (migration 075) was deleted in prod by
the user 2026-04-28; the canonical replacement is
"Alibaba Cloud Model Studio". `bailian_autogrant._is_bailian_provider`
now matches any of `bailian / 百炼 / dashscope / model studio` so
either naming works for the auto-grant flow.

**Migration renumber**: original draft was migration 078, but PR #91
landed `078_cloud_scope_environment_forward_fix.py` on main mid-PR.
Renumbered to 079 / `down_revision="078"` to preserve the linear
migration chain.

**Bailian usage API rewrite**: original `BailianClient.query_usage_daily`
called `QueryUserOmsData(Table=BailianModelInvocation)` — an
undocumented OMS table name. Production-grade rewrite (this PR)
switches to `QueryAccountBill(ProductCode='bailian')`, the canonical
documented BSS API the cmdb billing sync also uses. Trade-off: lose
per-(api_key × model) breakdown, gain correctness. The dashboard
acknowledges the lost granularity by excluding Bailian from
`active_user_count` and showing it as "(unattributed)" on the per-
user / per-department breakdowns.

**Two-round production review** (2026-04-28): both review rounds run
by independent agents found 33 issues collectively (18 in round 1,
15 in round 2). All CRITs and HIGHs fixed; ~80% of MEDIUMs fixed;
LOWs that touched the "no fake-empty / no fallback" mandate also
fixed. Specific hardening that landed: empty-state UI no longer
fakes $0 source rows; CNY rate misconfig raises explicitly instead
of falling back to the static default; LLM regenerate quota debit
runs in an independent session and survives a failing LLM call;
Excel export sanitises cell values to defuse formula injection;
resource-owner reassignment refuses leaver targets; migration
helper logs FK-creation errors instead of `except Exception: pass`.
See PLATFORM.md "Production-grade invariants" for the enforced
posture.

---

## 2026-04-29 — Asset inventory (盘点) — cycle-driven, two recipient kinds, IT fallback bucket

**Decision**: Asset inventory check is a first-class feature in the asset
domain, modelled as a **campaign** (`asset_inventory_cycles`) that
snapshots a set of `(recipient_person, asset)` rows
(`asset_inventory_items`) at start. Each item is sent to one of two
kinds of recipient:

* **`assignee`** — the asset has a holder (`status` ∈ `assigned |
  claimed | borrowed | maintenance | …`); the holder is the recipient.
* **`office_admin`** — the asset is idle and its `location` matches a
  row in `asset_office_addresses` whose `admin_person_id` is set; that
  person is the recipient.
* **IT fallback bucket** (no recipient) — idle assets where the
  location does NOT map to an office, OR the matched office has
  `admin_person_id IS NULL`, OR the assignee is inactive. These items
  have `holder_kind='it_fallback'` and `holder_person_id=NULL`. They
  are visible only in the admin console (no Feishu spam to a group of
  IT users); IT picks them up from a dedicated tab.

**Scope (what gets snapshotted)**: every Asset row whose `status` is
NOT in `{disposed, retired, disposal_pending, claim_pending,
return_pending, borrow_pending, borrow_return_pending, transferring}`.
The first set is "dead", the second is "in-flux" — both are excluded.

**Cycle scope filter**: `scope_filter` JSON on the cycle row supports
optional `departments[]`, `locations[]`, `categories[]`,
`person_ids[]`. Empty filter = company-wide. Snapshot is taken
**once at cycle creation**; subsequently-acquired assets are NOT
auto-added to an active cycle.

**Recipient response options** (stored in
`asset_inventory_items.response_type`):

| holder_kind | options |
|---|---|
| `assignee` | `in_use` / `transferred` (+ `transferred_to_id`) / `damaged` / `lost` / `not_mine` / `other` |
| `office_admin` | `in_storage` / `damaged` / `lost` / `taken_away` / `moved_to` (+ `new_location`) / `other` |

Anything other than `in_use` or `in_storage` flips
`status='issue_reported'`; the others go `status='confirmed'`. IT
processes `issue_reported` items from a dedicated admin tab.

**Per-person aggregation for Feishu push**: at cycle start, the
service groups all items by recipient `person_id` and sends ONE
Feishu interactive card per person (template key
`asset_inventory_notify`) regardless of role. Card body lists totals
("3 personal items + 80 office items"); button deep-links to
`/me/inventory/<cycle_id>` which renders both buckets in tabs.

**Reminder**: a separate scheduled handler `asset.inventory_reminder`
walks active cycles where `now() ≥ started_at + 7d` and re-pushes
ONLY to recipients whose pending items > 0 (status='pending'). Each
item carries `reminded_at` to avoid double reminders within the same
cycle. No manager escalation in v1.

**Auto-start cron**: `asset.inventory_auto_start` registered with
`cron_expr='0 1 15 1,7 *'` (Jan 15 + Jul 15, 01:00 UTC ≈ 09:00 CST),
**`enabled=False` at seed time**. Admins flip the toggle in
`/admin/scheduled-jobs` once they're ready to let the system run on
its own.

**No approval workflow integration**: `issue_reported` items become a
queue in the admin console; IT triages manually. If a specific issue
needs a buyback / transfer / etc., the admin starts that flow
through the existing entry points — the inventory item just records
"this surfaced an issue".

**Asset row denormalization**: `last_inventory_at`,
`last_inventory_cycle_id`, `last_inventory_status` are written on
the asset when an item is `confirmed` or `issue_reported`. Used by
the asset list page for a "最近盘点 / Last inventory" column +
filter. Issue-reported counts as "checked"; the column is meant for
"how stale is this record's reality check", not "is it healthy".

**Why these choices**:

* Cycle / campaign concept is mandatory — without it you can't tell
  "responded for this round" vs "responded for the last round" and
  the `last_inventory_at` field becomes unreliable.
* Office-admin routing for idle inventory leverages
  `asset_office_addresses.admin_person_id` (already exists); avoids
  building a separate physical-scan UI in v1.
* IT fallback as a silent bucket (not a Feishu blast to all
  it_admins) — avoids the "everyone gets pinged, nobody acts" failure
  mode.
* Per-person aggregation (not per-asset) — a person with 5 items must
  not get 5 Feishu cards.
* Auto-start disabled at seed — explicit opt-in by IT after they've
  seen the manual flow work end-to-end.

**Alternatives rejected**:

* Embedding inventory state directly on `assets` (no cycle table) —
  loses round-vs-round history; can't answer "who was due last cycle
  but never responded".
* Per-asset Feishu notifications — UX hostile.
* Forcing every issue through Feishu approval — over-heavy for the
  common cases ("damaged screen") that just need IT to notice.
* Manager-escalation reminders — premature; revisit if real
  unresponsiveness data shows it's needed.

**Where it lives**: `server/app/domains/asset/inventory_service.py`
+ `inventory_router.py`, mounted under `/api/v1/asset-inventory`;
tables prefixed `asset_inventory_*`; one Alembic migration adds the
two tables and the three Asset columns.

---

## 2026-05-19 — Asset office-held mode + offboarding hardware disposition

**Decision**: Promote "office-held" from an implicit routing
heuristic (`assigned_to IS NULL` + `location` string match) to a
first-class explicit holder mode on `asset_items`. Refactor the T+0
leaver hardware handler from "force-transfer to manager fallback" to
a per-asset disposition map captured by the offboarding console
before T+0 grace expires (`keep` / `recycle_to_office` /
`transfer_to_person`), default = `recycle_to_office` to the leaver's
home office.

**Context**: Helix's pre-2026-05-19 model required every asset to be
attributed to a `Person` via `assigned_to` — meeting-room kit, shared
docks, "上架待领" spare laptops had no native representation. The
inventory classifier (`_classify_asset`) papered the gap with a free-
text `asset.location ↔ asset_office_addresses.city` match, which
worked for `assigned_to IS NULL` rows but broke the moment T+0
`force_transfer` reattributed a leaver's laptop to an IT admin
(`_resolve_hardware_recipient`'s fallback chain) — now the row was
"`assigned`" to an active person, the office-admin routing never
fired, and 盘点 fan-out piled onto whoever inherited the leaver
assets. Prod data (2026-05-19): 4 explicit T+0 force_transfer victims
landed on `孟凡生 Jason Meng` (P0416) within a 30-day window, plus
~476 idle rows whose `location` matched a city but whose holder
identity was nowhere written down. The motivating bug fix (HUO hao /
P0092's `P-NB-202603-001`) was a hand-rolled SQL revert that
restored `assigned_to` to the leaver — the only way to express
"leaver was approved to keep this device" in the existing schema.

**Schema** (migrations 186-188 — renumbered from 183-185 during merge because PR #403 landed migration 183 first):
* `asset_items.holder_kind VARCHAR(16) NOT NULL DEFAULT 'personal'`
  (values: `'personal'`, `'office'`).
* `asset_items.holder_office_id CHAR(36) NULL` FK →
  `asset_office_addresses.id`, `ON DELETE SET NULL`.
* Composite index `ix_asset_items_holder (holder_kind,
  holder_office_id)`.
* New `status='held_by_office'` (free string, no enum migration).
* `offboarding_processes.hardware_disposition JSON NULL` — per-asset
  decision map.

**Invariant** (`holder_kind='office' ⇒ assigned_to IS NULL AND
holder_office_id IS NOT NULL`) is enforced at the application layer
(service-level guards in `create_asset`, `intake_asset`,
`_verify_office_active`, and every lifecycle method) rather than a
DB CHECK. TiDB 8.0's partial-row UPDATE semantics around CHECK
constraints are quirky enough that we kept the same convention used
for every other AssetItem invariant.

**Backfill** (migration 188) rules:
1. Terminal statuses (`disposed`/`retired`/`scrapped`/`sold`/`lost`/
   `held_by_leaver`) — skip.
2. T+0 `force_transfer` victims (history.action=`force_transfer`,
   from_user ∈ `left`/`leaver_pending`) — roll back to
   `held_by_office` routed via the leaver's
   `work_location_name`.
3. Idle + `location` matches an office (city exact, `市` suffix
   strip, country-name fallback for single-office countries) —
   promote to `held_by_office`.
4. Idle without a match — `holder_kind='personal'`, leave at `idle`.
5. `claimed`/`assigned` to a left person (T+0 missed) — promote to
   `held_by_leaver`.
6. Everything else — default `personal`. Each touched row writes a
   `system`-actor audit_log entry with action=
   `asset.holder_kind_backfill`.

**Routing change**: `_classify_asset` now branches on
`holder_kind='office'` FIRST. `'office'` lands the route at
`holder_office_id.admin_person_id` directly — no string match. The
legacy `location`-based path survives as a fallback for personal-
held idle stock whose location string still matches a city but
whose `holder_office_id` wasn't filled by the backfill (e.g. an
admin typed a non-canonical city variant).

**T+0 disposition** (migration 187): every hardware blocker in
`offboarding_processes.hardware_disposition` can be one of:
* `keep` → mark `held_by_leaver` (preserves `assigned_to`)
* `recycle_to_office` (default) → office_id from entry, OR
  resolved from the leaver's `work_location_name`, OR degrade to
  `held_by_leaver` if no office can be inferred. Status →
  `held_by_office`, `assigned_to = NULL`, `holder_kind = 'office'`.
* `transfer_to_person` → legacy force-transfer behaviour, but now
  opt-in (not a fallback). Person id required.

UI captures this in the offboarding console with a per-asset picker
(`HardwareDispositionPanel`) backed by `PUT /offboarding/processes/
{id}/hardware-disposition` + `GET .../hardware-disposition-preview`.

**RBAC**: no new role. Office-admin authority is a person-attribute
gate (`asset_office_addresses.admin_person_id == current_user.id`).
Adding an `office_admin` role would have produced N-offices × M-
roles bloat for the same capability the inventory classifier already
expresses person-attribute-wise.

**Alternatives rejected**:
* **Single boolean flag** `is_leaver_kept_asset` on `asset_items` —
  would have fixed the HUO hao case but not the structural
  "everything stacked on Meng" problem, and only solves the leaver
  pathway. Doesn't give us a primary intake mode.
* **Implicit office-held via `assigned_to IS NULL` + location**
  (status quo, pre-183) — known to silently fail when an asset's
  `location` string drifts from `office.city` (admin-typed; we have
  `'上海'`, `'上海 '`, `'中国-上海'` in prod), and breaks completely
  the moment force-transfer overwrites `assigned_to`.
* **Make `office_admin` a real role** — N×M bloat, also a perm-table
  write per office add/remove vs. just updating
  `admin_person_id`.
* **DB CHECK constraint for the holder_kind invariant** — TiDB 8.0
  semantics around partial-row UPDATE CHECK enforcement are still
  quirky enough that we kept the application-layer rule.
* **Bigger T+0 path: keep the legacy force_transfer-to-manager as
  default and only allow opt-in office routing** — would have left
  the original bug ("everything piles on Meng") intact for any
  process where the admin forgot to fill disposition. The chosen
  default (recycle_to_office to home office) is the safer-by-default
  direction.

**Where it lives**:
* Backend: `server/app/domains/asset/models.py` +
  `service.py::recycle_to_office` / `release_from_office` /
  `resolve_office_for_person`; `inventory_service.py::_classify_asset`;
  `server/app/hr_lifecycle/advance_service.py::_apply_hardware_disposition`
  (note: `hr_lifecycle` is a top-level package under `app/`, NOT under `app/domains/`);
  `domains/offboarding/service.py::set_hardware_disposition` +
  `preview_hardware_disposition`.
* Endpoints: `POST /assets/{id}/recycle-to-office`,
  `POST /assets/bulk-recycle-to-office`,
  `POST /assets/{id}/release-from-office`,
  `PUT /offboarding/processes/{id}/hardware-disposition`,
  `GET .../hardware-disposition-preview`.
* MCP tools: `recycle_asset_to_office`, `release_asset_from_office`
  + `_serialize_asset` carries `holder_kind` + `holder_office_id`.
* Frontend: `web/src/pages/asset/RecycleToOfficeModal.tsx`,
  `AssetIntakePage.tsx` (持有模式 radio), `AssetListPage.tsx` (持有
  方 column + bulk action), `AssetDetailPage.tsx` (recycle / release
  buttons), `web/src/pages/offboarding/HardwareDispositionPanel.tsx`
  (per-asset picker in the process detail page).
* Migrations 186 / 187 / 188 (renumbered from 183–185).

---

## 2026-05-25 — AWS Organizations CreateAccount has no `ClientRequestToken`; owner-alias auto-derive via Google Workspace + `DBaaS-DevLeader-Role` auto-grant

**Decision**:

1. **AWS Organizations CreateAccount idempotency is NOT delivered via `ClientRequestToken`** — that parameter does not exist on this API. Real redrive safety comes from sweeping `list_create_account_status(States=[IN_PROGRESS, SUCCEEDED])` for an entry whose `AccountName` matches before issuing `create_account`; if found, the driver resumes polling that RequestId instead of reissuing.

2. **AWS sub-account `owner_email` is auto-derived as a Google Workspace user-alias** on the requester's mailbox — `<sanitized_scope_name>@pingcap.com`, **slug verbatim, no forced `aws-` prefix** (per 2026-05-25 owner call: the request name already carries provider intent; names starting with `aws-` stay 1:1, names without it stay un-prefixed). AWS sees a globally-unique address (avoids `EMAIL_ALREADY_EXISTS`), the requester still receives the AWS root verification mail. Pathological inputs (all-CJK / all-punctuation / over Google's 64-char cap) raise `ValueError` — no synthetic placeholder fallback because two pathological-name requests would collide on the same `unnamed@pingcap.com`. Alias is persisted to `cmdb_cloud_scope_requests.metadata_json.owner_alias_email` for retry-idempotence and audit traceability.

3. **AWS auto-Owner grant is `DBaaS-DevLeader-Role` resolved by name at runtime** — `_post_provision_finalize` reads platform_setting `cloud.aws.scope_request_default_owner_role_name` (default `DBaaS-DevLeader-Role`), lists Identity Center permission sets, matches by display name, binds the resolved ARN via `sso-admin.create_account_assignment`.

**Context**: 2026-05-25 owner-reported failure on creating the `aws-pingcap-datalake` sub-account: `Parameter validation failed: Unknown parameter in input: "ClientRequestToken", must be one of: Email, AccountName, RoleName, IamUserAccessToBilling, Tags`. botocore rejected the request client-side; the call never reached AWS. Root cause: an earlier patch had assumed AWS Organizations CreateAccount accepted a `ClientRequestToken` (common idempotency pattern on STS / KMS / EC2 APIs), but Organizations doesn't expose one — its own idempotency model is implicit via globally-unique Email + the `CreateAccountStatus` history. Five comments across `aws_cloud.py` + `scope_request_service.py` + `driver_base.py` were rewritten on the strength of that assumption; all corrected.

Owner-alias derivation arose from the same conversation: AWS rejects duplicate Email globally, so reusing `requester.work_email` would have failed on the second AWS sub-account regardless of the `ClientRequestToken` bug. Auto-derived user-alias makes self-service viable without manual IT involvement per request.

`DBaaS-DevLeader-Role` auto-grant closes the UX gap where the requester landed on a freshly-minted scope with Helix-side ownership but no cloud-side IAM — they'd see an empty member list and couldn't actually use the project they'd just requested.

**Alternatives**:

* *Hardcode the AWS permission set ARN* — rejected, ARNs vary per Identity Center instance and aren't user-meaningful. By-name resolution with a platform_setting override is the durable choice.
* *Use a group-alias on a shared `cloud-aws-accounts@pingcap.com` mailbox* — rejected, owner explicitly chose user-alias on their own mailbox so AWS root verification mail lands in their inbox directly. Group-alias remains an option if requirements change.
* *Continue passing `ClientRequestToken` and catch the botocore validation error* — rejected, no purpose; AWS still wouldn't dedupe. List-based resume is the only correct path.
* *Synchronous post-provision auto-grant* — rejected; the existing BG-task pattern (lock-protected, best-effort, idempotent) is correct. Adding latency to the approve handler for a UX-convenience grant violates the durability/observability contract that's already in place.

**Implementation pointers**:

* [`AwsCloudDriver._find_existing_create_account_request`](../../server/app/integrations/drivers/aws_cloud.py) — list-based idempotency sweep.
* [`derive_aws_owner_alias`](../../server/app/domains/cmdb/scope_request_service.py) — pure slug helper, exposed at module level for preview / tests.
* [`CloudScopeRequestService._ensure_aws_owner_alias`](../../server/app/domains/cmdb/scope_request_service.py) — GWS `add_alias` orchestration + raw_data persistence.
* [`_resolve_default_owner_role`](../../server/app/domains/cmdb/lifecycle_router.py) — provider dispatch + AWS by-name permission-set resolve.
* Tests: [`server/tests/test_cmdb/test_aws_scope_create_account.py`](../../server/tests/test_cmdb/test_aws_scope_create_account.py) — 16 cases incl. the explicit regression guard `test_create_account_sync_does_not_pass_client_request_token`.

**Follow-up out of scope this PR**: GWS `remove_alias` on leaver decommission (the requester's user-aliases for closed AWS sub-accounts). Owner explicitly deferred — they're not leaving. A TODO comment + canonical audit row already exist so the cleanup is mechanical when needed.

---

## 2026-06-09 — DNS records are a company-public asset: org-wide read, owner-gated write

**Decision**: CMDB DNS records (`cmdb_dns_records`) are treated as a company-public asset. **Every** caller who passes `can_view_cloud_resources` sees the **full** DNS list regardless of owner — plain employees included. Visibility is org-wide read-only; **write/disable/delete still require `can_manage_cloud`**, and the my-cloud-dns page renders row actions only on the caller's own records (`owner_person_id === user.id`). The old "仅看我的 / 全部资源" toggle on `MyCloudDnsPage` was removed — it was a no-op for employees (the backend auto-demoted non-admins to their own records regardless), so the "全部资源" button promised something it never delivered. This applies to **DNS only** — `/servers` and `/domains` stay owner-scoped (recon-protected).

**Context**: A user (普通员工) searched the DNS page by keyword and got zero results, while an admin saw 3 matching records owned by someone else. Root cause: `GET /cmdb/dns-records` auto-demoted non-`can_read_cloud` callers to `owner_person_id IN (self, subordinates)`, so the keyword AND-ed with the owner filter and excluded records the employee didn't own. The owner explicitly ruled that DNS is public information (it's directory-grade infra data, not a secret), the toggle was misleading, and employees should be able to **see all** DNS records while only **operating on their own**.

**Alternatives**: (a) Keep owner-scope and fix only the misleading toggle label — rejected: owner ruled DNS is public, so hiding others' records is wrong, not just the wording. (b) Make the toggle actually work for employees (let them request all) — rejected: a toggle implies "mine" is a meaningful default, but the owner wanted all-by-default with no toggle. (c) Extend the same public-read treatment to `/servers` and `/domains` — rejected (scope): only DNS was decided public; internal hostnames + private-IP server inventory remain recon-sensitive and owner-scoped.

---

## 2026-06-15 — External API extends the `identity_consumers` framework (not a parallel key system); cloud subscriptions are its first non-directory resource + first write

**Decision**: The "external API + scoped key" capability is built by **extending the existing `identity_consumers` framework** (`helix_svc_` service tokens, `require_consumer(*scopes)` auth, per-consumer rate limit, service-attributed audit, admin governance console, accountable owner), **not** by introducing a parallel `helix_sk_` API-client system with its own tables/auth/audit column/admin UI. New external resources are added as (a) new scope constants in `KNOWN_SCOPES`, and (b) a new `/api/v1/open/...` router that gates on those scopes via `require_consumer`. Cloud subscriptions are the first such resource and introduce the framework's **first write scope** (`cloud.subscriptions:write`). Consumer/machine writes are audited as `actor_type="service"` with the consumer named in `detail` and **no `actor_user_id`** — reusing the framework's existing service-principal audit convention, so **no `actor_api_client_id` column is added** to `audit_logs`. Cloud writes use a dedicated `CloudLifecycleService.apply_open_update` (not the human-facing `assign_scope_owner`/`update_scope_config`) so the human owner-confirmation timeline/notifications are not triggered by a machine, while the leaver-owner and closed-scope guards are mirrored. Writes are Helix-metadata-only (owner/environment/name/notes/budget) — no cloud-side effect — so the prod-write lock does not apply. The framework keeps its `identity_consumers` table/route names and admin-UI label for now (zero migration risk); a future broadening to "API consumers" copy is deferred.

**Context**: A partner system asked to pull Helix's cloud subscription inventory (name/id/owner/environment) and update some fields. The initial confirmed plan was a fresh `helix_sk_` service-principal system with new tables, a new auth dependency, an `audit_logs.actor_api_client_id` column, and a new admin UI. A pre-build investigation found `identity_consumers` (shipped 2026-06-11, migrations 277/285/286) already provides exactly that credential/scope/governance plane — production-hardened and tested — limited only by a directory-specific data layer and a read-only posture. Reusing it (per the CLAUDE.md "reuse what exists" discipline) removed the new key system, tables, migration, audit column, auth dependency, and admin UI entirely; the only net-new code is the cloud scopes + one open router + one focused service method + docs + tests.

**Alternatives**: (a) Build the parallel `helix_sk_` system as originally confirmed — rejected: duplicates principals/auth/rate-limit/audit/admin against `helix_svc_`, doubling maintenance for no capability gain (user re-confirmed extend over parallel). (b) Reuse `assign_scope_owner` for the cloud owner write — rejected: it is tightly coupled to a human actor (`str(actor_user_id)`, owner-confirmation timeline, new-owner notification); driving it from a machine corrupts the governance timeline. (c) Add an `actor_api_client_id` column to `audit_logs` for machine attribution — rejected: the framework already attributes service-principal actions via `actor_type="service"` + consumer slug; a new central-table column was unnecessary. (d) Rename the framework to "API consumers" now — deferred: table/route renames carry migration + churn risk; only UI copy needs generalizing later.

---

## 2026-06-15 — External API is a STANDALONE feature, not built on identity_consumers (supersedes the same-day extend-the-framework ADR)

**Decision**: The external "scoped API access" capability is a **standalone feature** (`app/api_clients`) with its own credential store (`api_clients` / `api_client_tokens`, `helix_api_` prefix), its own auth dependency (`require_api_client`), its own scope registry (`cloud.subscriptions:*`), its own admin governance API + frontend console, and its **own top-level menu** (Config Center → 对外 API). It is NOT built on the `identity_consumers` framework. Only stateless low-level primitives (token gen/hash, Redis fixed-window rate limiting) are shared, via `app/core/service_token.py`. The cloud-subscription data router keeps its cmdb-domain home and simply gates on `require_api_client`. This **supersedes** the earlier 2026-06-15 ADR ("External API extends the identity_consumers framework").

**Context**: The first cut rode the freshly-shipped `identity_consumers` framework (reusing its `helix_svc_` tokens, scope catalog, and admin console) to avoid duplicating the credential/governance plane. On review the user identified a product-level conflation: `identity_consumers` exists to **distribute employee/department identity data** (the "身份源" / directory-pull surface); an external API for reading/writing **business resources** (cloud subscriptions, and later others) is a different product. Sharing the table, scope catalog, and admin menu mixed the two in both UI and function — a directory-consumer admin saw cloud scopes, and there was no separate menu. The user directed a thorough split ("彻底拆分", independent feature, separate menu AND function). Timing was favourable: the mixed version was merged to main but never deployed, so the split shipped before any production exposure.

**Alternatives**: (a) Keep it on identity_consumers (the superseded ADR) — rejected: conflates two products; cloud scopes leak into the directory-consumer admin; no separate menu. (b) Shared credential engine + split only the UI (a `kind` discriminator on one consumer table) — rejected: the backend principal/table/scope-catalog would still be shared, so "function not mixed" wouldn't hold. (c) Full standalone with its own copy of every primitive — chosen, but trimmed: the genuinely generic, stateless helpers (token hash/gen, rate-window) live in `app/core/service_token.py` and are shared, so the only duplication is the thin auth-dependency shell, justified by true product independence. identity_consumers is left untouched (no risk to the just-shipped 4-PR identity work); it retains its own copy of the shared helpers for now, with consolidation deferred.

---

## 2026-06-15 — "团队负责人 (leader)" is no longer a switchable view-role; manager surfaces are driven purely by the `is_manager` fact

**Decision**: Remove `leader` from the frontend `ViewRole` set and the header role-switcher. Being a manager is **not a perspective you switch into** — it is the fact `is_manager` (you have ≥1 direct report, computed server-side from the `manager_person_id` graph and returned by `/auth/me`). All manager surfaces are gated on that fact: the `我的团队` menu group (now including `团队资产 /team/assets`, previously the only item behind the leader view) renders **only when `is_manager`**, and the per-page `下属合计 / subordinates` toggles on `SoftwareCostPage` / `HardwareFinancialPage` are gated on `is_manager` instead of `viewRole === 'leader'`. A plain manager (no other admin role) therefore has `availableRoles === [employee]` and shows a **static chip with no dropdown** — the old `普通员工 ⇄ 团队负责人` toggle is gone. Non-managers no longer see the `我的团队` group at all (it appears automatically on the next `/auth/me` once HRIS marks them a manager — **this reverses PR-D's 2026-05-31 "keep the empty group for discoverability" choice**). The backend keeps `leader` as a tolerated `X-View-Role` alias (`_VIEW_ROLE_UI_SYNONYMS = {"leader"}` → narrows to `[employee]`) for backward compatibility with any in-flight client; no backend behaviour changes. The `leader` onboarding tour was retired — managers ride the employee tour with one extra `我的团队` step. The RBAC admin page (`RbacPage`) keeps `leader` as a documented **auto-derived** role blueprint — that concept (managers get a derived subordinate-visibility scope) is still true; only the switchable view is removed.

**Context**: The user (owner) flagged that the two "roles" were a mess: `leader` had **zero** permission or data-scope difference from `employee` (both `deriveEffectiveRoles` → `['employee']`; team endpoints have no role gate, only `is_manager`), yet the UI exposed a separate orange `团队负责人` chip whose only effect was toggling the visibility of one menu item (`团队资产`). Worse, the team surfaces were split across three inconsistent conditions: the `我的团队` group showed for everyone (empty state for ICs), `部门花费` showed on `is_manager`, and `团队资产` showed only in leader view. A manager in employee view saw the first two but not the third unless they manually switched into a perspective that switched almost nothing. The user asked whether the distinction was worth keeping; the answer was no — collapse to one employee identity, surface manager extras off the `is_manager` fact.

**Alternatives**: (a) Keep `leader` but make it meaningful (give it real extra scope) — rejected: the backend has no manager-only authority to expose; inventing one is scope-creep and contradicts the "manager = derived, not granted" model. (b) Keep the empty `我的团队` group for non-managers (PR-D's choice) — rejected by the user in favour of hiding it; the menu reappears automatically when `is_manager` flips, so the discoverability cost is one login cycle. (c) Remove the backend `leader` alias too — deferred: harmless backward-compat for cached clients; removing it risks 403-ing an in-flight `X-View-Role: leader` header for no gain.

---

## 2026-06-18 — `/help` 团队负责人指南并入员工(不再单列 audience)

**Decision**: 在 `/help` 文档门户重做中，去掉独立的「团队负责人指南」分区，把它的 3 个主题(团队视图 / 团队审批 / 兜底转让)作为**员工区下的「如果你是团队负责人」小节**渲染。`helpTopicIndex.ts` 的 `HelpAudience` 联合类型移除 `'manager'`,`HELP_AUDIENCE_TITLE_KEY` 删去对应项;`MANAGER_TOPIC_INDEX` 仍保留(仅用于给该小节加分组标题),但在扁平 `HELP_TOPIC_INDEX`(⌘K 搜索源)里这些主题标 `audience:'employee'`,所以命中副标题显示「帮助 · 员工自助」。主题内容与锚点(`team-view`/`team-approvals`/`escalation` 及 `#manager` 段锚点)不变,旧深链经 `resolveAnchor()` 落到员工区对应文章。

**Context**: owner 在帮助中心优化时指出「团队负责人指南感觉没有必要了,已经没有这个角色了,可以合在普通员工里」。这与 2026 年早些的「移除 `leader` ViewRole」决策一脉相承——manager 是从 `manager_person_id` 图派生的事实(`is_manager`),不是单独授予的角色;为它单列一整个帮助分区,对绝大多数读者(普通员工)是噪音。功能仍在(团队视图 / 飞书审批 / 兜底转让都还在),所以**保留文档、只是不再单列**,而非删除。

**Alternatives**: (a) 整段删除这 3 个主题——被否:功能仍在产品里,删了经理就没有对应文档。(b) 保留独立 Tab/分区——被否:owner 明确要并入,且与「manager 非独立角色」的既有模型冲突。

---

## 2026-06-23 — MCP OAuth connector grants full RBAC parity with PAT (single `mcp` scope)

**Decision**: The MCP OAuth 2.1 connector flow (Claude / Cursor self-onboarding via PKCE) issues a single `mcp` scope that maps to the authenticating employee's **complete RBAC** — read + write, identical to what a `helix_pat_…` PAT already grants. There is no read-only OAuth tier and no per-scope consent split. The access token carries no roles; the MCP middleware resolves the owner's roles fresh from the DB per request, and mutating tools stay behind the Leaver gate.

**Context**: Adding OAuth to the MCP endpoint (so Helix can be added via claude.ai's GUI "custom connector" dialog, which only supports OAuth discovery / Individual sign-in — no static-token field) raised the question of how much authority an OAuth-connected agent should get. The reference architecture the owner pointed at (NexusCRM "Public BI") was read-only, but that was incidental to its BI use case. Helix's existing **PAT decision already grants the issuer's full permissions** with no scoping; making OAuth weaker would split the model and make the GUI connector less capable than the CLI for no security gain (RBAC + Leaver gate + audience binding + force-logout already govern every tool call). Owner explicitly chose "与 PAT 对等(全权限)".

**Alternatives**: (a) Read-only `mcp:read` only (mirror NexusCRM) — rejected: writes would still need a PAT, halving the connector's usefulness; the platform's value is its full toolset, already RBAC-gated. (b) `mcp:read` / `mcp:write` selectable at consent — rejected as scope-creep: a second scope + consent-UI branch for a distinction RBAC already enforces per-tool; can be added later without breaking the single-scope clients if a real need appears.

---

## 2026-07-09 — Claude Code plugin distribution: S3 zip + local marketplace; no MCPB desktop bundle

**Decision**: The official Helix Claude Code plugin ships as a **zip of the whole
marketplace directory** published to S3 (`plugin-dist/`, mirroring `cli-dist/`),
installed either via `helix plugin install` (downloads, verifies sha256, unpacks
to a stable `~/.helix/claude-plugin/` path, registers with the `claude` binary)
or manually (`claude plugin marketplace add <unzipped-dir>` + `claude plugin
install helix@helix`). The plugin's `.mcp.json` ships **zero credentials** —
first use triggers Claude Code's OAuth discovery against the existing MCP
authorization server. Additionally, **no MCPB (Claude Desktop extension) bundle
is produced**: Desktop/claude.ai users connect through the existing OAuth custom
connector instead.

**Context**: Employees do not have access to the Helix repo, so the plugin needs
a distribution channel they can reach. Claude Code marketplaces added via a bare
HTTPS URL only fetch `marketplace.json` — relative-path plugin sources fail, and
external sources must be git/npm (both unavailable to employees). The CLI's S3
self-service pipeline already exists and solves exactly this problem, so the
marketplace ships as a zip through the same bucket/OIDC role, and the CLI gains
`plugin install/update/status` as the polished path. On MCPB: the format only
launches **local stdio** MCP servers, so bundling Helix would require a local
bridge process plus a long-lived pasted token — strictly worse than the OAuth
connector flow (short-lived, audience-bound tokens, SSO revocation) that already
covers Claude Desktop and claude.ai. (This decision round also re-confirmed the
"check what exists first" rule: the OAuth authorization server was almost
re-proposed as new work despite shipping on 2026-06-23.)

**Alternatives**: (a) Employee-readable git repo hosting the marketplace —
rejected by owner in favour of reusing the S3 channel (no new infrastructure).
(b) URL-hosted marketplace.json with git/npm plugin sources — rejected: sources
unreachable for employees. (c) MCPB bundle wrapping a stdio→HTTP bridge —
rejected: weaker auth posture and a redundant install path for the same users
the OAuth connector already serves.

---

## 2026-07-17 — 资产调拨收归管理员:盘点不再迁移所有权

**Decision**: 用户之间不能互相调拨资产。资产所有权的一切转移只能由 `asset_admin` /
`global_admin` 通过专用调拨端点(`POST /assets/{id}/transfer`、`change-assignee`,
均 `require_asset_admin` + 区域闸)执行。盘点响应侧的 `transferred`(转给某人)选项被
彻底移除——它此前是唯一让普通持有人单方改写 `asset.assigned_to` 的路径。持有人在盘点里
若东西已不在自己手上,改回 `not_mine`(需照片举证),由管理员再执行真实调拨。历史上已
记 `transferred` 的盘点条目仍正常展示并保持冻结。

**Context**: 用户在盘点操作记录里看到「已确认 王琳琳 → Park / Inventory cycle …」,
询问「用户可以自己调拨吗?需要双方确认吗?」。核查发现盘点 `transferred` 单方即时生效、
**无接收方确认、无区域闸(admin override 除外)**,持有人可把资产甩给任意在职同事而对方
无感知——与资产调拨应是管理员受控动作的预期不符。用户明确要求关闭该能力、所有调拨走
管理员。

**Alternatives**: (a) 保留持有人「转给某人」但降级为「上报建议」,不直接改所有权、进
管理员问题队列——被用户否决,选择「彻底移除该选项」。(b) 仅对普通持有人关闭、保留 admin
在盘点内的 override 转移——未采用:管理员已有专用调拨端点,盘点内再留一条转移路径是冗余
且半死的表面,清一色收归专用端点更干净。

```
## 2026-07-30 — HRIS offboard 契约:effective_date=最后工作日;排除原因两侧各拦一道

**Decision**: 与 HRIS 团队对齐的离职推送契约定稿:(1) offboard 事件的
`effective_date` 语义 = **最后工作日**,且与 SF `EmpEmployment.endDate` 同值
(HRIS 侧确认并修正其映射——他们此前误传入职日期;由于当时生产尚未收到过任何
offboard 推送,零存量损害);不新增独立的 `last_working_day` 字段(HRIS 提出的
方案 A)。(2) **D03 调往中国数据中心 / D04 兼岗结束 / D07 人员分类转换 / D08
调往德国数据中心** 四类离职原因不关号;HRIS 推送侧过滤,Helix 在**自己的每条
进料口**(webhook + 全量/增量/单人同步 + 窗口导入)按
`EmpEmploymentTermination.customString85`(经 `customString85Nav` 解析为
externalCode)再拦一道,名单存集成行 DB 配置 `excluded_termination_reason_codes`
(不硬编码)。命中的 webhook 事件落一等公民台账行 `offboard_skipped`。
(3) 跨 DC 调动工号不变、目标租户会发 onboard、两租户记录隔约一天——目标租户的
onboard 按调动处理(update/reactivate,凭 `owned_company_codes` 归属裁决),
绝不当新人重复建号。

**Context**: HRIS 正式环境接入离职推送时提出加 `Lastworkingday` 字段并说明已
对三类场景做推送侧过滤。核查发现:(a) 他们的 offboard `effective_date` 映射的是
入职日期,与 Helix 契约(=关号日)冲突;(b) 推送侧过滤挡不住 Helix 夜间全量同步
直读 OData 的兜底判定;(c) `customString85` 原始值是 picklist 选项 ID 而非
externalCode(生产双租户实测);(d) D04 兼岗结束是 Helix 侧审码表时追问出的第四类
(CN 生产已有 4 条该类终止记录,其中 3 人从未入库、1 人为软删行——无存量损害)。

**Alternatives**: (a) 方案 B(保留 effective_date 旧义 + 新增 last_working_day
字段)——更防误解,但 HRIS 选 A 且 A 与 Helix 现有代码语义零改动,尊重其选择;
schema 仍新增可选 `termination_reason` 透传字段,鼓励 HRIS 未来照发带码而非静默
过滤。(b) 只信推送侧过滤、Helix 不自查——被否:隐式规则不可审计,且兜底同步照样
关号。(c) 失败方向 fail-closed(查不到原因就不关号)——被否:坏掉的原因查询绝不能
让真离职者的权限活着;误关调动者可恢复且大声可见。

## 2026-08-15 — 业务折前口径 = 只统计 `cost_bucket='usage'`（行白名单）

**Decision**: 研发口径（业务折前成本 / list）**只统计 `cost_bucket='usage'` 的行**，
其余八个桶（`adjustment` / `commitment_fee` / `commitment_discount` /
`contract_discount` / `credit` / `tax` / `support` / `other`）一律计 0，由
`cost_view.engineering_row_clause()` 单点表达。判定依据是**桶**，不是
「`amount_usd_list` 是否为空」。被排除的行通过 `excluded_bucket_summary()`
以「笔数 + 桶」呈现，金额只对保密圈（`can_view_invoice_measure`）开放。

**Context**: 用户报云成本下钻按订阅筛 AliCloud (海外) RD master、7/17–8/14 窗口
总成本 −$2,601.65，而同账号订阅页「本月花费」$8,199.71 正常。查明两页读同一张
`cmdb_cost_facts`，根因是读侧 `fact_engineering_amount()` 的过渡性
`COALESCE(amount_usd_list, amount_usd)`：非 usage 行按建模契约 list 恒 NULL，
于是逐行回落实付，把公司层面的钱拽进研发口径。生产实测 2026 YTD：AWS 合同折扣
−$1,183 万 / 承诺费 +$662 万、GCP 承诺折扣 −$99.7 万 / 赠金 −$30.4 万、阿里云
调账 −$21.5 万；净效果是研发面折前成本被**低估** AWS $520 万、GCP $128 万。
`CmdbCostFact.amount_usd_list` 的列注释早已写明「其余行恒 NULL —— 读侧就是
SUM(list)，无需行过滤」，即本决定是**兑现既有契约**而非新口径。同期云管理/财务
给出的「业务 List Cost」规则（保留 Pay-As-You-Go/Purchase/Renewal/升降配；排除
Refund/Reconciliation/Round-off Discount、SP 与资源包购买费、集中采购 Support；
不减 invoice_discount/deducted_by_coupons；不用实付字段）映射到桶词汇上与本决定
逐条一致，两边口径因此可对账（见同期 BQ 月度对账 cron）。

**Alternatives**: (a) 判「`amount_usd_list IS NOT NULL` 才计入」——被否：生产上
有 2,844 行 `Item='Adjustment'` 却带 `PretaxGrossAmount` 的历史脏行（旧驱动无条件
读牌价写下，驱动已修但这些行不会被重新拉取），按 list 判会把 +$20,443 的退款行
算成研发成本；按桶判天然免疫。(b) 只修阿里云驱动写侧——被否：读侧才是收口，
AWS/GCP 的泄漏量大一个数量级，且驱动写侧当时已经是对的。(c) 在每个查询各自加
`WHERE cost_bucket='usage'`——被否：约 30 处消费点，漏一处就是新的泄漏，且与
「一个表达式收口」的既有架构相悖。(d) 把被排除行的金额也展示给研发面——被否：
折前总额与非 usage 桶实付额同框，相除即公司折扣率，撞双口径保密红线；折中为
「笔数恒可见、金额按圈开放」，保证钱不是凭空消失但不泄露折扣率。

## YYYY-MM-DD — <short title>

**Decision**: <one paragraph>

**Context**: <why it matters, what prompted the question>

**Alternatives**: <what was considered and why rejected>
```

## 2026-08-17 — 助手的写路径从「手写白名单」改为「从 MCP 派生 + 排除名单」

**背景**：2026-08-17 上线的写路径要求每个可提案动作手写一份 `AssistantAction`
(标题/意图三语、schema、normalise、summarise、execute)。结果目录停在 3 个，
而平台有 203 个写工具，员工要邮件别名却拿到工单。

**决策**：可提案动作**从 `mutates=True` 自动派生**，与只读桥同源同构；
`get_action()` 精修优先、派生兜底。确认卡片是唯一仍需人写的部分，且**可选**——
没有精修卡片只是渲染朴素，不再等于该流程不可用。

**为什么安全面没有放宽**：模型永远只能提案；执行仍需员工对**参数快照哈希**点确认；
`DESTRUCTIVE_TOOLS` 永久排除；RBAC 仍在工具内部按服务端解析的调用者执行。
派生不会扩大任何人本来的权限。

**新增的判断准则（`OPT_OUT`）**：助手只提案**员工为自己办的事**。三类排除——
替别人做决定、在人之间转移归属、行政台账编辑。它们的共同点不是权限不足，而是
「一句话触发、缺少上下文」本身就是评审屏幕存在的理由。这条准则是把当初资产动作
只收「借用/报修」的理由推广到全部动作。

**代价**：通用卡片按 schema 渲染，字段名是英文 title。可按需为单个动作加精修卡片。

**因此**：新流程按既有 MCP-sync 规则随 PR 带上 MCP 工具后，**当天即可被助手提案**，
无需再改助手代码。

## 2026-08-18 — DAX 数据集业务说明写入既有 `data_access_resources.notes`

**Decision**: `data_access_resources.notes` 是数据集业务说明的唯一持久化事实，由仓库
目录的 `description` 输入投影，供申请列表搜索、页面展示和飞书审批共同读取。HTTP
响应仍使用面向用户的字段名 `description`，但值来自 `notes`；不新增数据库列或迁移。
仓库目录保留 42 个稳定 catalog identity，但只有生产回读确认的 22 个设置
`requestable=true`；20 个历史项保留且不可发起新申请。

**Context**: 用户需要在申请前理解每个数据集的业务含义和可能看到的数据。原目录虽然
已有 `description` 输入并写入 `notes`，但 API 和页面没有展示；同时
仓库仍标记 42 个可申请项，而 2026-08-18 生产只读回查只有 22 个，重放目录会意外扩大
申请面。22 条说明依据实时 BigQuery 对象清单和精确 dbt main 血缘整理，不读取业务行
内容，也不改变现有授权。

**Alternatives**: (a) 新增 `description` 列 —— 未采用：用户明确要求说明写入既有 `notes`，
且增加第二列会产生迁移和双写口径。(b) 同时写 `notes` 与新列 —— 被否：两个活跃事实会
漂移。(c) 删除 20 个非申请项 —— 被否：会丢失稳定 catalog identity 和历史关联；保留行
并设 `requestable=false` 更符合生命周期语义。

## 2026-08-19 — DAX 表结构按需读取，不建立第二份字段目录

**Decision**: 员工点击可申请数据集后，Helix 用既有专用 `data_access/bigquery`
驱动按需读取 BigQuery 当前元数据：先在该精确 dataset 内列出表/视图，再只读取被选中表
的 schema。字段展示完整嵌套路径、类型、mode 和 BigQuery 原生 description；没有 description
时明确标为暂无说明。`HelixDaxDatasetIamReconciler` 在原 dataset-level 绑定上新增且仅新增
`bigquery.tables.list` / `bigquery.tables.get`，不新增 `tables.getData`、query job、项目 IAM
或宽角色。HTTP 每次都先把 resource id 重新约束为 active/requestable/owner-resolved dataset。

**Context**: 用户已经能在申请页看到数据集业务说明，但无法判断数据集里具体有哪些表、字段
及含义。目录当前只保存 dataset 级事实；为了一个浏览界面复制整套 table/column 目录会新增同步、
时效和双事实问题，而 BigQuery metadata API 已能返回权威当前 schema 与字段说明。

**Alternatives**: (a) 把全部 table/column schema 同步进 Helix DB —— 未采用：产生第二份易漂移
事实、迁移和定时同步，当前需求不需要离线查询。(b) 用 `INFORMATION_SCHEMA` SQL —— 未采用：
需要 `bigquery.jobs.create`，突破 DAX 明确不负责查询执行的边界。(c) 点击 dataset 一次性对所有表
逐个 `get` —— 未采用：大数据集会形成 provider N+1；改为表清单 + 单表懒加载。(d) 前端直连
BigQuery —— 被否：会把 GCP 凭证和权限边界带到浏览器。

## 2026-08-19 — “我的数据集权限”按原始申请人管理口径，而不是推断当前有效访问

**Decision**: 员工的“我的数据集权限”只展示由本人提交、审批后已经形成 binding 的授权。
唯一关联链是 `data_access_requests.requester_person_id = 当前用户`，再通过
`data_access_bindings.source_request_id` 读取实际授权；每条 binding 明确保留并展示
`google_user`、`google_group` 或 `gcp_service_account` principal snapshot。待审批但尚未形成
binding 的记录仍属于“数据访问申请”，不进入权限台账。

**Why**: “我申请并负责的授权”与“我此刻可能因为 Group membership 而享有的访问”是两个
不同事实。Group 成员关系会变化，而且同一个 Group 的申请可能由其他人发起；把两者混在一起
会让员工误以为自己负责一个没有申请/撤销权的授权，也会让来源审批无法审计。Group 当前与历史
授权继续由既有 Group access 页面表达。

**Consequences**: 新读模型必须分页、自身作用域并复用 binding/effect 状态，不从 BigQuery 反查；
HTTP、Web 与 MCP 共享同一查询。后续若需要“我当前实际能读什么”，应作为独立的 effective-access
产品口径设计，并明确处理嵌套 Group、成员同步时效和非 Helix IAM，而不是扩张本台账的含义。

## 2026-08-20 — DAX Terraform controller scope 与 requestable 申请准入解耦

**Decision**: Terraform 只管理 DAX 的低频关键控制面：专用 GCP Service Account、最小权限
自定义角色、每个 `active` catalog dataset 上的精确 controller binding，以及生成密钥写入 AWS
SSM。`data_access_resources.requestable` 只决定员工能否发起新申请，不参与 Terraform
controller scope 过滤。一个目录身份从 `requestable=true` 变为 false 时，Terraform 必须保留
其 dataset binding；只有显式生命周期离开 `active` 后，才允许计划移除 controller 权限。

**Context**: 48-requestable 目录第一次对 Production remote state 运行精确 plan 时得到
`24 add / 18 destroy / 2 change`。24 个新增项是 dbt 引用源数据集；18 个删除项仅来自旧目录身份
变为不可申请，并不证明历史 binding/effect 已撤销。直接 apply 会让 Helix 丢失观察或撤销历史授权
所需的 provider 权限。反过来让 Helix runtime 给自己补 controller binding 会形成自提权，不能接受。

**Consequences**: Provider 存在的 65 个 active 稳定 catalog identity 都保留 controller binding，
其中 47 个允许新申请；确定 404 的 identity 按下方项目本地角色/Provider 缺失决策暂停。owner、血缘、表/字段元数据、申请、审批和 effect 状态仍只走 Helix/Provider
既有读路径，不进入 Terraform。Production apply 必须使用 reviewed saved plan，并以零 destroy、无宽角色、
无 key replacement 为门禁；remote state 的 validation sentinel 只保存受控 locator 数量与稳定摘要，
不保存本机绝对路径或 owner/描述等业务元数据。目录删除还需要先证明相关历史授权已完成撤销，
再单独变更 lifecycle。

**Alternatives**: (a) 继续按 `requestable` 驱动 Terraform —— 被否：把产品准入误当 controller
生命周期，会静默删除撤销能力。(b) 由 Helix runtime 自动给自身授权 —— 被否：形成 provider
自提权。(c) 手工为新数据集逐条加 IAM —— 被否：没有声明式差异、reviewed plan 和 drift readback。

## 2026-08-20 — DAX 跨项目 controller role 必须项目本地化，Provider 缺失源暂停

**Decision**: `HelixDaxDatasetIamReconciler` 仍保持同一套八项最小权限，但 Terraform 在每个
`managed_dataset_project_ids` 项目内各创建一个 project custom role；dataset binding 只能引用该
dataset 所在项目的 role。主项目既有 singleton role 通过 Terraform `moved` block 迁移到 keyed
address，不删除或重建。dbt 血缘引用但 Production Provider 明确 404 的数据集保留稳定 catalog
identity 和血缘节点，设置 `status=suspended` / `requestable=false`，不进入 controller scope。

**Context**: exact-main saved plan 的第一次 Production apply 部分成功：主项目角色补齐两项表级 IAM
权限，20 个 `gcp-digital-bi` binding 创建成功；三个 `gcp-digital-bi-dev` binding 因 GCP 不允许资源
引用另一个项目层级内的 custom role 而失败。`gcp-digital-bi.usage_us_west2` 同时返回确定的 dataset
404。Terraform 已把成功项写入 remote state，因此恢复必须从该 postimage 重规划，不能重放旧 plan。

**Consequences**: 恢复 plan 只允许新增 dev 项目的同构角色和三个 dev dataset bindings，再更新
65-locator validation digest；不得回滚已成功的 20 个主项目 binding，不得替换 Service Account/key/SSM，
也不得为不存在的 dataset 创建占位 IAM。`usage_us_west2` 仍可在血缘中解释旧依赖，但员工不能选择
其表或发起新申请；若 Provider 后续恢复，必须经新的目录证据和正常 PR/plan 门禁重新激活。

## 2026-08-25 — 新增第 10 个规范角色 `security_officer`(安全员),安全域内与 auditor 等权

**决策**:新增规范角色 `security_officer`,通过 `can_view_security` + `can_manage_security`
两个既有闸,授予 zhuoran.li / dong.zhang / xiaosong.hu / mingda.cai(迁移 541)。

**为什么不复用现有角色**:实际跑安全运营的人此前只有两个选择 —— 给 `global_admin`
(远超岗位所需:用户与角色管理、集成配置、HR、财务、资产、云、定时任务全给了),
或者给 `auditor`(治理席位)。前者是权限过授,后者是身份错位。

**权限边界(重要,别被"审计员审计、安全员操作"这句话误导)**:
安全域内 `security_officer` 与 `auditor` **完全等权**,包括访问认证的逐项决策 ——
`compliance_router._require_manage` 就是 `can_manage_security`。这是明确选择,不是疏漏:
用户要的是"读写安全合规",没有更窄的例外。两者的区别是**身份**而非权限集(角色切换器、
菜单、审计轨迹里分开显示)。若将来需要把区别变成权限边界,必须新加更窄的闸,
不能靠这条 ADR 的措辞暗示。

域外一律不授权:没有 `global_admin`、不进任何硬编码 `global_admin` 的运维告警扇出。

**前端必须同步四处,否则角色形同虚设**:`ViewRole` 联合类型、`VIEW_ROLE_PRIORITY`、
`HOME_BY_VIEW_ROLE`、以及安全域路由的 `RoleRoute` 角色并集。`deriveEffectiveRoles`
会把会话收窄成单个 view-role 名字,不在 `ViewRole` 里的角色永远不会出现在
`effectiveUser.roles`,所有读它的守卫都 fail-closed —— `asset_global_admin` 当年
就是这样上线了却谁都进不去。

**落地页是 `/bounty` 而不是 AI 大脑**:`security_officer` 不在 AI 大脑路由的角色并集里,
后端也不为它解析 scope,落过去会弹到 fallback。安全运营台的日常就是报告队列。
同理菜单里**不放** AI 大脑入口 —— 那会是一条死链;要不要把这个视角接进 AI 大脑,
是 intelligence 域自己的决定。

---

## 2026-08-25 — 内部系统台账是新域,不是 CMDB / 软件台账的扩展

**Decision**: 「我们自己建的系统」这一层落在**新域 `system_registry`**(表 `registry_systems` /
`registry_system_events`),不复用 `cmdb_*`、不复用 `asset_software_catalog`、不复用 `sso_apps`、
不复用 `domain_registrations`。归属唯一事实源是 `registry_systems.owner_person_id`;漏洞报告
通过可空 `bounty_reports.system_id` 关联到它,跨域读写一律走
`core/coordinators/system_registry_coordinator.py`。台账由 **IT 维护**:
写面 `can_manage_system_registry` 只有 `global_admin`,比安全域的 `can_manage_security` 更窄。

**Context**: 用户要「一个新的系统的东西,用来和收到的漏洞问题做关联,离职的时候也要接定人,
整体的生命周期闭环,IT 来维护」。第一个要回答的问题是:这四张既有表里有没有一张已经是它。
逐张核对后的答案是没有 —— `cmdb_*` 是 provider 同步来的机器/域名/DNS/证书(事实源在云上,
Helix 只是镜像),`asset_software_catalog` 是**买来的** SaaS 及其坐席与账单,`sso_apps` 是
SAML 应用目录,`domain_registrations` 记的是谁注册了一个外部域名。它们都不回答
「TiCard 这个应用归谁负责」。CMDB 知道有一台机器在跑,台账知道谁为这个系统负责;
两者按主机名相连,不建外键。

**Alternatives**:
(a) 在 `cmdb_servers` 上加 `system_name` / `owner` —— 否决:CMDB 行由同步覆盖,
人工填的归属会被下一轮同步冲掉,而且一个系统跨多台机器/多云,行级归属无法表达「这个系统」。
(b) 复用 `asset_software_catalog` 加一个 `is_internal` 标记 —— 否决:那张表的每一个字段
(供应商、合同、坐席数、月度账单)对自建系统都是空的,而它缺的每一个字段(对外可达性、
备份 Owner、赏金范围)对采购 SaaS 都是空的。一张表两种半空形状,两边的报表都得先过滤。
(c) 只在赏金域加一个 `affected_system` 字典表 —— 否决:离职交接、责任覆盖率、对外攻击面
都不是赏金域的问题,把它们塞进赏金域会让第一个非赏金消费者去 import 赏金域。
(d) 写面开给 `security_officer` —— 否决:安全台**消费**归属来路由发现,不决定谁拥有系统。
两个台都能改归属的台账没人会信,而离职闸门是建立在这个答案上的。

**归属只能通过专门端点变更**:`update_system` 不能写 Owner,`transfer_owner` 必写事件。
这不是审计洁癖 —— 「五月份 Clinic 是谁负责的」是事故复盘会问的问题,而平台审计日志
(按人、按动作、跨全平台)答不了「这个系统的责任链」。

**离职强制交接优先给备份 Owner 而不是经理**:备份 Owner 就是为这一刻提前点名的人;
把一个在跑的系统交给一个从没碰过它的经理,是「下一次重组时它又变成无主」的成因。
经理兜底仍在(没有可用备份时),顺序是 备份 Owner → 通用兜底人。

## 2026-08-25 — 接地闸按「记录自己发布的名字」寻址;词表外的动作动词降级而不是拒绝整份分析

**Decision**: intelligence 的输出校验在两个地方从「整份拒绝」改为「按发布契约认账 / 降级」,
两处都**不放宽任何授权**:(a) 事实引用解析在所有字面地址失败之后,再试一次
「记录自己发布的 `key`」—— 翻译出的地址必须仍然存在于同一成员,所以编造的叶子、
越界的更深路径、被两条记录同时声明的名字仍然全部被拒;(b) `action_kind` 落在
`Literal` 词表之外时降级成 `review`(词表里唯一不在 `_APPROVAL_ACTIONS` 里、
`handling_steps` 走默认只读三步的动词),而不是丢弃整份文档;缺失或非字符串仍然是契约违约。

**Context**: 两个 subject 因此长期发不出来。跨域简报 2026-08-25 被拒的
`/facts/cloud_network.resource_waste_rate.value` **是包里真有的指标** —— 地址是
`dimensions.cloud_network.metrics.resource_waste_rate.value`,而紧邻一行就是
`key = "cloud_network.resource_waste_rate"`:摊平器剥掉指标 key 里重复的维度命名空间、
又插入一个 key 里没有的 `metrics` 段,生产真快照里 **40 个指标全部**如此,
「按名字引用指标」在物理上不可能成功。据此还推翻了 08-17 记下的一个结论:
`software_api.seat_utilization` 被判「真幻觉」,它其实是真实 catalog 指标,撞的是同一个 bug,
所以「剩下的问题是事实包太大、需要产品决策收小」这个判断不成立。
`cloud_scope_health` 10 次全败,最后一次的真因是一个词 `action_kind='confirm'`,
2548 token 的合规叙述整份丢弃。

**Alternatives**:
(a) **放宽接地闸的匹配**(允许引用扩展一个已发布地址)—— 被否,而且是**实测**被否:
`assets.asset_count.invented.deeper` 会解析成真数字,08-17 已回退过一次。同理不加
「两个事实之和」规则:1390 个数值里随便一对就能凑出目标。
(b) **继续一词一词加动词词表**(上一轮加 `assign` 的做法)—— 被否:每加一个写动词都要
连 `_APPROVAL_ACTIONS` 一起放宽并改齐四个消费点(契约 Literal / 审批集合 /
`_HANDLING_STEPS_BY_ACTION` / 前端联合类型),而第三个动词一定会来;把 `confirm` 做成
一等动词仍然可选,但那是产品选择,不该是发不出简报的原因。
(c) **改摊平器让地址等于 catalog key**(去掉 `metrics` 段)—— 被否:会改变已经在用的
可抄地址清单形状,且 `people_org` 维度下的 `people.*` 指标提升到一层后有撞名风险,
而 `_assign_flat` 的撞名是**整份包直接失败**。
(d) **把简报的事实包收小到每域头部指标(≈80 个)** —— 不再需要以此救简报(真因已定位),
作为纯优化仍可做;本批只从**可抄清单**里摘掉 693 条 `evidence_refs.<i>` 匿名锚点地址
(它们不是量级,仍然可解析),3505 → 2812 条、输入 token 125,745 → 114,806。

**验证口径**:三个 subject 都用「生产真快照 + 生产真 key 打 qwen3-max ×3 + 跑完整
`validate_intelligence_output`」的只读探针验过,3/3 通过,不写任何生产行。

---

## 2026-08-26 — MDM 纳管关系从「人」改挂到「设备」,清理触发改为资产处置

**Decision**: Jamf(以及未来任何 `kind='mdm'` 集成)的纳管关系落在**设备**上,
不再表现为人持有的软件坐席。离职**不产生**任何 MDM 待办;唯一的清理触发点是
**资产处置**(回购 / 外售 / 报废 / 报损),即设备离开公司所有权。

**Context**: Jamf 是从坐席对账那扇门进来的 —— 那是现成的面,而且「席位 = 设备」
把合同用量的账算平了。账是对的,模型是错的。软件坐席由人持有、随雇佣关系消亡;
一台受管 Mac 是公司硬件,回收、重装、交给下一个人,**全程从没离开过租户**。
`jamf.seat_sync` 按人写 `software_seat_allocations`,离职扫描把它当普通坐席建
blocker,于是每次离职都推一张「请手工回收 Jamf 坐席」的待办,而它唯一正确的
处理方式永远是什么都不做。Jamf 管理员主动提出取消这个检测,他是对的。

**Consequences**:
- 新表 `asset_mdm_devices`(迁移 558),键为 `(integration_id, device_type,
  external_id)`;人是每晚同步重算出来的属性。
- `jamf.seat_sync` 退役、`jamf.device_sync` 接任(按 `ScheduledJob` 的规矩
  **新建行而不是改名**,继承旧行 cron 与启用状态)。不再写 seat allocations。
- 离职扫描按 `kind='mdm'` 跳过坐席 blocker —— 不写死 provider,缺陷是
  「把设备管理建模成个人访问权」,任何 MDM 都一样。
- `dispose_asset` 之后开清理待办(`target_type='hardware'`,
  `source='asset_disposal'`),由 `mdm.cleanup_notify` 负责发卡与催办。
- 对账中心的 Jamf 一格保留 —— 「付 60 台用 45 台」是真实的合同问题;
  `leaver_residual` 保留但明确定义为**数据质量读数**,不是待办。

**Alternatives**:
(a) **完全取消 Jamf 的离职检测,不做处置触发** —— 被否:管理员自己提出的替代
是「只检测有回购流程的提醒我去删除」,而处置事件已经在平台里,不接就等于把
「设备卖了但还在纳管」永久留成盲区。
(b) **保留 seat allocations,只在离职时不建 blocker** —— 被否:分配行本身就是
错的模型,留着它等于让下一个读者(以及闲置回收、访问认证等所有读 allocations
的面)继续把设备当成人的坐席。
(c) **处置时实时打 Jamf API 判断设备是否纳管** —— 被否:处置跑在调用方事务里
(回购审批回调持着行锁),事务内做外部 HTTP 往返是这个平台掉过连接的老路。
改为查本地设备台账;同步之后才纳管的设备由下一轮同步兜住。
(d) **给设备台账的归属列加外键** —— 被否:TiDB 每条带 FK 的子表 INSERT 锁父行,
整队重写会让 50 条插入排队卡在同一个 integration 行上(03:00 cron 撞车形状)。
代价是读面必须容忍悬垂 id,已用测试钉住。
(e) **卡片带「一键 unbind」按钮** —— 被否:从租户里删记录是管理员在自己控制台
上的判断。平台的职责是让他知道、把序列号摆在他面前、并在他做完之后发现。

**「完成」的两种含义**:卡片按钮记的是**说法**(`cleanup_confirmed_via='admin'`),
下一次同步在租户里找不到该设备记的是**事实**(`='sync'`)。两者都关单但分开记;
确认过却仍被同步找到的设备重新进入催办 —— 否则「已勾选但没真删」会永远躲在
一个绿勾后面。**分不清说法和事实的台账,迟早会向审计报一个错数。**

---

## 2026-08-26 — MDM 设备退役必须拿得住证据(复审加固)

**Decision**: `jamf.device_sync` 只在清单可信时才把设备判为「已从 MDM 移除」。
空清单一律拒绝;已知设备 ≥5 台而本次报告数低于一半也拒绝;拒绝本身写进
运行摘要。设备消失一夜后又回来,**重开**它的清理待办并清掉那次确认。

**Context**: 判 gone 不是记账。它会盖上 `cleanup_confirmed_via='sync'`,并用
「confirmed by Jamf inventory (device removed)」结掉待办 —— 那是一句关于外部
系统状态的**事实性断言**,会进审计。而 API client 被改小权限、分页器 200 一个
空页、租户短暂故障,到这里全都长成同一个样子:「今天比昨天少」。没有护栏时,
一次权限收窄就会把整队设备退役,并对每一个开着的待办写下一句假话 —— 这种错误
审计发现得晚,发现时已经没人能重建当时发生了什么。

`safe_to_drift_revoke` 在这个仓库里存在,正是为了同一类事故(五个 seat-sync
都用它)。设备同步没有理由例外。

**Consequences**:
- 拒绝的代价是一夜的陈旧;接受坏清单的代价是整个台账加一批假确认。
- 回归重开:资产仍是处置态、设备仍在租户里,请求就仍然成立。不重开就等于
  永久丢单 —— **没有任何东西会回扫历史处置**(这一条写在退役 coordinator 的
  模块 docstring 里,是整条链路的前提)。
- 「说法」与「事实」继续分开记:回归时清掉的是 `cleanup_confirmed_*`,不是
  待办本身。

**Alternatives**:
(a) **信任清单,靠人发现** —— 被否。这类错误的表现形式是「待办都清干净了」,
没有人会去查一个看起来已完成的队列。
(b) **只拒绝空清单,不设比例闸** —— 被否。分页器截断返回的是子集而不是空集,
而这正是 seat-sync 家族当初补比例闸的原因。
(c) **回归时只恢复状态、不重开待办** —— 被否。状态对了但队列里没有它,等于
台账显示「待移除」而催办与结案都够不着它,是最难发现的那种半好状态。

**顺带定死的两件事**:`holder_key` 用租户的设备身份 `device_type:external_id`
而不是序列号 —— 重新纳管会在租户里留下两条同序列号记录(台账索引就是按这个
理由故意不唯一的),而对账恒等式对本 provider 是 fail-loud,一次碰撞会**回滚
整个台账写入**。厂商字符串一律按列宽截断后写入:上游 realname 无文档上限,
一条 1406 会掐掉整夜同步,而这个循环没有逐设备 savepoint。

---

## 2026-08-26 — 「说法」可以被「事实」推翻,而且重开一律新开一单

**Decision**: 同步若仍在租户里找到一台已被管理员标记「已移除」的设备,即判定
那次确认被事实推翻:清掉确认、清掉催办计时、**新开**一张清理待办(不复活旧单)。
通用 provisioning 队列上取消硬件清理,由同步理解为「这台不需要移除」并释放设备。

**Context**: 这套设计的招牌是「卡片上的勾是说法,同步找不到设备才是事实」,而
代码做不到。confirm 走 `complete_manual_task` 置 `completed`,催办查询跳过终态
任务 —— 一次错勾就把一台已售出机器的清理**永久**静默。`due_cleanup_notifications`
的 docstring 甚至写着相反的话,而「区域闸弱一点没关系,催办会再抓回来」这个
理由正是建立在那半句不成立的话上。**代码与它自己的文档对同一件事说了两种话,
而安全论证选了错的那一半。**

**Consequences**:
- 仲裁权归唯一有资格仲裁的东西:租户的清单。真删掉的人不会被再骚扰(同步找不到),
  没删成的会被重新排队。
- **重开必须新开一单,这是安全属性不是整洁**:一键确认是绑在 task id 上的签名
  token,14 天有效且不是一次性的。复用 id 等于让第一次关掉它的那张卡在别人的
  飞书历史里还能再关一次,把同步刚说仍然在的机器悄悄埋回去。新 id 让旧 token
  指向终态任务,点了无事发生。
- 台账上四条状态迁移全部写审计(auto_confirmed / reopened / withdrawn / released)。

**Alternatives**:
(a) **让催办查询把「已声称完成」的设备保留在范围内** —— 被否。那需要这个查询
自己去猜设备是否真的还在,而唯一知道答案的是同步。把判断放在能看到证据的地方。
(b) **给 `/provisioning/quick-complete` 加一次性 jti** —— 被否(本次)。那会改变
平台上**每一个** manual_notify 流程的行为,爆炸半径远超这个功能;而新开一单在
本功能内就解决了同一个问题。若将来平台决定全局单次化,这里不需要改。
(c) **复用 task id 重开** —— 被否,见上。
(d) **同步不管通用队列上的取消** —— 被否:设备会卡在 `pending_cleanup` 指着终态
任务,催办跳过、同步不重开、页面上显示一条没人在做的请求。两个域安静矛盾比
任何一种明确处置都糟。

---

## 2026-08-27 — 一个标记只回答一个问题;安全下限必须有出口

**Decision**: `reopened_from_task_id` 只决定卡片的**措辞**,时机一律由
`cleanup_last_reminded_at` 决定。退役护栏的 `known` 只数**在职**台账行,
拒绝进 `errors` 并带 `[retire-refused]` 稳定标记,比例可经任务 config 下调 —— 但**必须配 `retire_override_until`,过期自动回默认**。

**Context**: 两条都是「修复引入的缺陷」,由专门审修复本身的那一轮抓到。

第一条是仓库记过的 `reference_one_flag_two_questions` 原样重现。为了让被推翻的
清理用催办口吻而不是首次通知口吻,我让那个标记同时回答「怎么措辞」和「要不要
豁免间隔」。措辞需要它**永久**为真,豁免需要它**只生效一次** —— 而标记写进
`context_data` 后永不移除。结果:每工作日 10 张卡,每张都抄送全部管理员(重开的
待办按定义早已过 21 天升级线),直到设备离开租户。

第二条是护栏把自己锁死。`known` 数了全部台账行含已移除的,而退役循环跳过它们,
且没有任何东西清理 `gone` 行 —— 于是**累计已移除数达到在职机队规模后,任何一次
退役都被永久拒绝**,静默,任务照绿。另一种形态:办公室关闭一晚报废 30 台被拒,
此后每晚都拒,因为**拒绝正是它们永远退不掉的原因**。

**Consequences**:
- 标记与时机分离后,一次性由清空计时提供,而它自限(通知循环发完即重写)。
- `known` 与循环能处置的集合对齐,累积不再收紧护栏。
- 拒绝可见(进 `errors`,标记被共享 helper 认作防御性拒绝,不 page)+ 可绕过
  (config 下调 + 自过期),人有出口且忘了改回来不产生代价。

**Alternatives**:
(a) **用 `task.created_at` 与计时比大小来判断一次性** —— 可行但更绕,而且引入
第二个时间比较;清空计时本来就是现成的一次性机制。
(b) **给护栏加绝对增量下限而不是可调比例** —— 被否:救不了「一晚 30 台」这种
真实场景,只是把墙挪了个位置。
(c) **让拒绝直接 raise 以便 page** —— 被否:与 seat-sync 家族的既有姿态不一致
(`safe_to_drift_revoke` 的拒绝同样进 `errors` 且不 page),而不一致的告警语义
比不告警更难维护。可见 + 可绕过已经给了人足够的信息和手段。

**教训**:一个永久为真的标记不能用来控制只应发生一次的事。判断方法很简单 ——
问这个标记「什么时候变回 false」,答不上来就不能用它做一次性开关。

---

## 2026-08-27 — 两个覆盖键;以及「事实」必须被记录而不是被推断

**Decision**: 退役护栏的两条出口是**两个独立的键**:`retire_min_ratio`(比例闸)
与 `retire_allow_zero_overlap`(授权「租户返回了但一台都不认识」),共用一个
`retire_override_until` 自过期窗口。设备被推翻的事实记在
`cleanup_contradicted_at` 列上,不由其它字段推断;**新的确认压过这个历史标记**。

**Context**: 两条都是同一个东西的第 N 次复发。

`override_active` 曾由 `min_ratio != 默认值` 反推 —— 那回答的是「比例是否非默认」。
**收紧**到 0.9 于是解锁了整队旁路,而恰好等于默认的覆盖被宣告生效却空转。
这是「一个 flag 扛两个问题」在本次改动里的第三次。

UI 曾用 `last_seen_at > cleanup_confirmed_at` 判断「说法被推翻」。推翻本身会
**清空** `cleanup_confirmed_at`,所以这个谓词只在 30 分钟宽限窗内为真(后端
恰恰在那时不判定推翻),推翻之后永远为假 —— 正好反了。加列之后又发现新的确认
没有压过历史标记:在设计好的二次尝试路径上,界面拿**上一次**推翻的时间戳说
「删除可能没生效」,而同步没检验过这次新说法。「报告一件没发生的失败」第四次。

**Consequences**:
- 两个授权分开,一个收紧永远不能变成另一个的放行。
- `cleanup_contradicted_at` 是历史:保留到清理真正结案,由四条解决路径清除
  (撤单 / 释放 / 新开处置单 / 设备真的离开租户),**重开时刻意不清** —— 那正是
  横幅要读的事实。
- 读面优先级:新确认 > 历史推翻 > 待处理。

**Alternatives**:
(a) **让 UI 继续推断** —— 被否,已经证明推不出来:唯一能表达它的字段被这件事
本身清空了。
(b) **推翻时不清 `cleanup_confirmed_at`** —— 被否:那样「有确认」就不再意味着
「有人认领过且未被否定」,每个读者都得再学一条例外。
(c) **一个 `retire_override` 布尔管两件事** —— 被否,那就是第四次。

**判断方法(第三次记同一条)**:一个标记要用来做开关之前,先问它「什么时候变回
false」和「它回答的是哪一个问题」。答不上来第一问,就不能做一次性开关;第二问
有两个答案,就必须拆成两个键。

---

## 2026-08-27 —— 已修过的失败模式,会借新代码回来

第七轮复审的五项里有两项属于同一类:平台**已经**有专门治这个病的机制,而新写的代码没走它。

**一、平台原语的存在不等于被使用。** `_localized_context` 是为「英文卡片里出现生产者的中文字面量」建的,它的 docstring 里写着当初是怎么在生产 outbox 里发现的。MDM 清理卡是新的生产者,模板三语齐全,但填进 context 的两个值是纯中文字符串 —— 于是同一个病,同一种表现,换了一个生产者。**判断方法**:新生产者往 context 里填**面向人的措辞**(不是名字、不是 ID、不是厂商原样返回的错误串)时,它必须是 `LocalizedText`。「模板有三语」不构成卡片有三语。

**二、一个语义分成两个字段之后,读面要一起改,而且不止两个。** 同步推翻声称时清空 `cleanup_confirmed_via` 是对的(事实压过说法),代价是**任何只读这一个字段的界面**都会把「被推翻」和「从没人碰过」渲染成同一种。列表列、抽屉在第五、六轮各学了一次,MCP 队列工具是第三个,而且它的注释还**声称**自己区分得开 —— 注释描述了一个代码没有的行为。**判断方法**:清空一个字段时,列出所有读它的面(HTTP 列表 / 详情 / 导出 / MCP / 移动端),而不是只改当前正在看的那个。这与「审批人可达性三处」是同一条。

**三、覆盖面缺口的默认处置是记 baseline,不是顺手全修。** 新加的 `check-css-vars.mjs` 当场找出 15 处既有的未定义 CSS 变量引用。每一处都要在自己的调用点做设计判断(仓里没有 mono token 可指,`--m-fill-light` 从来不是 token),而这是一个 MDM 改动 —— 没有理由顺手重排赏金控制台的样式。按仓库既有惯例(三个 baseline/allowlist 文件)记进 baseline 并单独开待办:闸立刻拿到它的全部价值(这张单子只许变短),而不必先把不相关的页面改一遍。这条与「判断对但手段过宽」互为反面 —— 那次是发现一个案例后按模式全仓替换,这次是发现十五个案例后**只装闸不改样式**。

**四、静默失效的东西必须有闸。** 未定义的 CSS 自定义属性、canvas 上的 CSS 变量、`pnpm exec tsc --noEmit`、`bool("no")` —— 共同形状是**不报错、不打日志、工具链全绿**,于是错误的存活时间等于「下一个人肉眼发现它」。这类东西发现一次就装一道闸,哪怕闸只有三十行。

**五、共享落地页 / 共享模板的默认文案,是它最初那个调用方的。** 第八轮抓到的那一处不在新代码里 —— 新代码正确地复用了加固过的免登录确认页,而那一页的文案写着「确认已回收该坐席/账号?…推进离职流程」。复用一个共享 UI 时,要问的不是「它能不能跑」,而是「**它说的话对我这个调用方还成立吗**」。判断方法:把共享页面在新调用方下**真的渲染一次并逐句读**,不要只看它返回 200。与第一条(平台原语的存在不等于被使用)是一对:一个是该用没用,一个是用了但没读。

**六、窄分支要用两个变异验证,不是一个。** 给共享页面加 `if source == X` 分支时,`if False` 证明分支有用,`if True` 证明分支**没有波及别的调用方** —— 后者才是「判断对但手段过宽」那类缺陷的探针,而那是我反复犯的一类。

---

## 2026-08-27 —— 逐行读 diff 会漏掉「上线第一天会发生什么」

前八轮都是读 diff 找缺陷,第九轮换成问一个不同的问题:**这个功能上线第一天在生产上会发生什么?** 抓到的东西前八轮一次都没碰到,而且是这个功能的核心承诺。

**一、单向收敛的闭环不是闭环。** 撤单(前提被推翻 → 撤回)写了,开单(状态成立 → 补开)没写。判断方法:一个会「因为状态 X 而产生待办」的功能,必须有一个**周期性的对账**回答「现在处于状态 X 但没有待办的,有哪些」—— 只有事件钩子是不够的,因为钩子只覆盖「在我上线之后、且恰好走了我这条代码路径」的那一部分。这与「[[reference-recording-who-should-act-is-not-routing]]」同源:一次性写入 + 没有收敛器 = 历史数据整批隐形。**红旗**:代码注释里出现「nothing re-scans …」而它被当作论据而不是待办 —— 我自己就写了这一句。

**二、补完一个洞要同时问「补完之后第一次跑会发生什么」。** 对账器一上线就会把积压一次性变成一批推送。限速三要素缺一不可:限流、**排序**(不排序则队尾永远饿死,而摘要每次都报「发了 5 条,健康」)、**上报被压下的条数**(静默截断和全覆盖长得一模一样)。注意限速的位置:**记录全部,按有界速率讲话** —— 待办是持久事实,卡片才是稀缺资源。

**三、变异验证本身会写错,而写错的样子和「测试很好」完全一样。** 这一轮两次:
- 排序测试按最终顺序插入数据 ⇒ 删掉排序仍然绿。**造数据的顺序必须被期望结果置换**,否则断言的是数据库的返回顺序。同族:[[reference-fixture-makes-guarded-path-unreachable]]。
- 配置校验的变异写成 `int(cfg.get(k) or D)`,而 `0 or D` 就是 `D` —— **变异与原实现等价**。写完变异要先问「这个变异真的表达了我担心的那个行为吗」,再看测试红不红。

---

## 赏金域:三根轴收敛成一条生命周期,派生而非落库(2026-08-27)

**决定**:用户拍板「这个应该不是这么多状态 这么复杂的吧」。`status` /
`remediation_status` / `owner_decision` 三处对外收敛成**一条**生命周期 `stage`
(8 个值),`payment_status` **保持独立**。

**先量再改**:149 条报告只占 **14 种**状态组合,`owner_decision` **全表 NULL**
—— 后端 26 处引用、从来没有被写过。复杂度是 schema 的,不是工作的。

**派生,不落库**。可由输入算出的状态再存一份就是第二个真值来源,而两者并存的
窗口正好是它们开始不一致的时候。只有一个函数 `constants.lifecycle_stage()`,
序列化器 / 跨域接缝 / 控制台 / 负责人页 / 主管抽屉全读它。**没有迁移、没有回填、
149 条活数据上没有双写窗口。**

**顺序即规范**,两条被真实数据逼出来:
1. **已关闭优先于修复轴** —— closed 且 `remediation=fixed` 的报告若报成「已修复」,
   归档行会重回在办集合。
2. **进行中的修复优先于打架的状态列** —— 生产有一行 `in_discussion` +
   `in_progress`(有人在正式确认前就开修了),先读状态列会在正在修的洞上显示
   「负责人有异议」。

**赏金为什么不折进来**:它是另一个问题、另一个负责人、另一条审批路径,而且只在
有金额时才存在。把它折进生命周期正是控制台显示三个标签的成因。

**控制台的三个轴控件不动**:安全台是唯一有正当理由手工搬轴的地方(`wont_fix` /
`risk_accepted` 是风险决定,不是流程阶段),收掉就是砍能力。它缺的只是那句一行
结论,所以 `stage` 作为**只读**首格加在 StateBar 最前。

**待办(明确不在本次范围)**:热力矩阵仍用自己的六个**工单队列**列
(待回复 / 待定级 / 待指派 / 等业务确认 / 待修复 / 钱)。「下一步谁欠什么」和
「这条走到哪了」是两个问题,对齐它是设计决定而不是改名,所以挂起而不是半做。

**四、一个新功能要放回它所有的消费者里看一遍。** 第九轮加的对账器第一次把 `cleanup_requested_at` 和 `disposed_at` 拆成两个时刻,而催办文案里那句「这台设备已处置 N 天」在此之前**按构造为真**,之后就成了假话。判断方法:新代码让一个原本恒等的两个量分开了吗?如果是,把所有读其中一个的地方都找出来 —— 它们此前不需要区分,所以一定都读了手边那一个。

**五、产品里的说明也是代码的一部分。** `scheduled_jobs.description` 是管理员在控制台里唯一能读到的说明。第九轮给同步任务加了「会开待办」这个副作用,描述没改,于是它在产品里读起来仍是一个只读同步。**一个功能在产品里说不出自己会做什么,等于没做**。同族:[[feedback-config-links-must-point-at-owner]]、共享落地页那条。


## 出库 / 退库一律要求位置:界面必填,schema 保持 optional(2026-08-27)

**决定**:用户拍板「所有出库、退库操作的时候,把这个位置信息改成必填项」。**所有**
出入库界面(单台派发 / 借出、批量派发 / 批量借出、「变更持有」路由到 claim 与
return 的两条支线、移动端管理员退库,以及「资产入库」中选了领用人的那一支 ——
一个 Submit 背后是 intake + 追加一次 claim)都加 `required` 规则并去掉
`allowClear`;纯入库(没选领用人)位置仍是选填,资产是落进库存而不是出库;
退库自 2026-05-29 起已是必填,这次是把同一条规则铺满。

**Why**:位置是资产域权限的划分单位 —— `asset_location_admins` 挂在受管位置上,
每个位置的资产管理员即该区域的 `asset_admin`(见 2026-05-30「Managed lookup
columns」ADR)。一次没填位置的派发,往台账里留下的是一台没人负责、也不知道在哪个
办公室的设备,而下游的盘点路由、离职回收收件人、区域写权限全部按它算。

**请求 schema 不改成 required**,`location: str | None` 保留,只加一个拒绝纯空白的
校验(`_reject_blank_location`,`None` 放行、真值 strip)。理由是有真实的系统调用方
没有操作人可问:飞书审批的自动借出 / 自动退库、离职 T+0 强制退库、转移单确认时的
自动 claim、MCP 的 `claim_asset` / `borrow_asset` / `return_asset`。把 schema 收成
required 会让这些流在生产上 422 —— 而每个界面照常工作,是最难查的一类故障。
**代价照实说**:这条规则因此是界面级的,绕过界面的手工 POST 仍可不带位置。这与
`AssetReturnRequest` 2026-05-29 定下的形状一致,不是这次新开的口子。

**「变更持有」不一刀切**:它按 (状态 × 目标) 路由到六个端点,只有 claim / return
的请求体带 `location`。在 changeAssignee / recycleToOffice / releaseFromOffice /
markHeldByLeaver 上索要一个随后被丢弃的值,比不问更糟。判定与提交都从
`changeHolderTransitions.routedOperation()` 这**同一张表**派生,而不是两处各写一遍
if/else —— 否则「要不要问位置」和「问了往哪发」迟早各说各话。

**明确不在本次范围**:① MCP 的三个资产工具没有加 `location` 参数(与
`return_asset` 现状一致,AI 客户端不是「出库界面」);② `变更领用人`(changeAssignee)
的位置仍是选填 —— 它是持有人互换,设备并没有出入库;③ 借用归还
(`borrow_return`)的请求体本来就没有 `location` 字段,要加是一次 schema + service
改动,不搭在这次里。

## 助手动作的结果定位:声明,不是启发式(2026-08-30)

**决定**:每个助手可提案的动作,如果会创建业务对象,必须在
`app/assistant/outcome_contract.py` 里**显式声明**该对象在自己返回值中的位置
(`resource_type` + 精确的 `id_key` + 解析器 + deep link 模板)。禁止两件事:

1. **禁止从任意 JSON 猜 id** —— 没有「取第一个像 id 的键」这类回退。
2. **禁止让模型解析执行结果** —— 模型不参与决定 id、URL、审批人、审批节点,
   也不参与判定最终是否成功。

未声明的动作**不链接**,并把原因存进 `AssistantActionProposal.link_miss_reason`;
它降级成「已提交,未关联」,**永远不降级成失败**。

**为什么不能用启发式**。平台自己的写工具对同一个概念返回五种键名:`id` /
`allocation_id` / `request_id` / `request_group_id` / 序列化器产出。
`request_software_seat` 的返回 dict 里 `allocation_id` 和 `approval_request_id`
**并排躺着**,启发式没有任何依据在两者之间选。而 `request_cloud_grant_renewal`
返回的 `request_group_id` **根本不是一行** —— 它覆盖 `grant_count` 条授权,
「生效了没有」在那里是另一个问题;把它当成单行处理,会在第一条成功时就宣布整批完成。

一个指错的指针不是「少了个功能」,它会**把别人的状态当成你的报给员工**。

**推论:守门必须走 `get_action()` 的同一条解析路径。** 策展动作
(`app/assistant/actions.py`)会**遮蔽同名的 MCP 工具,而返回形状不同** ——
`create_support_ticket` 策展返回 `ticket_id`、工具返回 `id`;
`submit_vpn_application` 是 `application_id` vs `id`;`get_action` 优先策展。
只对着 `tools.py` 核过的契约在运行时是错的,而且**静默**:locator miss 按设计不
报错。这一条是被自己抓出来的 —— 第一版契约就是这样写错的,而写错的正是生产
最高频的那个动作。

**推论:链接失败必须吞掉。** 链接发生在领域**已经提交之后**。把真实的成功报成
失败,既误导员工,又因为失败路径重置为 `pending` 而把动作再提供一次、开出第二张
单。所以链接写在嵌套 savepoint 里(failed flush 会毒化整个事务,而那个事务载着
执行本身的状态更新),所有异常吞成一个**存下来的原因**。

**来源**:用户转达的需求(2026-08-29),原文「不能从任意 JSON 猜 ID,也不能靠
模型解析结果」。

## 助手需求闭环:四条约束,以及它们各自防的那件事(2026-08-30)

四个 PR(#3331–#3334)把助手从「能提交」做到「能答现在到哪一步、最后成没成」。约束记在这里,因为每一条都有一个很自然、很像对的做法被它挡住了。

**① 业务对象位置必须声明,不能从 JSON 猜。**
挡的是:「取第一个像 id 的键」。平台自己的写工具对同一概念返回**五种键名**,`request_software_seat` 的返回里 `allocation_id` 和 `approval_request_id` 并排躺着,而 `request_cloud_grant_renewal` 的 `request_group_id` **根本不是一行**。一个指错的指针会**把别人的状态当成你的报给员工**。
推论:守门必须走 `get_action()` 同一条解析路径 —— 策展动作遮蔽同名 MCP 工具**而返回形状不同**,只对着 `tools.py` 核过的契约在运行时是错的,而且**静默**。

**② 状态每次回原领域读,权限每次重新推导。**
挡的是:把 `link.phase` 当答案、把 `link.person_id` 当授权。缓存只服务漏斗;授权必须从领域行自己的归属列推导,而那个列有**六个不同的名字**。具体后果:资产易主后,前主人的旧链接必须**立刻**读不到。
每个域**自己声明**状态映射:生产有四个值落在「approved/rejected/cancelled/pending」四格之外(`stale_requires_resubmit` 是带修法的失败、`lapsed` 是到期不是取消、`pending_owner`/`pending_feishu` 等的不是同一个人)。未映射一律 `unknown`,不落默认桶。

**③ 「有用」是对答案的评价,不是对那件事成没成的判决。**
挡的是:点了就关单。那让头号「自助解决」数字度量的是**礼貌** —— 申请刚被拒的人,为一句清楚的解释点了「有用」,就被记成已解决。
`system_verified`(查过)与 `self_solved`(他说的)**永远分开报、从不相加**;`needs_attention` **派生不落库**(status 有 21 处消费者,而真正需要的只是「不被算成已解决」)。

**④ 「复验」不能有对外副作用。**
挡的是:用 `run_turn` 重放原问题。那条路径的提案分发**是真会发卡的** —— 重问一个三周前的问题,会给一位从没要过的同事推确认卡;而且它写 transcript,会污染刚做诚实的那些计数。用 `simulate_employee_turn`(dry-run 孪生)。
存**形状**不存回复正文:正文是那位员工自己的数据,存进一个别人会读的队列 = 把三方工单台变成全公司个人信息的第二份副本。

**贯穿四条的一句**:凡是「两个东西必须保持一致」的地方,都要有一道**会变红的守门**,而不是一条注释。这一轮新增的守门(locator 走 `get_action`、解析器声明的列必须在模型上、提示词点名的工具必须真的注册过、deep_link 模板必须能格式化)**每一条都在写的时候就抓到过真缺陷**。

**来源**:用户转达的需求(2026-08-29)+ 实现中的生产取证。


---

## 2026-08-30 —— 「一直催到你按按钮」保留,但改成递减;阶梯只能拉长间隔

**背景。** 2026-08-24 有一条明确决定:云订阅 Owner 确认的未回应提示,**每天**给同一个人发同一张卡,直到他按按钮为止;并且明确否掉了「14 天后转交 IT」的老做法,理由是把九十来个别人的待办丢给 5 个管理员「产生了一个追单队列,但没有推动任何一个决定」。

**这条决定的判断是对的,本次不回退。** 但它当时比较的是两个选项 —— 「每天催」对「转交别人」—— 而没有比较第三个:**继续催,但是越来越慢。**

**新的证据。** 最近 30 天,发给在职员工的推送里人均最高的两个:资产盘点提醒 **人均 16.7 条**、云 Owner 确认 **人均 7.6 条**。一个已经无视了 20 张同样卡片的人不会被第 21 张说动;那 21 张唯一确定做到的事,是训练他把这个来源整个过滤掉 —— 连同将来某一张真正要紧的卡。

**决定。** 引入统一的催办阶梯(`app/notifications/cadence.py`):1/1/1/2/3/5 天,之后每周一次,**永不停止**。
* 2026-08-24 的要求原样成立:开着的事一直开着,没有任何人被放弃、没有任何事被转交。
* 头几天保持每天 —— 绝大多数会回应的人就在那几天回应。省下来的量全部来自长尾:第一个月 30 → 8 条,之后每月约 4 条。

**随之固化的一条平台规则:阶梯只能拉长间隔,永远不能收紧一个已经存在的下限。**
接入云那条时被既有测试当场抓到:该模块自己有一道 20 小时下限(**故意小于 24**,好让固定时刻的 cron 不跳过一天),而阶梯又叠了一道「24 小时减 2 小时」,两者在 21 小时那一格不一致 —— 一张本该发的卡被丢掉,**一个为降噪做的改动静默地变成了一个改投递的改动**。因此调用方若已有自己的日闸,一律走 `taper_holds`(还在每日档时完全让位),而不是 `should_send`。

**配套的可证伪面。** 通知发件箱新增「员工触达」页签:按模板报**人均**而非总量,并按人均排序 —— 500 条发给 500 人是正常的,500 条发给 30 人是有人在被反复打扰,总量分不出这两者,而这正是人均 16.7 的提醒能长期藏在一个普通总量下面的原因。

**来源**:用户要求(2026-08-30)「彻底解决离职账号问题,又不给员工发过多通知」+ 生产 `notification_outbox` 取数。


---

## 2026-08-30 —— GitHub ID 由员工自己改:审批人是数据不是角色;而 HRIS 回写是「尽力而为 + 一道防回滚闸」

**背景。** `identity_persons.github_id` 只在入职的「其他声明」表单里采集一次(SF `cust_Other.cust_id`),之后**没有任何更新入口**。生产 417 名在职员工里 154 人没有 handle,而 github_org 域三处靠它认人(自动加入、对账的「认不出是谁」、离职回收)。更难看的是 `github_org_missing_handle` 那条推送在对员工说「申请一次就顺便登记上了」—— 而 `GithubOrgJoinRequest` 刻意不回写人员主数据,理由正是「下一次 HRIS 同步会盖掉」。平台知道那条路不通,还在这么说。

**决定 ①:审批人是 HR Ops(缺失回退 HRBP),站内审批,不走飞书审批实例。**
飞书审批要一个**固定**的审批人分派,而这里的审批人逐人不同(`hris_hrops_person_id`)—— 与邮件组 owner 同构:一个随人而变的责任,不是一个角色。两者皆无的那 4 个人(生产实测)不判成提交失败,而是 `approver_person_id` 留空、由 HR / global_admin 的兜底队列接住;把它判成失败等于让这 4 个人永远改不了自己的 handle。用户选定 HR Ops 为主、HRBP 兜底(顺带印证:那条飞书表单里的必填自查项写的就是「If not, please contact your HRBP」)。

**决定 ②:写入顺序 SF → Helix → 飞书;SF 失败不回滚,但必须留下痕迹 + 一道闸。**
SF 是这个字段的上游,HR 同步对它是 write-when-present。写反顺序会留一个「同步把员工的改动 revert 掉」的窗口。SF 拒了或连接器没配时**变更仍然落地** —— 员工已经被确认过了,而飞书是他真正看得见的那一面 —— 失败记 `hris_write_status='failed'` 等人手工改 SF。

**这条决定真正的代价是 `identity_persons.github_id_locked_at`,它不是可选项。** 没有它,「Helix 改了、SF 还没落地」这个中间态会让下一次同步把员工刚改的值**静默改回去**:没有通知、没有报错,员工只会某天发现自己改的没了。闸**在成功路径上也落** —— SF 接受了 upsert ≠ SF 已经把新值吐回来,中间那次同步不能拿一次旧读把它冲掉;SF 报回同样的值时闸自动清掉,权威交还 HRIS。**「SF 收敛了」是观察到的,不是假设的。** 闸放在人员行上(而不是从申请单查),是为了让 hr 域不必 import github_org 域,并且不给全量同步的每个人加一次查询。

**决定 ③:`cust_Other` 的 upsert 形状做成配置,不做成代码。**
它是自定义 MDF 对象,三件事我们答不了:是否 effective-dated(是则必须带 `effectiveStartDate`)、API 用户能否为「还没有这一行」的人新建、对象上还有没有别的必填字段。三个答案都在 HR 的 SF 管理员那里,而每个答案都是一次 payload 变化 —— 编码成 `platform_settings.hris.github_writeback` 的键,意味着答案回来是**改一次配置**,而不是改代码再发一次版。**并且这个开关默认关**:与默认开的邮箱回写不同,那条的 payload 是 HR 原样给的、目标是标准 EC 实体;这条的失败模式不对称 —— 对 effective-dated 的 MDF 对象,缺 `effectiveStartDate` 的 upsert 不一定被拒,而可能建出一条起始日期错误的时间片,也就是往人事系统的权威记录里静默写错一行。**「拿到写权限」因此不足以开始写**,必须有人读过 HR 的答复再显式打开。写权限本身也尚未开通,所以上线初期的预期路径是 `skipped`,不是异常。

**决定 ④:handle 撞车直接拒,不是只警告。**
`resolve_person_by_login` 和 `reconcile` 都按 `lower(trim(github_id))` 反查人,而它们的返回形状只容得下一个人;重复 handle 会让台账把成员资格接到错的人身上,离职时摘错账号。**不建唯一索引** —— 存量是否已有重复未知,一条建不上的唯一索引会让迁移在生产上直接失败;拦截放在提交与审批两处入口(提交到审批可能隔几天,所以审批时在写 SF 之前重查一次),存量重复留给对账去暴露。占用判定只看在职的人:一个离职三年的人不该永久占着一个 handle。

**决定 ⑤:GitHub 侧的存在性校验是三态、只提示、不阻断。**
`true` 存在 / `false` GitHub 明确 404 / `null` 我们没问到(没配凭证、限流、网络抖)。**三态不能塌成两态** —— 把 `null` 画成「查无此人」,一次 GitHub 抖动就换来一次对拼写正确的 handle 的冤枉驳回。提交时求证一次并存下来(审批面是列表,逐行现问 = 一次翻页 N 次调用 + 撞限流)。

**来源**:用户需求(2026-08-30)+ 提问时逐条拍板(HR Ops→HRBP 兜底、撞车直接拒、飞书 DM + 站内信)+ 生产 TiDB 只读取数(413/417 有 HR Ops、412 有 HRBP、263 有 handle;`feishu-contact` 目标 enabled 且映射里确有 `{github_id}` → `custom_attrs.github_id`)。

---

## 2026-08-31 —— 港澳台的数据驻留地跟随中国口径;以及「缺权限」是 blocked 不是 failed

**背景。** 一个新加坡新人(P10025)入职当天没有飞书账号。根因是 #3084(2026-08-19)把建号从 `POST /contact/v3/users`(scope `contact:user:write`,早有)换成 `POST /directory/v1/employees`(scope `directory:employee:write`,**从没开通**),而**没有任何机制会在真正需要写之前验一次权限**。接下来 12 天恰好没有新人需要建号,于是建号面死了 12 天、每天的同步却全绿(更新走另一个 scope,一直好使),最后是被真人踩出来的。

**决定 ①:`residence_country ∈ {CN, HK, MO, TW}` → 驻留地 `cn`,其余 → `jp`。**
原规则是裸的 `residence == "CN"`,于是一个香港员工静默落到了日本。SF 自己把工作国家写作 **"Hong Kong, China (HKG)"**,驻留地轴跟随这个口径。**这条决定有不可逆的一面**:`geo_name` 只在建号那一刻可写(Directory 的 PATCH 直接忽略这个字段,返回 `2221250 No fields to be updated`,已实测),改映射**不会移动任何已建的账号** —— 存量得走 `security_and_compliance/v1/user_migrations`。所以口径要一次定对,改一次的代价是一次批量迁移。

**决定 ②:写入面遇到「缺 scope」(99991672)记 `blocked`,不记 `failed`,也不 halt 整个 sweep。**
- **不是 `failed`**:重试治不好一个缺失的权限。事故当天它每小时把 sweep 染红一次共 9 次,而 `failed` 在本仓的语义是「暂时性、会自己好」—— 读者被这个词骗了。`blocked` 才是「人必须去改配置」,它让 run 保持绿、不产生重试 churn,并由 `maybe_notify_blocked_backlog` 一次性 page IT。新人那条路的响度不受影响:入职协调器对 `failed` 和 `blocked` **一视同仁**地发卡片。
- **不 halt sweep**:99991672 仍留在 `_FEISHU_SYSTEMIC_NUMERIC_CODES` 里给**查询面**用(查不了就什么都干不了,halt 一次好过 N 条同样的行),但**写入面必须先用 `is_feishu_permission_failure` 判**。理由是**飞书按 API 面授予 scope**:那 12 天里建号面是死的,而更新面对全体 ~500 人正常工作 —— halt 会把还活着的一半也停掉。

**决定 ③:早发现放在定时任务里,不放在建号路径上。**
一度把权限探针放进 `_create_user`,随后撤掉了:探针放在写入路径上只省下「一次注定失败的 POST」,却给每次 sweep 加一次调用、给每个建号测试加一次线序(实测打红 16 条既有用例)。而**真正缺的能力是「没有人入职时也要能发现」** —— 那只能是一个与入职无关的定时任务。于是新增 `identity_sync.feishu_write_scope_preflight`(每天 07:30),探针是**对一个故意非法的 employee_id 发 PATCH**:改不动任何数据,且判据明确(该端点**先查权限再校验 id**,已实测:`99991672` = 没权限,`99992360` = 已过权限闸)。缺权限时 handler `raise`,复用既有的定时任务失败通知去 page IT,不新建告警通道。

**来源**:用户决策(2026-08-31,港澳台归 `cn`)+ 生产实测(两个 SF 实例、飞书 Directory/迁移接口、生产台账 409 人逐个核 geo)。

---

## 2026-09-03 —— 「谁都读不到」要顺着数据被抄到哪里去查;以及慢路径上的租约就是钱

**背景。** AI 实验室四个 PR 合完后按要求做了一次整功能复审,四个独立视角各读一遍全部新增代码,33 处。修完之后有四条判断值得当决定记下来,因为它们都不是「这里写错了」,而是「下一次遇到同形状的东西该怎么想」。

**决定 ①:一条「没人看得到」的承诺,验证范围是这条数据会被抄到哪里去,而不是本域读接口的权限。**
本域这一侧原本就是对的:没有任何管理端点查得到对话表,管理面查的是一张只有计数列的表。漏掉的是**对象键**——格式里带着员工的文件名,而键会被抄进 `storage_upload_grants` 台账行、抄进 `storage.upload.authorize` 审计行(**全公司可读**,含 CSV 导出),并且**活过员工自己的硬删**。`离职谈话录音.m4a` 是内容,不是元数据。同形状的第二处是 `core_idempotency_records`:缓存的响应体带着提示词,在本域之外躺满 24h TTL,硬删同样碰不到。
**修法选在生成键的那一层**(`storage_uploads.OPAQUE_NAME_PURPOSES`:这三个 purpose 的键不带文件名),而不是给审计读接口加角色过滤——键已经被抄到三个地方了,过滤读者改不了这个事实。展示名改成随请求走、只存在员工自己的消息行上。
**代价是承认的**:这三个前缀下的对象名对人类不可读,运维排查时只能靠键里的 `person_id` 段认人。这是这条承诺的价格,不是疏漏。其余每个 purpose 都照旧保留文件名——管理员查盘点纠纷时要看见 `receipt-2026-03.pdf`。

**决定 ②:凡是「一个长 await」的付费调用,租约必须由一个独立心跳守着;`_finish` 的返回值是唯一可信的落地信号。**
异步任务有轮询循环,顺手就能心跳;**同步生成没有循环可以顺手**——它就是一个 await。租约 180s 一过,行变成可抢,reaper 抢走并**再提交一次**,同一次点击付两遍钱。所以有了 `_LeaseKeeper`(自己的 session,`LEASE_SECONDS/6` 一跳 —— 间隔即安全余量:一次被吞掉的心跳失败只花掉一次续约,所以能吸收几次失败就是余量本身,三次太少),它同时盖住了异步任务成功后下载几十 MB 视频的那一段——那一段虽然在 `while` 循环体内,但夹在两次 `_heartbeat` 之间,进行时没有任何心跳会跑。**心跳连续失败到快要失约时,keeper 停止心跳并大声记一条,但不把租约判为已丢**(2026-09-04 修正,见下方「订正」)。
配套的一半当时写成**别忽略 `_finish` 返回的布尔**(2026-09-04 已推翻,见下方订正③)。「我以为我完成了」和「我的终态真的落地了」确实是两件事 —— 这一句仍然成立 —— 但**拿它当「记不记用量」的判据是错的**:终态写入落没落地和这一次跑有没有花钱无关。
**推论**(2026-09-04 已订正,见下方):成功之后的终态写入失败,当时写成一律`_release_for_retry`,理由是「异步任务重轮询同一个 task id 一分钱不花」—— 而这个理由只对异步那一半成立。

**决定 ③:流式端点的产出必须在 `finally` 里落地,而且 guard 要从第一个 yield 就开始。**
按停止 / 关标签页 / 断网时,生成器在**它当时停在的那个 yield** 上被取消,成功和失败两条路都走不到:已经读到的回答没了、厂商已计费的 token 没记、`seq` 留一个永久空洞(问题在调厂商之前就 commit 了,这是对的——但因此空洞是永久的)。
落地必须用**分离任务**(`spawn`)而不是在 `finally` 里 await:`GeneratorExit` 下一个不能立即完成的 await 会抛,而 `create_task` 压根不 await。
**第一版修复漏了一格,值得记住**:guard 从第二个 yield 才开始,于是「厂商还没回话就关掉」——最常见的断点——照旧什么都不写。是新写的那条「零产出」测试逼出来的,不是读代码看出来的。**教训:断流测试要覆盖每一个 yield 点,而不只是数据流中间那个。**

**决定 ④:UI 说的上限必须是被执行的上限;做不到就把两侧锁在一条契约测试上。**
目录发布了**一个** `attachment_bytes`,而 presign 按 purpose 分别收 10 / 20 / 15 MB。附一张 12 MB 照片:客户端放行、presign 413、员工上一句刚被告知 20 MB 没问题。同类的还有转写页那个**什么都不做**的提示词框(能力继承了 4000 字默认预算,而厂商请求只由音频构成)、以及那个**不下载的「下载」按钮**(没签 disposition,图片在新标签页里渲染;移动端 webview 里那个标签页经常压根不开)。
三个都是同一种缺陷:**一个控件或一个数字,承诺了代码不兑现的事**。修法统一是「让代码兑现,或者让控件消失」——按 purpose 发布上限并加契约测试锁住两侧、`prompt_max_chars=0` 让前端据此不渲染、`download=true` 真的签 `attachment`。**不接受的修法是改文案去描述缺陷。**

**来源**:整功能复审(2026-09-03,四视角 33 处)+ 五处变异验证(lease keeper、`_finish` 布尔、断流 finally、`prompt_extend`、重复渲染:注入缺陷即红,还原即绿)。

---

## 2026-09-04 —— 订正 2026-09-03 决定②的两句;以及「记不记用量」的判据是「这一次跑有没有花钱」

**背景。** 上面那条决定②里有两句话,写下来的时候听着都对,实现出来各带一个真缺陷。是第三、四轮复审逐个抓出来的,记在这里是因为**下一个读 DECISIONS 的人会拿它当已批准的授权去改回来**。

**订正 ①:心跳「报错」不等于租约「丢了」,keeper 不再据此判丢。**
原文说心跳连续失败到快要失约时直接判租约已丢,「让 runner 提前知道」。实现出来是:一次 90 秒的 DB 抖动会让 runner 跳过终态写入、并**删掉已经付过钱的作品**,行留在 `running`,下个 tick 被 reaper 全价重渲。
真正的分辨点是:**「心跳匹配不到行」和「我连不上数据库」是两个不同的判断**,只有前者意味着这活不是我的了。而且**没有任何东西需要靠提前判丢来保护** —— `_finish` 自己就带租约闸(`WHERE lease_owner = token`),租约真的易主时它只是一条空转 UPDATE。所以现在:连续失败到上限就停止心跳并按 `error` 记一条,但**照旧尝试终态写入**。

**订正 ②:「成功之后终态写入失败该怎么办」按 transport 分开答。**
异步任务重轮询已落库的 `vendor_task_id`,确实一分钱不花,`_release_for_retry` 是对的。**同步任务没有 task id**:reaper 会重跑 `_run_sync`,也就是再向厂商付一次钱。所以同步侧改成判失败(`AI_LABS_OUTPUT_UNAVAILABLE`),并顺手清掉已经落桶但再也不会被引用的字节 —— `ai_labs_output/` 没有 grant 台账行,任何 sweep 都扫不到它。

**订正 ③(第五轮):判据是对的,而「在哪里问」错了 —— 于是它换出了一个更大的漏账。**
第四轮把判据从「终态写入有没有落地」换成「这一次跑有没有产生厂商费用」,这一步是对的:前者会让同一个真实事件按「哪个观察者先发现」得出两种结果,而一次比心跳间隔更短的同步渲染(绝大多数图片)恰好总由 `_finish` 发现。
**但它把这个判据实现成了一个在函数入口算出来的布尔量,而记账发生在终态。** 两者不是同一时刻,而且提前离开的路径压根到不了记账那一行:异步 runner 提交(=已付费)后丢掉租约,是从 `payload is None` 那条路返回的;接手的 reaper 看到已有 task id,判定「不是我付的」也不记 —— 于是**每一个换过手的异步生成,包括任何一次重启打断渲染后由 reaper 恢复的(这正是 reaper 注册说明里写的用途),都是员工拿到了片子、而用量表一行都没有**:管理台看不到这笔花费、成本归不到人、视频配额一秒都没扣。第四轮自己新加的那条测试断言 `counters == []`,把这个缺陷钉成了期望。

**现在的写法:钱在哪一刻花掉,就在那一刻记。** `_note_charge` 由**接受这次工作的那个调用**在原地触发 —— 同步是厂商返回结果那一刻,异步是提交成功那一刻(重轮询已有 task 的那一跑不触发)。谁付钱谁记账,而且立刻记,之后再出什么事都不影响这一笔。终态只补「产出」(作品数、媒体秒数、失败),永远不再加第二个 `requests`。
所以 `requests` 的含义是「厂商接受了一次计费工作」,`failures` 是「这个任务最终失败了」;在厂商接受任何工作之前就失败的任务,只记后者、不记前者。

**来源**:第三轮 → 第五轮复审(2026-09-03 → 09-04)。第四轮那条崩溃有 mypy 的类型报告在先(而 mypy 不在任何 CI workflow 里);第五轮那条漏账是把两次 `run_job` 串起来跑出来的 —— **一个只跑单次 `run_job` 的测试永远看不见它**。

---

## 2026-09-05 —— 访问对象闭环:三项拍板,以及「撤销」被拆成两半

**背景。** 五路全生命周期代码审计(资产 / 访问对象 / 云 / 入离职 / AI 用法)之后,用户选定「访问对象从授予到真的没了」为下一块要花 3–5 个 PR 补的最低分的板,资产侧整批交给并行会话。开工前三项拍板:

**决定 ①:新增的员工触达先发 IT 汇总,逐类灰度。** 这一批会新增几类直达员工的卡(API Key 零花费 / 新厂商坐席闲置 / DAX 到期 / 自证 AI 建议)。每一类**功能做齐**(卡片、动作、去重),但由管理端按类开关放开到员工,**默认关**;IT 的汇总不受开关影响。第一处落地:`apikey.idle_alert.enabled_for_employees`,开关在管理台「闲置」tab,翻开关落审计。

**决定 ②:权限目录的审批不走飞书,管理员站内批**(软件负责人 / IT 一键批准)。目录里 `requires_approval=True` 的条目此前是死路(`PERMISSION_REQUIRES_ADMIN_GRANT`,`app/approval/` 里零消费者),而飞书级联选项还在服。PR2 落地。

**决定 ③:数据访问授权(BigQuery)个人授权默认有效期 180 天(platform_settings 可调),服务账号授权不过期但纳入半年度负责人确认**,与云 scope 同节奏。PR2 落地。

**PR1 自己做的结构性决定 —— 「撤销」拆成「决定撤」和「真的没了」两半,后一半是一等公民。** `sso_grants.status='revoked'` 记录决定;`removal_status` 记录目标系统 / IdP 侧是否真的停用、以及登录审计是否同意。四条约束:
1. **通知 ≠ 处理。** 每张卡都对应一条有人拥有的任务行和一个时钟(阶梯);忽略卡片不会让它安静。
2. **凭证是硬要求,管理员也一样。** 一键页渲染必填的 evidence 输入,`complete_manual_task` 对 `sso_grant` 目标拒绝裸提交 —— 要的是留痕,不是权限。
3. **不做推测性回填。** 603 之前的撤销标 `unverified_legacy`,由管理员在台账勾选批量开任务;把它们回填成 `pending` 会宣称一个不存在的任务。
4. **核实是证据不是推断。** 只有 IdP 历史上出现过同名 SAML 登录的应用,「撤销后无登录」才算证据;名字对不上记 `app_name_unmatched`;IdP 账号已不存在算最强证据;缺 Reports 权限记原因不判失败。

**同类决定推广到 API Key**:所有权变更即轮换(原持有人仍知道明文,不轮换等于共享凭证;不可轮换 → 明说 manual_required 并通知,失败永不阻断转移);自证里的 `api_key` 从「只确认不可撤」改成走持有人自己的释放流(主管审批),批准 / 驳回都镜像回自证项 —— **从不把「交给人了」写成 completed**。

**来源**:2026-09-05 会话(用户原话:「不考虑工作量、不留以后补齐、直接生产级;开始后不再问;一个 PR 合并就做下一个;全部做完后整体复审 → 安全 + 逻辑扫描 → 循环到无问题 → 合并并发布」)。

## 2026-09-05(PR2 落地)—— DAX 生命周期与权限站内审批的执行规则

> **状态: 决定 ①②③ 已于本日上一节由用户批准;本节记录落地时**由实现者定、未再问用户**的配套规则**,后续改动前先读。

**规则 1:自动回收开关不能在对应推送开关关着时打开。** `auto_revoke_on_expiry` 依赖 `employee_push_enabled`,`auto_revoke_on_confirmation_lapse` 依赖 `owner_confirmation_push_enabled`,服务端校验(`LifecycleSettings.validate`)。理由:决定①把员工触达做成灰度,而「灰度期间授权照样到期消失」等于让员工在没被提醒过的情况下丢访问 —— 那不是灰度,是事故。推送关着时,过期授权在管理台标「已过期待处理」、日报单列,由 IT 延长或回收。

**规则 2:推送关着时提醒不推进阶梯。** 否则开关一开,T-14 那一格已经「发过」了,持有人只能从 T-7 开始被提醒。代价是开关打开当天会有一批 T-14 / T-7 同时补发 —— 可接受,那正是「开始提醒」的含义。

**规则 3:个人自助续期有上限(默认 2 次),之后必须重新走飞书申请。** 续期是持有人一句话就能完成的动作;无上限就等于一条授权永远不再经过数据集负责人的眼睛。管理员的「延长」不消耗次数、不受上限约束 —— 那是有人在看着做的。

**规则 4:所有生命周期回收都走 `revoke_data_access_binding`(新增 `system_context`),不另写 BigQuery 写路径。** 到期、逾期、离职 T+0、自证放弃、持有人释放、管理员回收 —— 六个入口一个出口(absent effect → apply-absent reconciler)。`system_context=True` 跳过的是**行为人授权**,不是审计:审计写 `actor_type='system'`。

**规则 5:`pending_approval` 是新状态,不借用 `pending`。** 权限域里 `pending` 一直是「已批准、云侧开通中」,provisioning 成功钩子据此翻 `active`;把未决的申请也塞进 `pending` 会让一条 GRANT 任务把没人批过的权限开出去。同理 `expired` 只由到期 sweep 通过 `terminal_status` 落下,人撤的仍是 `revoked`。

**规则 6:软件没登记负责人 / IT 联系人时,权限申请路由给全体 global_admin,并在卡片和列表里写明「已路由给 IT」。** 不做「找不到审批人就拒绝」—— 那是把 2026-07-07 的死路换个说法。

**规则 7:迁移回填的到期日至少给 30 天。** `GREATEST(granted_at + ttl, now + 30d)`,每日 sweep 补齐缺失到期日时同样。一次迁移不能在当天让一批人失去数据集访问。

**规则 8:批准权限时的有效期是审批人的选择(长期 / 90 / 180 / 365 天),默认按策略(默认长期)。** 没有为权限目录强加统一 TTL —— 目录里既有「仓库管理员」也有「某个只读组」,由认识这个软件的人决定;想要统一节奏的管理员在审批策略里设 `default_validity_days`。
