---
title: "出貨升級鏈補齊 — 需求討論稿 (FR-093)"
brand: "Guidant AI · **FR-093** 出貨升級鏈補齊"
eyebrow: "FR-093 · 套件 migration 進出貨線＋能力點與選單自動補 row — 需求討論稿 · 2026-09-13（已拍板，定案見 design.md）"
h1: "讓「套件裡的東西」真的跟著版本出貨到客戶那裡"
lede: "FR-089／FR-091 把 21 支 jedi-* 套件整成標準形狀之後，每支套件都會自己宣告「我需要哪些表（migration）」與「我需要哪些權限開關（能力點）」。**但今天這兩份宣告都只是寫在那裡，出貨線完全不讀**：套件的 SQL 從來沒有被任何環境執行過、新的能力點沒有機制寫進客戶的資料庫、新頁面沒有機制長出選單。結果是——新版套件裝上去，功能存在、資料表可能不存在、管理員點進去 403、選單找不到入口。本案要把這條「套件宣告 → 出貨 bundle → 客戶升級 → 資料庫到位」的鏈補齊。"
chips: [
  {text: "待決策 D1–D8", kind: warn},
  {text: "前作：FR-089／FR-091 套件標準形狀", kind: accent},
  {text: "前作：FR-065 Installer（migrate 模式）", kind: accent},
  {text: "探脈：A 套件 migration ／ B 能力點（2026-09-13 唯讀實查）", kind: plain},
  {text: "活例：CM-1678 enum 修正從未出貨", kind: crit}
]
footer: "FR-093 · 出貨升級鏈補齊 — 需求討論稿 · 2026-09-13 v0（待審）· 前作：FR-089／FR-091（套件標準形狀，21 支 1.0.0 已推 Nexus）／FR-065（Installer 與 migrate 模式）／FR-062（licensed_modules）· 探脈來源：explore-A（套件 migration，唯讀）＋ explore-B（能力點與選單，DEV localhost:5432 唯讀）· 沿革見 LOG 與 git log"
---

| 日期 | 變更 | 依據 |
|------|------|------|
| 2026-09-13 | 討論稿 v0：三個缺口的問題陳述＋現況盤點＋D1–D8 待決策＋三階段拆分草案 | 探脈 A／B 報告（2026-09-13 唯讀實查） |

## 需求背景：三個缺口 {#why nav="背景"}

本案的前提是 FR-089／FR-091 完成後的世界：每支 jedi-* 套件都有標準形狀，其中兩樣東西跟資料庫有關——

- **migration**（資料庫遷移腳本）：套件自己帶一疊 SQL，宣告「我的功能需要這幾張表、這幾個型別」。
- **能力點**（capability，權限開關）：套件在 `CAPABILITIES` 常數裡宣告「我的端點要用這幾個權限名稱守門」。

這兩樣東西**現在都只是宣告，沒有任何一條路把它們送進客戶的資料庫**。以下三段各一個缺口，每段附探脈報告查到的檔案與行號當證據。

### 缺口 ①：套件 migration 從來沒被執行過，而且已經跟真實資料庫漂移

**現況一句話**：11 支套件共 24 支 SQL 躺在 wheel（Python 套件包）裡，從 DEV 到客戶端沒有任何環境跑過它們；同一批表的「真正 DDL」在主專案 `scripts/sql/` 另有一份，兩份已經對不上。

三處斷點（沿著出貨線從頭數）：

| 斷點 | 在哪 | 證據 |
|------|------|------|
| **A. BE image 不帶 migration 檔** | Nuitka 編譯的 BE image 只複製白名單資料目錄，白名單沒有 `*/migrations` | `scripts/build/build_release.sh:661-669` `PKG_DATA_DIRS` 只列 `jedi_auth/translations`、`jedi_evidence_classification/resources` |
| **B. init image 只 COPY 主專案 SQL** | 建庫／升級用的 init image 是 alpine＋psql，沒有 Python，只拷 `scripts/init/` 與 `scripts/sql/`，不掃 site-packages | `docker/init/Dockerfile:33,35`；`scripts/init/migrate.sh:46-47` 只讀 `/opt/guidant/sql/manifest.tsv` |
| **C. manifest 沒有任何一列指向套件檔** | 升級差集的唯一真相是 `scripts/sql/manifest.tsv`（269 行、active 且 envs=* 共 150 支），沒有套件檔 | 探脈 A §1；`migrate.sh:99-106` 差集只算 manifest 內的檔 |

套件那頭的形狀：`iter_migrations()` 回 `[(檔名, SQL)]`（`jedi-asset/jedi_asset/plugin/migrations.py:15-18`），docstring 自己寫「套件只提供、不執行」（`:13`），而 BE 端**零生產呼叫者**。FR-080 review 已記「客戶端實際建表走 02-schema.sql，migrate.sh 完全不碰套件腳本」（`docs/features/FR-080-*/review/2026-09-10-four-merges-review.md:215`）。

**雙份 DDL 已漂移**：主專案 `scripts/sql/` 有至少 41 支其實是「套件表」的 migration（detection／license／integrity／remote-agent／upload-file／participant／bulletin 整串），全已收進 `02-schema.sql`（179 個 CREATE TABLE）。套件裡那份是抽套件時回頭補寫的第二份；`inventory/batch-D.md:836` 記 detection 隨包 migration 缺 7 條 DEV 實存的 FK。

**enum bug 活例（CM-1678）**：`jedi-asset/jedi_asset/migrations/002-asset-rls-grants.sql:120-146` 用 `ALTER TYPE ... RENAME VALUE` 把 `compliance.system_status_enum`、`security_sensitivity_level_enum` 收斂成 OSCAL 小寫（commit `3d9772f`），`001:23,31` 的 CREATE TYPE 同步改。但出貨基線 `02-schema.sql:116-120` 仍是 `'LOW','MODERATE','HIGH'`、`:127-133` 大小寫混雜；主專案 `scripts/sql/` 與 manifest grep CM-1678 零命中，三環境都沒套。**這個修正對客戶端等於不存在**——它只寫在一條沒人走的路上。

::: {.callout .crit}
**🔴 「有 migration」是錯覺**

wheel 確實帶著 SQL（`jedi-asset/pyproject.toml:42-45` 顯式 include，抽查 1.0.1 wheel 都有），但出貨 image 不帶、init image 不讀、manifest 不列。三處任一斷掉都等於沒有，現在是三處全斷。往後任何人在套件裡改表、修 enum，都會像 CM-1678 一樣默默消失。
:::

### 缺口 ②：新套件的能力點沒進客戶資料庫，管理員點進去就是 403

**現況一句話**：權限判定是拿「名稱」比對（`require_capability("system_config.manage")`），但名稱要先存在於 `capabilities` 表、又要被綁到角色，管理員才拿得到；今天沒有任何自動機制做這兩步。

證據：

- 判權限的實體在 `jedi-iam/jedi_iam/authz/capability.py:134-160`，比對取 name 集合交集（`:120-127`）；底層 `user_role_repo_impl.py:81-87` 靠 `Capability.name` join `RoleCapability.capability_id`。**key 是 name、join 靠 id**——所以補 row 時 id 隨機無妨，只要 `role_capabilities` 用 name 反查 id。
- BE 啟動只 mount 插件不寫 DB（`core/app_factory.py:294-301`）；`Plugin.capabilities` 只是宣告引用（`_host.py:103`）；`migrate.sh` grep capabilit|seed 零命中；`init.sh:124-126` 依序跑 04 核心字典，05／06 留空。
- **唯一自動化只在建租戶那一刻**：`jedi-iam/.../tenant_provisioning_service.py:103-150` `_add_tenant_with_defaults` 取 capability 全集 → 三道扣除（不販售的 resource_type／`is_platform`／未授權模組，`:127-133`）→ 建 `System Manager` 帶 capability_ids。之後新增的能力點**不回補既有租戶**——DEV 印證 tenant 級角色 43/44＝85、49＝86、55/56/57＝98 筆，都少於全集 134。
- root Administrator（role 2）是「持有全部」而非「繞過」：seed 給它全部 127 筆；`roles.is_admin=1` **不繞過** capability 守門（`authz/admin.py` 檔頭），唯一繞過是帳號層 `users.is_super_admin`（break-glass）。

所以升級後：新能力點 row 不存在 → 判定失敗 → 403，**且沒有錯誤訊息告訴你是缺 row**。

### 缺口 ③：新頁面沒補 `ui_routes` 就沒選單，補了卻漏綁權限就全員可見

**現況一句話**：選單來自 `ui_routes` 表，頁面能不能看由 `route_capabilities` 決定；一張新頁面要同時補這兩處，而套件契約沒有這一段，`fail-open`（沒綁權限＝放行）讓漏綁變成靜默的越權。

證據：

- 選單邏輯 `jedi-iam/.../ui_route_repo_impl.py:70-100`：撈 user 持有的 capability_id 集合，ANY 持任一即可見、ALL 需全部；**沒有任何 route_capabilities 的 route 視為可見（`:77`）**。
- `route_capabilities.capability_id` 無 FK、`role_capabilities.capability_id` 也無 FK——指錯 id 資料庫不會擋（目前孤兒 0，但這是運氣不是機制）。
- seed 的 `ui_routes` 寫死 id，`pid`（分組節點）指 42~47 的 `group-*`。

現成樣板已經存在：`scripts/sql/2026-08-29-cm1423-security-policy-page.sql` 不寫 id、`WHERE NOT EXISTS (name=)`、`route_capabilities` 用 `WITH r AS (SELECT id FROM ui_routes WHERE name=)` 反查——只差沒有變成規則。

## 分工概述（30 秒版） {#overview nav="30 秒概述"}

**整案一句話**：讓套件宣告的「需要哪些表」與「需要哪些權限、哪些選單」在客戶升級時**自動到位**——出貨線讀套件 migration、升級時補能力點與角色授權、每支新頁面必綁權限。

| 階段 | 做什麼 | 產出 | 完成怎麼判定（決策者檢查法） |
|------|--------|------|------------------------------|
| **階段 1 出貨線接套件 migration** | build 期把 site-packages 裡的套件 SQL 攤平進出貨 SQL 目錄並自動產子清單；init image 帶著它；`migrate.sh` 算差集時把套件檔一起算；stamp gate 守住「出貨包的 SQL 清單跟 wheel 一致」；DEV／STG／POC／已出貨客戶庫做一次性補登 | build 腳本＋init image＋migrate.sh 改動、既有庫補登 SQL | 在乾淨機器裝新版，`SELECT filename FROM schema_migrations WHERE filename LIKE 'packages/%'` 看得到 24 支；在 DEV 補登後重跑 `migrate.sh` 顯示「待套 0 支」；**CM-1678 的 enum 修正**在 DEV `\dT+ compliance.system_status_enum` 看到小寫值 |
| **階段 2 能力點自動到位** | 出貨 seed 四段改成用名稱反查（不再寫死 id）；升級時把 21 支套件＋主專案宣告的能力點 upsert 進 `capabilities`；root Administrator 自動補齊全部、各租戶管理員依基準角色回補（扣掉平台級與未授權模組） | name-based seed、宣告對照 JSON／SQL、migrate 模式新步驟 | 升級 DEV 後用**租戶管理員**（非 root）登入，點新套件的頁面不再 403；`SELECT count(*) FROM role_capabilities WHERE role_id=2` 等於 `capabilities` 總數；隨便刪一筆 capabilities 再跑升級，row 回來且 id 不同也不影響登入 |
| **階段 3 選單守衛＋套件 SQL 冪等核對** | 每支套件的 migration 必含「補 ui_routes＋綁 route_capabilities」；加守衛「有 ui_routes 的套件頁面必有 route_capabilities」；逐支核對 24 支套件 SQL 與 DEV 實況的差（已知 detection 差 7 FK）修到可重複執行 | 守衛測試、24 支套件 SQL 修正（併 jedi 1.1.0） | 故意寫一支只補 ui_routes 不綁權限的 SQL，守衛測試變紅；把 24 支套件 SQL 對著 DEV **連跑兩次**不報錯（冪等） |

**依賴一句**：階段 1 是地基（沒有它階段 2／3 的 SQL 出不了貨）；階段 2 不動套件源碼可與階段 3 平行；階段 3 的套件 SQL 修正要動 jedi-*，併 1.1.0 發版。**決策者功課**：本稿 D1–D8 逐項拍板，其中 **D3（往後套件表只在套件改）是長期規則**，拍了以後所有人開卡都照它走。

## 現況：三處斷點示意 {#breakpoints nav="現況斷點"}

```{.mermaid cap="圖 1 — 套件 migration 現況：三處斷點，任一斷掉都等於沒有"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart LR
  subgraph PKG["套件 wheel（Nexus 1.0.1）"]
    M["jedi_xxx/migrations/NNN-*.sql<br/>11 支套件 · 24 支 SQL"]
    C["CAPABILITIES 宣告<br/>21 支 · 80 筆"]
  end
  subgraph BUILD["build_release.sh"]
    PD["PKG_DATA_DIRS 白名單<br/>只有 translations／resources"]
  end
  subgraph INIT["init image（alpine＋psql）"]
    DF["Dockerfile COPY<br/>scripts/init／scripts/sql"]
    MF["manifest.tsv<br/>150 支主專案檔"]
    MS["migrate.sh 差集"]
  end
  subgraph DB["客戶 DB"]
    SM["schema_migrations"]
    CAP["capabilities／role_capabilities"]
  end
  M -. "斷點 A：不在白名單" .-x PD
  M -. "斷點 B：不掃 site-packages" .-x DF
  M -. "斷點 C：manifest 無此列" .-x MF
  MF --> MS --> SM
  C -. "無任何寫入路徑（啟動只 mount）" .-x CAP
  SQL41["scripts/sql/ 41 支「其實是套件表」<br/>＋02-schema.sql 179 張表"] --> MF
  SQL41 -. "雙份 DDL 已漂移<br/>（detection 缺 7 FK、enum 大小寫）" .- M
```

## 現況盤點表 {#inventory nav="現況盤點"}

| 元件 | 現況 | 本案動作 |
|------|------|----------|
| `scripts/sql/manifest.tsv` | 四欄 TSV，行序即套用順序；只列主專案檔，無套件列（探脈 A §1） | 不改格式；主 manifest 之後追加「套件子清單」段（D1／D2） |
| `scripts/init/migrate.sh` | 讀單一 manifest 算差集（`:99-106`），`grep '\.sql$'` 過濾（`:86`），`__init_baseline_*` 守門（`:92-95`） | 加第二輪：主線套完後依套件子清單套 `packages/<pkg>/`；補登用相對路徑檔名（D2） |
| `public.schema_migrations` | `filename text PK / applied_at / note`，無 version／checksum／source（`02-schema.sql:16056-16061`） | **不動表結構**；用 filename 內含相對路徑當命名空間（D2） |
| `scripts/build/build_release.sh` `copy_pkg_data_dirs()` | 依 `PKG_DATA_DIRS` 從 site-packages 複製套件資料目錄（`:671-683`） | 加 `*/migrations` 攤平到 `/opt/guidant/sql/packages/<pkg>/` 並產子清單（D1） |
| `docker/init/Dockerfile` | `COPY scripts/sql/ /opt/guidant/sql/`（`:35`），無 Python | 改為 COPY build 期已攤平的目錄（含 packages/）；不加 Python 層（D1） |
| `scripts/build/build_init_image.sh` stamp gate | 99-stamp.sql 補登清單須與 manifest active/* 逐字一致（`:70-83`） | 擴到套件子清單：wheel 內檔案集合 vs 攤平集合 vs stamp 清單三者一致（D1） |
| `scripts/init/gen_stamp_sql.sh` | 把 manifest active/* 補登、標 `converged into __init_baseline_v<版>__`（`:66-70`） | 補登清單同步納入套件檔（階段 1 既有庫補登也用同手法，D3） |
| `scripts/build/assert_db_current.sh` | 前置斷言只比主 manifest（`:68,174-199`） | 同步比套件子清單 |
| 套件 `migrations/*.sql`（24 支） | 三位數序號檔名、無版號、無 manifest；已知 detection 缺 7 FK、asset 002 有 `RENAME VALUE` | 階段 3 逐支核對冪等；往後套件表只在這裡改（D3） |
| 主專案 `scripts/sql/` 41 支套件表 migration | 已收進 `02-schema.sql`；歷史真相 | **保留不動**；往後不再新增套件表 migration（D3） |
| `scripts/init/04-seed-core.sql` capabilities／role_capabilities／route_capabilities／ui_routes 四段 | pg_dump 產、id 寫死、`ON CONFLICT DO NOTHING` 無 target（`L216`／`L476` 起，探脈 B §2） | 改成 `INSERT...SELECT` name 反查，仿 labels 特案後處理 `gen_seed_sql.sh`（D4） |
| `scripts/init/gen_seed_sql.sh` | `--inserts --column-inserts --on-conflict-do-nothing`，無法指定 conflict target（`L43-46`） | 加四段後處理（D4） |
| 套件 `CAPABILITIES` 宣告（21 支 80 筆）＋主專案 36 筆（CM-1748） | 只是引用，無寫入路徑 | build 期攤平成宣告對照，migrate 模式 upsert by name（D5） |
| `_add_tenant_with_defaults`（jedi-iam） | 建租戶時一次性快照，三道扣除（`:127-133`） | 不改；回補邏輯**複用同三道扣除**（D6） |
| `trg_role_capabilities_platform_guard` | `is_platform=true` 只能給 root 租戶的角色，否則 raise | 回補段必 `NOT is_platform`（D6） |
| `ui_route_repo_impl.py` fail-open（`:77`） | 無 route_capabilities 的 route 全員可見 | 不改邏輯；加守衛擋「有頁無綁」（D7） |
| `2026-08-29-cm1423-security-policy-page.sql` | name-based 補 ui_routes＋route_capabilities 的現成寫法 | 升格為套件 migration 的樣板（D7） |
| `2026-08-01-fr059-2-detection-profile-capability.sql` | name-based capabilities＋「參照角色法」補 role_capabilities 的現成寫法 | 升格為回補樣板（D6） |

## 目標形狀：出貨升級鏈端到端 {#target nav="目標鏈"}

```{.mermaid cap="圖 2 — 補齊後的出貨升級鏈：從 build 期攤平到客戶 DB 到位"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
    participant W as 套件 wheel（site-packages）
    participant B as build_release.sh／build_init_image.sh
    participant I as init image（migrate 模式）
    participant D as 客戶 DB

    B->>W: 掃 <pkg>/migrations/*.sql ＋ CAPABILITIES 宣告
    B->>B: 攤平到 /opt/guidant/sql/packages/<pkg>/ ＋ 產套件子清單 ＋ 宣告對照 capabilities.json
    B->>B: stamp gate：wheel 檔案集合 ＝ 攤平集合 ＝ 99-stamp 清單，不一致 build fail
    B->>I: COPY 整個 /opt/guidant/sql（主線＋packages/）
    Note over I,D: 客戶執行 install.sh --upgrade
    I->>D: SELECT filename FROM schema_migrations
    I->>I: 差集①：主 manifest active/*（既有行為）
    I->>D: 套主線待套檔（psql --single-transaction）＋ INSERT schema_migrations
    I->>I: 差集②：packages/<pkg>/NNN-*.sql 依套件名字母序、套件內序號序
    I->>D: 套套件待套檔（每支 SQL 冪等）＋ INSERT 'packages/<pkg>/NNN-*.sql'
    I->>D: upsert capabilities by name（宣告對照 → INSERT ... ON CONFLICT (name) DO UPDATE description/is_platform）
    I->>D: 回補 role_capabilities：root Administrator 補齊全部；租戶管理員照基準角色補，NOT is_platform ＋ licensed_modules 扣除
    D-->>I: 完成，印出「主線 N 支／套件 M 支／能力點新增 K 筆／回補角色 R 個」
```

## 待決策 D1–D8 {#decisions nav="待決策"}

::: {.callout .pending}
**D1 — 套件 migration 進出貨線的形狀：靜態併入 vs 動態掃描**

兩個方向：

- **靜態併入**：build 期把 site-packages 的 `<pkg>/migrations/*.sql` 攤平進 `/opt/guidant/sql/packages/<pkg>/`，自動產一份套件子清單，stamp gate 守「wheel 裡有的＝攤平的＝stamp 清單有的」三者一致。init image 照舊只是 alpine＋psql，多 COPY 一個目錄。
- **動態掃描**：init image 加一層有 Python 的 build stage，升級時現場跑 `iter_migrations()` 掃 site-packages。

[**建議：靜態併入**。理由：既有 `copy_pkg_data_dirs()`（`build_release.sh:671-683`）與 `build_init_image.sh` 的 stamp gate（`:70-83`）形狀就是「build 期產物＋build 期驗證」，加一條白名單、擴一個 gate 就接上；動態掃描要把 init image 從 alpine 變成帶 Python 的 image，升級路徑多一個執行環境就多一種現場才會炸的失敗。另一個好處：攤平後的 SQL 是出貨包裡看得見的檔案，客戶端出事時工程師開包就能對。]{.rec}
:::

::: {.callout .pending}
**D2 — 命名空間與排序：`schema_migrations.filename` 是裸檔名 PK，11 支套件全從 `001-` 起算必撞**

`schema_migrations` 只有 `filename text PK`（`02-schema.sql:16056-16061`），套件檔名是 `001-asset-tables.sql`、`001-participant-tables.sql`……直接登記第二支就撞 PK。

[**建議**：filename 記成 **`packages/<pkg>/NNN-xxx.sql`**——相對路徑本身就是命名空間，不動表結構、不加欄位。`migrate.sh:86` 的 `grep '\.sql$'` 與哨兵過濾照樣過得去。**排序**：主線 manifest 先跑完，再依**套件名字母序**、套件內**序號序**跑 `001..NNN`。跨套件依賴（如 task-platform 002 要先有 `projects`、`002-*-rls-grants` 要先有 `cm_app` 與租戶判定函式，`jedi-asset/README.md:249-250`）以「主線已把宿主生態建好」為前提，**不做拓撲宣告**——套件之間如果真的互相依賴表，那是套件疆界劃錯，應該回到 D3 的規則處理，不是加一層依賴解析器。配套硬規則：**套件 SQL 一律冪等**（`CREATE ... IF NOT EXISTS`、`ON CONFLICT`、`DO $$ IF NOT EXISTS`），這是階段 3 的核對目標。]{.rec}
:::

::: {.callout .pending}
**D3 — 雙份 DDL 收斂責任（本案最重要的長期規則）**

今天同一張套件表有兩份 DDL：主專案 `scripts/sql/`（41 支，已收進 `02-schema.sql`，是實際建表的那份）與套件 `migrations/`（24 支，從沒跑過，已漂移）。不定規則，「兩份會漂移」在本案接線後會變成「兩份都會被套」。

[**建議**：**往後套件疆界的表只在套件 `migrations/` 改，主專案 `scripts/sql/` 不再新增套件表的 migration**；既有 41 支歷史 migration 保留不動（它們是已出貨基線的一部分）。FR-089 README:45 短期建議的「主專案 scripts/sql 另放一支」**不採**——那正是加深雙份。代價要說清楚：① 第一次上線要做**既有庫一次性補登**：DEV／STG／POC／已出貨客戶庫的表都在了但 `schema_migrations` 沒有套件檔名，新機制上線差集會算成 24 支全待套 → 大面積重放失敗；補登用 `gen_stamp_sql.sh:66-70` 的 converged 手法（標 `converged into __init_baseline_*`），**先補登再上線**。② 補登前要**逐支核對 24 支套件 SQL 與 DEV 實況的差**修到冪等（detection 已知差 7 FK；asset 002 的 `RENAME VALUE` 在已是小寫的庫上會炸，見風險段），這是階段 3。③ **CM-1678 的 enum 修法就用這條線第一次真的套出去**——它是活例也是驗收案。]{.rec}
:::

::: {.callout .pending}
**D4 — 出貨 seed 的四段改成用名稱反查（不再寫死 id）**

`04-seed-core.sql` 的 capabilities／role_capabilities／route_capabilities／ui_routes 四段是 pg_dump 產的：`INSERT INTO capabilities (id, name, ...) VALUES (1, 'bulletin.create', ...) ON CONFLICT DO NOTHING`（`L216` 起），role_capabilities 是寫死的整數對（`L476` 起）。`gen_seed_sql.sh` 用 `--on-conflict-do-nothing` 產不出 conflict target（`L43-46`）。

地雷組合：**id 寫死 ＋ ON CONFLICT 無 target ＋ `capability_id` 無 FK**。一旦 D5 的自動補 row 先跑、佔走某個 id，seed 那筆因 name unique 撞而靜默跳過，接著 `role_capabilities (2, 135)` 指向錯的能力點或孤兒——**資料庫不擋、沒有錯誤、權限就是錯的**。

[**建議：做**。四段改 `INSERT ... SELECT` 以 name 反查 id（capabilities 用 `ON CONFLICT (name)`；role_capabilities／route_capabilities 反查 `capabilities.name`＋`ui_routes.name`；ui_routes 的 `pid` 分組反查 `group-*` 的 name），作法比照 labels 那段——在 `gen_seed_sql.sh` 加特案後處理，不改 pg_dump 參數。現成樣板：`2026-08-01-fr059-2-detection-profile-capability.sql`（name-based ＋ 參照角色法）與 `2026-08-29-cm1423-security-policy-page.sql`（ui_routes 反查）。附帶效果：seed 不再依賴基線庫的 id 跳號（1,2,3,4,12,13,15…）。]{.rec}
:::

::: {.callout .pending}
**D5 — 能力點自動補 row 的執行點與範圍**

三個可能的執行點：BE 啟動期、migrate 模式、seed 檔。

[**建議：在 migrate 模式加一支「宣告對照」步驟，不在 BE 啟動期寫 DB**。作法：init image build 期把 21 支套件的 `CAPABILITIES`（80 筆）＋ CM-1748 主專案 36 筆攤平成一份 `capabilities.json`（或直接產 SQL），migrate 時 `INSERT ... ON CONFLICT (name) DO UPDATE SET description, is_platform`。理由：① 啟動期寫 DB 是多 worker 競態（gunicorn 多個 worker 同時起，誰先誰後、誰重試）＋ RLS 身分問題（BE 用 `cm_app` 受 RLS，系統層寫入會 silent fail——CLAUDE.md 已載明 migration 一律 `cmmgr`）；② migrate 模式本來就是「升級時用 cmmgr 改 DB」的唯一合法時點，能力點只是多一種要改的東西；③ 宣告對照是 build 期產物，跟 D1 的攤平同一道工序，stamp gate 一起守。**範圍**：只 upsert `capabilities` 表本體；角色授權另見 D6。]{.rec}
:::

::: {.callout .pending}
**D6 — Administrator 與租戶管理員回補（升級等於自動擴權）**

新能力點進了 `capabilities` 表還不夠，要綁到角色管理員才拿得到。兩類角色：

- **root Administrator（role 2，tenant 1）**：seed 給全部 127 筆，是「持有全部」不是「繞過」（探脈 B §7）。
- **租戶管理員**（Billows Admin id 3、System Manager id 43/44/49/55/56/57……）：建租戶時一次性快照，之後不回補。

[**建議**：① root Administrator **維持「持有全部」並自動補齊**——改成繞過會跟設計哲學衝突（角色層 `is_admin` 不該有能力語意）且 UI 看不見它有什麼；持有全部的代價只是每次補 capability 同步補 role 2，這在 migrate 步驟裡是一條 `INSERT ... SELECT`。② 租戶管理員用**「參照角色法」**（比照 fr059-2：凡持有某基準能力點的角色都補這批新能力點），**不採 `roles.is_admin=1` 條件**——它會誤中客戶自訂的管理角色。③ 回補段**必 `AND NOT is_platform`**，否則 `trg_role_capabilities_platform_guard` 對非 root 租戶整批 raise。④ **比照 `_add_tenant_with_defaults` 三道扣除**（不販售 resource_type／is_platform／`licensed_modules` 未授權模組，`tenant_provisioning_service.py:127-133`）——沒買的模組升級後不該憑空出現權限。基準能力點怎麼選（哪一筆代表「這是管理員」）是開卡時要定的細節，建議選各套件既有的核心 `*.manage`。]{.rec}
:::

::: {.callout .pending}
**D7 — `ui_routes`／`route_capabilities`：套件契約要不要加 `UI_ROUTES` 宣告**

選單來自 `ui_routes`，可見性來自 `route_capabilities`；缺前者沒入口、缺後者 fail-open 全員可見（`ui_route_repo_impl.py:77`）。要不要像 `CAPABILITIES` 一樣讓套件用 Python 宣告 `UI_ROUTES`，由本案的機制自動寫入？

[**建議：本案不擴契約**。理由：`ui_routes` 有 `pid` 分組、`sort`、`icon`、`url`，這些是 FE 版面知識，用 Python 常數表達會把選單樹的一半塞進套件；而 SQL 已經有現成的 name-based 寫法（cm1423 那支）。作法：把「補 `ui_routes` ＋ 綁 `route_capabilities`」列為**每支有頁面的套件 migration 的必做項**，以 cm1423 為樣板（不寫 id、`WHERE NOT EXISTS (name=)`、`pid` 用 `group-*` 的 name 反查而非硬編 47）。並加**守衛**：「有 `ui_routes` 的套件頁面必有 `route_capabilities`」——擋 fail-open，做法可以是 migrate 尾端一條斷言 SQL，或主專案守衛測試對套件 SQL 靜態掃描。契約擴充留給日後有第二個消費者時再議。]{.rec}
:::

::: {.callout .pending}
**D8 — 排程與 jedi 1.1.0 的關係**

[**建議**：本案主體（階段 1、2）**純主專案＋build 腳本，不動套件源碼**——套件 `migrations/` 已在 wheel 裡、`CAPABILITIES` 已宣告，讀它們不需要改它們，所以**不綁 1.1.0**，可以在 21 支 1.0.x 上直接做。**例外是 D3 的「24 支套件 SQL 修到冪等」＋ D7 的「每支套件補 ui_routes／route_capabilities」要動套件**，那部分併 1.1.0 發版（走 jedi-package-dev 的 path dependency dev loop，發版等令）。順序上：階段 1 先在主專案側接線並用現有 24 支驗通（DEV 補登後差集為 0 即可驗），套件 SQL 的修正隨 1.1.0 進來時再走同一條線第一次真的套。]{.rec}
:::

## 階段拆分草案 {#phases nav="階段拆分"}

只給形狀，不開卡；D 項拍板後轉 design.md 時細拆。

| 階段 | 內容 | 完成怎麼判定 |
|------|------|--------------|
| **FR-093.1 出貨線接套件 migration** | `build_release.sh` 白名單加 `*/migrations` 並攤平到 `packages/<pkg>/`＋產套件子清單（D1）；`docker/init/Dockerfile` COPY 該目錄；`migrate.sh` 第二輪差集（相對路徑命名空間＋字母序／序號序，D2）；`build_init_image.sh` stamp gate 擴到套件；`gen_stamp_sql.sh`／`assert_db_current.sh` 同步；**既有庫一次性補登 SQL**（DEV→STG→POC→客戶，照環境鐵律逐級放行）；CM-1678 enum 修正走這條線套進 DEV 當驗收案 | 乾淨機新裝：`schema_migrations` 有 24 支 `packages/%`；DEV 補登後 `migrate.sh` 待套 0；故意刪一支攤平檔 → `build_init_image.sh` fail；DEV `\dT+` 看到小寫 enum |
| **FR-093.2 能力點自動到位** | `gen_seed_sql.sh` 四段 name-based 後處理（D4）；build 期宣告對照 `capabilities.json`（21 支 80 筆＋主專案 36 筆，D5）；migrate 尾端 upsert capabilities ＋ root 補齊 ＋ 租戶管理員參照角色法回補（NOT is_platform ＋ 三道扣除，D6） | 租戶管理員登入點新頁不 403；role 2 筆數＝capabilities 總數；刪一筆 capabilities 重跑升級回來且權限不變；非 root 租戶沒拿到任何 is_platform 能力點；未授權模組的能力點沒回補 |
| **FR-093.3 選單守衛＋套件 SQL 冪等核對（併 jedi 1.1.0）** | 守衛「有 ui_routes 必有 route_capabilities」（D7）；cm1423 寫法升格為套件 migration 樣板；24 支套件 SQL 逐支對 DEV 實況核對（detection 7 FK、asset 002 RENAME VALUE 防呆）修到冪等；每支有頁面的套件補 ui_routes／route_capabilities；發 1.1.0 | 守衛測試對「只補 ui_routes」的 SQL 變紅；24 支 SQL 對 DEV 連跑兩次零錯；1.1.0 裝上 DEV 後新頁面有選單且非管理員看不到 |

依賴：.1 → .2（.2 的 SQL 要走 .1 的線出貨）；.3 與 .2 可平行但發版在 .1 驗通之後。

## 風險 {#risks nav="風險"}

::: {.callout .warn}
**既有客戶升級失敗面：差集算成 24 支全待套**

已出貨客戶庫的表都由 `02-schema.sql` 建好了，但 `schema_migrations` 沒有任何 `packages/%` 列。新機制上線那一版若沒先補登，`migrate.sh` 會把 24 支全算成待套並逐支重放——`CREATE TABLE` 撞表名、`CREATE TYPE` 撞型別名，`--single-transaction` 一炸整段回滾，升級卡死。

對策：① 補登 SQL 隨該版主線 manifest 出貨，**排在套件差集之前**執行（主線先跑完再算套件差集正是 D2 的排序理由之一）；② 24 支全部冪等化是硬前置（階段 3），補登只是保險不是替代；③ 出包前用「帶舊資料升級」驗（既有規範：每版必須可直接升級不影響舊資料）。
:::

::: {.callout .warn}
**自動擴權的產品面：新版一裝，租戶管理員自動多出權限**

D6 的回補等於「升級＝擴權」。對客戶是好事（新功能裝上就能用）也是風險（客戶可能刻意收掉某些權限，升級後又長回來）。

對策：① 只回補**新增的**能力點，不碰既有 role_capabilities（客戶刪掉的不會長回來）；② 三道扣除保證不越過 `licensed_modules`；③ migrate 輸出明列「回補了哪些角色、哪幾筆」，客戶端工程師看得到；④ 手冊註明此行為。要不要提供「升級時不回補」的開關，開卡時再議。
:::

::: {.callout .crit}
**🔴 `ALTER TYPE ... RENAME VALUE` 在有資料的庫上的行為**

asset 002 的 enum 修正用 `RENAME VALUE`（`002-asset-rls-grants.sql:120-146`）。它在 PG 上會直接改型別定義、既有資料列跟著變（不需要 rewrite 表），這是好消息；壞消息有三：① **目標值已存在時炸**（DEV 若已是小寫，再跑一次 `RENAME VALUE 'LOW' TO 'low'` 報 `enum label "low" already exists`）——與 D2「套件 SQL 一律冪等」直接衝突，要包 `DO $$ IF EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel='LOW' ...)`；② `RENAME VALUE` 不能在同一 transaction 內接著使用新值（PG 的 enum 安全限制）——`--single-transaction` 下同一支 SQL 後段若 INSERT 新值會炸；③ 出貨基線 `02-schema.sql:116-133` 仍是大小寫混雜——新裝機走 02 建出舊型別、再由套件 002 改成小寫，**新裝與升級兩條路要收斂到同一終態**，這要在階段 1 拿 CM-1678 當驗收案時親眼驗。
:::

::: {.callout .warn}
**stamp gate 變嚴後，套件發版與 BE 出包的耦合**

擴大後的 gate 要求「wheel 裡的 migration 檔案集合＝攤平集合＝stamp 清單」。任何一支套件發新版帶新 SQL，BE 出包就要重跑 gen_stamp 才過 gate。這是刻意的（generate-then-commit 產物天生漂移，只驗烙印會出貨壞包），但要寫進 `scripts/build/README.md` 與 version-bump skill，否則第一次撞到的人會以為是 build 壞了。
:::

## 附：探脈來源與未核實項 {#sources nav="附：來源"}

- 探脈 A（套件 migration，唯讀）：`scripts/sql/manifest.tsv`、`scripts/init/migrate.sh`、`scripts/init/02-schema.sql`、`scripts/build/build_release.sh`、`scripts/build/build_init_image.sh`、`docker/init/Dockerfile`、11 支套件 `migrations/`、jedi 1.0.1 wheel 抽查。
- 探脈 B（能力點與選單，DEV localhost:5432 唯讀）：`capabilities`／`role_capabilities`／`roles`／`ui_routes`／`route_capabilities` schema 與筆數、`04-seed-core.sql`、`gen_seed_sql.sh`、jedi-iam authz 與 repo 實作、`tenant_provisioning_service.py`。
- **未核實、開卡前要查**：① `assets` 與 `tamper_events` 在 `02-schema.sql` 查不到同名表（可能表名不同或被排除）；② 10 支沒有 migrations 的套件（ai-bot／ai-dashboard／common／compliance-audit／flow-engine／iam／issue／notification／oscal-v2／survey）是真的沒有表，還是表都在主專案 41 支裡——若是後者，D3 生效後它們第一次改表就要開 `migrations/`；③ D6 各套件的「基準能力點」清單。
  - **首腦已核（2026-09-13）**：不是缺表，是表名不同——asset 套件建的是 `public.devices`／`compliance.information_systems`（`001-asset-tables.sql:46,97`），integrity 建 `public.integrity_tamper_events`（`02-schema.sql:15104` 已在基線）。D3「表都已存在」前提成立。
