# 云成本口径保密面扫描表（2026-07-29）

## 这份表为什么存在

「折前 / 实付两个口径不得同框、也不得让同一受众跨面相除」这个目标在
2026-07-28~29 的审计里**被漏过两次**：第一轮漏了 `/billing/daily` 与
`/billing/overview`，第二轮把 `/billing/monthly` 翻成折前口径时又**新造**了
两处配对（`/cmdb/analytics`、`/dashboard/insights/cloud` 当时仍是纯实付，
受众恰好相同、粒度恰好相同）。

原因是每轮都在「修被点名的那几个端点」，没人持有一份**全量清单**。这份表
就是那份清单：下一次改动只需核对它，不需要重新推导。

## 不变量（唯一的一条，2026-07-29 二次修正）

> **在 list 数据存在的地方,任何非保密圈用户都不得从任何面拿到实付基数字;
> 在 list 数据不存在的地方,`COALESCE(list, invoice)` 会把实付数字作为
> 「当前可得的最佳研发口径」发给所有人 —— 这是刻意的、已接受的残留。**

这比「不得同框」更好核查:只要非圈用户在有 list 的地方永远只拿到折前口径,
两个面相除得不到折扣率,跨面泄漏被结构性排除,不必逐对枚举。

> ⚠️ **首版把这条写成无条件的「任何面、任何实付数字」——那是结构性错误。**
> `cost_view.daily_engineering_amount()` 就是
> `COALESCE(total_cost_usd_list, total_cost_usd)`,所以某个 scope/日若没有
> list 值,「折前」读面返回的**就是实付列**。对**永久没有 list 列**的云
> (Azure / 腾讯 / UCloud / 金山)这是常态,不是边缘情况。
>
> 为什么保留这个残留而不是改掉:`COALESCE` 存在的理由是"没接入牌价的云,
> 研发面不能空着"。去掉它,那些云在所有研发面上显示为空或 $0——而 $0 会被
> 读成"这个月没花钱",比显示一个口径不纯的真实数字更糟(这正是本周另一条
> 修复的主题)。
>
> **它对折扣率保密的影响有限但非零**:list 与 invoice 相等时相除得 1.0,
> 不泄漏折扣率;但**实付金额本身**也是受限量(见 `cost_view` 模块 docstring),
> 所以这确实是金额层面的披露。结论是:红线保护的是**比值**,金额层面在无
> list 数据处存在已接受的缺口。谈论这条红线时必须带上这个限定,否则下一个
> 审计者会把"卡片已脱敏"误读成"金额已保密"。

* 折前 = `list` / 业务折前成本 —— **默认，人人可读**
* 实付 = `invoice` / net —— `cost_view.can_view_invoice_measure`
  = `finance` / `cloud_admin` / `global_admin`
* ⚠️ `can_view_finance` **包含** `auditor` + `management`，`can_read_cloud`
  也包含它们，`can_view_cloud_resources` 还包含**普通员工**（scope 成员）。
  这三个谓词都**不是**保密圈，是这张表上最容易踩的坑。
* ⚠️ 治理侧还有两个同样宽的门禁，2026-07-29 首轮扫描**整类漏掉**（见表四）：
  `can_access_audit`（= `require_audit_access`）与 `require_admin_read` 都是
  `global_admin` + `auditor` + `management`。**「面」不只是成本读端点** ——
  审计行的 `detail` JSON、通知的 `body_text` 一旦落了实付数字，就是一个面。
* `resolve_measure(None)` → 折前；`'invoice'` 无权限 → 返回 `None`，调用方
  必须 **403**，**绝不静默降级**（把折前当实付做决策比拒绝更危险）。

## 表一：measure 路由的成本面

| 面 | 门禁 | 默认口径 | 粒度 | 同响应伴随字段 | 状态 |
|---|---|---|---|---|---|
| `GET /cmdb/billing/monthly` | `can_read_cloud` | 折前 | 月 × 云 | 折前下**不发** `cost_by_service` | ✅ |
| `GET /cmdb/billing/summary` | `can_read_cloud` | 折前 | 期间 × 账号 | 折前服务榜来自 `cmdb_cost_facts` 折前列 | ✅ |
| `GET /cmdb/billing/daily` | `can_view_cloud_resources`（**含普通员工**） | 折前 | scope × 日 | 折前下 `cost_by_service = {}` | ✅ R3-F3 |
| `GET /cmdb/billing/overview` | `can_read_cloud` | 折前 | 期间 × 云 | 折前下 `top_services` 走 facts、`owner_breakdown` 恒空 | ✅ R3-F4 |
| `GET /cmdb/billing/export.csv` | `can_read_cloud`/`can_view_finance` | 折前 | 日 × 账号 | 无 | ✅ |
| `GET /cmdb/analytics` | `can_read_cloud` | 折前 | 月 × 云 × provider | 无 | ✅ R3-F1（本轮新造，已修） |
| `GET /dashboard/insights/cloud` + xlsx 导出 | `_require_insights_section`（**含 management**） | 折前 | 月 / 云 / 服务 | 导出在工作簿内标注口径 | ✅ R3-F2（本轮新造，已修） |
| `GET /cmdb/scopes/{id}/billing-detail` | owner/member/admin | 折前 | scope × 月 × 服务 | 服务拆分与总额**同源同口径**（都走 facts + 同 measure） | ✅ |
| `GET /cost/breakdown` | `can_view_finance` | 折前 | 期间 × group_by | 无 | ✅ |
| `GET /cost/timeseries` | `can_view_finance` | 折前 | 日/月 × key | 无 | ✅ |
| `GET /cost/methodology` | `can_view_finance` | 折前（对账块整组同口径） | 全局 | 无 | ✅ |
| `GET /api/v1/cmdb/cost/finance/waterfall` | **`can_view_invoice_measure`** | 双口径**并列且标注** | 月 × 云 | `public_total − engineer_savings ≡ savings_list_base`（4 位小数精确） | ✅ 见注 1 |
| `GET /cmdb/scopes/my-subscriptions` | 仅登录（**无角色门禁**）+ 按 `grant.person_id == 调用者` 自我收窄 | 折前 | 我的订阅 × 月 | 无 | ✅ 见注 3 |
| `GET /cmdb/cost/subordinates` | `can_view_finance` + 重查调用者 Person、拒 `left`/`leaver_pending` | 折前 | 下属 × 期间 | 无；**刻意不进 Redis 缓存**（唯一按人收窄的成本面） | ✅ |
| `GET /cmdb/cost/resources` | `can_view_finance` | 折前 | 资源 × 期间 | 无 | ✅ |
| `GET /cmdb/cost/forecast` | `can_view_finance` | 折前 | 期间 | 无 | ✅ |
| `GET /cmdb/cost/anomalies` | `can_view_finance` | 折前 | 资源 × 日 | 无 | ✅ 全窗口口径闸 |
| `GET /cmdb/cost/export.csv` | `can_view_finance` | 折前 | 明细行 | 原币列走同一 COALESCE 回退，不会出现「折前 USD 旁挂实付原币」 | ✅ |
| `GET /cmdb/analytics/my` | 自我收窄 | 折前 | 我的 × 月 | 无 | ✅ |
| `GET /cmdb/scopes/{id}/billing-daily` RG 分支 | 同 scope 主分支 | 折前 | RG × 日 | 总额与拆分**同一 measure 列**导出 | ✅ |
| MCP `get_cmdb_billing_summary` | 同 HTTP 孪生 | 折前 | 同上 | 同上 | ✅ |
| MCP `cost_{breakdown,timeseries,top_resources,forecast,anomalies,subordinates_breakdown}`、`get_cloud_scope_billing_detail` | 各自孪生门禁 | 折前 | 同孪生 | 无 | ✅ 见注 4 |

**注 1（财务瀑布）**：这个面**故意**并列折前与实付——它就是折扣构成分析，是
保密圈的工作台。它的门禁是 **`can_view_invoice_measure`**(finance /
cloud_admin / global_admin)，自 #2065(`e749cfd63`)起如此。
> ⚠️ **这张表 2026-07-29 首版把它写成 `can_view_finance` 并标注「未收紧，留待
> 你定」——那是错的**，代码从来不是那样。修正于同日的安全扫描。教训按原样记
> 在这里：这张表存在的意义就是「下次核对它而不要重新推导」，一处错误描述会
> 直接制造一个假的待决项，比没有这张表更贵。**改这张表时必须回读代码，不能
> 凭记忆或凭上一版表述。**

**注 3（`my-subscriptions`）**：它**没有角色门禁**——任何登录用户都能调。这不是
缺口:它按 `CmdbCloudAccessGrant.person_id == str(user.id)` 自我收窄，只返回调
用者自己有授权的订阅，且 `measure` 走 `resolve_measure`、`None → 403`。列在这里
是因为「无角色门禁」这四个字容易被下一个读者误判成漏洞。

**注 4(MCP 未参数化的成本工具)**:这些工具不接 `measure` 入参，安全性来自
**服务层每个 `measure` 形参都默认 `"list"`**(`cost_service.py` 的 9 处 +
`scope_billing_detail.py` 的 2 处)。即「默认安全」而非「恰好没接错」——但这也
意味着**任何新增的服务层入口若把默认值写成 invoice，就会静默绕过全部门禁**。
新增服务层函数时默认值必须是 list。

## 表一之二：通知面（2026-07-29 安全扫描新增 —— 首版整类漏掉）

⚠️ **首版这张表把「面」读成了「HTTP 端点」,于是整个通知面不在表内。而告警卡的
受众比表上任何端点都宽,并且部分由归属人自选(`cc_person_ids`)。**

| 面 | 受众 | 口径 | 状态 |
|---|---|---|---|
| 云账单告警卡(日/月阈值) | scope owner + billing 关键词 grant 持有人 + owner 自选 `cc_person_ids`;admin-tier 收窄**只对 prod** | 按规则标记;**新建绝对金额规则不打标记 → 实付** | ✅ 已修 |
| 日环比尖峰卡 | 同上 | 缺 list 列即整体退实付(Azure/腾讯/UCloud/金山**永久**如此) | ✅ 已修 |
| 卡上的**月度预算 tile** | 同上 | 独立于比较口径 —— 按 `scope.budget_measure`(NULL ≡ 实付) | ✅ 已修 2026-07-30,见下注 A |
| 月度账单超预算**群 webhook**(`alert_webhook_url`) | **飞书群成员**(代码无从校验成员资格) | `cmdb_billing_monthly.total_cost_usd` —— 该表无 list 列,**恒为实付** | ✅ 已修 2026-07-30,见下注 B |

**已修(2026-07-29)**:卡片改为**按收件人分渲染**。非保密圈收件人在实付基卡片上
只看到无量纲比值 / 周期 / 规则名,绝对金额与 FX 汇率一律不发;保密圈收件人照旧
拿完整卡片。`cc_person_ids` 一并覆盖(脱敏对整个已解析收件人集合生效)。根因也修了:
新建的绝对金额规则现在**显式打口径标记**——非圈作者 → `list`(他唯一读得懂数字
的口径),圈内作者 → 该 scope 声明的 `budget_measure`。

**注 A(预算 tile 是卡上的第二个口径)**:list 规则的卡片对**全体**收件人发全量
(比较金额是折前,人人可读,这是对的),但预算 tile 走 `scope.budget_measure`
—— net 时它是实付绝对值,于是全量卡把它发给了员工归属人、billing grant 持有人
和规则 `cc` 第三方。更要命的是**根因修复会制造这个组合**:非圈作者新建的绝对
规则被标 `list`,而 scope 预算多数是 net(NULL ≡ net)。修法 = 预算 tile 有
**独立的**按收件人门禁(`hide_budget`),与比较口径解耦;圈内读者照旧看到,
`budget_measure='list'` 时人人看到(折前无需保密)。

**注 B(群 webhook 只能走写侧不落)**:告警卡那两族能按收件人分渲染,是因为
收件人是解析出来的 Person 行;群 webhook 的受众是**飞书群成员**,代码无从枚举、
结构上不可能是保密圈的子集,`_invoice_measure_recipient_ids` 在这条通道上没有
着力点。金额来自 `cmdb_billing_monthly.total_cost_usd`(**该表无 list 列** ——
同 `masking.AUDIT_ACTION_COST_BASIS` 里这个 action 被分类为 invoice 的理由),
所以只能照审计行的同一条修法:发**超出百分比**(无量纲,正是「为什么这条告警会
发」要的那个数)+ 对账 deep link,绝对值让收件人去有 measure 门禁的账单页看;
审计行仍存完整金额供圈内取证。回归:`test_billing_webhook_invoice_basis_2026_07_30.py`。

**残留(明示,不要当成已关闭)**:
1. **`spend ≥ threshold` 不等式预言机**是任何阈值告警固有的——收到卡片本身就说明
   花费越过了收件人自己设的那条线。根因修复只挡住**新建**规则,存量未标记规则仍
   按实付评估。
2. **对没有 list 数据的云,脱敏在金额层面收益接近零**(见顶部不变量的限定):
   `spike_measure` 恰恰在 list 缺失时才落到 `net`,而那时折前读面返回的就是同一个
   实付数字。脱敏在那些云上仍有一点价值(卡片不再**标注**这是实付口径),但别把它
   当作金额已保密。

**证据**:`test_billing_alert_invoice_basis_redaction_2026_07_29.py`(11 项)。
> ⚠️ 首版这里引用了 `test_non_prod_scope_recipients_keep_employees` 与
> `test_net_only_rows_still_fire_on_net_spike` 作为"泄漏仍在"的证明——**这两条在
> 同一个 diff 里就被改写成断言相反的性质了**,引用它们等于引用已经过期的证据。
> 教训与注 1 同类:**写进这张表的每一条引用都必须在写的当刻回读,而不是凭报告转述。**

**教训**:「不得从任何**面**拿到实付数字」里的「面」必须包含**推送**,不只是拉取。
凡是把成本数字送出系统的通道(卡片、邮件、Webhook、导出、报表附件)都要在这张
表里,并且要问「这条通道的受众是谁决定的」——`cc_person_ids` 这种由被审计对象
自己决定收件人的字段,是这条红线上最宽的口子。

**2026-07-31 补（#2223）**：阿里云四条 BSS walk（CN/intl × daily/cost_facts）的
「`PretaxAmount == 0` → skip」曾跑在**牌价累计之前**，把被代金券 / RI / 满减
全额覆盖的用量行整行丢掉 —— 那类行实付为 0 而 `PretaxGrossAmount > 0`，被丢掉
的正是**折前**口径的钱。折扣不该让折前口径动，这条是折前列可信度的前提。同款
在 #2175（AWS BQ）与 #2180（principal-tag）已修，这四条是漏网的孪生。

## 表二：只读折前列的面（结构性安全）

只读 `fact_engineering_amount()`（= `COALESCE(折前, 实付)`）、**不含**任何
实付列、**不含**实付基伴随字段。单口径响应导不出比值。

`admin_overview_service` · `efficiency` · `owner_confirmation_service` ·
`savings_service` · `savings_tracker`

## 表三：直读实付列的地方（逐个甄别）

| 位置 | 是否用户可达 | 判定 |
|---|---|---|
| `service.py:2843,2918`（analytics） | 是 | ✅ 在 `if wants_invoice:` 分支内 |
| `insights_router.py:2866` | 是 | ✅ 在 `if wants_invoice:` 分支内 |
| `cost_service.py:1899,3397` | 是 | ✅ 明确标记为 invoice_sum，供瀑布/异常检测按口径取用 |
| `lifecycle_service.py:10625` | 否 | ✅ 只做「零/非零」闲置判定，从不显示 USD；CNY 通胀不能把零变非零，**故意不折算** |
| `handlers.py:2605` | 否 | ✅ 写侧 cron |
| `finance_spend_coordinator.py:314,1096` | 是（**经 reconcile、`/finance/spend/*` 八个直读面、以及同 router 的 8 个 BU / vendor / self 读面**） | ✅ 2026-07-31 两轮收口，见下 |
| `apikey/spend.py:1086,1284,2251,2259` | 是 | ⚠️ 见注 2 |

**`GET /finance/spend/reconcile/internal` + MCP 孪生**：原门禁
`can_view_finance`（含 auditor/management），返回
`cloud.source_usd = SUM(cmdb_cost_facts.amount_usd)`，粒度「公司 × 月」。
对账无法用折前口径重述（账本只能对上实付），所以**只能收窄授权**：
2026-07-29 改为 `can_operate_finance`（`global_admin` + `finance`），它是保密圈
的**严格子集**，且本来就是同域月结操作的门禁。回归测试
`test_reconcile_internal_invoice_circle_2026_07_29.py` 同时钉住端点行为与
**谓词级不变量**（遍历角色断言「放行的每个角色都在圈内」），所以未来给
`can_operate_finance` 加圈外角色会先红。

**2026-07-31 更正 —— 这一格 2026-07-29 判错了（#2121）。** 原判定写「经
reconcile ✅ 已收窄」，只看到 `reconcile_internal` 这**一个**消费者，漏了
`finance_spend_facts` 这个账本还被 `finance/router.py` **直读八次**：
`summary` / `pivot` / `vendors` / `timeseries` / `detail` / `movers` /
`completeness` / `digest`（+ `export.xlsx`，+ 六个 MCP 孪生），门禁全是
`can_view_finance`（含 auditor / management），粒度比 reconcile **更细**
（pivot/detail/export 到 provider × service × 月逐行 `amount_usd`）。表一
通篇也没列 `/finance/spend/*`。

**修法与 reconcile 不同,是刻意的**：reconcile 无法用折前重述,只能收窄授权；
账本可以 —— 圈外读者按**口径路由**,只丢 `cost_type='cloud'` 行,hardware /
software / AI 照常可见（AI 用量计费无折扣,见注 2）,并在 payload 里用
`withheld_cost_types` **显式声明**扣了什么。谓词 `can_view_invoice_measure`
同时从 `domains/cmdb/cost_view` 移到 `core/rbac`（cmdb 保留同名再导出）——
财务账本与 cmdb 成本面必须共用**同一个**圈定义,而域间不允许互相 import。
回归测试 `test_spend_invoice_circle_2026_07_31.py` 逐面钉住,并做过变异验证
（撤掉闸 → 12 例转红）。

**教训（与注 1 同源）**：判「已收窄」时必须问「这张表 / 这个物化结果**还有谁
在读**」,而不只是看被点名的那个消费者。

**2026-07-31 第二次更正 —— 上面这条更正自己也只做了一半（#2216）。** 07-29
判错的是「只看到 reconcile 一个消费者」；07-31 第一版判错的是「只看到
`/finance/spend/*` 这一组路由」。**同一个 `finance/router.py`** 还有 8 个端点
直读同一本账的云行，粒度到 BU / vendor / 月：

| 面 | 原门禁 | 现状 |
|---|---|---|
| `GET /finance/leadership-dashboard`（+ MCP `finance_leadership_dashboard`） | `can_view_finance` | ✅ 接 `withhold_cloud`（内部的 summary / movers / top_vendors / bu_costs / budget 全部下传） |
| `GET /finance/bu-costs`（+ MCP `finance_bu_costs`） | `can_view_finance` | ✅ 云轴从 `axes` 移除（不报 0）+ 声明 |
| `GET /finance/bu-costs/drill` | `can_view_finance` | ✅ 闸在显式 `cost_type` 之后，点名 cloud 也只得空集 |
| `GET /finance/vendors/top` | `can_view_finance` | ✅ |
| `GET /finance/vendor-360`（+ MCP `finance_vendor_360`） | `can_view_finance` | ✅ concentration 的**分子分母同口径**（否则比值漂移量就是被扣的数） |
| `GET /finance/cost-centers/coverage`（+ MCP twin） | `can_view_finance` | ✅ |
| `GET /finance/me/attributed-cost`（+ MCP twin） | **无角色闸** | ✅ 自助 ≠ 入圈：同一人能从 `/cmdb/analytics/my` 拿折前 |
| `GET /finance/me/bu-costs`（+ MCP twin） | **无角色闸** | ✅ 同上；此前任意员工可读整个 BU 的云实付 YTD |
| `GET /finance/budgets` · `/budgets/me`（+ MCP `finance_budget_status` / `finance_my_budgets`） | `can_view_finance` / 无闸 | ✅ `_actuals_for_year` 接闸 —— 含云的 `ytd_actual` 摆在抽掉云轴的 per-BU 明细旁边，一次减法就还原 |

**教训（第三次同源）**：这类修复的覆盖范围必须按**表**（谁读
`finance_spend_facts`）枚举，不能按**路由前缀**枚举。上一轮写下的
「判『已收窄』时必须问这张表还有谁在读」被应用成了「`/spend/*` 里还有谁在
读」。回归测试 `test_spend_invoice_circle_siblings_2026_07_31.py` 用同样的
表驱动写法逐面钉住（含两条只在算术上成立的旁路：concentration 比值与
budget-vs-actual 的减法）。


**注 2（AI / Bedrock 花费）**：`apikey/spend.py` 读 `CmdbCostFact.amount_usd`
（实付列）算按 key 的 AI 花费。**未收窄**，理由：AI 服务（Bedrock / 通义 /
Vertex）走用量计费，折前与实付在这批服务上不构成公司折扣口径差；而且这些面
的粒度是「API key × 月」，与任何折前面都不同粒度，相除得不到折扣率。若将来
AI 服务开始出现合同折扣，这一格必须重新评估。

## 表四：审计 / 通知面（不走 measure 路由，但会**落地**实付数字）

前三张表只覆盖「读成本的端点」。2026-07-29 复查发现这个口子整类没扫：
一条审计行、一封通知正文，只要**写进去**过实付数字，读它的门禁就是一个面，
而这两处的门禁（`can_access_audit` / `require_admin_read`）都比保密圈宽。

| 落地点 | 谁能读到 | 圈外? | 状态 |
|---|---|---|---|
| `audit_logs` 的 `cloud.scope_alert.fired`（日环比尖峰卡的取证行） | `GET /audit/logs`、`/logs/{id}`、`/trace/{id}`、`/export`、MCP `query_audit_logs` = `can_access_audit`；`/audit/resources/history` 的 `cmdb` 前缀再放宽一档（+`cloud_admin`，**在圈内**，非泄漏点） | 是（auditor / management） | ✅ 已修 2026-07-29，见下注 3 |
| `notification_outbox.card_payload`（两张云账单卡的 `card_stats` 金额都只在这里） | **无任何读面返回它**：`_serialize` 不含该字段、`/outbox/export.xlsx` 的列表也不含；只有重试 / 重发在服务端把它回灌进新 envelope，收件人**恒为原收件人**（`row.recipient_person_id`）。inbox 全部按 owner 收窄。 | — | ✅ 结构性安全 |
| `notification_outbox.body_text`（**阈值卡**把两个绝对金额写进了正文） | `GET /notifications/outbox` = `require_admin_read`；前端 [NotificationsOutboxPage.tsx](../../web/src/pages/admin/NotificationsOutboxPage.tsx) 的详情抽屉直接渲染并可一键复制。（导出 xlsx 的列表**不含** `body_text`；日环比尖峰卡的正文**不带**金额，它的数字只在 `card_stats` 里 → 见上一行） | 是（auditor / management） | ✅ 已修 2026-07-30，见下注 4 |
| 卡片本体（收件人自己收到的那张） | 非 prod scope：scope owner + billing 类 grant 持有人 + 规则 cc（**含普通员工**）；prod scope：2026-07-28 ADR 已收窄为 admin-tier（`global_admin` / `cloud_admin`，**都在圈内**） | 是（非 prod 的普通员工 owner） | ✅ 已修 2026-07-30，见下注 4 |

**注 3（审计行的修法 = 写侧不落绝对金额）**：`_audit_cloud_scope_alert_fired`
新增 **必填** `measure` 参数（无默认值，同 `_alert_spike` 的理由）。`list`
口径下金额照旧全留（折前人人可读，没有理由损失取证精度）；`net` 口径下
`threshold_usd` / `current_usd` / `baseline_usd` **一个都不落**，改记
`measure` + `actual_multiple`（= current / baseline，**无量纲比值**）+
`amounts_withheld` / `amounts_withheld_reason` 显式标记。
注意 `threshold_usd` 也必须一起抽掉：在尖峰路径上它是 `baseline × multiplier`
**算出来的**，本身就是花费派生量，不是管理员填的配置值（阈值规则路径上的
`threshold_usd` 才是配置值）。
**为什么是写侧而不是读侧 mask**：① `_enrich_audit_logs` 被 5 个 HTTP 面 + 导出
+ MCP 孪生共用，在那里按键名脱敏要跨全部域猜「哪个 `*_usd` 是保密成本、
哪个是资产原值 / 采购金额」，钝且炸面大；② 审计行不可变、无限期保留，写进去
就等于永久暴露给一张门禁**故意更宽且不打算收窄**的表的每个未来读者；
③ 绝对值并没丢 —— 它在 `cmdb_cloud_billing_daily` / `cmdb_cost_facts` 里，
那两张表有正确的 measure 门禁，而审计行记着 `scope_id` + `period_key` +
`measure`，圈内人照着去查即可。
回归测试 [`test_scope_alert_audit_invoice_measure_2026_07_29.py`](../../server/tests/test_cmdb/test_scope_alert_audit_invoice_measure_2026_07_29.py)
同时钉住**可达性**（auditor / management 确实读得到这行 —— 修法的必要性不能
随注释一起腐烂）、**载荷形状**（net 下全文搜不到金额，覆盖「换个键名继续写」）
和**谓词层不变量**（审计门禁比圈宽，且正确方向是别往里写，而不是收紧审计面）。

**注 4（通知面：2026-07-30 已修 = 下述选项 (a) + (d) 组合）**。
(a) 卡片按**收件人**分渲染（`_invoice_measure_recipient_ids` —
`can_view_invoice_measure` 的通知侧孪生）：实付基卡片对非圈收件人只发
无量纲比值 / 周期 / 规则序号，绝对金额与 FX 汇率不发，`cc_person_ids`
一并覆盖；(d) `GET /notifications/outbox` 读侧对圈外读者（auditor /
management）把 `cmdb_cloud_scope` 行正文里的货币金额片段替换为
`REDACTED_TEXT_AMOUNT`（`app.core.masking.redact_money_in_text`），
覆盖圈内收件人的行与全部**存量**行——(a) 管不到已写入的历史正文。
回归：`test_outbox_cloud_money_masking_2026_07_30.py`。
以下保留当时的严重度分析与选项清单，作为取舍记录：

原始事实（修前）：2026-07-27 那轮只给卡片加了 **口径说明** 文案
（"金额为发票净额（折后）口径"），等于把实付基数字**明示地**发出去，
且代码里当时没有任何按收件人的脱敏。

- **严重度是窄的，不是宽的**。Azure / 腾讯 / 金山 / UCloud 没有 list 列，
  `fact_engineering_amount()` 的 `COALESCE(list, invoice)` 对它们**长期回落
  实付**——这是本文档开头就写明、且「成本口径」说明页对外明示的既定行为，
  即同一受众在 `/cmdb/billing/daily` 上**本来就合法地**看得到同一个数。
  对这些云，抽掉卡片金额是**表演性**的：点进账单页照样能看到。
  真正锐利的只有一种组合：**已接入 list 的云（AWS / GCP / 阿里云）上，
  一条按 net 判定的规则触发** —— 那时同 scope 同期存在折前数，两个数一除
  就是折扣率。这既包括未标记规则（迁移 433 已尽力回填，剩下的评估器会
  打 warning），也包括 list 数据在该窗口不齐、`spike_measure` 回落 net 的
  **常规过渡态**。
- **因此可选的修法不止一个**，且「给卡片脱敏」未必是最优：
  (a) 按收件人是否在圈内渲染两套卡片（正文 + `card_stats` 都要分叉，
  两个卡片家族 × 三语，并需按 person 查角色）；(b) 把绝对金额从**阈值卡正文**
  移走、只留在 `card_stats`（顺手关掉 `/outbox` 那条泄漏，因为读面只发
  `body_text`，而邮件渲染本就会渲染 `card_stats`）；(c) 把「已接入 list 的云
  上不得按 net 触发」升级成硬闸（治根因，但会让过渡期/数据不齐时**不告警**）；
  (d) 只给 `/outbox` 的 `body_text` 加一层圈外 mask（照抄同文件已有的
  `_SECRET_BODY_MASK` 先例），收件人侧不动。
  这几条对**员工能看到什么**的影响各不相同。2026-07-30 选定 (a)+(d)：
  (a) 治收件人侧的增量，(d) 治管理读面 + 存量行；(b) 被 (a) 覆盖且更彻底；
  (c) 未采纳 —— 过渡期不告警的代价大于收益（残留的不等式预言机见表一之二）。

## 加改面时怎么用这张表

1. 新面若返回云成本数字 → 走 `resolve_measure`，默认折前，`None` → 403。
2. 若响应里带任何 JSON 伴随字段（`cost_by_service` / `owner_breakdown`）→
   它们**只有实付版本**，折前口径下必须不发或另算折前版本。
3. 若你的面**必须**发实付（对账、账本、发票核对）→ 门禁用保密圈或其子集，
   **不要**用 `can_view_finance` / `can_read_cloud`。
4. 测试必须让 list ≠ invoice **并在数字上断言**。造数时两个口径给同一个数
   会让「端点读了哪一列」在断言里不可见——这正是 R3-F7 的根因，
   `_add_daily` 现在用 `assert invoice_amount != list_amount` 强制它。
5. **「面」= 任何圈外用户能读到数字的地方，不只是成本端点**。写审计
   `detail`、通知 `body_text` / `card_stats`、导出列、错误消息、日志外发
   —— 都按面对待。判据是「读它的门禁是否比 `can_view_invoice_measure` 宽」，
   而 `can_access_audit` / `require_admin_read` / `can_view_finance` /
   `can_read_cloud` **全都更宽**。
6. **「面」包含推送,不只拉取。** 卡片 / 邮件 / Webhook / 导出 / 报表附件都是面。
   通知面按**收件人**分渲染(`_invoice_measure_recipient_ids`),并且要问
   「这条通道的受众由谁决定」——被审计对象自己能填收件人的字段
   (`cc_person_ids`)是这条红线上最宽的口子。
7. 若某个面**必须**在圈外可读、而它的数字又只有实付版本 → 优先**写侧不落**
   （改记口径 + 无量纲比值，绝对值去有正确门禁的源表查），其次收窄门禁到
   保密圈子集；读侧 mask 是兜底而非首选。已上线的读侧兜底
   (`app.core.masking.redact_invoice_basis_amounts`) **不是**在共用序列化器上
   按 `*_usd` 猜语义（表四注 3 反对的正是那种做法），而是按
   `AUDIT_ACTION_COST_BASIS` 动作分类表放行/脱敏，**未登记动作一律按实付脱敏**
   —— 所以任何往 `audit_logs.detail` 写钱的新动作必须在同一 PR 登记
   （DECISIONS 2026-07-30 ③，性质同 MCP 工具计数 hint 规则）。
8. **新增服务层函数的 `measure` 默认值必须是 `list`。** 未参数化的 MCP 工具全靠
   这个默认值才安全;写成 invoice 会静默绕过全部门禁。
9. 改完把这张表对应行更新,**并回读代码核对你写的门禁名**。空着比写错好,
   写错比不写更贵——首版就把财务瀑布的门禁写错并因此制造了一个假的待决项
   (见注 1)。
