# FR-060 檢測基準內容管理 — 設計文件

> 功能編號：**FR-060**｜名稱：檢測基準內容管理（Detection Profile Content Management）
> 狀態：**設計定案，待實作**（D1–D19 全數拍板）｜建立日期：2026-08-03
> 前作：FR-059 掃描設定檔庫（Profile 庫）／FR-058 檢測工具整合平台｜後續：FR-061 選用範圍・豁免・人工判定・自訂項
> 工作 branch：`feature/e2e-env-build`
> 討論稿（含四張流程圖與五支探脈實測）：[`discussion.md`](./discussion.md)

## 變更紀錄

| 日期 | 版本 | 變更 |
|------|------|------|
| 2026-08-03 | v1.0 | 設計定案（D1–D19 全數拍板） |
| 2026-08-03 | v1.1 | D5 改為 enum 存 DB（推翻原「FE 寫死」建議）；多語不落庫，DB 只存 key、由前端 i18n 翻譯 |
| 2026-08-03 | v1.2 | 釐清 RLS 掛載判準（控制項表不掛，補既有慣例佐證）；記錄公版名稱／描述多語化為已識別未實作項 |

---

## 1. 需求背景與 WHY

### 1.1 使用者現在看到的只有一個名字

FR-059 解決了「profile 怎麼進系統」——把掃描設定檔打包收進平台庫、由 agent 拉檔執行。但平台把 profile 當成不透明的二進位檔，**完全不理解它的內容**：使用者在管理頁與任務下拉看到的只有一個名稱（例如「TWGCB-01-014 Ubuntu 22.04 LTS」），不知道它會驗什麼、驗幾項、哪些項目其實跑不出結果。

決策者原話（2026-08-03）：

> 目前只有項目，user 完全不知道這個東西會驗證什麼，我怎麼知道我要選什麼？哪些基準可以參考，我可以先請負責人去判斷、提前先處理，而不是等報告出來才去處理。

### 1.2 一組實測數字說明急迫性

拿系統內最常被選用的 `TWGCB-01-014`（Ubuntu 22.04 政府組態基準）在 DEV agent 主機（192.168.50.123，CINC Auditor 7.1.7）實跑抽取：

| 指標 | 數值 |
|------|-----:|
| 控制項總數 | **234** |
| 人工待判項（`impact 0.0`，執行時直接 skip） | **199** |
| 真正會自動檢查的條數 | **35** |
| 自動化覆蓋率 | **15%** |

使用者現在完全看不到這件事。他選了基準、跑完掃描、拿到一份報告，才發現絕大多數條目是 skip——而 skip 的意思是「這條要人工判斷，工具幫不了你」。這正是原話裡「先請負責人去判斷、提前先處理」所指的缺口：**199 條人工待判項如果在選擇當下就看得到，負責人可以提前展開作業；等報告出來才知道，等於白等一輪掃描。**

### 1.3 三個需求

| # | 需求 | 內容 |
|---|------|------|
| ① | **瀏覽 profile 檢驗內容**（核心） | 打開一支基準，看得到控制項清單：編號、標題、說明、期望值、分類標籤、是否為人工待判項。使用者要能在**選擇之前**判斷「這份基準適不適合我」「我要先請誰去準備什麼」 |
| ② | **修改基本資料（含改名）** | 租戶可以改自己上傳的 profile 名稱與描述；公版（`scope = SYSTEM`）只有 root 能改。現況是改不動的——名稱參與唯一索引，唯一的改名途徑是複製一份新的（fork 時順便改名） |
| ③ | **分類體系** | 決策者指出兩件事：CINC 的 profile 不只作業系統，連瀏覽器都有（還有資料庫、容器等）；以及 profile 一多，下拉會很長、會選錯。需要一套分類軸讓下拉分組、列表篩選 |

下一階段（FR-061）才做的是**對基準內容動手**的能力：選用範圍、豁免、人工判定、自訂項。本案只定義「資料模型必須支撐什麼」，不做實作——這些能力直接改變掃描參數，改錯會讓結果不正確，必須等瀏覽與分類上線、看到實際使用情形再定形狀。

### 1.4 為什麼一個「瀏覽功能」要順帶重構資料模型

決策者在討論過程中明確要求：「你要拿出你專業設計師的模式，而不是每次都用最快達到要求的模式把功能做出來，底層設計很重要。」

三個需求裡有兩個被現行資料模型直接卡住，第三個無處落腳：

1. **名稱被當成身分，所以改名改不動**——現行唯一索引是 `(detection_tool_id, tenant_id, name)`。改名會撞索引、會讓版本鏈斷開。
2. **分類屬性歸屬錯層**——分類是「這支基準是什麼」的屬性，應該跟著基準走，不該每個版本重填一次。現行單表沒有「基準」這一層，只有「某支基準的某一版」；分類存單表會每版重複，版更漏帶就漂移。
3. **控制項無處可放**——234～708 條控制項是「某一版的內容」，需要一張自己的表，且要能被 FR-061 的豁免以外鍵參照。

因此本案第一步是**主從拆表**：

```
detection_profiles          基準主檔：名稱 / 描述 / 分類三軸 / 範圍 / 所屬工具 / 啟用狀態
  └─ detection_profile_versions   版本從檔：來源檔 / sha256 / 版號 / 當前版旗標 / 抽取狀態
       └─ detection_profile_controls  控制項：一條一列，跨格式正規欄 + attributes jsonb
```

**而且現在是最便宜的時機**：DEV 僅 13 筆資料（10 筆 SYSTEM／3 筆 TENANT）、FR-059 上線才兩天、沒有租戶真的在用。等 FR-061 把選用範圍與豁免綁上去再拆，成本是現在的好幾倍。

---

## 2. 端到端流程

### 2.1 上傳到瀏覽的主幹道

```
使用者上傳 profile 壓縮檔
  → BE 結構驗證（找 inspec.yml / 防壓縮炸彈 / 防 zip-slip）── 同步、毫秒級
  → INSERT 版本列（extraction_status = pending）
  → 立即回應前端「上傳成功，內容解析中」    ← 不等抽取
  → 排入背景抽取工作（extraction_status = running）
  → 壓縮檔 bytes 落成臨時檔（NamedTemporaryFile，副檔名必須正確）
  → 依 profile_format 取對應 extractor（本期只實作 InSpec）
  → cinc-auditor json <臨時檔>      ── 234 條約 28 秒 / 708 條約 117 秒
  → 解析：剝掉 source_location 絕對路徑前綴 / 丟 code / 丟重複 desc
          impact == 0.0 標記人工待判
  → 批次 INSERT 控制項，extraction_status = succeeded
  → 刪除臨時檔
  → 前端輪詢（建議 5 秒一次）看到狀態轉換，開啟版本詳細頁瀏覽
```

失敗有三種分支，其中第三種最危險：

| 分支 | 判定 | 處理 |
|------|------|------|
| 正常 | `exit == 0` 且 `controls` 非空 | 落庫，`succeeded` |
| 明確失敗（六種實測形狀） | `exit != 0`，stderr 有明確訊息（無 `inspec.yml`／空目錄／路徑不存在／YAML 壞／檔案無副檔名等） | `failed`，記錄錯誤訊息 |
| **靜默失敗（第七種）** | `exit == 0`、stderr 全空、JSON 合法，但 `controls == []` | 一律視為 `failed`，訊息「未解析到任何控制項，請確認 profile 內容」（見 D11） |

抽取耗時與體積為實跑數據，非推估：

| Profile | 控制項數 | JSON 體積 | 抽取耗時 | 丟 code + 丟重複 desc 後 |
|---|---:|---:|---:|---:|
| `TWGCB-01-014`（Ubuntu） | 234 | 704 KB | **28–29 秒** | 約 292 KB |
| `TWGCB-01-011`（Windows） | 708 | 1.82 MB | **117 秒** | 約 870 KB |

欄位佔比實測：`code` **47.8%**／`tags` 16.2%／`descriptions` 13.1%／`desc` 11.8%，其餘各低於 10%。丟掉 `code` 與與 `descriptions.default` 重複的 `desc` 共省 **59.6%**。

> **117 秒遠超任何 HTTP 逾時設定**——抽取必須非同步，這一點沒有折衷空間，同步做法在 Windows 那支上必定逾時。

> ### 🔴 紅框一：人工待判項的判準是 `impact == 0.0`，不可用任何自訂 tag
>
> 四種寫法交叉比對的實測結果：
>
> | 判準 | 命中條數 | 結果 |
> |------|---:|------|
> | `impact == 0.0` | 199 | ✅ 正確。InSpec 原生語意，任何 profile 都有 |
> | `code` 含 skip | 199 | ✅ 正確。與上者同一集合 |
> | `tags.status == 'pending-mapping'` | 199 | ✅ 正確，但屬 TWGCB 自訂 tag，外來 profile 沒有 |
> | `tags.check_type == 'pending'` | 193 | ❌ **錯誤，漏 6 條** |
>
> `twgcb_01_014_0079` ~ `twgcb_01_014_0084` 這 6 條標的是 `check_type: 'service'`，但實際上 `impact` 為 **0.0** 且程式碼只有一個 skip——它們是貨真價實的人工待判項，只是 tag 標錯了。
>
> **正解是 `impact == 0.0`**——這是 InSpec 原生語意（`impact 0` 代表這條不做自動判定），客戶自帶的外來 profile 也一定有這個欄位，實測 100% 準確。**不要依賴任何自訂 tag 做這個判定。**（依 D19，此判準寫在 InSpec extractor 內，不是寫死在表結構的假設裡。）

### 2.2 既有派工鏈為何不受影響

本案 .1–.3 全在管理面，**agent 拿到的 payload 形狀與今天一模一樣**：

- `common/util/detection_profile_ref.py` 的 `build_payload()` 全部讀從檔欄位，拆表後原樣可用。
- `agent_file_access_service._profile_ref_resolver` 比對的是 `upload_files` 的 uid，根本不碰 profile 表。
- 兩條凍結契約的 soft-ref 不變：`config.job_execution_detection_tools.tool_params` JSONB 的 4 筆 `profile:<uid>` 字串、`compliance.agent_tasks.params` JSONB 的 6 筆 `_profile` 物件。因為 D4 定案「uid 由從檔逐列沿用」，這 10 筆既有引用**零轉換、零風險**。

⚠️ 注意 `_profile.uid` 的語意依 `source_type` 而異：**url 型是 profile 列的 uid**、**file 型是 `upload_files` 的 uid**（agent 拿它去打 FR-058.7 取檔通道）。兩個 uid 來自不同的表，搬遷時必須分開對待。

> ### 🔴 紅框二：派工鏈「零影響」的假設不成立，有兩處破口
>
> 實際 grep `app/detection_tools/service/detection_orchestration_service.py` 後發現兩處會壞，其中一處是無聲的授權漏洞：
>
> **破口 ① — `_load_profile()` 檢查 `profile.is_active`**
> 拆表後 `is_active` 在**主檔**。若 `_load_profile()` 拿到的是從檔 entity：從檔沒有該屬性 → `AttributeError` → 500；從檔有但預設 `None` → `None is False` 為假 → **守門靜默失效，已停用的 profile 照樣派得出去**。後者不會有人發現，比 500 更糟。
>
> **破口 ② — `_humanize_profile_param()` 用 `getattr(profile, "name", None)`**
> `name` 拆表後在主檔。這裡有 default 值所以不會報錯，只會讓通知信裡的「掃描設定」**靜默退化成 `profile:<uuid>`**，而且**目前沒有任何測試會抓到這個退化**。
>
> **解法**：domain service 新增 `get_version_with_profile(uid)`，回傳主檔＋從檔合成的 entity，orchestration 的三處改指它。對外行為零變化，且把「派工需要哪些欄位」收斂到一個方法裡。

### 2.3 環境紀律

> **開發期間所有 migration 只套 DEV。** STG 與 POC 一律等決策者當次明確指示才可套。
> 本案的拆表 migration 會 **`ALTER TABLE ... RENAME` 一張 STG 正在服役的表**，比加欄位危險一個量級。

| 環境 | Host | Port | DB name | 現況 |
|------|------|------|---------|------|
| DEV | 192.168.50.188 | 25432 | `guidant_ai_dev` | 13 筆（10 SYSTEM／3 TENANT），全部 `version = 1` |
| STG | 192.168.50.188 | 25432 | `guidant_ai_stg` | **已服役**，`config.detection_tool_profiles` 有 10 筆，全部 SYSTEM、無 TENANT，RLS 四段齊全 |
| POC | 192.168.50.189 | 25432 | `guidant_ai_poc` | 本次未查（遵守環境紀律，唯讀查詢也不主動做）；上版前需另行唯讀確認 |

連線帳號為 `cmmgr`（跑 migration）／`cm_app`（受 RLS），**密碼請查 `.env` 的 `DB_SECRET`，不得寫入任何版控檔案**。

> ### 🔴 紅框三：「STG 跑得順」不能當驗收依據
>
> STG 與 DEV 同為 125 筆 migration、FR-059 全 5 支都在，但 **STG 資料全部是 SYSTEM、沒有任何 TENANT 資料**。這意味著搬遷 SQL 在 STG：
> - **測不到租戶 RLS 路徑**
> - **測不到 id 32／33 的三欄 JOIN 陷阱**（同 tenant、同 name、同 file_id 但屬不同工具）
> - **測不到停用列造成的孤兒主檔**（`current_version_id IS NULL` 的合法狀態）
>
> **驗收必須在 DEV 完成**——那裡才有完整的資料形態。

---

## 3. 決策定案（D1–D19）

以下每一項都含**決策內容／定案理由／被排除方案與原因**。被排除方案不可省略——未來要反悔時，必須看得到當初排除了什麼、為什麼。

### 3.0 已拍板兩項

#### ✅ 抽取落點 ＝ BE 主機安裝 CINC Auditor

**決策內容**：抽取工作在 BE 主機執行，不派給 agent。安裝方式：

```bash
curl -fsSL https://omnitruck.cinc.sh/install.sh | bash -s -- -P cinc-auditor -v 7
```

**定案理由**：抽取是上傳當下就要發生的事，不能依賴 agent 在線。成本已列明並接受：體積 **275MB**（其中約 200MB 是本案用不到的 AWS／Azure／GCP SDK 與遠端 transport）、**三台部署機加每台開發機都要裝**、收尾要在 `docs/claude/host-dependencies.md` 新增第三項（現有兩項是中文字型與 LibreOffice）。

- ⚖️ **授權紅線**：必須是 **CINC Auditor（Apache 2.0）**。**絕不可換成官方 InSpec 6+ 商業 binary**（需接受 Chef EULA 並取得 license key）；`inspec-core` gem 由 Chef 官方發布、同樣受 EULA 影響，也不可用。
- **路徑解析比照 LibreOffice 慣例**（`docs/claude/host-dependencies.md:85-89`）：環境變數 `CINC_AUDITOR_CMD` → PATH → 絕對路徑 fallback。**不要硬編 `/usr/bin/cinc-auditor`**（macOS 開發機路徑不同）。

**被排除：派 agent 抽取**——BE 端零主機依賴是優點，但要新增一種 agent 指令型別，且依賴 agent 在線才能抽取。使用者上傳完卻因為 agent 離線而看不到內容，體驗不可接受。

#### ✅ 版本鎖定 ＝ 鎖版 ＋ 提示

**決策內容**：任務綁定**版本 uid**（即現行行為），FR-059 D2 契約零變動、10 筆既有綁定零轉換。**但要配一個提示徽章**：「此基準已有新版 v3，目前使用 v1」加一鍵換版。

**定案理由**：純鎖版有合規漏洞——TWGCB 發了新版，排程任務會無聲地繼續掃舊版，使用者以為自己合規、實際上不是。**在 GRC 系統裡這是正確性問題，不是 UX 瑕疵。**換不換是使用者的決定，但必須讓他知情。拆表後「是不是現行版」就是主檔 `current_version_id` 的一次比對，menu API 順手帶回，成本極低。BE 側留在 FR-060；FE 的徽章與一鍵換版若範圍太肥可移到 FR-061（BE 先把資料備好）。

**被排除：跟隨現行版**——同一張任務前後兩次執行會跑出不同結果、稽核無法回溯、破壞 FR-059 已凍結的契約，還要轉換 10 筆既有資料。代價全付了，換來的性質卻更差。

---

### D1 · 主從欄位歸屬

**決策內容**：

- **主檔 `detection_profiles`**：`name`／`description`／分類三軸（標的類型／標的產品／基準體系）／`scope`／`tenant_id`／`org_unit_id`／`detection_tool_id`／`is_active`／`current_version_id`
- **從檔 `detection_profile_versions`**：`source_type`／`file_id`／`url`／`sha256`／`version`／`is_current`／`supports`／`extraction_status`／控制項統計索引（總數／待判數）

**定案理由**：判準一句話——「換一版會不會變」。會變的進從檔，不會變的進主檔。`supports`（執行平台）放從檔，是因為它從該版的 `inspec.yml` 抽出來，不同版可能不同。`detection_tool_id` 放主檔，因為「這支基準給哪個工具用」是基準本身的屬性，行為與現行零變化。

**被排除：分類放從檔**——那等於每次上傳新版都要重填一次分類，且同一支基準的不同版可能分到不同類，篩選結果會前後矛盾。

### D2 · 當前版指標：`is_current` 布林 ＋ partial unique index

**決策內容**：保留 FR-059 現行的 `is_current` 布林，配 `UNIQUE (profile_id) WHERE is_current` 的 partial unique index。`current_version_id` 仍可存在當作查詢便利欄（避免列表每次都 join 從檔篩 `is_current`），但**唯一性的保證來源是 partial unique index，不是這個外鍵**。

**定案理由**：資料庫直接擋住兩筆 current，`demote_current` 先降後插的順序是被索引**強制**的，不是靠開發者自律。

**被排除：以 `current_version_id` 外鍵作為唯一性保證**——這正是 `oscal.frameworks` 走過的路，而且**官方已認錯並事實廢棄**（2026-06-16 改用 `main_version` 純字串欄，外鍵降級為冗餘、列入待 DROP，見 `docs/features/FR-038-2606-oscal-redesign/handoff/2026-06-16-framework-maintenance-fixes-record.md:60`）。三個具體傷害：① 循環外鍵逼得 DDL 拆兩段、Python model 退化成 plain int 欄位；② 刪除流程被綁死，不能先刪版本只能靠 cascade；③ **資料庫保證不了「只有一個當前版」**，而應用層從頭到尾沒實作過切換（`add_version` 完全不碰它）。

### D3 · 從檔 RLS：冗餘欄位而非 EXISTS 子查詢

**決策內容**：從檔冗餘存 `tenant_id`／`scope`／`org_unit_id`，policy 與主檔逐字對稱（四段 × 兩表 = 八段）。

**定案理由**：

1. **PostgreSQL 的 RLS 不沿外鍵繼承。** 只掛主檔的話，`SELECT * FROM 從檔` 完全不受限制 → 租戶 A 能列出租戶 B 的全部 `sha256`／`file_id`／`url`。**`file_id` 洩漏搭配 FR-058.7 取檔通道，就是一條實質的檔案越權路徑。**
2. 從檔**會被直接查**——派工主幹道 `_load_profile()` 走的就是從檔的 `get_by_uid`，不是先查主檔再 join。
3. **本專案零 EXISTS 繼承前例**——`detection_executions`、`job_execution_detection_tools` 等子表全部是冗餘欄位形狀，跟進既有形狀降低理解成本。

**冗餘的代價**：改主檔的 `scope`／`tenant_id` 時要同步更新從檔。但實務上這兩者建立後幾乎不變（fork 是建新列而非改欄位），代價很低；集中在 app service 層處理，不要散在各處。

**被排除：EXISTS 子查詢繼承主檔判定**——① 效能，每一列都要跑一次子查詢；② **子查詢本身還會再套一次主檔的 policy**，形成 policy 套 policy，除錯時極難推理。

⚠️ 兩項實作陷阱：`app_tenant_allowed_for_session(integer)` **只接 integer**，而 `tenant_id` 是 bigint → 必須寫 `tenant_id::integer`，**四段 policy × 兩張表 = 共 8 處**，漏任何一處都會在套 migration 當下硬失敗。另外**本專案目前沒有任何「主從兩表都帶 RLS」的前例，FR-060 是首例**，migration 內必須附上跨租戶讀取的驗證查詢。

### D4 · uid 歸屬：從檔逐列沿用舊 uid，主檔另生新 uid

**決策內容**：舊表那 13 個 uid **逐列沿用給從檔**；主檔另生新 uid。

**定案理由**：舊 uid 是**凍結契約**——`job_execution_detection_tools.tool_params` 的 4 筆 `profile:<uid>`、`agent_tasks.params` 的 6 筆 `_profile` 都指著它。從檔沿用等於這 10 筆既有引用零轉換、零風險；語意也對——派工綁的本來就是「某一版」不是「某支基準」。

**被排除：主檔沿用舊 uid**——那 10 筆引用全部要改寫（改 JSONB 內容），而且語意變成「派工綁基準」，與已拍板的「鎖版」相牴觸。

### D5 · 分類三軸的定義與 enum 來源

**決策內容**：

| 軸 | 型態 | 值域 |
|---|---|---|
| ① 標的類型 | 受控 enum（值存 DB） | 作業系統／瀏覽器／應用程式／資料庫／網通設備／雲端服務／容器／其他（**DB 存的是 key，此處為前端翻譯後的顯示樣貌**） |
| ② 標的產品 | 自由文字 | Ubuntu 22.04、Google Chrome、PostgreSQL 15 …（前端 autocomplete 既有值） |
| ③ 基準體系 | 受控 enum（值存 DB） | TWGCB／CIS／STIG／dev-sec／自訂（同上，DB 存 key） |
| （④ 執行平台） | **不是第四軸** | 從 `inspec.yml` 的 `supports` 自動抽取、**唯讀不可編**——它是事實不是分類 |

**enum 值來源定案：存 DB，由 root 維護**（🔴 決策者 2026-08-03 推翻原「FE 寫死」建議）。

決策者原話：

> D5 存 DB 不要寫死，這類最好都要可以維護。

**定案理由**：新增一個標的類型不該需要發版。原建議把八個類型視為「穩定的分類骨幹」，但這個前提在 D19 之後不成立——多工具擴充上線後，新格式帶進來的標的類型只會更頻繁（XCCDF 系的框架涵蓋面與 InSpec 系不同），每次都要發一版是不合理的維運模式。標的產品維持自由文字而非 enum，是因為產品名稱長尾極長（客戶自帶的 profile 可能是任何東西），硬做 enum 會逼使用者選「其他」，反而失去篩選價值。

**被排除：FE 寫死常數**——這是原討論稿的建議。排除理由是「要新增類型就得發版」，與 D19「擴充點一律是加資料、不動已上線的東西」的精神不一致：D19 才剛把工具與格式的擴充從「改表」降級成「加一列宣告」，分類 enum 卻要改常數再發版，兩者標準不一。
**被排除：標的產品做成 enum**——理由同上，長尾會讓 enum 失去篩選價值。

#### enum 存 DB 帶來的三個附帶需求（因本次改決策而新增）

原討論稿以「FE 寫死」為前提，故未涵蓋下列三項。改存 DB 後三項都是必做，不是可選：

| # | 附帶需求 | 內容 |
|---|---------|------|
| ① | **顯示名稱不落庫，由前端 i18n 翻譯** | 🔴 決策者裁示：「i18n 不要用你的方式做，DB 也是存 key 就好，由前端翻譯，這樣才不用多開一張表。」DB **只存 key**（純英數 slug，例如 `os` / `browser` / `database` / `twgcb` / `cis` / `stig`），**不存任何顯示名稱、不開 `*_trans` 翻譯表、不走 failover 讀取**。顯示層翻譯完全由前端既有 i18n 檔負責。理由：為了幾個分類值多開一張翻譯表與一套 failover 讀取鏈，維護成本遠高於收益 |
| ② | **刪除保護** | 已被 profile 引用的分類值**不可刪**——刪掉之後既有 profile 的分類值會變成懸空孤兒（指向一個不存在的 key），列表篩選與下拉分組都會出現無法解釋的空白。至少要在刪除前檢查引用數並擋下；更好的做法是**改為停用**（加 `is_active`），停用後不出現在新建與編輯的選項裡，但既有引用仍解得出這個 key（顯示層照常走前端 fallback） |
| ③ | **root 維護介面** | 需要一個 enum 值 CRUD 的落點（列表／新增／改排序／停用），權限限 root。⚠️ **不提供「改 key」**——key 是既有 profile 引用的值，改掉等同刪掉再新增，會製造與刪除同款的懸空孤兒；要換名稱是前端 i18n 的事，不是 DB 的事。若 .2 的範圍評估後太肥，**可先只做「讀 DB + 靠 migration seed 維護」**，但**必須在 FR-060 的 spec 明寫「分類值目前仍靠 migration 維護，尚未有管理 UI」**——不要讓它變成第二個懸空孤兒。D18 已有前例教訓：FR-059 預留了 `field_scope` / `hint_scope_system` 兩個 i18n key 卻沒做 UI，一年後沒人知道那兩個 key 為什麼在那裡 |

#### 前端三層 fallback 就是本案的多語方案

分類值改由 DB 供給 key 後，前端顯示邏輯**直接照抄 `DeviceManage.vue:36-57` 的三層 fallback**（那 28 行面對的是同一個問題：自由文字欄位要枚舉化、同時相容既有資料）：

1. i18n key 存在（用 `te()` 測試）→ 顯示翻譯
2. 否則顯示 DB 回傳的 key 本身
3. 再否則顯示原值；舊的自由文字用 `unshift` 保留在選項最前

**這套 fallback 就是本案的多語機制，不需要另建任何東西**——BE 只負責供給 key 與排序，翻譯是純前端的事。

> ### ⚠️ 這個做法的代價（要寫進 spec，不要讓未來的人以為是 bug）
>
> root 在 DB 新增一個 enum key 之後，**前端若還沒有對應的 i18n 條目，該值會以原始 key 顯示**——畫面上會出現 `network_device` 而不是「網通設備」，要等前端補上翻譯並發版才會正常顯示。
>
> 這是**可接受的優雅降級**：不會壞、不會空白、不會報錯，篩選與分組功能全部照常運作，只是標籤不好看。但它必須寫進 spec，否則未來有人看到畫面上出現英數 slug 會當成 bug 去追。
>
> 換句話說，「新增分類值免發版」這件事只在**功能層面**成立；要讓新值顯示為中文，仍需一次前端發版。這個取捨是決策者裁示後的既定結果，記在此處供未來反悔時參考。

> ### 🔴 不要跟進 `options.json`
>
> 專案內另有一份 `options.json` 集中式 enum i18n 檔，但實查 `grep "lang.options\." src/` **零命中——那份檔案是死的**，沒有任何地方在用。看到它不要以為那是專案的 enum i18n 標準機制而跟進，正解是上面那套三層 fallback。

### D6 · `platform_hint` 退役

**決策內容**：退役 `platform_hint` 欄位。拆表時**不搬到新表**，值隨舊表一起保留在 rename 後的 `_deprecated` 表裡。**不要在新表建一個空的 `platform_hint` 欄位。**

**定案理由**：它的功能被 D5 三軸完全覆蓋而且更精確——「這支基準是給什麼用的」由標的類型加標的產品表達，「它能在哪些平台跑」由 `supports` 自動抽取表達。而且實查 DEV 12 筆資料，值有兩種形態：空字串 `''` 與大寫的 `Windows`，**髒值已經存在**。一個沒有約束、已經有髒值、語意含糊的自由文字欄位撐不起分類。保留在 `_deprecated` 表裡是為了觀察期內若發現有人在用還撈得回來。

**被排除：保留並清洗**——清洗 12 筆很容易，但清洗完它仍然與新三軸功能重疊，只是多一個要同步維護的欄位。
**被排除：在新表建空欄位先佔位**——那會變成第二個沒人維護的髒欄位。

### D7 · 改名的語意：整鏈一起改，且不升版

**決策內容**：改名是**主檔一列 UPDATE**，所有版本天然共用同一個名稱；不升版。

**定案理由**：名稱是「這支基準叫什麼」，不是「這一版的內容」。若歷史版本保留舊名，列表上會出現兩個看起來不同的基準其實是同一支，比不改更混亂。這個行為是拆表的自然結果，不需要額外邏輯。

**被排除：改名建新版**——版號應該反映**內容**變更。改個錯字就升一版，會讓版號完全失去意義，也讓「有新版了」的提示變成噪音。

### D8 · 新增編輯端點 `PUT /detection-tool-profiles/<uid>`

**決策內容**：承 D7，既然改名／改描述／改分類都不升版，就需要一個純粹的「編輯基本資料」端點。新增 `PUT /detection-tool-profiles/<uid>`，消費那顆**已 seed 但至今沒人用的 `detection-profile.update` capability**（FR-059 spec §12 坑 2 稱它為「預留孤兒」）。

權限規則：租戶只能編自己的（`scope = TENANT` 且 `tenant_id` 相符）；公版（`scope = SYSTEM`）只有 root 能編。此判定走 `common/authz/` 的**資源域守門**（app service 層），**不要另立 helper**。

**定案理由**：現況完全沒有「編輯基本資料」的入口——名稱只能在 fork／copy 時順便改，等於「改名要先複製一份」，這正是需求 ② 的直接來源。附帶效益：那顆 capability 從孤兒變成有實際消費者，權限矩陣不再有一列是死的。

**被排除：沿用建立端點做 upsert**——建立與編輯的權限判準不同（建立看 scope 能不能選、編輯看資源歸屬），混在一起會讓授權判定難以推理。

### D9 · fork／copy_to_tool 的版本複製語意

**決策內容**：**只複製當前版，成為新基準的 v1**（與現行為一致）；控制項一併複製（不重新抽取），`extraction_status` 一併設為 `succeeded`。fork 與 `copy_to_tool` 抽成共用的 `_clone_profile()`，差異收斂成三個參數（`scope`／`tenant_id`／`tool_id`）。

**定案理由**：fork 的意圖是「我要以這份為起點做我自己的」，歷史版本對新的那支沒有意義（那是原基準的演進史，不是新基準的），且複製全部版本會讓儲存量與 `file_id` 引用倍增。控制項內容完全相同，重抽要再等 28～117 秒沒有意義。

**被排除：複製全部版本鏈**——儲存量與引用倍增，換來的是對新基準無意義的歷史。
**被排除：fork 後重新抽取控制項**——內容完全相同，白等一輪抽取。

### D10 · 抽取非同步化與 `extraction_status` 落點

**決策內容**：`extraction_status` 四態 `pending`／`running`／`succeeded`／`failed`，**放從檔**；再配一個 `extraction_error` 文字欄存失敗訊息。前端在列表與版本子表顯示狀態徽章，`pending`／`running` 時輪詢（建議 5 秒一次）。

**定案理由**：實測 708 條的 profile 抽取要 117 秒，遠超任何 HTTP 逾時，非同步是必然不是選項。狀態放從檔，是因為抽取是對**某一版**做的，不是對整支基準。

**被排除：`extraction_status` 放主檔**——主檔若只有一個狀態欄，上傳 v3 失敗會讓 v1、v2 的狀態一起被覆蓋成 `failed`，但那兩版的控制項其實好好的。
**被排除：走 WebSocket 推播**——為一個低頻操作接推播不划算，且專案的 SocketIO 走另一個 port。

### D11 · 抽取失敗的判定條件

**決策內容**：**`exit != 0` 或 `controls == []`，兩者任一都算失敗。**

**定案理由**：實測第七種錯誤形狀——`.rb` 控制項檔有 Ruby 語法錯誤時，`cinc-auditor json` 會 **exit 0、stderr 全空、輸出合法 JSON、但 `controls` 是空陣列**，整個檔案的控制項（包含語法正常的那些）全部被靜默丟棄。若只看 exit code，使用者會拿到一支「抽取成功但零控制項」的基準，他會以為是這份基準本來就沒東西，而不是解析壞了。**靜默的錯誤比明顯的錯誤更貴。**

**誤判風險評估**：理論上存在「合法但零控制項」的 profile（純 wrapper profile 只有 `depends` 沒有自己的控制項），但那種 profile 在本系統的使用情境下也是無效的（掃了什麼都不會驗）。把它判成失敗是正確的，錯誤訊息寫「未解析到任何控制項，請確認 profile 內容」即可。

**被排除：只看 exit code**——會讓第七種錯誤形狀變成無人察覺的資料缺漏。

### D12 · 控制項的存儲形式：一條一列拆表

**決策內容**：控制項一條一列存入 `detection_profile_controls`，**欄位以「跨格式共通的最小集合」為正規欄，格式專屬的中繼資料整包進 `attributes` jsonb**（見 D19），**丟掉 `code`、丟掉與 `descriptions.default` 重複的 `desc`**。

完整欄位：

| 類別 | 欄位 |
|------|------|
| 正規欄 | `control_id`／`title`／`description`／`severity_raw`／`severity_norm`／`is_pending`／`source_ref`（剝掉絕對路徑前綴的相對路徑） |
| 標記欄 | `extraction_format`（哪個格式抽出來的）＋ `origin`（`extracted` ｜ `custom`，FR-061 自訂項用；兩者正交，不可合併） |
| 其餘 | 全進 `attributes` jsonb（InSpec 放 `tags` ＋ `descriptions`；XCCDF 未來放 `ident`／`fixtext`／`rationale`），為常用 key 建 GIN 索引（至少 `category`） |

> 🔴 **欄位命名不可用 InSpec 專有名詞**（D19 定調）：嚴重度欄位**不叫 `impact`**——那是 InSpec 的 0.0–1.0 浮點語意，XCCDF 用五級列舉，其他工具各有各的。改成 `severity_raw`（原樣存來源值，文字）＋ `severity_norm`（跨工具可比的統一級距）。`is_pending` 保留欄位名，但**判準寫在各格式的 extractor 裡**（InSpec 是 `impact == 0.0`，XCCDF 另有 `role=unchecked`），不是寫死在表結構的假設裡。

**定案理由（決定性）**：**FR-061 的豁免要能綁單一控制項**——那需要控制項是一列可被外鍵參照的資料，不是 jsonb 陣列裡的一個元素。這是不可逆的架構決定，現在做對成本很低，之後改要動 FR-061 已寫好的東西。其他理由：搜尋／篩選／分頁走 SQL 而非 Python；「人工待判 199 條」這種統計是一句 `COUNT(*) WHERE is_pending`；未來要跨 profile 找「哪些基準有驗這條」也有路。

`attributes` 整包存 jsonb 的實測理由：tag key 集合**隨 profile 變動**——TWGCB-01-014 的 `twgcb_id`／`role`／`category`／`gpo_path`／`gcb_value`／`check_type` 各 234/234、`status` 199/234；TWGCB-01-011 則多出一個 `service_display_name`（368/708）。客戶自帶的外來 profile 更不可能有 TWGCB 那套 key，**schema 本來就不可寫死成固定欄位**。

期望值兩條路都拿得到（`tags.gcb_value` 與 `descriptions.gcb_value`，本 profile 兩者一致），**以 tags 為主、descriptions 為 fallback**。

**丟 `code` 的代價**：使用者看不到「這條實際怎麼檢查」的 Ruby 原始碼。可接受——目標受眾是合規負責人不是 InSpec 開發者，47.8% 的體積換一個幾乎沒人看的欄位不划算；若日後有需求，從 `source_ref` 可以定位到檔案，不是完全無路。

**被排除：整支 profile 的控制項存成一個大 jsonb**——豁免無法以外鍵綁單條控制項，FR-061 直接卡死；搜尋、篩選、統計全部退回 Python 端處理。

⚠️ 兩個容易混淆的欄位：`source_location.ref` 是**執行當下的絕對路徑**（含臨時目錄名），落庫前要剝掉前綴只留 `controls/xxx.rb`，否則存進去的是無意義且會洩漏內部路徑的字串；頂層 `sha256` 是 CINC 自算的 profile 內容雜湊，**與 `detection_tool_profiles.sha256`（壓縮檔原始 bytes 的雜湊，FR-039 對帳的信任根）不是同一個值**，切勿混用。

### D13 · menu API 保持扁平，由 FE 自己 groupBy

**決策內容**：BE 的 menu API 維持扁平陣列 `[{value, name, target_type, target_product, benchmark_family, ...}]`，分組由 FE 自己做。

**定案理由**：① **相容性**——`JobExecutionDrawer.vue:279-282` 的 `for (const m of items)` 若拿到巢狀會**靜默失效**（不 throw、不進 catch），任務抽屜的參數顯示直接退回裸的 `profile:<uuid>`，使用者看到一串 UUID；② **彈性**——FE 想改用哪一軸分組（依標的類型或依基準體系）不必動 BE；③ 符合 CLAUDE.md 的 Menu pattern（回傳 `List[MenuDto]` 不包 `PageDataDto`）。

PrimeVue 3.53 的分組能力已實查確認：Dropdown 支援 `optionGroupLabel`／`optionGroupChildren`（`Dropdown.d.ts:289,293`；`Dropdown.vue:285-293,881-891`），**且 `editable` 與分組可以並存**（`editable` 只切換輸入框呈現、`visibleOptions` 的計算完全不看 `editable`、`isValidOption` 已排除 group 分隔項）——這對本案關鍵，FR-059 P6 定案「`select_or_text` 手填能力保留」，分組不能犧牲手填。群組標頭天然不可點選；`filter` 與分組的協同路徑現成（`Dropdown.vue:910-920`）；專案內已有實例 `RoundApAuthoringView.vue:779`。

**被排除：BE 回傳巢狀**——省了 FE 十幾行 groupBy，換來一個靜默失效的相容性風險，不划算。

> **必配的 fallback 修補（不論選哪個形狀都要做）**：`DetectionConfigField.vue:82-92` 有一段 `opts.some(o => o.value === current)`，用來判斷「目前選中的值是否還在選項裡」，不在就補一個「已不可用」的 fallback 項。**選項改成巢狀後這個比對必然全部 miss**（它比對到的是 group 物件不是 leaf 選項）→ **每一個已選值都會多長一個「已不可用」項**。不會報錯、不會進 catch，只會讓每張已設定的任務都顯示成「這個 profile 已不可用」。對策：比對前先攤平選項樹；fallback 項本身也要包成一個群組，否則會混在群組之間造成渲染錯亂。

### D14 · 列表語意：主列表一列 ＝ 一支基準

**決策內容**：主列表一列代表一支基準（主檔），預設只顯示 `is_active` 的；版本歷史走**展開列**（expander，pattern 抄 `SheetPreviewControls.vue`），並另有獨立分頁 API 供版本多的情況。原有的「顯示已停用與舊版」開關拆成兩個：主列表上是「顯示已停用基準」（主檔 `is_active = false`），版本子表上是「顯示歷史版本」（從檔 `is_current = false`；子表本來就該全顯，這個開關可能根本不需要）。

**定案理由**：拆表後「已停用的基準」與「非當前版」是兩個獨立概念，混在一個開關裡語意分裂。

**被排除：一列一版本（維持現行形狀）**——那等於沒拆表，改名、分類、篩選全部回到原點。

FE 實作素材：`DetectionProfileManageView.vue` 共 1,239 行，其中約 600–700 行要重構，建議趁機拆檔（主表格／版本子表／各 Dialog 各自獨立元件），否則會膨脹到 2,000 行以上。控制項清單（234～708 條）以 client-side DataTable ＋ `:paginator` 25 筆／頁即可（PrimeVue 只渲染當前頁）；搜尋複製 `DetectionProfileManageView.vue:506-513` 的 IconField（已含 300ms debounce）。**不建議 server-side lazy loading**——控制項是某一版的靜態內容，載一次快取住比每次翻頁多打 N 次 API 快得多；**VirtualScroller 專案內零使用**，除非實測超過 1MB 或 2 秒，否則不要為這個功能開一個新 pattern。分類枚舉化直接照抄 `DeviceManage.vue:36-57` 的三層 fallback（i18n key 存在用 `te()` 測 → DB 回傳的 key → 原值，舊的自由文字用 `unshift` 保留在最前）；⚠️ **不要跟進 `options.json`**，實查 `grep "lang.options\." src/` 零命中，那份檔案是死的。

### D15 · `is_active` 只放主檔，從檔第一期不設停用欄

**決策內容**：`is_active` 只放主檔（＝停用整支基準），從檔不另設停用欄。

**定案理由**：現況 13 筆裡沒有任何「想停用某一版但保留其他版」的需求證據（唯一的停用列 id 33 是整支不要了）。加一個 boolean 欄很便宜，但憑空設計一套「版本級停用」的語意（停用當前版後哪一版變 current？）很貴。**先不做，等出現實際需求再加。**

**id 33 的搬遷處理**：它會變成「一支 `is_active = false` 的主檔，底下有一個版本，但 `current_version_id` 是 NULL」——這是合法狀態，UI 要能顯示（不要因為 current 是 NULL 就渲染爆掉），migration 的驗證查詢也要預期 `current_version_id IS NOT NULL` 只有 12 筆而非 13。

**未來若要加**：從檔加 `is_deprecated`（而非 `is_active`，避免與主檔同名混淆），且要定義「棄用當前版時 current 怎麼移轉」。

**被排除：第一期就做版本級停用**——沒有需求證據，且語意設計成本遠高於欄位成本。

### D16 · 舊表 rename 保留，觀察期後另開 migration 才 DROP

**決策內容**：本案的 migration 只做 `ALTER TABLE ... RENAME TO detection_tool_profiles_deprecated_20260803`，**不 DROP**。DROP 另開一支 migration，於觀察期（建議一個 release 週期）後執行。

**定案理由**：專案慣例——「重構搬遷」與「刪舊表」是兩支獨立的 migration，中間隔一段觀察期。前例：`2026-06-03-log-tables-partitioning.sql:66`、`2026-05-19-ssp-system-implementation-restructure.sql:255-280`（附反向 ROLLBACK 註解）、`2026-05-24-ssp-oscal-alignment-drop-old.sql`（獨立成一支才真正 DROP）。

⚠️ **rename 不會自動改索引名稱** → 新表的索引一律用新前綴（`idx_dp_*`／`idx_dpv_*`／`idx_dpc_*`），避免與舊表殘留的索引名撞名導致 migration 中途失敗。DROP 前比照 CLAUDE.md「DROP 退役表前安全四查」，實際 grep 確認沒有任何 live-wired 的 code 還在引用，不要假設。

**被排除：本案直接 DROP 舊表**——一旦搬遷有誤，資料無法回頭；且違反專案慣例。

### D17 · 既有 13 筆的分類補登：有把握的才填，其餘留 NULL

**決策內容**：migration 內做部分補登——名稱裡含 `twgcb-01-*` 明確是作業系統、`twgcb-02-003-google-chrome` 明確是瀏覽器，這類從名稱可高信心推導的直接填；推不出來的留 NULL，由使用者在管理頁補。下拉分組時「未分類」放**最後**（分類明確是常態、未分類是待補的例外），列表篩選要有「未分類」選項讓使用者找得到待補的。

**定案理由**：13 筆裡有 10 筆是我們自己 seed 的 TWGCB 公版，我們比使用者更清楚它們是什麼。

**被排除：全部留 NULL 等人工補**——把已知資訊丟掉，等於讓使用者做我們已經知道答案的工。
**被排除：全部靠猜測規則自動填**——猜錯比留白更糟（使用者會信任錯誤的分類去篩選，篩掉他該看的東西）。**寧可空著也不要猜。**

### D18 · 補做 root 建公版的 UI，讓兩個懸空 i18n key 活起來

**決策內容**：補做 root 建立公版的 UI，消費 FR-059 預留但未使用的 `field_scope`／`hint_scope_system` 兩個 i18n key（現況 `createDialog.scope` 恆為 `TENANT`，root 沒有辦法從 UI 建公版，只能靠 migration seed）。`scope` 欄位對非 root 隱藏或唯讀。

**定案理由**：本案要開放編輯基本資料（D8），編輯 Dialog 與新增 Dialog 是同一組欄位；既然都要重構那 600–700 行，順手補上成本極低。而且**公版只能靠 migration 建**本身就是 FR-059 未收乾淨的尾巴——每加一支公版又要寫一支 migration，正是 FR-059 立案時要消滅的維運模式。

**被排除：標記刪除這兩個 key**——那等於承認「公版只能靠 migration 建」是最終狀態，與 FR-059 的立案動機相牴觸。

**若實作時發現範圍太肥**：至少要在 FR-060 的 spec 明寫「公版建立目前仍走 migration」，不要讓這兩個 key 繼續懸空無人知曉。

### D19 · 多工具擴充邊界（決策者當場定調，非「照建議」）

決策者原話（2026-08-03）：

> 要預留擴充方式，以目前有的工具為主，未來如果又有多工具有需要設定檔的，也要可以擴充，不能像這次一樣又大幅度更新，上線後很危險。

**問題背景**：工具維度本來就在——`detection_tool_id` 是建表就有的 not-null 欄位，唯一索引三欄含它，列表頁已有工具篩選、建立時必選工具、「複製到另一工具」整個功能繞著它轉。但目前**只有 InSpec 系真的入庫**：

| 工具 | `profile` 欄位 | 選項來源 | DEV 筆數 |
|---|---|---|---:|
| `gcb` 政府組態基準 | `select_or_text` | `options_source: profile_library` ← 接庫 | 10 |
| `inspec` CINC Auditor | `select_or_text` | `options_source: profile_library` ← 接庫 | 2 |
| `openscap` OpenSCAP | `select_or_text` | **硬編 3 個 XCCDF profile id**，沒接庫 | 1 |
| 其餘五個（openvas／nessus／sonarqube／zap／nmap） | — | 無 profile 欄位 | 0 |

掛在 openscap 底下那 1 筆是 id 33，即「使用者選錯工具後停用重建」的殘留，是誤操作痕跡不是 OpenSCAP 真的在用庫。

> 🔴 **若照原提案直接刻 InSpec 語意，第二個工具進來就要改已上線的表。** 原 D12 提案的欄位（`impact` 浮點／`tags`／`descriptions`／`is_pending` 由 `impact==0.0` 推導）是 InSpec 專有語意。XCCDF 的對應概念完全不同：`severity` 是五級列舉不是浮點、沒有「人工待判」概念（另有 `role=unchecked`）、中繼資料是 `ident`（CCE／CCI）／`fixtext`／`rationale`。第二個工具入庫時只剩兩條路：加一個 `severity` 欄，或把五級硬塞進浮點——兩條都是上線後改表，正是決策者指的「大幅度更新、上線後很危險」。

**定案：三層擴充結構，擴充點一律是「加資料／加實作類別」，不是「加欄位／改欄位語意」**

**① 工具端宣告能力——沿用既有慣例，不發明新機制。**
專案已有這個 pattern（`detection_tool_param_schemas` 的 `options_source: profile_library` 就是「這個工具的設定檔走庫」的宣告）。把宣告補完整：加 **`profile_format`**（`inspec`／`xccdf`／…）＋ **`supports_extraction`**（能不能抽內容）。**新工具入庫 ＝ 加一列宣告，不動任何表。**

**② 抽取器走註冊表——依 `profile_format` 取對應 extractor。**
管線不寫成「呼叫 cinc-auditor」，寫成「依格式取 extractor」。**新格式 ＝ 新增一個 extractor 類別 ＋ 註冊一行**，不動管線骨架、不動既有工具路徑。附帶效益：BE 裝 CINC 這項主機依賴只綁 `inspec` 格式，不會變成全平台前提。

**③ controls 表存正規化語意 ＋ 原始中繼資料分離。**
正規欄只收**所有格式一定都有**的最小集合（`control_id`／`title`／`description`／`severity_raw`／`severity_norm`／`is_pending`／`source_ref`）；格式差異全進 `attributes` jsonb；`extraction_format` 標記來源格式讓讀取端知道該期待什麼；`origin`（`extracted` ｜ `custom`）與它正交，供 FR-061 自訂項使用。

**代價要講清楚**：現在只有 InSpec 一種真實格式，正規化是「照一個實例抽象」，有抽錯風險。降低風險的做法是**正規欄只做最小集合**——不要為了預想中的 XCCDF 去猜欄位。寧可多留在 `attributes` 裡，日後有第二個實例驗證後再提升為正規欄（那是加欄位，不是改語意，成本可接受）。

**被排除：明寫「profile 庫只服務 InSpec 系」**——太樂觀。決策者已明確表示未來其他工具的設定檔也會在這裡維護，把邊界劃死等於把問題推給下一次改表。
**被排除：現在就完整抽象化（正規欄涵蓋預想中的 XCCDF 欄位）**——只有一種真實格式時抽象化容易抽錯，沒有第二個實例可以驗證抽象是否正確。**抽錯的抽象比沒有抽象更貴**，因為它會誤導後續實作照錯的形狀寫。

**對其他決策項的影響**：D12 欄位定義已依此改寫（不用 `impact` 當欄位名）；.1 建表採上述形狀；.3 抽取管線改註冊表骨架（工作量差異不大，都是新寫）；D5 的「基準體系」軸不受影響——CIS／STIG 本來就是跨格式的體系名。

---

### D20 · url 型基準的抽取：BE 代為下載，但只在記憶體、掃描路徑一行不改

**背景**：`.3` 抽取管線（CM-1065）上線後，url 型基準（DEV 有兩支 dev-sec 公版）一律落 `failed`，訊息是「平台不代為下載」。使用者在下拉裡選得到它們，卻**完全看不到內容**——這正是本案 §1 原始需求要解的同一件事（「使用者完全不知道這個東西會驗證什麼」）。

**推翻原假設的實測**：協調者 2026-08-03 本機實測 `cinc-auditor json <dev-sec tarball 網址>` → `exit=0`、**59 條控制項**、profile name `linux-baseline`。CINC **原生就吃 URL**。原本那道限制**不是技術限制，是決策範圍問題**。

**P5 的原始範圍**：FR-059 design P5「平台完全不經手檔案」的理由是 CM-992 限制②——「`params.profile` 是 `secret:false` 純文字，token 塞進 URL 會洩漏到 FE 執行紀錄與稽核 log」。那講的是**掃描路徑代管私有 repo 憑證**。而抽取是 FR-060 才有的另一個面向，且 dev-sec 兩支是完全公開、不需任何憑證的 GitHub URL。**本決策不推翻 P5**：URL 憑證／認證代管仍然不做。

**選了「BE 代下載」而非「直接把 URL 餵 CINC」**：後者看似更省事（少一段程式），但等於把出網整包交給 CINC——我們**管不到逾時、大小上限、目標 IP，連它會不會跟隨重導向都不知道**。走自家 `httpx` 才有地方掛防護。

**SSRF 防護五道**（決策者裁示**不做網域白名單**，新增來源要改設定的維護成本大於收益）：scheme 限 https／解析後每一顆 IP 都不得落私有保留段／**IP pinning**／50MB 邊讀邊斷／重導向 ≤3 跳且每跳重驗。實作在 `common/util/safe_http_fetch.py`。

🔴 **IP pinning 是這裡最容易漏的一道**：「解析 → 檢查 IP → 交給 httpx 連 hostname」的寫法，httpx 會自己再解析一次 DNS，攻擊者讓兩次解析回不同結果（DNS rebinding）即可完全繞過檢查。故改為**用檢查過的那顆 IP 連線**，`Host` header 與 TLS SNI 帶原 hostname，憑證驗證仍對原 hostname 生效（已實測：SNI 給錯會 `CERTIFICATE_VERIFY_FAILED`，安全性不是靠關掉驗證換來的）。同理「重導向每一跳都要重跑全部檢查」——突變測試證實只驗第一跳的話，302 到 `127.0.0.1`／`//192.168.x.x`／降級 `http://` 三種繞過全部通過。

**三條不變式**（做錯任一條都會製造更難查的問題）：
1. **掃描路徑完全不動**——agent 仍原樣把 URL 交給 `cinc-auditor exec`；`_load_profile()`／`build_payload()` 零異動（已逐欄比對 git HEAD 證明 payload 相同）
2. **下載只在記憶體**——不寫 `upload_files`、`source_type` 維持 `url`、`file_id`／`sha256` 維持 `NULL`
3. **下載在 `session_scope()` 之外**——`_prepare()` 對 url 型只回「待下載的網址」，實際下載由 worker 在兩段 session 之間做，維持既有三段式

**被排除：把下載內容存進 `upload_files` 變成 file 型快照**——那會讓 url 型悄悄變成 file 型，破壞 P5 的雙軌語意；更糟的是掃描（agent 自己拉 URL）與抽取（平台存的快照）從此可能是不同的內容，**對不上時沒人知道該信哪個**。
**被排除：網域白名單**——決策者裁示不做（新增來源就要改設定），改以上述五道防護收斂風險。

**FE 對應**：url 型指向的是 `master` branch tarball，**內容會漂**，且沒有 `sha256` 當信任根。所以 url 型的控制項清單**不可以跟 file 型長得一模一樣**（否則使用者會拿它當稽核依據），成功態加一條「內容取自外部網址的當下快照，實際掃描時以檢測代理取得的版本為準」＋顯示解析時間；file 型不顯示。

**未來反悔條件**：若出現**私有 repo**（需要憑證）的需求 → 回到 P5 的憑證代管議題，另案討論，不在本決策範圍內延伸。

---

### 3.20 四項不可逆決策

以下四項一旦上線就改不了（或改的成本高到不會有人願意付），是本案最需要現在想對的地方：

| # | 決策 | 為什麼事後改不了 |
|---|------|-----------------|
| **D4** | uid 由**從檔**逐列沿用舊值 | uid 是凍結契約，已被 `job_execution_detection_tools.tool_params`（4 筆）與 `agent_tasks.params`（6 筆）的 JSONB 內容硬指著。搬遷後若要換歸屬，等於要回頭改寫既有 JSONB 資料，且 `_profile.uid` 在 file 型指的還是 `upload_files` 的 uid，兩種語意要分開處理，出錯就是派工拿不到檔 |
| **D12 + D19** | controls 表的欄位形狀（正規欄最小集合 ＋ `attributes` jsonb ＋ `extraction_format`／`origin` 雙標記） | 這是全案最不可逆的決定。上線後要接第二個格式，**只能加資料，不能改欄位語意**——已經落庫的幾萬列控制項會照舊語意被讀。若第一版就刻 InSpec 專有欄位（`impact` 浮點），XCCDF 進來時只剩「上線後改表」一條路 |
| **D2** | 當前版指標用 `is_current` 布林 ＋ partial unique index，不用 FK 當唯一性保證 | 唯一性保證換寫法要重建索引與改寫所有升版路徑。`oscal.frameworks` 走過 FK 那條路、官方已認錯廢棄，補救動作至今仍列在待 DROP 清單裡——那就是「事後改」的實際代價 |
| **D3** | 從檔自己掛 RLS（冗餘 `tenant_id`／`scope`／`org_unit_id`） | 若第一版漏掛，租戶隔離從上線那刻起就是破的，且 `file_id` 洩漏搭配 FR-058.7 取檔通道是實質的檔案越權路徑。事後補掛要先確認沒有既存的越權存取、還要回頭補冗餘欄位的資料。**本專案首例主從雙表 RLS，沒有前例可抄，必須一次做對** |

---

## 4. 現況接入點盤點

拆表不只是 DDL，會牽動既有的 repository、service 與派工鏈。以下是實際 grep 過程式碼後的盤點，不是推估。

### 4.1 同構前例：`oscal.frameworks` / `oscal.framework_versions`

這兩張表在 DEV **不存在**，屬 jedi-oscal-v2 套件的表——只能參考程式碼模式，**RLS 部分無實例可抄**。

**該抄的模式**：`ON DELETE CASCADE` 掛從檔的 `profile_id`；不用 SQLAlchemy `relationship()`、純外鍵 int 欄（DDD 不在 infra 層 JOIN）；Query Entity 帶關聯欄位；對外只露 uid 不露 id；主檔列表內嵌 versions 之外，版本另有獨立分頁 API；serializer 自參照樹用 `lambda:` ＋ `exclude` 防遞迴。

**該避的坑**：

- 不要寫成 N+1 的 `_enrich`（每列主檔各打一次 `list_versions`）
- 不要在 Python 端過濾／排序／分頁
- 🔴 **不要新增與既有 marshmallow schema 撞名的 class**——已有 500 事故前例（兩個 module 出現同名 schema 造成 RegistryError）。本案要新增的 schema 數量不少（主檔／從檔／控制項／分類值各一組），命名前先 grep 確認

`main_version_id` 外鍵的失敗史已寫在 D2，不重複。

### 4.2 拆表的三個實質收益

| # | 現況問題 | 拆表後 |
|---:|------|------|
| 1 | 唯一索引是 `(detection_tool_id, tenant_id, name)`——**拿 name 當身分**，這是改名改不動的根因 | 收斂成 `(profile_id) WHERE is_current`。改名變成**主檔一列 UPDATE**，版本鏈完全不受影響 |
| 2 | `deactivate_profile` 現在要同時降 `is_active` 和 `is_current`（後者純粹是為了不擋同名新版撞索引） | **那行整個消失**——`is_current` 不再與 name 唯一性糾纏 |
| 3 | fork 與 `copy_to_tool` 是兩套幾乎重複的邏輯 | 差異收斂成三個參數（`scope` / `tenant_id` / `tool_id`），版本複製邏輯相同 → 抽共用 `_clone_profile()`（D9） |

### 4.3 🔴 派工鏈兩處破口（本章最重要的一節）

原本以為拆表只影響管理面。實際 grep `app/detection_tools/service/detection_orchestration_service.py` 後發現**兩處會壞**，其中一處是無聲的授權漏洞。

> ### 🔴 破口 ① — `_load_profile()` 檢查 `profile.is_active`
>
> `is_active` 拆表後在**主檔**。若 `_load_profile()` 拿到的是從檔 entity，有兩種壞法：
>
> | 從檔 entity 的狀態 | 後果 | 嚴重度 |
> |---|---|---|
> | **沒有** `is_active` 屬性 | `AttributeError` → 500 | 高，但會被發現 |
> | **有但預設 `None`** | `None is False` 為假 → **守門靜默失效，已停用的 profile 照樣派得出去** | **更高——不會有人發現** |
>
> 後者是**無聲的授權漏洞，比 500 更糟**：使用者以為某支基準已經停用不再被掃描，實際上排程照跑。在 GRC 系統裡這是正確性問題。

> ### 🔴 破口 ② — `_humanize_profile_param()` 用 `getattr(profile, "name", None)`
>
> `name` 拆表後也在主檔。這裡因為 `getattr` 帶 default 值所以**不會報錯**，只會讓通知信裡的「掃描設定」欄位**靜默退化成 `profile:<uuid>`**——使用者收到一封通知信，看到的是一串 UUID 而不是基準名稱。
>
> **而且目前沒有任何測試會抓到這個退化。** .1 收尾時要補一支測試斷言通知信的參數顯示不是 `profile:` 開頭的裸字串。

**兩處共同的解法**：domain service 新增 **`get_version_with_profile(uid)`**，回傳主檔＋從檔合成的 entity（從檔全欄位 ＋ 主檔的 `name` / `is_active` / `detection_tool_id` / 分類三軸），orchestration 的三處呼叫點改指它。

這個解法有兩個好處：**對外行為零變化**（agent 拿到的 payload 形狀與今天一模一樣）；把「派工需要哪些欄位」這件事**收斂到一個方法裡**——未來再有欄位搬家，只有這一個方法要改，不必再 grep 一輪 orchestration。

### 4.4 確認零影響的兩處

這兩處已實際確認拆表後原樣可用，實作時**不要順手改**：

| 位置 | 為什麼不受影響 |
|------|---------------|
| `common/util/detection_profile_ref.py` 的 `build_payload()` | 全部讀從檔欄位（`source_type` / `file_id` / `url` / `sha256` / `version`），拆表後這些欄位仍在從檔，原樣可用 |
| `agent_file_access_service._profile_ref_resolver` | 它比對的是 `upload_files` 的 uid，**根本不碰 profile 表**。FR-058.7 取檔通道的授權判定與本案拆表正交 |

### 4.5 兩條凍結契約的引用不動

依 D4「uid 由從檔逐列沿用」，下列 10 筆既有引用**零轉換、零風險**：

| 位置 | 現況筆數 | 內容 |
|------|---:|------|
| `config.job_execution_detection_tools.tool_params` JSONB | 4 | `profile:<uid>` 字串 |
| `compliance.agent_tasks.params` JSONB | 6 | 帶 `_profile` 物件 |

⚠️ 再次提醒 `_profile.uid` 的語意依 `source_type` 而異：**url 型是 profile 列的 uid**、**file 型是 `upload_files` 的 uid**。搬遷驗證查詢比對 uid 時必須分開對待，不要把兩者混在同一個 `NOT EXISTS` 裡比。

---

## 5. 詳細設計

### 5.1 三張表的欄位定義

欄位歸屬照 D1（判準：「換一版會不會變」）。以下為邏輯欄位清單，實際 DDL 於 .1 的 migration 撰寫時定稿。

#### 主檔 `config.detection_profiles`

| 類別 | 欄位 | 說明 |
|------|------|------|
| 身分 | `id`／`uid` | **`uid` 另生新值**（舊 uid 歸從檔，見 D4） |
| 基本資料 | `name`／`description` | 改名只動這一列，不升版（D7） |
| 分類三軸 | `target_type`／`target_product`／`benchmark_family` | 型別軸與體系軸存 enum key（D5），產品軸為自由文字 |
| 歸屬 | `scope`／`tenant_id`／`org_unit_id` | RLS 判定來源 |
| 關聯 | `detection_tool_id` | 「這支基準給哪個工具用」是基準本身的屬性 |
| 狀態 | `is_active`／`current_version_id` | `current_version_id` 是查詢便利欄，**唯一性保證不靠它**（D2） |
| 審計 | `created_at`／`created_user`／`updated_at`／`updated_user` | API 回傳時須 enrich `*_user_name`（專案審計欄位規範） |

唯一索引：`(detection_tool_id, tenant_id, name)` **仍然保留**——同一租戶同一工具下不該有兩支同名基準。但它現在約束的是「基準」而不是「某一版」，改名不再撞版本鏈。

⚠️ **id 33 搬遷後會產生 `current_version_id IS NULL` 的主檔**，這是合法狀態（D15），UI 與 serializer 都要能處理。

#### 版本從檔 `config.detection_profile_versions`

| 類別 | 欄位 | 說明 |
|------|------|------|
| 身分 | `id`／`uid` | 🔴 **`uid` 逐列沿用舊表**（凍結契約，D4） |
| 關聯 | `profile_id` | FK → 主檔，`ON DELETE CASCADE` |
| 來源 | `source_type`／`file_id`／`url`／`sha256` | `sha256` 是壓縮檔原始 bytes 的雜湊（FR-039 對帳信任根），**不是 CINC 自算的 profile 雜湊** |
| 版本 | `version`／`is_current` | 配 `UNIQUE (profile_id) WHERE is_current` partial unique index（D2） |
| 內容衍生 | `supports` | 從該版 `inspec.yml` 抽取，**唯讀不可編** |
| 抽取 | `extraction_status`／`extraction_error`／`control_count`／`pending_count` | 四態 `pending`／`running`／`succeeded`／`failed`（D10）；兩個計數是統計索引，避免列表每次 `COUNT(*)` |
| RLS 冗餘 | `scope`／`tenant_id`／`org_unit_id` | 🔴 **必須有**（D3），policy 與主檔逐字對稱 |
| 審計 | 同主檔 | |

#### 控制項 `config.detection_profile_controls`

欄位形狀照 **D12 ＋ D19**：正規欄只收跨格式共通的最小集合，格式差異全進 jsonb。

| 類別 | 欄位 | 說明 |
|------|------|------|
| 身分 | `id`／`uid` | |
| 關聯 | `version_id` | FK → 從檔，`ON DELETE CASCADE` |
| 正規欄 | `control_id` | 控制項編號（如 `twgcb_01_014_0079`）。跨版本識別靠它，但不保證穩定（FR-061 定案） |
| 正規欄 | `title`／`description` | 清單主要顯示欄；`description` 取 `descriptions.default`，**丟掉與它重複的 `desc`** |
| 正規欄 | `severity_raw`／`severity_norm` | 🔴 **不叫 `impact`**——那是 InSpec 專有的 0.0–1.0 浮點語意。`severity_raw` 原樣存來源值（文字），`severity_norm` 是跨工具可比的統一級距 |
| 正規欄 | `is_pending` | 人工待判標記。**判準寫在各格式的 extractor 裡**（InSpec 是 `impact == 0.0`），不是寫死在表結構的假設裡 |
| 正規欄 | `source_ref` | 🔴 剝掉絕對路徑前綴後的相對路徑（`controls/xxx.rb`）。不剝會存進無意義且洩漏內部路徑的字串 |
| 標記欄 | `extraction_format` | 哪個格式抽出來的（`inspec`／`xccdf`／…） |
| 標記欄 | `origin` | `extracted` ｜ `custom`（FR-061 自訂項用）。🔴 **與 `extraction_format` 正交，兩者不可合併成一個欄位** |
| 其餘 | `attributes` jsonb | InSpec 放 `tags` ＋ `descriptions`；XCCDF 未來放 `ident`／`fixtext`／`rationale`。為常用 key 建 GIN 索引（至少 `category`，供列表 rowGroup 分組用） |

`attributes` 整包存 jsonb 的實測依據：tag key 集合**隨 profile 變動**——TWGCB-01-014 的 `twgcb_id`／`role`／`category`／`gpo_path`／`gcb_value`／`check_type` 各 234/234、`status` 199/234；TWGCB-01-011 多出一個 `service_display_name`（368/708）。客戶自帶的外來 profile 更不可能有 TWGCB 那套 key，**schema 本來就不可寫死成固定欄位**。

期望值兩條路都拿得到（`tags.gcb_value` 與 `descriptions.gcb_value`，本 profile 兩者一致），**以 tags 為主、descriptions 為 fallback**。

### 5.2 分類 enum 表（因 D5 改存 DB 而新增）

決策者裁示「DB 只存 key、由前端翻譯」，因此這張表要**盡可能薄**——不存顯示名稱、不開翻譯表。

**表名建議 `config.detection_profile_taxonomies`**（一張表容納兩個受控軸，用 `axis` 欄位區分，避免為兩個軸各開一張幾乎相同的表）。

| 欄位 | 型別 | 說明 |
|------|------|------|
| `id`／`uid` | | |
| `axis` | text | 哪一軸：`target_type` ｜ `benchmark_family`。**標的產品是自由文字，不進這張表** |
| `key` | text | 純英數 slug，如 `os`／`browser`／`database`／`twgcb`／`cis`／`stig`。**這是唯一會被 profile 引用的值** |
| `sort_order` | int | 下拉與分組的顯示順序（分組時「未分類」放最後，D17） |
| `is_active` | bool | 停用後不出現在新建／編輯選項，但既有引用仍解得出 key（見下方刪除保護） |
| 審計欄位 | | 同其他表 |

唯一索引 `(axis, key)`。**這張表是全域的**（不分租戶）——分類骨幹由 root 統一維護，租戶不得增刪，否則篩選語意會漂移。因此**不掛 RLS**，比照專案內其他全域字典表的做法。

#### 刪除保護（必做）

已被 profile 引用的分類值**不可刪**。刪掉之後既有 profile 的 `target_type` 會指向一個不存在的 key，列表篩選與下拉分組都會出現無法解釋的空白，且前端 fallback 只能顯示原始 key。

實作上兩層：

1. **應用層**：刪除前 `COUNT(*)` 查引用數，非 0 直接擋下並回明確錯誤（引用數要一起回給前端，讓 root 知道擋在哪）
2. **偏好做法：根本不提供刪除，只提供停用**（`is_active = false`）——停用不影響既有引用的解析，語意也更誠實：分類骨幹是歷史紀錄的一部分，本來就不該被抹掉

**不要用 FK 約束來做這件事**——分類值是 soft-ref（profile 存的是 key 字串不是 id），改成 FK 會牽動搬遷 SQL 與 D17 的部分補登（留 NULL 的那些）。應用層擋下即可。

#### 維護介面的落點與但書

需要一個 enum 值 CRUD 的落點（列表／新增／改 key 排序／停用），權限限 root，走 `common/authz/` 的**主體域守門**（platform-admin 軸，允許 route 層 decorator 形式）。

**若 .2 的範圍評估後太肥**，可先只做「讀 DB ＋ 靠 migration seed 維護」——但**必須在 FR-060 的 spec 明寫「分類值目前仍靠 migration 維護，尚未有管理 UI」**。D18 已有前例教訓：FR-059 預留了 `field_scope`／`hint_scope_system` 兩個 i18n key 卻沒做 UI，後來沒人知道那兩個 key 為什麼在那裡。**不要製造第二個懸空孤兒。**

---

### 5.3 RLS：兩表八段

現行 `config.detection_tool_profiles` 的四段 policy 結構，是本案要複製到兩張表上的樣板：

| Policy | 邏輯 |
|--------|------|
| **SELECT** | `scope = 'SYSTEM'` 人人可讀（此分支放**最前**），其餘走租戶隔離 |
| **INSERT** | 堵 `scope <> 'SYSTEM'`——防租戶偽造公版 |
| **UPDATE (WITH CHECK)** | 堵 `scope <> 'SYSTEM'`——防租戶把自己的 profile 竊升成公版 |
| **DELETE** | 堵 `scope <> 'SYSTEM'` |

**主檔與從檔各掛一套，共八段，policy 內容逐字對稱。控制項表不掛。**

#### 為什麼是兩表而不是三表：判準是「會不會被當作獨立查詢入口」

| 表 | 掛不掛 | 判準 |
|----|-------|------|
| 主檔 `detection_profiles` | **掛** | 有租戶身分，且是列表頁的主要查詢入口 |
| 從檔 `detection_profile_versions` | **掛** | **它是獨立入口**——派工主幹道 `_load_profile()` 走的是從檔的 `get_by_uid(uid)`，**拿著一個 uid 就能查、不經過主檔**。它自己就是一張帶租戶身分的表 |
| 控制項 `detection_profile_controls` | **不掛** | **它永遠是被帶出來的**——任何查詢都得先有 `version_id`，而 `version_id` 只能從已受 RLS 保護的從檔取得。**沒有繞過從檔直接觸及控制項的路徑** |

**專案既有慣例佐證**（實查 DEV 全庫）：本專案 RLS 一律掛在**有租戶身分的主體表**那一層，純附屬明細表不掛，且明細表通常連 `tenant_id` 欄位都沒有。實例：`oscal.catalog_controls`、`oscal.poam_items`、`oscal.assessment_findings`、`oscal.ar_results`、`oscal.ssp_inventory_items`、`survey.task_survey_ref_items`、`survey.question_answer_history_details`——**九張明細表，零張掛 RLS**。

其中 **`oscal.catalog_controls` 與本案的控制項表是同一種東西**（控制項明細），它就是不掛。本案跟進既有形狀，不開新做法。

> **判準寫成規則**（未來新增子表時據以判斷）：
>
> **本專案 RLS 掛在「有租戶身分、且可能被直接查詢」的主體表；純附屬明細（必須經主體帶出）不掛，也不加冗餘 `tenant_id`。**

> ### 🔴 從檔一定要自己掛 RLS
>
> **PostgreSQL 的 RLS 不沿外鍵繼承。** 只掛主檔的話，`SELECT * FROM 從檔` 完全不受限制 → 租戶 A 能列出租戶 B 的全部 `sha256`／`file_id`／`url`。
>
> **`file_id` 洩漏搭配 FR-058.7 取檔通道，就是一條實質的檔案越權路徑。**
>
> 而且從檔**會被直接查**——派工主幹道 `_load_profile()` 走的就是從檔的 `get_by_uid`，不是先查主檔再 join。

> ### ⚠️ helper 型別 cast 陷阱：八處漏一處就硬失敗
>
> `app_tenant_allowed_for_session(integer)` **只接 integer**，而 `tenant_id` 是 **bigint** → policy 內必須寫 **`tenant_id::integer`**。
>
> **四段 policy × 兩張表 = 共 8 處**，漏任何一處都會在**套 migration 當下硬失敗**。這個失敗是明顯的（不是靜默），但會讓整支 migration 回滾重來，撰寫時逐處對過。

> ### ⚠️ 本專案首例：主從兩表都帶 RLS
>
> 目前**沒有任何「主從兩表都帶 RLS」的前例**，FR-060 是首例，沒有既有實作可抄。因此 migration 內**必須附上跨租戶讀取的驗證查詢**——以 `cm_app` 身分（受 RLS）分別模擬租戶 A 與租戶 B，確認：
>
> 1. 租戶 A 讀不到租戶 B 的**從檔**列（這是最關鍵的一條，主檔擋得住不代表從檔擋得住）
> 2. 兩者都讀得到 `scope = 'SYSTEM'` 的公版
> 3. 租戶 A 無法 INSERT／UPDATE 出 `scope = 'SYSTEM'` 的列

冗餘欄位的代價（D3 已述）：改主檔的 `scope`／`tenant_id` 時要同步更新從檔。實務上這兩者建立後幾乎不變（fork 是建新列而非改欄位），代價很低；**集中在 app service 層處理，不要散在各處**。

### 5.4 三段式遷移路徑

全程在 `psql --single-transaction -v ON_ERROR_STOP=1` 內原子完成。十個步驟：

| # | 步驟 | 要點 |
|--:|------|------|
| ① | CREATE 主檔 `detection_profiles` | `current_version_id` 欄位建好，**FK constraint 先不加**（循環相依） |
| ② | CREATE 從檔 `detection_profile_versions` | `profile_id` FK 立即生效；冗餘 `tenant_id`／`scope`／`org_unit_id` |
| ③ | CREATE 控制項表 `detection_profile_controls` | `version_id` FK，`ON DELETE CASCADE` |
| ④ | INSERT 主檔 | `SELECT DISTINCT (tool_id, tenant_id, name)`，`current_version_id` 留 NULL |
| ⑤ | INSERT ... SELECT 從檔 | 🔴 **JOIN 條件三欄缺一不可**；🔴 **uid 逐列沿用舊表** |
| ⑥ | UPDATE 主檔回填 `current_version_id` | 依從檔的 `is_current` |
| ⑦ | ALTER TABLE 主檔 ADD FK `current_version_id` → 從檔 | 循環相依在此收口 |
| ⑧ | 建 RLS：四段 × 兩表 = 8 段 | `tenant_id::integer` cast 逐處確認 |
| ⑨ | RENAME 舊表 → `detection_tool_profiles_deprecated_20260803` | **不 DROP**（D16） |
| ⑩ | 驗證查詢，全部通過才 COMMIT | 見下 |

⑩ 的驗證查詢（缺一不可）：

- 主檔 = **13 筆**
- 從檔 = **13 筆**
- `current_version_id IS NOT NULL` = **12 筆**（不是 13——id 33 那條是合法的 NULL，D15）
- 🔴 **uid 零遺失**：以 `NOT EXISTS` 比對舊表全部 uid，結果**必須是 0**
- 跨租戶讀取驗證（§5.3 的三條）
- 收尾 `INSERT public.schema_migrations`

> ### 🔴 搬遷 JOIN 條件必須是 `(tool_id, tenant_id, name)` 三欄
>
> DEV 的 id 32 與 33 同 tenant、同 name（`twgcb-02-003-google-chrome`）、同 `file_id`，但**屬於不同工具**（tool 8 vs tool 4）——是使用者選錯工具後停用重建留下的痕跡。
>
> **少一欄，32 與 33 會 cross join，搬出錯誤的主從關係。** 而 STG 測不到這個陷阱（全 SYSTEM、無此形態資料），**這條只有在 DEV 才驗得出來**。

**為什麼不用 DEFERRABLE**：`INITIALLY DEFERRED` 會讓應用層**每一次寫入**都延到 commit 才檢查外鍵，錯誤發生點離現場很遠、極難除錯。而應用層「先 INSERT 從檔 → 再 UPDATE 主檔指標」的順序本來就自然，不需要 defer。遷移期間的循環相依用「先不加 FK、最後 ALTER 補上」解決即可。

> ### ⚠️ 索引命名一律用新前綴
>
> `RENAME TABLE` **不會自動改索引名稱**——舊表的索引名會原封不動留在 `_deprecated` 表上。新表的索引一律用新前綴 **`idx_dp_*`／`idx_dpv_*`／`idx_dpc_*`**，避免與舊表殘留的索引名撞名導致 migration 中途失敗。

舊表 DROP 另開一支 migration，觀察期（建議一個 release 週期）後執行，且比照 CLAUDE.md「DROP 退役表前安全四查」實際 grep 確認沒有 live-wired 的 code 還在引用（D16）。

---

### 5.5 抽取器註冊表（D19 的擴充骨架）

管線**不寫成「呼叫 cinc-auditor」，寫成「依格式取 extractor」**。三層結構：

**① 工具端宣告能力** — 沿用既有慣例，不發明新機制。`detection_tool_param_schemas` 已有 `options_source: profile_library` 這個「這個工具的設定檔走庫」的宣告，把它補完整：

| 新增宣告欄 | 值域 | 用途 |
|-----------|------|------|
| `profile_format` | `inspec`／`xccdf`／… | 決定取哪個 extractor |
| `supports_extraction` | bool | 這個工具的 profile 能不能抽內容 |

**新工具入庫 ＝ 加一列宣告，不動任何表。**

**② 抽取器註冊表** — 依 `profile_format` 取對應 extractor 類別。**新格式 ＝ 新增一個 extractor 類別 ＋ 註冊一行**，不動管線骨架、不動既有工具路徑。

介面最小集合：吃「壓縮檔臨時路徑」，吐「控制項列表 ＋ profile 層中繼資料（`supports` 等）」，以及三種結果分支之一。**本期只實作 InSpec 一種。**

附帶效益：BE 裝 CINC 這項主機依賴**只綁 `inspec` 格式**，不會變成全平台前提——未來 XCCDF extractor 進來時，沒裝 CINC 的環境仍可處理 XCCDF profile。

**③ controls 表存正規化語意 ＋ 原始中繼資料分離** — 已在 §5.1 定義。

**代價要講清楚**：現在只有 InSpec 一種真實格式，正規化是「照一個實例抽象」，有抽錯風險。降低風險的做法是**正規欄只做最小集合**——不要為了預想中的 XCCDF 去猜欄位。寧可多留在 `attributes` 裡，日後有第二個實例驗證後再提升為正規欄（那是加欄位，不是改語意，成本可接受）。

#### CINC 路徑解析

比照 LibreOffice 慣例（`docs/claude/host-dependencies.md:85-89`）三層解析：

1. 環境變數 **`CINC_AUDITOR_CMD`**
2. PATH
3. 絕對路徑 fallback

🔴 **不要硬編 `/usr/bin/cinc-auditor`**——macOS 開發機路徑不同。

> ### ⚖️ 授權紅線
>
> 必須是 **CINC Auditor（Apache 2.0）**。**絕不可換成官方 InSpec 6+ 商業 binary**（需接受 Chef EULA 並取得 license key）；**`inspec-core` gem 由 Chef 官方發布、同樣受 EULA 影響，也不可用**。
>
> 安裝：`curl -fsSL https://omnitruck.cinc.sh/install.sh | bash -s -- -P cinc-auditor -v 7`
>
> 成本：體積 **275MB**（其中約 200MB 是本案用不到的 AWS／Azure／GCP SDK 與遠端 transport），**三台部署機加每台開發機都要裝**，收尾要在 `docs/claude/host-dependencies.md` 新增第三項（現有兩項是中文字型與 LibreOffice）。

### 5.6 抽取管線的三種結果分支

壓縮檔 bytes 落成臨時檔（`tempfile.NamedTemporaryFile(suffix=info.ext)`，**副檔名必須正確**，CINC 靠副檔名判斷格式）後交給 extractor。**全程零 `extract()` 呼叫**——不解壓成目錄樹，與 FR-059 T-1.3「不落地解壓」驗收條件不衝突（該條件禁止的是解壓成目錄，那是 zip-slip 可能寫出惡意路徑的時刻）。

| 分支 | 判定 | 處理 |
|------|------|------|
| 正常 | `exit == 0` 且 `controls` 非空 | 落庫，`succeeded` |
| 明確失敗 | `exit != 0`，stderr 有明確訊息（六種實測形狀：無 `inspec.yml`／空目錄／路徑不存在／YAML 格式壞／檔案無副檔名等） | `failed`，記錄 stderr 到 `extraction_error` |
| 🔴 **靜默失敗** | `exit == 0`、stderr 全空、JSON 合法，但 **`controls == []`** | **也必須判 `failed`**，訊息「未解析到任何控制項，請確認 profile 內容」 |

> ### 🔴 第三種分支：只看 exit code 會漏
>
> `.rb` 控制項檔有 **Ruby 語法錯誤**時，`cinc-auditor json` 會 **exit 0、stderr 全空、輸出合法 JSON、但 `controls` 是空陣列**——整個檔案的控制項（**包含語法正常的那些**）全部被靜默丟棄。
>
> 若只看 exit code，使用者會拿到一支「抽取成功但零控制項」的基準，他會以為是這份基準本來就沒東西，而不是解析壞了。**靜默的錯誤比明顯的錯誤更貴。**
>
> **誤判風險**：理論上存在「合法但零控制項」的 profile（純 wrapper profile 只有 `depends` 沒有自己的控制項），但那種 profile 在本系統的使用情境下也是無效的（掃了什麼都不會驗）。判成失敗是正確的。

落庫前的解析動作：剝掉 `source_location.ref` 的絕對路徑前綴、丟 `code`（省 47.8%）、丟與 `descriptions.default` 重複的 `desc`（合計省 **59.6%**）、`impact == 0.0` 標記人工待判。收尾刪除臨時檔。

耗時參考（實跑，非推估）：234 條約 **28–29 秒**（704 KB → 約 292 KB）、708 條約 **117 秒**（1.82 MB → 約 870 KB）。**117 秒遠超任何 HTTP 逾時設定**，非同步沒有折衷空間。

### 5.7 API 端點增修

| 方法 | 端點 | 變更 | 說明 |
|------|------|------|------|
| GET | `/detection-tool-profiles` | 改語意 | 一列 = 一支基準（主檔），內嵌當前版摘要 ＋ 控制項統計；篩選加分類三軸 |
| **PUT** | `/detection-tool-profiles/<uid>` | **新增** | 編輯基本資料（名稱／描述／分類三軸），**不升版**（D7／D8）。消費既有但至今無人使用的 **`detection-profile.update` capability** |
| GET | `/detection-tool-profiles/<uid>/versions` | **新增** | 版本歷史**獨立分頁 API**（列表已內嵌摘要，此端點供版本多時使用） |
| GET | `/detection-tool-profile-versions/<uid>/controls` | **新增** | 某一版的控制項清單。**回全量供前端快取**（控制項是靜態內容，不做 server-side lazy loading） |
| GET | `/detection-tool-profiles/menu` | **維持扁平**（D13） | `[{value, name, target_type, target_product, benchmark_family, ...}]`，分組由 FE 自己 groupBy |
| GET | `/detection-profile-taxonomies` | **新增** | 分類 enum 值供給（依 `axis` 篩選），只回 key ＋ 排序 |

**PUT 端點的權限規則**：租戶只能編自己的（`scope = TENANT` 且 `tenant_id` 相符）；公版（`scope = SYSTEM`）只有 root 能編。此判定屬**資源域守門**（要先 resolve 資源才知道判誰），走 `common/authz/` 在 **app service 層**執行，**不要另立 helper**。

> ### 🔴 menu API 不可改成巢狀
>
> `JobExecutionDrawer.vue:279-282` 的 `for (const m of items)` 若拿到巢狀會**靜默失效**（不 throw、不進 catch）——任務抽屜的參數顯示直接退回裸的 `profile:<uuid>`，使用者看到一串 UUID。
>
> 分組交給 FE 還有兩個好處：FE 想改用哪一軸分組不必動 BE；符合 CLAUDE.md 的 Menu pattern（回傳 `List[MenuDto]` 不包 `PageDataDto`）。

> ### 🔴 FE 必配的 fallback 修補（不論 BE 回什麼形狀都要做）
>
> `DetectionConfigField.vue:82-92` 有一段 `opts.some(o => o.value === current)`，用來判斷「目前選中的值是否還在選項裡」，不在就補一個「已不可用」的 fallback 項。
>
> **選項在 FE 端改成巢狀後這個比對必然全部 miss**（它比對到的是 group 物件不是 leaf 選項）→ **每一個已選值都會多長一個「已不可用」項**。不會報錯、不會進 catch，只會讓每張已設定的任務都顯示成「這個 profile 已不可用」。
>
> 對策：比對前先攤平選項樹；fallback 項本身也要包成一個群組，否則會混在群組之間造成渲染錯亂。

---

## 6. FR-061 模型契約

本案**不實作**選用範圍、豁免、人工判定與自訂項，但資料模型必須現在就留好掛載點——這些是不可逆的架構決定，事後再改要動 FR-061 已寫好的東西。

### 6.1 四條硬約束

| FR-061 能力 | 對 FR-060 模型的要求 |
|-------------|---------------------|
| **豁免**（某條控制項對某單位不適用） | 豁免要能綁定**單一控制項** → **控制項必須是一列可被外鍵參照的資料，不能是 jsonb 陣列裡的元素**。這是 D12 選擇「一條一列拆表」的決定性理由 |
| **選用範圍**（這個單位只驗這些條目） | 選用範圍綁**主檔不綁版本**——單位的適用範圍是跨版本有效的政策決定，不該因為上傳新版就消失。但範圍內的條目清單要能對應到具體控制項，因此需要「控制項在跨版本間可識別」的機制（`control_id` 字串在同一支基準的不同版之間**通常穩定，但不保證**；此點留給 FR-061 定案） |
| **人工判定**（199 條待判項的處理結果） | 判定結果要能接上**既有的證據鏈**——判定記錄要能引用 evidence／SSP 程序書，形狀比照現有的證據關聯。控制項表要有穩定的主鍵讓判定記錄指過來 |
| **自訂項**（單位自己加的檢查條目） | 控制項表要能容納「不是抽取來的」列 → 來源標記欄 `origin = extracted \| custom`，且**重新抽取時不可清空自訂項**。🔴 注意這與 D19 的 `extraction_format`（哪個格式抽出來的）**是兩個正交的標記，不可合併成一個欄位** |

> ### 🔴 抽取欄位必須一次抽完整
>
> **不是抽「目前要顯示的子集」，是抽「CINC 給的全部（扣掉 `code` 與重複的 `desc`）」。**
>
> 如果 FR-060 只抽 `id`／`title`／`desc` 三欄，FR-061 做豁免時發現需要 `tags`、做人工判定時發現需要 `descriptions.gcb_value`，就得**把所有 profile 全部重抽一次**——每支 28～117 秒，而且要寫一支資料回填 migration。
>
> **存下來不顯示，成本是幾百 KB；沒存要重抽，成本是一整輪重跑加一支 migration。**
>
> **D19 讓這條約束更強**：重抽的成本不只是時間——多工具之後，某些格式可能**根本無法重抽**（工具端 API 已改版、或原始檔已不在）。`attributes` 整包存下來是唯一保險。

### 6.2 InSpec 原生機制對照（FR-061 實作時的落點）

FR-061 的三種能力在 InSpec / CINC 都有原生對應，不需要自己發明機制——這也回頭確認了 FR-060 該抽哪些欄位：

| 能力 | InSpec 原生機制 | 細節 |
|------|----------------|------|
| **選用範圍** | `--controls` / `--tags` | 執行時以參數限縮要跑的控制項。**這是 `tags` 必須完整存下來的直接理由**——沒有 tags 就無法用 tag 篩選 |
| **豁免** | `--waiver-file` | 欄位：`control_id`（必填）／`justification`（必填）／`expiration_date`／`run`。**豁免要有理由與到期日是 InSpec 原生設計**，FR-061 照做即可，不必自創欄位 |
| **自訂項** | wrapper profile | `depends` ＋ `include_controls` ＋ `skip_control`——用一支 wrapper profile 引用原基準並增刪條目。這條路徑意味著 FR-061 可能要**產生** profile 而非只是傳參數，複雜度較高，是它單獨成案的理由之一 |

### 6.3 已識別但本期不做：公版名稱／描述的多語化

決策者裁示（2026-08-03）：

> D20 我不想把需求拉複雜，名稱跟描述之後再補吧，目前就先選單類型的做就好。

本期**不做**，但缺口已經識別出來，記在這裡免得半年後有人重新發現一次。

**現況缺口（不是未來的問題，是現在就存在的）**：公版（`scope = SYSTEM`）的名稱與描述是我們自己 seed 的中文字面值，**英文語系租戶登入後看到的仍是中文**。實例是 DEV 那 10 筆公版，名稱形如 `TWGCB-01-014 Ubuntu 22.04 LTS v1.2（35 項自動檢查）`、`dev-sec Linux 基準（公開範例，適用 Linux 目標）`。

**為什麼本期不做**：決策者裁示不擴大範圍。這件事牽涉 root 編輯權限、seed 流程、前端 i18n 檔補齊，是完整一條線；混進 `.2` 會讓「分類與編輯」這個子項失焦。

**未來要做時的形狀**（先寫下來，免得屆時重新設計一次）：

- 主檔加 `name_i18n_key` / `description_i18n_key` 兩個 **nullable** 欄
- **公版才填、租戶自建留 NULL**
- 顯示走與 D5 相同的 fallback：有 key 且翻譯存在 → 顯示翻譯，否則顯示 `name` 字面值
- 🔴 **`name` 欄一律保留字面值不變**——搜尋（`DetectionProfileManageView.vue:506-513` 的 IconField 搜尋）與排序都在 SQL 端吃這個欄位，改成純 key 會讓搜尋與排序失效
- 這是**純追加欄位、不改既有語意**，符合 D19「擴充不動已上線的東西」的精神

> ### 與 D5 的區別（同一個問題在不同欄位有不同答案）
>
> **分類 enum 可以只存 key**——值域有限、只被下拉選取與分組使用、**沒有人會打字搜尋分類值**。
>
> **名稱不行**——它**會被搜尋、會被排序**，兩者都在 SQL 端執行。存成純 key 會讓搜尋框搜不到中文名稱、排序變成按 slug 字母序。
>
> 理由是**存取方式不同**，不是標準不一致。未來看到這兩處做法有別，是刻意的。

---

## 7. 階段拆分

切線在**管理面／執行面**之間，不是在「BE／FE」之間。

```
FR-060
 .1 資料模型重構    主從拆表 + controls 表 + 13 筆搬遷 + RLS 移位
                    + repo/service 改寫 + 派工鏈兩處破口修補
 .2 分類體系 + 基本資料編輯（含改名）
 .3 控制項抽取 + 瀏覽    BE 裝 CINC + 非同步抽取管線 + 詳細頁
 ─────────  以上第一批：全在管理面，不碰 agent、不動派工參數
 .4 選用範圍 + 豁免      （FR-061，本案只定模型契約）
 .5 人工判定 / 自訂項    （FR-061，待 .1-.3 上線後看實際使用再定形狀）

順序：.1 → .2 ∥ .3
```

| 子項 | 範圍 | 依賴與風險 |
|------|------|-----------|
| **FR-060.1**<br>資料模型 | 主從拆表 ＋ 控制項表建立（欄位採 D19 跨格式形狀）＋ 13 筆三段式搬遷 ＋ RLS 兩表八段 ＋ repository／domain service／app service 改寫 ＋ **派工鏈兩處破口修補**（`get_version_with_profile()`） | **全案的地基，必須先行。** 風險最高的一項——rename 的是 **STG 正在服役**的表，且 STG 資料全 SYSTEM 測不到租戶路徑。**驗收必須在 DEV 完成**。controls 表欄位是本案最不可逆的決定（D19） |
| **FR-060.2**<br>分類與編輯 | 分類三軸落地（D5）＋ **分類 enum 表 ＋ seed ＋ 刪除保護 ＋ root 維護介面**（D5 改存 DB 後新增的工作）＋ `platform_hint` 退役（D6）＋ 編輯端點（D8）＋ root 建公版 UI（D18）＋ 管理頁重構 ＋ 下拉分組 ＋ **fallback 比對修補**（D13）＋ 既有 13 筆分類補登（D17） | 依賴 .1 的主檔存在。與 .3 **可並行**（.2 動主檔與 UI 框架，.3 動從檔與詳細頁）。⚠️ **範圍比原討論稿估的更肥**——D5 改存 DB 後多出 enum 表、刪除保護、維護介面三塊；FE 還要補 i18n 條目（否則新值以原始 key 顯示）。若排不下，維護介面可退為「migration seed ＋ spec 明寫尚未有 UI」，但 enum 表與刪除保護**不可退** |
| **FR-060.3**<br>抽取與瀏覽 | BE 主機安裝 CINC（含 `host-dependencies.md` 補第三項）＋ **抽取器註冊表骨架**（D19）＋ InSpec extractor 實作（含三種失敗分支）＋ 非同步管線 ＋ `extraction_status` 輪詢 ＋ 控制項清單詳細頁 ＋ 摘要區（234／199／35） | 依賴 .1 的控制項表存在。**主機依賴要提前備妥**——三台部署機 ＋ 每台開發機都要裝，這是實作前的準備動作不是實作內容。**本期只實作 InSpec 一種 extractor** |
| **FR-061.4 / .5**<br>下一階段 | 選用範圍／豁免／人工判定／自訂項 | 另案。本案只定模型契約（第 6 章），**不實作** |

**為什麼切線在這裡**：.1–.3 最壞的情況是「詳細頁少一塊內容」——**既有掃描零影響**，因為這三項完全不動掃描參數。而 .4 開始就動掃描參數（`--controls`／`--tags`／`--waiver-file`），改錯會讓掃描結果不正確。**在 GRC 系統裡，使用者以為自己合規但實際不是，是嚴重問題**——這種風險等級的變更值得單獨立案、單獨驗收。

### 7.1 實作前的準備動作

1. **BE 主機裝 CINC Auditor**——三台部署機 ＋ 每台開發機，先確認 macOS 開發機的路徑解析可行（`CINC_AUDITOR_CMD` 環境變數）
2. **POC 環境唯讀確認**——本次未查（環境紀律），需確認 `config.detection_tool_profiles` 在 POC 的存在狀態與資料筆數，作為未來上版的基準
3. **Notion 開卡**，關聯 FR-059 與 CM-992

---

## 8. 端到端驗收條件

**兩組分開**：DEV 組是開發期間必須逐項打勾的；上版前組是決策者放行 STG／POC 之前的守門，**兩組不可混淆**。

> ### 🔴 「STG 跑得順」不能當驗收依據
>
> STG 與 DEV 同為 125 筆 migration、FR-059 全 5 支都在，但 **STG 的 10 筆資料全部是 SYSTEM、沒有任何 TENANT 資料**。這意味著搬遷 SQL 在 STG：
>
> - **測不到租戶 RLS 路徑**（全公版，租戶隔離那段 policy 根本沒被走過）
> - **測不到 id 32／33 的三欄 JOIN 陷阱**（同 tenant、同 name、同 file_id 但屬不同工具的形態，STG 沒有）
> - **測不到停用列造成的孤兒主檔**（`current_version_id IS NULL` 的合法狀態）
>
> **驗收必須在 DEV 完成**——那裡才有完整的資料形態（10 SYSTEM ＋ 3 TENANT）。

### 8.1 DEV 驗收（開發期間逐項打勾）

**搬遷正確性**

- [ ] 主檔筆數 = **13**
- [ ] 從檔筆數 = **13**
- [ ] `current_version_id IS NOT NULL` = **12**（不是 13——id 33 是合法的 NULL）
- [ ] 🔴 **uid 零遺失**：`NOT EXISTS` 比對舊表全部 uid，結果**必須是 0**
- [ ] id 32 與 33 各自掛在**正確的工具**底下（三欄 JOIN 沒有 cross join）
- [ ] `platform_hint` **沒有**出現在任何新表（D6：不搬、不佔位）
- [ ] 舊表已 rename 為 `detection_tool_profiles_deprecated_20260803`，**未 DROP**
- [ ] `INSERT public.schema_migrations` 已執行

**RLS（本專案首例主從雙表，必須逐條驗）**

- [ ] 以 `cm_app` 身分模擬租戶 A：讀不到租戶 B 的**主檔**列
- [ ] 🔴 以 `cm_app` 身分模擬租戶 A：**讀不到租戶 B 的從檔列**（這條是關鍵——主檔擋得住不代表從檔擋得住）
- [ ] 租戶 A 與 B 都讀得到 `scope = 'SYSTEM'` 的公版
- [ ] 租戶 A 無法 INSERT／UPDATE 出 `scope = 'SYSTEM'` 的列（主檔與從檔各驗一次）
- [ ] 八段 policy 的 `tenant_id::integer` cast 逐處確認（migration 能套過即代表通過）

**派工鏈迴歸（兩處破口）**

- [ ] 🔴 **停用的 profile 派不出去**——把某支 profile 設 `is_active = false`，確認 `_load_profile()` 的守門確實擋下（不是 500，也不是靜默放行）
- [ ] 🔴 **通知信的「掃描設定」顯示基準名稱，不是 `profile:<uuid>`**——並補一支測試斷言此事（目前沒有任何測試會抓到這個退化）
- [ ] agent 拿到的 payload 形狀與拆表前**逐欄一致**（`build_payload()` 輸出比對）
- [ ] 既有 10 筆 soft-ref 引用（4 筆 `tool_params` ＋ 6 筆 `agent_tasks.params`）**零轉換仍可解析**，url 型與 file 型分開驗

**抽取管線（三種分支各驗一次）**

- [ ] 正常：`TWGCB-01-014` 抽出 **234** 條，`is_pending` = **199**，自動檢查 **35** 條，狀態 `succeeded`
- [ ] 明確失敗：餵一個無 `inspec.yml` 的壓縮檔 → `failed` ＋ `extraction_error` 有明確訊息
- [ ] 🔴 靜默失敗：餵一個 `.rb` 有 Ruby 語法錯的 profile → `exit 0` 但 `controls == []` → **仍判 `failed`**，訊息「未解析到任何控制項」
- [ ] `source_ref` 已剝掉絕對路徑前綴（值形如 `controls/xxx.rb`，不含臨時目錄名）
- [ ] `code` 欄位**未落庫**；與 `descriptions.default` 重複的 `desc` **未落庫**
- [ ] 從檔的 `sha256`（壓縮檔 bytes）與 CINC 自算的 profile 雜湊**沒有被混用**
- [ ] 非同步驗證：`TWGCB-01-011`（708 條、約 117 秒）上傳後**立即回應**，前端輪詢看得到狀態轉換

**分類與編輯**

- [ ] enum 值取自 DB（`detection_profile_taxonomies`），非 FE 常數
- [ ] 🔴 **刪除保護**：已被 profile 引用的分類 key 不可刪（或已改為停用），且錯誤訊息帶引用數
- [ ] 新增一個 DB enum key 但前端無對應 i18n 條目時，**畫面顯示原始 key、不報錯不空白**（優雅降級，符合設計預期）
- [ ] 13 筆分類補登：`twgcb-01-*` 為作業系統、`twgcb-02-003-google-chrome` 為瀏覽器；**推不出來的留 NULL**（沒有猜測填值）
- [ ] 改名：主檔一列 UPDATE，**不升版**，版本鏈完整
- [ ] 權限：租戶編不動 `scope = SYSTEM` 的公版；root 可編
- [ ] 若維護介面未做 → **spec 已明寫「分類值目前仍靠 migration 維護，尚未有管理 UI」**

**前端**

- [ ] menu API 回**扁平**陣列，`JobExecutionDrawer.vue` 的參數顯示正常（非裸 UUID）
- [ ] 🔴 `DetectionConfigField.vue` 的 fallback 比對已改為**先攤平選項樹**——既有已設定的任務**沒有**多長出「已不可用」項
- [ ] 下拉分組與 `editable` 手填能力**並存**（FR-059 P6 契約）
- [ ] `current_version_id IS NULL` 的主檔（id 33）在列表**渲染正常不爆掉**
- [ ] 控制項清單 234／708 條在 DataTable ＋ paginator 25 筆／頁下**體感流暢**（未達 1MB 或 2 秒門檻則不引入 VirtualScroller）

### 8.2 上版前條件（決策者放行才執行）

> **開發期間所有 migration 只套 DEV。** STG（已服役、有 10 筆資料）與 POC（**等同 production**，對外 demo／客戶試玩）一律等決策者**當次明確指示**才可套。

- [ ] **POC 環境唯讀確認**——確認 `config.detection_tool_profiles` 在 POC 的存在狀態與資料筆數（本次未查，遵守環境紀律）
- [ ] **三台部署機已安裝 CINC Auditor**，`CINC_AUDITOR_CMD` 路徑解析在各機實測可用
- [ ] **`docs/claude/host-dependencies.md` 已新增第三項**（CINC Auditor），含各主機已確認狀況
- [ ] **DEV 驗收（§8.1）全數通過**——含只有 DEV 驗得出來的三項（租戶 RLS 路徑、32／33 三欄 JOIN、孤兒主檔）
- [ ] 🔴 **決策者當次明示放行**，才依序套 STG → POC；派工單或交接文件若寫「三環境都套」，**該指令本身可能就是錯的，停下問決策者**
- [ ] 上版後 POC 的既有掃描任務**仍可正常派工**（10 筆 soft-ref 引用零轉換的實地確認）

### 8.3 觀察期後另案

- [ ] 舊表 `detection_tool_profiles_deprecated_20260803` DROP——**另開一支 migration**，觀察期（建議一個 release 週期）後執行，且比照「DROP 退役表前安全四查」實際 grep 確認無 live-wired 引用（D16）
