# 出貨升級鏈端到端驗收：乾淨新裝 vs 舊機升級（CM-1778，2026-09-14）

拿 build 好的 `guidant-ai-1.20.0b1` 安裝包，在兩台乾淨機器上各走一條路，比對終態是否一致。

- **A 路（新裝）**：空機 → 用新包裝一次
- **B 路（升級）**：空機 → 先裝 1.19.0 → 造客戶資料 → 用新包 `--upgrade`

**結論：兩路終態實質一致，但發現一處真落差（內建稽核流程範本的 scope），詳見第 5 節。該落差會讓新裝客戶的租戶看不到兩個內建流程範本。依卡片第 ④ 條不自行修復，回寫等首腦裁示。**

## 1. 驗收用的包

| 項目 | 值 |
|---|---|
| 新版包 | `guidant-ai-1.20.0b1.tar.gz`（933 MB）|
| SHA256 | `daa49224cef4ff7f7e5a773956e10986a929b1de1d6be34a6b14bfa4aad52f43`（與派工單一致）|
| 來源 commit | `4a7be93f`（兩路 BE 實跑回報的 commit 也是這串）|
| 舊版包 | `guidant-ai-1.19.0.tar.gz`（2026-09-04 正式版）|

取包方式為 `scp` 唯讀取檔，未對 188 做任何變更。

## 2. 驗收環境與打折說明

兩台「乾淨主機」都是本機 docker 內自建的 Debian 12 容器（amd64、跑自己的 dockerd），互相獨立、各自空白。

| 項目 | 實際狀況 | 是否打折 |
|---|---|---|
| 主機 OS／架構 | Linux x86_64（容器內） | ⚠️ **是**。實體機是 macOS arm64，amd64 由 Docker Desktop 模擬。安裝包的 `check_environment` 硬性要求 Linux + x86_64，此模擬讓檢查通過。CPU 指令集層面與真實 x86 主機不完全等價 |
| 起始 image 數 | **0**（兩台都是） | 否。包自帶的 6 顆 image 全部靠自己載入 |
| 外部網路 | **實際切斷**（`docker network disconnect`，容器內 DNS 解不到任何外部網域） | 否。是真封閉網路，不是模擬 |
| 記憶體 | 7 GB（建議值 8 GB） | ⚠️ 安裝程式印警告後照常通過。不影響功能，可能影響大檔匯入速度 |
| 主機時區 | Etc/UTC（部署設 Asia/Taipei） | ⚠️ 安裝程式印警告後照常通過 |
| 對外 port | 80／443，**未改埠** | 否 |
| 授權匯入 | **未驗** | ⚠️ **是**。本包以 `--skip-prod-key-check` 產出（無 PROD 授權簽發鑰），依派工單指示跳過此項 |

其餘各項（安裝流程、DB、權限、選單、檔案）皆為真實執行，無替代或跳過。

## 3. 兩條路的執行結果

| 步驟 | A 路（新裝） | B 路（升級） |
|---|---|---|
| 環境檢查 | 通過（2 項警告：記憶體、時區） | 同左 |
| 安裝／升級 | `install.sh --config` → **exit 0** | 先 1.19.0 → exit 0；再 `--upgrade` → **exit 0** |
| 映像載入 | 6 顆全數載入 | 6 顆重新載入並通過完整性驗證 |
| migration | init 三輪建庫 | 主線 24 支 ＋ 套件 46 支 ＝ **實際套用 70 支**，能力點 upsert 115 筆 |
| 系統檔灌入 | 10 個 | 補灌 10 個（不重複） |
| 服務健康 | 6 個容器全 healthy | 同左 |
| 跑的映像 | `guidant-ai-be/fe:1.20.0b1` | **完全相同** |
| `/api/1.0/version` | `1.20.0b1` / commit `4a7be93f` | **完全相同** |

升級前 B 路造的客戶資料：1 個租戶、1 個專案、1 筆正常稽核輪次、**1 筆孤兒稽核輪次**（父專案不存在，用來驗孤兒清理）、1 位專案參與者、1 個使用者上傳檔。

## 4. 逐項比對（一致的部分）

| 比對項目 | 方法 | 結果 |
|---|---|---|
| DB 結構 | `pg_dump --schema-only` 全文 diff | **16904 行完全相同**。唯一差異是 5 條 CHECK 的陣列轉型寫法（`ARRAY[(x)::text,…]` vs `(ARRAY[x,…])::text[]`）與 pg_dump 每次隨機的 restrict token——逐條取 `pg_get_constraintdef` 核對，**語意等價** |
| 表數量 | `information_schema.tables` | 兩路皆 **169** |
| 權限（表＋序列＋預設 ACL） | 全量列出排序 diff | **4188 行，0 差異** |
| RLS 開關與 policy | `pg_class` ＋ `pg_policies` 全量 | **461 條 policy、165 張表旗標，0 差異** |
| CM-1800 的 13 張表 | 派工單指定查詢 | 兩路皆 **13 張全部 `relrowsecurity=t`、每張 4 條 policy** |
| CM-1800 哨兵 | `schema_migrations` | 兩路皆有 `2026-09-14-fr094-cm1800-rls-project-scoped-tables.sql` |
| migration 清單 | 全 211 筆檔名排序 diff | **0 差異**（兩路皆 211 筆） |
| 索引 | `pg_indexes` 全量 | 0 差異 |
| 函式 | `pg_proc` 全量 | 0 差異（83 個） |
| enum 型別 | 全部 12 個型別與標籤 | **0 差異** |
| 能力點 | 127 筆，名稱／resource_type／action／is_platform | **0 差異** |
| 路由↔能力點、角色↔能力點綁定 | 366 行全量 diff | **0 差異** |
| 選單路由 | 48 筆，含層級／url／排序／啟用 | 0 差異 |
| fail-open 路由 | 未綁能力點的葉節點 | 兩路皆 **恰好 3 筆**（`license-status`／`my-tasks`／`project-dashboard`，即 CM-1760 白名單） |
| 登入後的實際選單 | 建租戶管理員 → 登入 → `/user/web-menu` | 兩路皆 http 200，**10 個節點、結構／網址／能力點名稱完全相同**（僅自動編號流水號不同，屬正常） |
| 檢測基準 | `detection_profiles` | 兩路皆 **10 套**；版本 10、控制項 4133、分類 13、工具 8，皆 0 差異 |
| 框架 | `oscal.frameworks` | 兩路皆 1 個（CMMC_2），版本 2、catalog 2 |
| 稽核階段字典 | `stage_objects` | 兩路皆 6 筆 |
| storage-seed 檔案 | bucket 內容逐檔比對 | **10 個系統檔，檔名與大小完全相同** |
| 框架 PDF 可讀性 | 從 filer 實際下載 | 兩路皆 http 200、2272014 bytes、`%PDF-` 開頭 `%%EOF` 結尾——**不是空白檔** |
| 租戶管理員登入 | 設定精靈建帳號 → 登入 → 打 API | 兩路皆成功，`/oscal-frameworks/menu` 200、`/users/menu` 200，**無 403** |

### 升級路專項（新裝路不適用）

| 項目 | 結果 |
|---|---|
| 孤兒稽核輪次清理 | 升級前 2 筆（1 正常 ＋ 1 孤兒）→ 升級後**孤兒 0 筆、正常那筆完好**。清理生效且沒誤刪 |
| 舊資料保留 | 租戶、專案、參與者、上傳檔**全部還在** |
| 客戶上傳檔 | `cm1778-user.txt` 升級後仍在 bucket 內，未被 storage-seed 補灌覆蓋 |

## 5. 🔴 發現的落差

### 5.1 內建稽核流程範本的 scope 兩路不同（**真缺陷，會影響客戶**）

| | 新裝路 | 升級路 |
|---|---|---|
| `完整稽核流程` / `自我評估稽核` 的 `scope` | **`TENANT`** | `SYSTEM` |
| 非 1 號租戶的使用者看得到幾個內建範本 | **0 個** | 2 個 |

**實測方式**：以 `cm_app`（受 RLS 約束的業務帳號）模擬一個新租戶的 session，查 `compliance.flow_templates`。

**為什麼會這樣**（追到根因）：

1. `scope='SYSTEM'` 是 RLS policy 裡讓「原廠公版全租戶可讀」的那條分支（`flow_templates_select` 第一條件）。
2. 這兩筆範本由 `2026-09-14-fr093-4d-flow-templates-stage-objects.sql` 插入，該 INSERT **沒有指定 `scope` 欄位**，吃資料表預設值 `TENANT`。
3. 本該把它改正的是 `2026-09-14-fr094-cm1790-shared-scope-subtree.sql`——它有一句 `UPDATE … SET scope='SYSTEM' WHERE is_builtin=true`。
4. **升級路**：兩支依序都跑（先插入、後回填）→ 正確變 `SYSTEM`。
5. **新裝路**：`99-stamp.sql` 把 CM-1790 蓋章為「已收斂進基線」所以不跑；但範本是**蓋章之後**才由 4d migration 插入的 → 沒有任何語句把它改成 `SYSTEM`，停在預設值。

**關鍵佐證**：188 基線庫本身這兩筆是 `SYSTEM`（唯讀查證，正確）。所以問題不在基線資料，而在「蓋章跳過的 migration，其回填對象卻在蓋章後才被另一支 migration 產生」這個順序交錯。

**客戶端症狀**：新安裝的客戶，建了自己的租戶後，**流程範本清單是空的**，看不到原廠附的兩個內建稽核流程；升級上來的舊客戶則正常看得到。且不會有錯誤訊息——RLS 過濾是靜默的。

**未修復**（依派工單第 ④ 條）。可能修法有幾種（把 4d 的 INSERT 補上 `scope='SYSTEM'`／基線重產時連帶處理／99-stamp 不蓋 CM-1790 的章），屬決策者裁示範圍，runner 不自行選。

### 5.2 `public.schema_version` 兩路不同（**不影響功能，但仍是不一致**）

| | 新裝路 | 升級路 |
|---|---|---|
| `schema_version.version` | `1.20.0b1` | **`1.19.0b1`**（停在舊值）|
| `schema_migrations` 的基線哨兵 | `__init_baseline_v1.20.0b1__` | `__init_baseline_v1.19.0b1__` |

**原因**：`schema_version` 只在新裝時由 `99-stamp.sql` 寫入，升級路的 `migrate.sh` 完全不碰這張表。

**影響評估**：查過全 codebase 與 `install.sh`／`guidant` 維運腳本，**沒有任何程式或腳本讀 `public.schema_version`**（Python 側同名字串都是 SSP 匯入的欄位，無關）。升級判定實際讀的是 `/srv/guidant-ai/.env` 的 `GUIDANT_VERSION`，**兩路都正確是 `1.20.0b1`**。所以目前不影響任何功能，但「同一版的兩台機器 DB 自報版號不同」對日後除錯與盤點是個誤導源。

基線哨兵那筆屬同源現象：升級路的庫當初是 1.19.0 裝的，該筆是歷史事實，不宜也不必改寫。

### 5.3 已排除、不算落差的項目

| 觀察到的差異 | 判定 |
|---|---|
| 派工單寫「能力點 115」，實測 127 | **非缺陷**。115 是**套件宣告數**（升級日誌「upsert 115 筆能力點宣告」），127 是**總數**（含主專案）。出貨 seed `04-seed-core.sql` 本身即 127 筆，兩路一致 |
| 派工單寫「新裝 enum 應為小寫」，實測有 6 個大寫 | **非缺陷**。大寫的是流程引擎狀態 enum（`jobstatus`／`taskstatus` 等，值為 `TODO,PROCESSING,…`），與 CM-1756 Q2 講的 asset enum 不同組。asset 相關 enum 在此庫中不存在（無對應表）。**兩路 12 個 enum 完全相同**，不存在兩路不一致問題 |
| 5 條 CHECK 約束的 SQL 文字不同 | **非缺陷**，`pg_get_constraintdef` 核對語意等價，僅 pg_dump 輸出寫法差異 |
| 選單能力點的數字 id 不同（如 72 vs 80） | **非缺陷**，自動編號，兩路產生順序不同所致；名稱層比對 0 差異 |
| 升級路多 1 個租戶、多 1 個上傳檔 | **預期**，是驗收時刻意造的客戶資料，正是「舊資料沒弄丟」的證據 |

## 6. 待首腦裁示

1. **5.1 內建流程範本 scope**（會影響新裝客戶，建議優先）：怎麼修、要不要重產出貨基線。
2. **5.2 `schema_version` 升級不更新**：要不要讓 `migrate.sh` 也寫這張表，或確認它已無用途可考慮廢除。
