---
title: "出貨升級鏈補齊 — 設計文件 (FR-093)"
brand: "Guidant AI · **FR-093** 出貨升級鏈補齊"
eyebrow: "FR-093 · 套件 migration 進出貨線＋能力點與選單自動補 row — 設計文件 · 2026-09-13（設計定案）"
h1: "套件宣告的表、權限、選單，在客戶升級時自動到位"
lede: "本文是 [討論稿](./discussion.html) D1–D8 拍板後的定案版：三個階段、每階段拆到 runner 拿到就能開工的粒度，末段的拆分表是開 Notion 卡的依據。**只寫定案與理由，不寫討論沿革**——沿革見討論稿與 git log。"
chips: [
  {text: "設計定案（2026-09-13）", kind: ok},
  {text: "D1–D8 全數拍板", kind: ok},
  {text: "3 子需求 · 10 棒", kind: accent},
  {text: "前作：FR-089／FR-091／FR-092", kind: plain},
  {text: "驗收活例：CM-1678 enum 修正", kind: crit}
]
footer: "FR-093 · 出貨升級鏈補齊 — 設計文件 · 2026-09-13 · 前作：FR-089／FR-091（套件標準形狀，21 支已推 Nexus）／FR-092（廢碼清理）／FR-065（Installer 與 migrate 模式）／FR-062（licensed_modules）· 沿革見討論稿、LOG 與 git log"
---

> 狀態：**設計定案（D1–D8 已拍板，2026-09-13）**｜前作：[FR-089](../FR-089-2609-package-shape-unification/)、[FR-091](../FR-091-2609-package-shape-rollout/)（前置三卡 CM-1728～1730）、[FR-092](../FR-092-2609-dead-code-cleanup/)｜討論稿：[`discussion.html`](./discussion.html)

## 變更紀錄

| 日期 | 變更 | 依據 |
|------|------|------|
| 2026-09-13 | 設計定案：D1–D8 全數照討論稿建議拍板；三階段詳細設計＋拆分表（3 子需求 10 棒） | 討論稿 v0＋決策者拍板 |
| 2026-09-13 | 拆分表補 Notion 卡號（母卡 CM-1752，子卡 CM-1753～1762） | 開卡 runner |

---

## 1. 需求背景與端到端流程 {#why nav="背景"}

FR-089／FR-091 之後，21 支 jedi-* 套件都有標準形狀，其中兩樣東西跟資料庫有關：**migration**（套件自帶 SQL，宣告「我需要哪些表」）與**能力點**（`CAPABILITIES` 常數，宣告「我的端點用哪些權限名稱守門」）。今天這兩份宣告**都沒有任何一條路送進客戶的資料庫**：

| 缺口 | 一句話 | 後果 |
|------|--------|------|
| ① 套件 migration 從沒被執行 | 11 支套件 24 支 SQL 躺在 wheel 裡；BE image 不帶（`build_release.sh:661-669` 白名單無 `*/migrations`）、init image 不讀（`docker/init/Dockerfile:35` 只 COPY `scripts/sql/`）、manifest 不列（`migrate.sh:99-106` 只算 manifest 內的檔） | 套件裡改表、修 enum 都默默消失。活例：CM-1678 把 `compliance.system_status_enum` 收斂成小寫，只寫在套件 002，三環境全沒套 |
| ② 新能力點沒進 DB | 權限判定拿 name 比對（`jedi_iam/authz/capability.py:134-160`），但 row 沒人寫、角色沒人綁；唯一自動化只在建租戶那一刻（`tenant_provisioning_service.py:103-150`），之後不回補 | 升級後租戶管理員點新頁面 403，且無錯誤訊息說是缺 row |
| ③ 新頁面沒選單／漏綁權限全員可見 | 選單來自 `ui_routes`，可見性來自 `route_capabilities`；沒綁任何 route_capabilities 的 route 視為可見（`ui_route_repo_impl.py:77`，fail-open） | 漏補一筆是靜默越權，不是報錯 |

同一批套件表另有一份「真正的 DDL」在主專案 `scripts/sql/`（41 支，已收進 `02-schema.sql`），兩份已漂移（detection 缺 7 FK、enum 大小寫）。本案把「套件宣告 → 出貨 bundle → 客戶升級 → 資料庫到位」這條鏈補齊，並定下**長期規則 D3**：套件疆界的表往後只在套件改。

### 目標鏈（補齊後）

```{.mermaid cap="圖 1 — 補齊後的出貨升級鏈：從 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.sql
    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 個」
```

---

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

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

| 階段 | 做什麼 | 產出 | 完成怎麼判定（決策者檢查法） |
|------|--------|------|------------------------------|
| **FR-093.1 出貨線接套件 migration** | build 期把 site-packages 的套件 SQL 攤平進出貨 SQL 目錄並自動產子清單；init image 帶著它；`migrate.sh` 第二輪差集套套件檔；stamp gate 守「wheel＝攤平＝stamp 清單」；既有庫一次性補登；CM-1678 enum 修正當首次驗收案 | `build_release.sh`／`build_init_image.sh`／`Dockerfile`／`migrate.sh`／`assert_db_current.sh`／`gen_stamp_sql.sh` 改動、補登 SQL | 乾淨機新裝：`SELECT filename FROM schema_migrations WHERE filename LIKE 'packages/%'` 得 24 支；DEV 補登後重跑 `migrate.sh` 顯示待套 0；故意刪一支攤平檔 → `build_init_image.sh` fail；DEV `\dT+ compliance.system_status_enum` 看到小寫值 |
| **FR-093.2 能力點自動到位** | 出貨 seed 四段改用名稱反查（不寫死 id）；build 期把 21 支套件 80 筆＋主專案 36 筆能力點攤成 upsert SQL；migrate 尾端 upsert `capabilities`、root Administrator 補齊全部、租戶管理員參照角色法回補（扣平台級與未授權模組） | `gen_seed_sql.sh` 後處理、`scripts/sql/packages/_capabilities.sql`（build 產）、回補 SQL、`migrate.sh` 第三輪 | 用**租戶管理員**（非 root）登入點新套件頁面不 403；`SELECT count(*) FROM role_capabilities WHERE role_id=2` ＝ `capabilities` 總數；刪一筆 capabilities 再跑升級 row 回來且 id 不同不影響登入；非 root 租戶沒拿到任何 `is_platform` 能力點 |
| **FR-093.3 選單守衛＋套件 SQL 冪等核對** | 守衛「有 `ui_routes` 的套件頁面必有 `route_capabilities`」；每支有頁面的套件 migration 補 ui_routes＋route_capabilities；24 支套件 SQL 逐支對 DEV 實況核對修到冪等；併 jedi 1.1.0 | 守衛測試、24 支套件 SQL 修正、1.1.0 發版 | 故意寫一支只補 ui_routes 不綁權限的 SQL 守衛變紅；24 支 SQL 對 DEV **連跑兩次**零錯；1.1.0 裝上 DEV 後新頁面有選單且非管理員看不到 |

**依賴一句**：.1 是地基（.2／.3 的 SQL 要走 .1 的線出貨）；.2 不動套件源碼，可與 .3 平行；.3 動 jedi-*，發版在 .1 驗通之後、等令。

---

## 3. 決策定案 D1–D8 {#decisions nav="決策定案"}

| # | 定案 | 理由 | 被排除方案與原因 |
|---|------|------|------------------|
| **D1** | **靜態併入**：build 期把 site-packages 的 `<pkg>/migrations/*.sql` 攤平進 `/opt/guidant/sql/packages/<pkg>/`，自動產套件子清單，stamp gate 守「wheel 有的＝攤平的＝stamp 清單有的」三者一致；init image 照舊 alpine＋psql，多 COPY 一個目錄 | 既有 `copy_pkg_data_dirs()`（`build_release.sh:671-683`）與 stamp gate（`build_init_image.sh:70-83`）形狀就是「build 期產物＋build 期驗證」，加白名單、擴 gate 即接上；攤平後的 SQL 是出貨包裡看得見的檔案，客戶端出事工程師開包就能對 | 動態掃描（init image 加 Python 層、升級時現場跑 `iter_migrations()`）：init image 從 alpine 變成帶 Python，升級路徑多一個執行環境就多一種現場才炸的失敗 |
| **D2** | `schema_migrations.filename` 記 **`packages/<pkg>/NNN-xxx.sql`**——相對路徑即命名空間，不動表結構；排序＝主線 manifest 先跑完，再依**套件名字母序**、套件內**序號序**；跨套件依賴不做拓撲宣告；配套硬規則：**套件 SQL 一律冪等** | 表只有 `filename text PK`（`02-schema.sql:16056-16061`），11 支套件全從 `001-` 起算直接撞；`migrate.sh:86` 的 `grep '\.sql$'` 與哨兵過濾對相對路徑照樣過得去；套件之間若真的互相依賴表，那是疆界劃錯，回 D3 處理 | 加 `source`／`version` 欄：動出貨基線表結構，且 24 支既有客戶庫都要 ALTER；拓撲依賴解析器：多一層機制，而「主線已把宿主生態建好」的前提已足夠 |
| **D3** | **往後套件疆界的表只在套件 `migrations/` 改，主專案 `scripts/sql/` 不再新增套件表 migration**；既有 41 支歷史 migration 保留不動。代價明列：① 上線前既有庫（DEV／STG／POC／已出貨客戶）一次性補登 24 支檔名（converged 手法）；② 補登前 24 支逐支核對冪等（階段 3）；③ CM-1678 enum 修法用這條線第一次真的套出去，是驗收案 | 不定規則，「兩份會漂移」在接線後變成「兩份都會被套」；41 支歷史檔是已出貨基線的一部分，動它等於改基線 | FR-089 README 短期建議「主專案 scripts/sql 另放一支」：正是加深雙份 |
| **D4** | `04-seed-core.sql` 的 capabilities／role_capabilities／route_capabilities／ui_routes 四段改 **`INSERT ... SELECT` 以 name 反查 id**，在 `gen_seed_sql.sh` 加特案後處理（比照 labels 段），不改 pg_dump 參數 | 地雷組合：id 寫死＋`ON CONFLICT` 無 target＋`capability_id` 無 FK——D5 自動補 row 先佔走某 id，seed 那筆因 name unique 靜默跳過，`role_capabilities (2, 135)` 指向錯的能力點，**資料庫不擋、沒錯誤、權限就是錯的**；附帶效果：seed 不再依賴基線庫的 id 跳號 | 改 pg_dump 參數：`--on-conflict-do-nothing` 產不出 conflict target（`gen_seed_sql.sh:43-46`）；給 `capability_id` 加 FK：擋得住孤兒但擋不住「指到錯的 id」 |
| **D5** | 在 **migrate 模式加一支「宣告對照」步驟**：build 期把 21 支套件 `CAPABILITIES`（80 筆）＋主專案 36 筆攤成 `scripts/sql/packages/_capabilities.sql`，migrate 時 `INSERT ... ON CONFLICT (name) DO UPDATE SET description, is_platform`；範圍只 upsert `capabilities` 本體 | 啟動期寫 DB 是多 worker 競態＋RLS 身分問題（BE 用 `cm_app` 受 RLS，系統層寫入 silent fail）；migrate 模式本來就是「升級時用 cmmgr 改 DB」的唯一合法時點；宣告對照是 build 期產物，與 D1 同一道工序、stamp gate 一起守 | BE 啟動期寫 DB（競態＋RLS）；寫進 seed 檔（seed 只在新裝跑，升級不跑） |
| **D6** | ① root Administrator（role 2）**維持「持有全部」並自動補齊**；② 租戶管理員用**參照角色法**（凡持有基準能力點 `flow_template.create` 的角色都補這批新能力點），**不採 `roles.is_admin=1`**；③ 回補段必 `AND NOT is_platform`；④ 比照 `_add_tenant_with_defaults` 三道扣除（不販售 resource_type／`is_platform`／`licensed_modules` 未授權模組）；⑤ 只回補**新增的**能力點，不碰客戶已刪的 | 改 root 成繞過與設計哲學衝突（角色層 `is_admin` 不該有能力語意，`authz/admin.py` 檔頭）且 UI 看不見它有什麼；`is_admin=1` 會誤中客戶自訂的管理角色；不加 `NOT is_platform` 會被 `trg_role_capabilities_platform_guard` 對非 root 租戶整批 raise；沒買的模組升級後不該憑空出現權限 | root 改繞過（見左）；用 `is_admin` 條件（誤中自訂角色）；回補全集（越過 licensed_modules） |
| **D7** | **本案不擴套件契約**（不加 `UI_ROUTES` 宣告）。「補 `ui_routes`＋綁 `route_capabilities`」列為每支有頁面的套件 migration 必做項，以 `2026-08-29-cm1423-security-policy-page.sql` 為樣板（不寫 id、`WHERE NOT EXISTS (name=)`、`pid` 用 `group-*` 的 name 反查）；加守衛「有 `ui_routes` 的套件頁面必有 `route_capabilities`」 | `ui_routes` 有 `pid`／`sort`／`icon`／`url`，是 FE 版面知識，用 Python 常數表達會把選單樹的一半塞進套件；SQL 已有現成 name-based 寫法；fail-open 要靠守衛擋 | 契約加 `UI_ROUTES`：留給日後有第二個消費者時再議 |
| **D8** | 階段 1、2 **純主專案＋build 腳本，不動套件源碼，不綁 1.1.0**，在 21 支 1.0.x 上直接做；階段 3 的「24 支套件 SQL 冪等」＋「每支套件補 ui_routes／route_capabilities」要動套件，**併 1.1.0 發版**（jedi-package-dev path dependency dev loop，發版等令）。順序：階段 1 先接線並用現有 24 支驗通，套件修正隨 1.1.0 進來時走同一條線第一次真的套 | 套件 `migrations/` 已在 wheel、`CAPABILITIES` 已宣告，讀它們不需改它們 | 全案綁 1.1.0：階段 1、2 被套件發版節奏卡住 |

---

## 4. 現況接入點盤點 {#inventory nav="現況盤點"}

| 元件 | 現況 | 本案動作 |
|------|------|----------|
| `scripts/sql/manifest.tsv` | 四欄 TSV（`filename／phase／envs／note`），行序即套用順序；只列主專案檔 | 不改格式、不加套件列；套件檔另有子清單（D1／D2） |
| `scripts/init/migrate.sh` | 讀單一 manifest 算差集（`:99-106`），`grep '\.sql$'` 過濾（`:86`），`__init_baseline_*` 守門（`:92-95`），逐支 `--single-transaction` 套並 `INSERT schema_migrations`（`:138-155`） | 加第二輪：主線套完後依套件子清單套 `packages/<pkg>/`；加第三輪：能力點 upsert＋角色回補（D2／D5／D6） |
| `public.schema_migrations` | `filename text PK / applied_at / note`（`02-schema.sql:16056-16061`） | **不動表結構**；filename 內含相對路徑當命名空間（D2） |
| `scripts/build/build_release.sh` `PKG_DATA_DIRS`／`copy_pkg_data_dirs()` | 依白名單從 site-packages 複製套件資料目錄到產物（`:661-683`），`assert_pkg_data_files` 斷言代表檔（`:686`） | 新增一支獨立函式攤平 `*/migrations` 到 `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 內檔案集合＝攤平集合＝stamp 清單（D1） |
| `scripts/init/gen_stamp_sql.sh` | 把 manifest active/* 補登、標 `converged into __init_baseline_v<版>__`（`:66-70`） | 補登清單同步納入套件檔（D3） |
| `scripts/build/assert_db_current.sh` | 前置斷言只比主 manifest（`:174-199`） | 同步比套件子清單 |
| 套件 `migrations/*.sql`（11 支套件 24 支） | 三位數序號檔名、無版號；detection 缺 7 FK；asset 002 的 `RENAME VALUE` 已用 `pg_enum` 判斷包冪等 | 階段 3 逐支核對；往後套件表只在這裡改（D3） |
| 主專案 `scripts/sql/` 41 支套件表 migration | 已收進 `02-schema.sql`；歷史真相 | **保留不動**；往後不再新增（D3） |
| `scripts/init/04-seed-core.sql` 四段 | pg_dump 產、id 寫死、`ON CONFLICT DO NOTHING` 無 target（`L216`／`L476` 起） | 改 `INSERT...SELECT` name 反查（D4） |
| `scripts/init/gen_seed_sql.sh` | `--inserts --column-inserts --on-conflict-do-nothing`；labels 段已是「不 dump、改寫邏輯模板」的特案（`LABELS_SECTION`，`:257-299`） | 加四段後處理，比照 labels 特案（D4） |
| 套件 `CAPABILITIES`（21 支 80 筆）＋主專案 36 筆（CM-1748） | 只是宣告引用（`core/plugins/*.py` 傳給 `Plugin.capabilities`），無寫入路徑 | build 期攤成 `_capabilities.sql`，migrate 模式 upsert by name（D5） |
| `_add_tenant_with_defaults`（jedi-iam `tenant_provisioning_service.py:103-150`） | 建租戶時一次性快照，三道扣除（`:121-133`）：`TENANT_ADMIN_EXCLUDED_RESOURCE_TYPES`（`core/plugins/identity.py:89-95`）／`is_platform`／`licensed_resource_types()` | 不改；回補 SQL **複用同三道扣除**（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＋參照角色法的現成寫法 | 升格為回補樣板（D6） |

---

## 5. 詳細設計 {#design nav="詳細設計"}

### 5.1 階段 1：出貨線接套件 migration {#phase1}

#### ① `build_release.sh`：攤平套件 migration（build 期產物，不入版控）

- **不用 `PKG_DATA_DIRS` 白名單**（它是「複製到 BE 產物目錄」的機制，目標是 BE image；migration 的目標是 init image 的 `/opt/guidant/sql/`），新增獨立函式 `flatten_pkg_migrations <目標目錄>`：
  1. 取 `sysconfig.get_paths()["purelib"]`，glob `*/migrations/*.sql`（只掃 `jedi_*` 前綴目錄，避免撈到第三方套件）。
  2. 每支複製到 `<目標目錄>/packages/<pkg import 名>/<原檔名>`（`<pkg>` 用 import 名如 `jedi_asset`，與 site-packages 目錄名一致，避免 dist name／import name 兩套）。
  3. 同時產 `<目標目錄>/packages/manifest.tsv`（套件子清單），格式**與主 manifest 同四欄**，每列：`packages/<pkg>/NNN-xxx.sql<TAB>active<TAB>*<TAB>auto-generated from <dist-name> <version>`；行序＝套件 import 名字母序、套件內檔名序（`LC_ALL=C sort`）。
- **產物落點**：`scripts/sql/packages/`——`.gitignore` 排除，**不入版控、build 期產生**。理由：它是 wheel 的鏡像，入版控就變成第三份 DDL；但 `build_init_image.sh`（跑在 repo 根、COPY context 是 `$REPO_ROOT`）與 `assert_db_current.sh` 要讀得到它，所以攤平動作要在 `build_init_image.sh` 前置段也能單獨觸發（抽成 `scripts/build/flatten_pkg_migrations.sh`，兩支 build 腳本共用；`build_all.sh` 只跑一次）。
- 攤平時對每支 SQL 做**靜態檢查**：檔名符合 `^\d{3}-[a-z0-9-]+\.sql$`；不含 `INSERT INTO public.schema_migrations`（套件 SQL 不自登記，由 migrate.sh 統一登記相對路徑檔名，否則主鍵會登成裸檔名而再撞）。不符即 build fail。

#### ② 套件子清單格式

同主 manifest 四欄；`phase` 固定 `active`、`envs` 固定 `*`（套件沒有「原廠內部環境限定」的概念）。`migrate.sh` 讀它時走同一段解析程式碼（抽成函式 `read_manifest <檔> <陣列名>`），差別只在 `filename` 帶 `packages/` 前綴。

#### ③ `docker/init/Dockerfile`

`COPY scripts/sql/ /opt/guidant/sql/` 已會把 `scripts/sql/packages/` 一併帶入（子目錄）；**不需新增 COPY 行**，但要在 Dockerfile 加一行斷言 `RUN test -f /opt/guidant/sql/packages/manifest.tsv`——沒先跑攤平就 build image 要當場 fail，不能出一顆「主線正常、套件段空白」的 init image。

#### ④ `migrate.sh` 第二輪差集

- 第一輪（主線）完全不動。
- 主線套完（或待套 0）後：讀 `${SQL_DIR}/packages/manifest.tsv`（缺檔 → `die 1 "出貨包不完整"`），算差集 `PENDING_PKG`，印「套件待套 M 支」，同樣 dry-run／APPLY 兩段。
- 套用迴圈與登記共用第一輪的程式碼（`apply_one <相對路徑>`），登記值就是 `packages/<pkg>/NNN-xxx.sql`。
- **核對過的過濾行為**：`:86` 的 `grep -v '^__' | grep '\.sql$'` 對 `packages/jedi_asset/002-asset-rls-grants.sql` 過得去；`grep -qxF` 全字比對相對路徑也正確。哨兵 `__init_baseline_*` 不受影響。
- **既有客戶庫保護**：第二輪前若 `schema_migrations` 內 `packages/%` 筆數為 0 **且** `PENDING_PKG` 等於子清單全量，印警告「套件段從未登記過，將全數重放；請確認本版主線已含補登 SQL」——不擋（補登 SQL 在主線先跑，跑完就不會是 0），只是讓人看得到。

#### ⑤ stamp gate 與 `assert_db_current.sh`

- `build_init_image.sh`：現有 diff 改成兩張：主 manifest active/* ↔ stamp 內無 `packages/` 前綴的列；套件子清單 ↔ stamp 內 `packages/` 前綴的列。任一不一致 fail，訊息分開印。另加第三道：wheel 內實際檔案集合（重新 glob site-packages）↔ 攤平集合，抓「攤平產物 stale」。
- `gen_stamp_sql.sh`：補登段多讀套件子清單，同樣標 `converged into __init_baseline_v<版>__`。
- `assert_db_current.sh:174-199`：`tmp_expect` 併入套件子清單；訊息「落後 N 支」分主線／套件兩個數字。

#### ⑥ 既有庫一次性補登

- 一支主線 migration `scripts/sql/2026-09-<日>-fr093-1-backfill-package-migrations.sql`，走 sql-migration skill 全部規範（檔頭 `-- Date:`、語句日期註解、收尾 `INSERT schema_migrations`）。內容：24 列 `INSERT INTO public.schema_migrations(filename, note) VALUES ('packages/<pkg>/NNN-xxx.sql', 'FR-093.1 backfill：表已由 02-schema.sql／主線 migration 建立，converged into __init_baseline_v1.19.0__') ON CONFLICT (filename) DO NOTHING`。
- 這支登在**主 manifest**（active／*），所以升級時走第一輪、在第二輪套件差集之前執行——這是 D2 排序的用意。
- **CM-1678 那一支例外不補登**：`packages/jedi_asset/002-asset-rls-grants.sql` 從補登清單**排除**，讓它成為第二輪唯一待套的檔，真的套進 DEV。
- 環境：先 DEV（localhost:5432）；STG／POC 與已出貨客戶等放行（環境鐵律）。
- 新裝路徑：`99-stamp.sql` 由 gen_stamp 產、已含 24 支（含 002），新裝機不會重放。

#### ⑦ 首次真實驗收案：CM-1678 enum 修正（jedi-asset 002）

- 已核：wheel 1.0.1 內的 `002-asset-rls-grants.sql:120-146` **已經是冪等寫法**（`DO $$ ... IF EXISTS (SELECT 1 FROM pg_enum ...) THEN ALTER TYPE ... RENAME VALUE`），在已是小寫的庫上會跳過、不會 `enum label already exists`。**階段 1 不必先改套件**，直接用 1.0.1 驗。
- 仍要親眼驗的兩件事：(a) 新裝路徑：`02-schema.sql:116-133` 建出大寫型別 → 第二輪套 002 改成小寫，終態與升級路徑一致（`\dT+ compliance.system_status_enum` 兩條路都是小寫）；(b) `RENAME VALUE` 後同一交易內不能使用新值——002 檔內 enum 段之後**不得**有用到新值的 INSERT／UPDATE，核對一次。
- 驗收指令：DEV 套完 → `\dT+ compliance.system_status_enum`、`\dT+ compliance.security_sensitivity_level_enum` 全小寫；`SELECT filename FROM schema_migrations WHERE filename LIKE 'packages/%'` 得 24 支（23 補登＋1 實套）。

### 5.2 階段 2：seed name-based＋能力點 upsert＋角色回補 {#phase2}

#### ① `04-seed-core.sql` 四段改 name 反查（`gen_seed_sql.sh` 後處理）

比照 `LABELS_SECTION` 的做法：這四張表**不再 pg_dump**（從 dump 表清單移除），改由產生器內建模板從基線庫 `SELECT` 出資料後**產成邏輯 SQL**：

| 表 | 產出形狀 |
|----|----------|
| `capabilities` | `INSERT INTO capabilities (name, resource_type, action, description, is_platform) VALUES (...) ON CONFLICT (name) DO NOTHING`——不帶 id |
| `ui_routes` | 先插 `pid=0` 的 `group-*` 節點（`WHERE NOT EXISTS (name=)`），再插子節點：`INSERT ... SELECT gen_random_uuid(), 0, (SELECT id FROM ui_routes WHERE name='group-xxx'), 'name', ... WHERE NOT EXISTS (SELECT 1 FROM ui_routes WHERE name=...)`——`pid` 用父節點 name 反查 |
| `role_capabilities` | `INSERT ... SELECT r.id, c.id FROM roles r, capabilities c WHERE r.name='Administrator' AND r.tenant_id=1 AND c.name IN (...) ON CONFLICT (role_id, capability_id) DO NOTHING`——seed 只有 root 租戶角色，用 `(tenant_id=1, name)` 定位 |
| `route_capabilities` | `WITH r AS (SELECT id FROM ui_routes WHERE name=...) INSERT ... SELECT r.id, c.id, '<requirement>' FROM r, capabilities c WHERE c.name=... ON CONFLICT (route_id, capability_id) DO NOTHING` |

- 產生器內的 `setval` 段對這四張表照舊（sequence 仍要推進）。
- 驗證段（`gen_seed_sql.sh:333-381` 那個 `DO $$`）加四項筆數斷言：capabilities 筆數＝模板筆數、role 2 持有數＝capabilities 總數、每筆 route_capabilities 的 route_id／capability_id 都反查得到。
- `ui_routes` 子節點的 `id` 不再寫死，因此 `labels` 段（D11.5 由 ui_routes 重建）不受影響（它本來就用 name）。

#### ② 能力點宣告攤平：`scripts/sql/packages/_capabilities.sql`（build 期產）

- 攤平腳本（與 5.1① 同一支 `flatten_pkg_migrations.sh`，多一段）用 `$VPY -c` 載入 21 支套件的 `<pkg>.plugin.CAPABILITIES`＋主專案宣告（CM-1748 落地後的位置，開卡前確認，見 §8），產：

```sql
-- 由 flatten_pkg_migrations.sh 自 CAPABILITIES 宣告產生，勿手改
SET LOCAL app.is_super_admin = 't';
INSERT INTO public.capabilities (name, resource_type, action, description, is_platform) VALUES
  ('device.create', 'device', 'create', 'device create access', FALSE),
  ...
ON CONFLICT (name) DO UPDATE
  SET description = EXCLUDED.description,
      is_platform = EXCLUDED.is_platform;
```

- 檔名以 `_` 開頭是刻意的：它**不進套件子清單、不登 `schema_migrations`**（每次升級都要跑，不是一次性 migration）。攤平時的重複 name 檢查：兩支套件宣告同名能力點 → build fail。
- `is_platform` 直接取 `Capability.is_platform`（`jedi_common/plugin/capability.py:46-51`，預設 `False`）；dataclass 另有 `default_roles`（預設 `Administrator`），**本案不消費它**——授權走 D6 的參照角色法，不由套件決定角色名。

#### ③ 角色回補 SQL：`scripts/init/migrate-capability-grants.sql`（入版控、固定內容）

每次升級在 `_capabilities.sql` 之後執行，兩段：

- **root Administrator 補齊**：定位用 `roles.tenant_id=1 AND name='Administrator' AND is_delete=0`（不用 `is_admin=1`：同一理由，客戶在 root 租戶自訂的管理角色不該被當 Administrator）。`INSERT INTO role_capabilities SELECT r.id, c.id FROM roles r CROSS JOIN capabilities c WHERE <定位> ON CONFLICT DO NOTHING`——含 `is_platform` 能力點（root 租戶可持有）。
- **租戶管理員參照角色法**：基準能力點 **`flow_template.create`**（比照 fr059-2；它是每個租戶 System Manager 建立時必得的租戶級能力點）。凡持有它、且 `roles.tenant_id <> 1` 的角色，補「該角色尚未持有」的能力點，三道扣除在 SQL 內表達：

```sql
INSERT INTO public.role_capabilities (role_id, capability_id)
SELECT r.role_id, c.id
  FROM (SELECT DISTINCT rc.role_id FROM role_capabilities rc
          JOIN capabilities b ON b.id = rc.capability_id
          JOIN roles ro ON ro.id = rc.role_id
         WHERE b.name = 'flow_template.create' AND ro.tenant_id <> 1 AND ro.is_delete = 0) r
 CROSS JOIN public.capabilities c
 WHERE NOT c.is_platform                                              -- 扣除②
   AND c.resource_type NOT IN ('cruise-project','dashboard','workflow','resource','report')  -- 扣除①
   AND c.resource_type = ANY (:licensed_resource_types)               -- 扣除③
ON CONFLICT (role_id, capability_id) DO NOTHING;
```

- **扣除①**：`TENANT_ADMIN_EXCLUDED_RESOURCE_TYPES`（`core/plugins/identity.py:89-95`）的五個值**由攤平腳本從 Python 常數讀出後注入**，不在 SQL 手抄（兩份會漂移）。
- **扣除③ licensed_modules 在 SQL 表達不了**：授權模組是從租戶現行照（`config.tenant_licenses` 的解析快取）算出來的集合，且 provisioning 時「無照＝不扣」（`ProvisioningLicensedModules`，`identity.py:275-303`）。**定案**：階段 2 的回補 SQL **只做扣除①②，不做扣除③**，改由 **request-time 執法軸⑥ `@require_license` 擋**——這與 provisioning 的 A 方案同一哲學（授予寬鬆、判定嚴格）：多給的能力點在無照模組上打不動任何端點，照上傳後自動對齊。手冊註明「升級後角色可能持有未授權模組的能力點，實際可用性由授權決定」。若決策者要求 SQL 層也扣，開卡前先查 `tenant_licenses` 解析快取欄位能否直接 `jsonb` 取模組清單（§8）。
- 執行順序：在 `migrate.sh` **第三輪**、所有 migration（主線＋套件）套完之後——避免 D4 的 id 地雷（seed 尚未 name-based 的舊庫上，先 upsert 再跑 seed 會佔 id）。
- 輸出：`RAISE NOTICE` 印「能力點新增 K 筆／root 補 X 筆／租戶角色回補 R 個角色 Y 筆」，migrate.sh 原樣轉印。

#### ④ `migrate.sh` 第三輪

`APPLY=1` 時在第二輪之後：`psql --single-transaction -f packages/_capabilities.sql` → `-f init/migrate-capability-grants.sql`；dry-run 只印「將 upsert N 筆能力點宣告」（N＝檔內 VALUES 列數）。兩支都不登記 `schema_migrations`。

### 5.3 階段 3：選單守衛＋24 支套件 SQL 冪等核對 {#phase3}

#### ① 守衛 `test/test_package_ui_routes_have_route_capabilities.py`

- 掃描範圍：`~/Projects/Jedicogy/module/jedi-python-package/jedi-*/*/migrations/*.sql`（本機源碼，CI 以 site-packages 為準：兩者擇一由環境變數決定）＋主專案 `scripts/sql/*.sql`。
- 規則：每個 `INSERT INTO public.ui_routes` 語句抽出 `name=` 字面值（正則抓 `WHERE NOT EXISTS (SELECT 1 FROM public.ui_routes WHERE name = '<x>')` 或 VALUES 內第 4 欄），同一檔內必須有 `route_capabilities` INSERT 引用同一 name（`FROM public.ui_routes WHERE name = '<x>'`）。`group-*` 節點（`pid=0`）豁免。
- 突變測試：故意把 cm1423 的 ④ 段刪掉跑一次要紅，還原後綠。
- 另一道 migrate 尾端斷言 SQL（放 `migrate-capability-grants.sql` 末段）：`SELECT count(*) FROM ui_routes u WHERE u.pid<>0 AND NOT EXISTS (SELECT 1 FROM route_capabilities WHERE route_id=u.id)` 大於基線白名單數即 `RAISE WARNING`（不 EXCEPTION：既有基線本來就有 fail-open 的頁，先列出不擋升級；白名單由開卡棒盤點 DEV 現況產出）。

#### ② 每支有頁面的套件 migration 補 ui_routes＋route_capabilities

- 樣板：`scripts/sql/2026-08-29-cm1423-security-policy-page.sql` 的 ③④ 段；`pid` 改成 `(SELECT id FROM public.ui_routes WHERE name = 'group-system-config')` 反查，不寫 47。
- 開卡前盤點哪些套件有頁面（FE 路由 ↔ 套件對照），逐套件開新序號檔（如 `jedi_asset/003-asset-ui-routes.sql`），不改既有 001／002。

#### ③ 24 支套件 SQL 逐支對 DEV 實況 diff、修到冪等

- 方法：對每支 SQL 在 DEV 快照庫（`pg_dump` 本機 DEV 後 restore 到臨時庫）**連跑兩次**，第一次可容忍「表已存在」類錯誤並記錄，第二次零錯才過；同時 `pg_dump --schema-only` 比對套件 SQL 建出的結構 vs DEV 實況（`inventory/batch-D.md:836` 記 detection 缺 7 條 FK 是已知差）。
- 修法：`CREATE TABLE/INDEX/TYPE ... IF NOT EXISTS`、`DO $$ BEGIN ... EXCEPTION WHEN duplicate_object THEN NULL END $$`、`ALTER TABLE ... ADD COLUMN IF NOT EXISTS`、FK 用 `DO $$ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname=...)`、資料用 `ON CONFLICT`／`WHERE NOT EXISTS`。
- 漂移補正方向：**以 DEV 實況（＝02-schema.sql 基線）為準改套件 SQL**，不反向改基線（D3）。
- 產出：每支套件 `migrations/` 內的修正 commit（jedi repo）＋一份對照表（套件／檔／差異／修法）進 `docs/features/FR-093-.../inventory/package-sql-idempotency.md`。

#### ④ 併 jedi 1.1.0

走 jedi-package-dev：主專案 pyproject 切 path dependency 開發、驗通後還原 pin；發版等令；1.1.0 wheel 裝上後重跑階段 1 攤平＋stamp gate，第二輪差集應只剩新增的 003 檔。

---

## 6. 拆分表 {#breakdown nav="拆分"}

> 每列＝一張 Notion 子卡。「動哪些檔」是 runner 開工座標；「驗收條件」是決策者複測清單。

### FR-093.1 出貨線接套件 migration — 4 棒

| 棒 | 做什麼 | 動哪些檔 | 驗收條件 | 依賴 |
|----|--------|----------|----------|------|
| **.1a 攤平腳本＋子清單（CM-1753）** | 新增 `scripts/build/flatten_pkg_migrations.sh`（掃 site-packages `jedi_*/migrations/*.sql` → `scripts/sql/packages/<pkg>/`＋`packages/manifest.tsv`＋靜態檢查）；`.gitignore` 排除 `scripts/sql/packages/`；`build_release.sh`／`build_init_image.sh`／`build_all.sh` 接上 | `scripts/build/flatten_pkg_migrations.sh`（新）、`build_release.sh`、`build_init_image.sh`、`build_all.sh`、`.gitignore`、`scripts/build/README.md` | 本機跑後 `scripts/sql/packages/` 有 11 目錄 24 檔＋manifest.tsv 24 列、字母序；故意放一支含 `INSERT INTO public.schema_migrations` 的 SQL 進某套件 migrations → 腳本 fail | 無 |
| **.1b migrate.sh 第二輪＋Dockerfile＋三支斷言腳本（CM-1754）** | `migrate.sh` 抽 `read_manifest`／`apply_one`，加第二輪差集與客戶庫警告；`Dockerfile` 加 `test -f` 斷言；`build_init_image.sh` stamp gate 三張 diff；`gen_stamp_sql.sh` 補登納套件；`assert_db_current.sh` 併子清單 | `scripts/init/migrate.sh`、`docker/init/Dockerfile`、`scripts/build/build_init_image.sh`、`scripts/init/gen_stamp_sql.sh`、`scripts/build/assert_db_current.sh` | 對本機 DEV 快照庫 dry-run 印「主線待套 0／套件待套 24」；故意刪一支攤平檔 → `build_init_image.sh` fail 且訊息指出檔名；沒跑攤平直接 build init image → Dockerfile 斷言 fail | .1a |
| **.1c 既有庫補登 SQL＋DEV 套用（CM-1755）** | 寫 `scripts/sql/2026-09-<日>-fr093-1-backfill-package-migrations.sql`（23 支，排除 asset 002）、登 manifest；套 DEV；重生 99-stamp.sql（含 24 支） | `scripts/sql/2026-09-*-fr093-1-backfill-*.sql`（新）、`scripts/sql/manifest.tsv`、`scripts/init/99-stamp.sql`（產物） | DEV `SELECT count(*) FROM schema_migrations WHERE filename LIKE 'packages/%'`＝23；`migrate.sh` dry-run 顯示套件待套 1（asset 002）；stamp gate 通過 | .1b |
| **.1d CM-1678 驗收案：真套 asset 002＋新裝路徑（CM-1756）** | 對 DEV `MIGRATE_APPLY=1` 跑第二輪套 002；建 init image 在乾淨臨時庫 init → 驗新裝終態；核對 002 enum 段後無用到新值的語句 | 不改檔（驗收棒）；產出驗收紀錄進 `handoff/fr093-LOG.md` | DEV 與新裝庫 `\dT+ compliance.system_status_enum`、`security_sensitivity_level_enum` 全小寫；兩庫 `packages/%` 都是 24 筆；DEV 重跑 `migrate.sh` 待套 0 | .1c |

### FR-093.2 能力點自動到位 — 3 棒

| 棒 | 做什麼 | 動哪些檔 | 驗收條件 | 依賴 |
|----|--------|----------|----------|------|
| **.2a seed 四段 name-based（CM-1757）** | `gen_seed_sql.sh` 從 dump 清單移除四張表，加邏輯模板產生；驗證段加四項斷言；重生 `04-seed-core.sql` | `scripts/init/gen_seed_sql.sh`、`scripts/init/04-seed-core.sql`（產物）、`scripts/init/README.md` | 產出的 04 檔四段 grep 不到 `INSERT INTO public.capabilities (id`／`VALUES (2, ` 這類寫死 id；乾淨庫 init 後 role 2 持有數＝capabilities 總數、`route_capabilities` 孤兒 0；連 init 兩次第二次零錯 | 無（可與 .1 平行） |
| **.2b 能力點宣告攤平（CM-1758）** | `flatten_pkg_migrations.sh` 加段：載入 21 支 `CAPABILITIES`＋主專案宣告 → `scripts/sql/packages/_capabilities.sql`（同名衝突 fail）；讀 `TENANT_ADMIN_EXCLUDED_RESOURCE_TYPES` 注入回補 SQL 的扣除① | `scripts/build/flatten_pkg_migrations.sh`、`scripts/init/migrate-capability-grants.sql`（新，模板） | `_capabilities.sql` 116 筆（80＋36；以 CM-1748 落地後實數為準）；故意在某套件宣告一筆同名 → fail；扣除①五個值與 `identity.py:89-95` 一致 | .1a、CM-1748 |
| **.2c migrate 第三輪＋回補＋DEV 驗（CM-1759）** | `migrate.sh` 第三輪接 `_capabilities.sql`→`migrate-capability-grants.sql`；DEV 套用；用租戶管理員實測 | `scripts/init/migrate.sh`、`scripts/init/migrate-capability-grants.sql` | 租戶管理員登入點新套件頁面不 403；`role_id=2` 持有數＝capabilities 總數；`SELECT count(*) FROM role_capabilities rc JOIN capabilities c ON c.id=rc.capability_id JOIN roles r ON r.id=rc.role_id WHERE c.is_platform AND r.tenant_id<>1`＝0；扣除①的 resource_type 沒被回補到租戶角色；刪一筆 capabilities 重跑升級 row 回來、id 不同、登入權限不變；migrate 輸出印出 K／X／R／Y 四個數 | .1b、.2a、.2b |

### FR-093.3 選單守衛＋套件 SQL 冪等核對（併 jedi 1.1.0）— 3 棒

| 棒 | 做什麼 | 動哪些檔 | 驗收條件 | 依賴 |
|----|--------|----------|----------|------|
| **.3a 守衛測試＋fail-open 白名單（CM-1760）** | 寫 `test_package_ui_routes_have_route_capabilities.py`；盤點 DEV 現況 fail-open 的 ui_routes 產白名單；`migrate-capability-grants.sql` 末段加 WARNING 斷言 | `test/test_package_ui_routes_have_route_capabilities.py`（新）、`scripts/init/migrate-capability-grants.sql`、`docs/features/FR-093-.../inventory/fail-open-routes-baseline.md`（新） | 突變：刪 cm1423 ④ 段跑測試紅、還原綠；DEV 跑 migrate 印出白名單外 fail-open 路由 0 筆 | .2c |
| **.3b 24 支套件 SQL 冪等核對＋修正（CM-1761）** | DEV 快照臨時庫連跑兩次；schema diff；以 DEV 為準修 24 支（含 detection 7 FK）；產對照表 | jedi repo 11 支套件 `migrations/*.sql`（path dependency）、`docs/features/FR-093-.../inventory/package-sql-idempotency.md`（新） | 24 支對臨時庫第二次零錯；`pg_dump --schema-only` 套件建出的結構 ⊆ DEV 實況（無多餘、無缺 FK）；ddd-compliance-reviewer 掃 diff 無施工日誌型註解 | .1d |
| **.3c 套件補 ui_routes／route_capabilities＋發 1.1.0（CM-1762）** | 盤點有頁面的套件；每支開 `NNN-<pkg>-ui-routes.sql`（cm1423 樣板、pid name 反查）；守衛綠；path dependency 驗通 → 還原 pin → **發版等令** → 1.1.0 裝 DEV 重跑攤平＋升級 | jedi repo 各套件 `migrations/`＋`pyproject.toml` 版號、主專案 `pyproject.toml` pin | 1.1.0 裝 DEV 後 `migrate.sh` 第二輪只多出新 ui-routes 檔；新頁面選單可見、非管理員看不到；stamp gate 因 wheel 變動 fail → 重生 stamp 後過（把這段寫進 `scripts/build/README.md` 與 version-bump skill） | .3a、.3b |

**共 10 棒**（.1 四棒、.2 三棒、.3 三棒）。平行關係：.2a 可與 .1 全程平行；.2b 等 .1a；.3b 等 .1d；.3c 收尾。

---

## 7. 端到端驗收 {#acceptance nav="驗收"}

全案收口時在一台乾淨機器依序做，任一步不過即退回對應階段：

1. **乾淨機裝新版**：`install.sh` 新裝 → `SELECT filename FROM schema_migrations WHERE filename LIKE 'packages/%'` 得 24 筆（1.1.0 後含新 ui-routes 檔）；`\dT+ compliance.system_status_enum` 小寫。
2. **帶舊資料升級**：用 v1.19.0 出貨基線建庫、灌一個租戶＋租戶管理員＋幾筆 devices，再 `install.sh --upgrade` 到本版 → 舊資料完整、`packages/%` 24 筆、enum 小寫且既有列值跟著變。
3. **租戶管理員登入點新頁面不 403**：以升級前就存在的租戶管理員登入，開套件新頁面（如資產清冊），能進、能寫。
4. **選單可見性正確**：管理員看得到新頁面選單；只持有基礎角色的使用者看不到；`route_capabilities` 孤兒 0。
5. **`schema_migrations` 含 `packages/` 前綴 24 筆**（與 1 同查詢，升級路徑再驗一次），且重跑 `migrate.sh` 待套 0、能力點 upsert 0 新增、回補 0 筆（冪等）。
6. **守衛**：`pytest test/test_package_ui_routes_have_route_capabilities.py` 綠；`build_init_image.sh` stamp gate 三張 diff 全空。

---

## 8. 開卡前查（未核實項，標明歸哪一棒） {#precheck nav="開卡前查"}

| 項 | 要查什麼 | 歸哪一棒查 | 查到後怎麼用 |
|----|----------|------------|--------------|
| 10 支無 `migrations/` 套件的表歸屬 | ai-bot／ai-dashboard／common／compliance-audit／flow-engine／iam／issue／notification／oscal-v2／survey 是真的沒有表，還是表在主專案 41 支裡 | **.3b**（做 schema diff 時順手，同一套工具） | 若在主專案 41 支裡：D3 生效後該套件第一次改表要開 `migrations/`，寫進 `docs/claude/jedi-packages.md` 提醒；不影響本案任何棒 |
| 各套件基準能力點候選 | D6 定案基準能力點為 `flow_template.create`（單一基準、參照角色法）；要驗 DEV 每個租戶的 System Manager 都持有它（`tenant_provisioning` 全集扣三道後必含，除非 `workflow` 被扣——已核 `flow_template.create` 的 resource_type 是 `flow_template`（`04-seed-core.sql:307`），不在扣除①的五個值內，新租戶會持有） | **.2b**（寫回補 SQL 前） | DEV 實查每個非 root 租戶至少一個角色持有它；若有租戶零角色持有 → 該租戶不會被回補，.2b 卡要列出並由決策者裁是否補選第二基準（`smtp-config.update`） |
| `RENAME VALUE` 冪等 | 已核 wheel 1.0.1 的 002 已用 `pg_enum` 判斷包冪等；**剩下要查**：002 檔內 enum 段之後有無用到新值的語句（PG 限制：同交易內不能用剛 RENAME 的值） | **.1d** | 有 → 拆成兩支檔（003）；無 → 直接驗收 |
| CM-1748 主專案 36 筆能力點的落地位置 | 宣告會放哪個模組／常數名（本文假設是一個可 import 的 tuple） | **.2b** | 攤平腳本的 import 路徑 |
| licensed_modules 在 SQL 層可否表達 | `config.tenant_licenses` 解析快取欄位能否直接取模組清單 | **.2b**（只查不做；本案定案是不在 SQL 扣，靠軸⑥執法） | 決策者若改判要 SQL 層扣，才據此加扣除③ |
| FE 路由 ↔ 套件對照（哪些套件有頁面） | 21 支套件哪些有自己的 FE 頁面、掛哪個 `group-*` | **.3c** | 決定要開幾支 ui-routes 檔 |

---

## 9. 風險與對策 {#risks nav="風險"}

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

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

對策：① 補登 SQL 登在主線 manifest，**排在套件差集之前**執行（.1c）；② 24 支全部冪等化是硬前置（.3b），補登只是保險不是替代；③ 出包前用「帶舊資料升級」驗（§7 第 2 步）；④ `migrate.sh` 第二輪前的「從未登記過」警告讓人看得到。
:::

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

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

對策：① 只回補**新增的**能力點，不碰既有 `role_capabilities`（客戶刪掉的不會長回來——因為 `ON CONFLICT DO NOTHING` 只補不存在的列，而客戶刪掉的列在 upsert 後仍不存在……**注意**：這句只對「客戶刪掉某能力點的授權」成立；若客戶刪的是能力點 row 本身，upsert 會補回 row，回補會再授予——手冊要註明）；② 扣除①②保證不給平台級與不販售的 resource_type；③ 未授權模組由軸⑥執法擋；④ migrate 輸出明列回補了哪些角色、哪幾筆；⑤ 「升級時不回補」的開關本案不做，客戶提出再議。
:::

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

它直接改型別定義、既有資料列跟著變（不需 rewrite 表），這是好消息；壞消息：① 目標值已存在時炸——wheel 1.0.1 的 002 已用 `pg_enum` 判斷包住，**但往後任何新的 RENAME VALUE 都要照這寫法**（列入 .3b 核對規則）；② `RENAME VALUE` 不能在同一交易內接著使用新值——`--single-transaction` 下同一支 SQL 後段若 INSERT 新值會炸（.1d 核對）；③ 出貨基線 `02-schema.sql:116-133` 仍是大小寫混雜——新裝走 02 建出舊型別、再由 002 改成小寫，**新裝與升級兩條路要收斂到同一終態**（.1d 親眼驗）。
:::

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

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

::: {.callout .warn}
**能力點 upsert 的 `DO UPDATE` 會覆蓋客戶改過的 description**

D5 的 upsert 每次升級都把 `description`／`is_platform` 蓋成宣告值。`description` 只是顯示文字，客戶不該改它（無 UI）；`is_platform` 被蓋回宣告值是**刻意的**（它是守門語意，不該由資料庫側漂移）。但若某環境曾用 SQL 手動把某能力點 `is_platform` 改掉以下放（cm1423／CM-1283 那類「租戶級系統設定頁」的下放是靠宣告 FALSE，不是事後改），升級後會被蓋回——.2b 攤平前先 diff DEV `capabilities` 表與宣告，有差即列出讓決策者裁。
:::
