---
title: "檢測基準內容管理 — 需求討論稿 (FR-060)"
brand: "Guidant AI · **FR-060** 檢測基準內容管理"
eyebrow: "FR-060 · Detection Profile Content Management — 需求討論稿 · 2026-08-03（v1 待審）"
h1: "讓使用者看得見「這份基準到底要驗什麼」"
lede: "FR-059 把 profile 檔搬進了後台，但使用者在下拉選單裡看到的仍只有一個名字。**他不知道選下去會驗證什麼、有幾條、哪些機器要驗、哪些其實根本不會自動檢查。**本案要把 profile 的內容打開——抽取控制項清單、建立分類體系、開放基本資料編修（含改名）。同時做一次**資料模型重構**：把現在的單表（名稱與版本混在一起）拆成「基準主檔 ／ 版本從檔 ／ 控制項」三層，改名才動得了、分類才掛得上、FR-061 的豁免與選用範圍才有得掛。"
chips: [
  {text: "已拍板 3 項", kind: ok},
  {text: "待決策 D1–D18（18 項）", kind: warn},
  {text: "D19 多工具擴充已定調", kind: ok},
  {text: "前作：FR-059（profile 庫）", kind: accent},
  {text: "後續：FR-061（選用範圍／豁免／人工判定）", kind: accent},
  {text: "STG 已服役 · 拆表要 rename 線上表", kind: crit}
]
footer: "FR-060 · 檢測基準內容管理 — 需求討論稿 · 2026-08-03 v1 待審 · 前作：FR-059（Profile 庫）／FR-058（檢測工具整合）· 後續：FR-061（選用範圍／豁免／人工判定／自訂項）· 已拍板 3 項（含 D19 多工具擴充邊界）· 待決策 D1–D18 · 實測資料來源：DEV agent 主機 192.168.50.123（CINC 7.1.7）、DEV／STG／各工具參數 schema 資料庫唯讀查詢"
---

## 需求背景：使用者現在看到的是一個名字 {#why sec="1" nav="背景"}

FR-059 解決了「profile 怎麼進系統」，本案解決「profile 進來之後使用者怎麼看懂它」。

> 目前只有項目，user 完全不知道這個東西會驗證什麼，我怎麼知道我要選什麼？哪些基準可以參考，我可以先請負責人去判斷、提前先處理，而不是等報告出來才去處理。 [— 使用者原話（2026-08-03）]{.attr}

### 1.1 一個數字說明急迫性

拿目前系統內最常被選的 `TWGCB-01-014`（Ubuntu 22.04 政府組態基準）實跑抽取，結果是：

::::::: statgrid
::: stat
[234]{.v}[控制項總數]{.k}
:::

::: {.stat .crit}
[199]{.v}[人工待判項（`impact 0.0`，執行時直接 skip）]{.k}
:::

::: stat
[35]{.v}[真正會自動檢查的條數]{.k}
:::

::: {.stat .warn}
[15%]{.v}[自動化覆蓋率]{.k}
:::
:::::::

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

### 1.2 三個需求

::::::: grid2
::: {.card .ok}
#### ① 瀏覽 profile 檢驗內容[核心]{.tag .ok style="margin-left:8px"}

打開一支基準，看得到它的控制項清單：編號、標題、說明、期望值、分類標籤、是否為人工待判項。

使用者要能在**選擇之前**判斷「這份基準適不適合我」「我要先請誰去準備什麼」。
:::

::: {.card .ok}
#### ② 修改基本資料（含改名）

租戶可以改自己上傳的 profile 名稱與描述；公版（`scope = SYSTEM`）只有 root 能改。

**現況是改不動的**——名稱參與唯一索引，改名等於改身分。目前唯一的「改名」途徑是複製一份新的（fork 時順便改名）。
:::

::: {.card .ok}
#### ③ 分類

決策者指出兩件事：**CINC 連瀏覽器都有**（不只作業系統，還有 Chrome、資料庫、容器等），以及 **profile 一多，下拉會很長、會選錯**。

需要一套分類軸讓下拉可以分組、列表可以篩選。
:::

::: {.card .warn}
#### （下一階段）編修

選用範圍、豁免、人工判定、自訂項——這些是**對基準內容動手**的能力，放 **FR-061**。

**本案只寫「資料模型必須支撐什麼」**（見 §4），不做實作。原因：這些能力直接改變掃描參數，改錯會讓結果不正確，必須等瀏覽與分類上線、看到實際使用情形再定形狀。
:::
:::::::

### 1.3 為什麼要順帶重構資料模型

三個需求裡有兩個被現行資料模型直接卡住：

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

所以本案的第一步是**主從拆表**：`detection_profiles`（基準主檔：名稱／描述／分類／範圍）＋ `detection_profile_versions`（版本從檔：來源檔／sha256／版號）＋ `detection_profile_controls`（控制項）。詳細影響面見 §2B、§2C。

## 探脈實測發現 {#probe sec="2" nav="探脈"}

以下四組發現**全部經過實際執行、實際查表驗證**，不是推估。凡屬推測之處都會明白標示。這是後面所有決策項的事實基礎。

### 2.0 目標資料模型（先看圖再看細節）

```{.mermaid cap="圖 1 — 現況單表 vs 拆表後三層模型（欄位歸屬詳見 D1）"}
%%{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 NOW["現況（FR-059 上線形態）"]
    A["config.detection_tool_profiles<br/>單表 · DEV 13 筆 · 全部 version=1<br/>唯一索引 (tool_id, tenant_id, name)<br/>名稱=身分 → 改名改不動<br/>分類無處掛 · 控制項無處放"]
  end
  subgraph NEXT["拆表後（FR-060.1）"]
    B["detection_profiles 主檔<br/>name / description<br/>標的類型 / 標的產品 / 基準體系<br/>scope / tenant_id / org_unit_id<br/>is_active · current_version_id"]
    C["detection_profile_versions 從檔<br/>uid 沿用舊表（凍結契約）<br/>source_type / file_id / url / sha256<br/>version / is_current / supports<br/>extraction_status<br/>冗餘 tenant_id / scope（RLS 用）"]
    D["detection_profile_controls 控制項<br/>control_id / title / desc<br/>descriptions jsonb / tags jsonb<br/>impact / is_pending / source_ref<br/>（丟 code · 丟重複 desc）"]
  end
  A -->|"三段式搬遷<br/>13 筆 uid 逐列沿用"| B
  B -->|"1 : N<br/>ON DELETE CASCADE"| C
  C -->|"1 : N（234～708 條）<br/>ON DELETE CASCADE"| D
```

### 2.A　`cinc-auditor json` 實跑結果

[執行環境：DEV agent 主機 192.168.50.123，CINC Auditor 7.1.7]{.src}

#### 輸出形狀 {#輸出形狀 .sub}

頂層 17 個 key（`controls` / `groups` / `name` / `title` / `supports` / `sha256` / `status` …）。每一條 control 有 9 個欄位，**234 條全數覆蓋、無缺漏**：

| 欄位 | 型別 | 內容與用途 |
|----|----|----|
| `id` | string | 控制項編號，如 `twgcb_01_014_0079` |
| `title` | string | 控制項標題（清單主要顯示欄） |
| `desc` | string | 主描述。**與 `descriptions.default` 完全重複** |
| `descriptions` | object | 具名描述集合。`desc 'gcb_value', '12個字元以上'` 會進 `descriptions.gcb_value` |
| `impact` | float | **人工待判項的判準**（見下方紅框） |
| `tags` | object | 扁平物件、值皆為 string。key 集合**隨 profile 變動** |
| `refs` | array | 外部參照 |
| `code` | string | 整段 Ruby 原始碼。**佔輸出體積 47.8%** |
| `source_location` | object | 檔案位置。`ref` 是**執行當下的絕對路徑** |

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

#### tags 覆蓋率（實測） {#tags-覆蓋率實測 .sub}

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）。

::: {.callout .warn}
**結論**

tag key 集合會隨 profile 變動 → **schema 不可寫死成固定欄位，整包存 jsonb**。客戶自帶的外來 profile 更不可能有 TWGCB 那套 key。
:::

#### 人工待判項的判準（四種寫法交叉比對） {#人工待判項的判準四種寫法交叉比對 .sub}

| 判準 | 命中條數 | 結果 |
|----|---:|----|
| `impact == 0.0` | 199 | [正確]{.tag .ok} InSpec 原生語意，任何 profile 都有 |
| `code` 含 skip | 199 | [正確]{.tag .ok} 與上者同一集合 |
| `tags.status == 'pending-mapping'` | 199 | [正確]{.tag .ok} 但屬 TWGCB 自訂 tag，外來 profile 沒有 |
| `tags.check_type == 'pending'` | 193 | [錯誤 · 漏 6 條]{.tag .crit} |

::: {.callout .crit}
**🔴 陷阱：用 check\_type 判會漏 6 條**

`twgcb_01_014_0079` ~ `0084` 這 6 條標的是 `check_type: 'service'`，但實際上 `impact` 為 **0.0** 且程式碼只有一個 skip——它們是貨真價實的人工待判項，只是 tag 標錯了。

[**正解是 `impact == 0.0`**——這是 InSpec 原生語意（`impact 0` 代表這條不做自動判定），客戶自帶的外來 profile 也一定有這個欄位，實測 100% 準確。**不要依賴任何自訂 tag 做這個判定。**]{.rec}
:::

#### 體積與耗時（兩支都實跑，非推估） {#體積與耗時兩支都實跑非推估 .sub}

| 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` 與重複的 `desc` 共省 59.6%。**

::: {.callout .crit}
**🔴 117 秒遠超任何 HTTP 逾時設定**

抽取**必須非同步**——上傳時做快速的結構驗證後立刻回應，抽取丟背景 job，前端輪詢或等通知。這一點沒有折衷空間，同步做法在 Windows 那支上必定逾時。
:::

#### 抽取管線（含失敗分支） {#抽取管線含失敗分支 .sub}

```{.mermaid cap="圖 2 — 抽取管線與三種結果分支（第三種是最危險的靜默失敗）"}
%%{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
    autonumber
    participant U as 使用者
    participant FE as 前端
    participant BE as BE API
    participant JOB as 背景 job
    participant CINC as cinc-auditor（BE 主機）
    participant DB as PostgreSQL

    U->>FE: 上傳 profile 壓縮檔
    FE->>BE: POST 新版本
    BE->>BE: 結構驗證（找 inspec.yml / 防 bomb / 防 slip）
    Note over BE: 同步、毫秒級
    BE->>DB: INSERT 版本列<br/>extraction_status = pending
    BE-->>FE: 立即回應（不等抽取）
    FE-->>U: 上傳成功 · 內容解析中

    BE->>JOB: 排入抽取工作
    JOB->>DB: extraction_status = running
    JOB->>JOB: 壓縮檔落成臨時檔<br/>NamedTemporaryFile(suffix=.tar.gz)
    JOB->>CINC: cinc-auditor json <臨時檔>
    Note over CINC: 234 條 ≈ 28 秒<br/>708 條 ≈ 117 秒

    alt exit == 0 且 controls 非空
        CINC-->>JOB: JSON（17 個頂層 key）
        JOB->>JOB: 解析：剝 source_location 路徑前綴<br/>丟 code · 丟重複 desc<br/>impact==0.0 標記人工待判
        JOB->>DB: 批次 INSERT 控制項<br/>extraction_status = succeeded
    else exit != 0（六種已知錯誤形狀）
        CINC-->>JOB: stderr 有明確訊息
        JOB->>DB: extraction_status = failed<br/>記錄錯誤訊息
    else exit == 0 但 controls == []
        CINC-->>JOB: 🔴 stderr 全空 · JSON 合法 · 零控制項
        Note over JOB: Ruby 語法錯導致整檔靜默丟棄<br/>只看 exit code 會誤判成功
        JOB->>DB: extraction_status = failed<br/>訊息：未解析到任何控制項
    end
    JOB->>JOB: 刪除臨時檔
```

#### 錯誤形狀：七種實測案例 {#錯誤形狀七種實測案例 .sub}

六種會 `exit 1` 並在 stderr 給出明確訊息：無 `inspec.yml`／空目錄／路徑不存在／YAML 格式壞／檔案無副檔名等。

::: {.callout .crit}
**🔴 第七種：靜默失敗**

`.rb` 控制項檔有 Ruby 語法錯誤時，`cinc-auditor json` 會 **exit 0、stderr 全空、輸出合法 JSON、但 `controls` 是空陣列**——整個檔案的控制項（包含語法正常的那些）全部被靜默丟棄。

[**對策：不能只看 exit code。`controls == []` 也必須視為抽取失敗**（見 D11）。若只看 exit code，使用者會拿到一支「抽取成功但零控制項」的基準，比抽取失敗更難察覺。]{.rec}
:::

#### 🔴 推翻原假設：CINC 直接吃壓縮檔 {#推翻原假設cinc-直接吃壓縮檔 .sub}

原本假設必須先解壓成目錄才能餵給 CINC。**實測結果：`cinc-auditor json` 直接對 `.tar.gz` 與 `.zip` 執行即可**——exit 0、234 條、輸出與讀目錄完全一致。

::: callout
**與 FR-059 T-1.3「不落地解壓」驗收條件的關係**

**不衝突。**該條件禁止的是「解壓成目錄樹」——那是 zip-slip 可能寫出惡意路徑的時刻；它不禁止「把壓縮檔本身寫成一個臨時檔」。本案全程**零 `extract()` 呼叫**，只是把已驗證過的壓縮檔 bytes 落成臨時檔再交給 CINC。

[**限制**：stdin 不支援、process substitution 不支援、**副檔名必須正確**（CINC 靠副檔名判斷格式）→ 實作用 `tempfile.NamedTemporaryFile(suffix=info.ext)`。]{.con}
:::

#### 兩個容易混淆的欄位 {#兩個容易混淆的欄位 .sub}

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

#### BE 裝 CINC（已定案，仍列出成本） {#be-裝-cinc已定案仍列出成本 .sub}

```bash
# omnitruck 官方安裝腳本，Linux / macOS 皆有包（macOS arm64 與 x86_64 dmg 都在）
curl -fsSL https://omnitruck.cinc.sh/install.sh | bash -s -- -P cinc-auditor -v 7
```

::: {.callout .warn}
**安裝成本與紀律**

**體積 275MB**，其中約 200MB 是本案用不到的 AWS / Azure / GCP SDK 與遠端 transport。

**⚖️ 授權紅線**：必須是 **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 開發機路徑不同）。

**三台部署機 ＋ 每台開發機都要裝**，收尾時要在 `host-dependencies.md` 新增第三項（現有兩項是中文字型與 LibreOffice）。
:::

## 主從拆表的 BE 影響面 {#probe-b sec="2B" nav="-"}

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

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

[注意：這兩張表在 DEV **不存在**，屬 jedi-oscal-v2 套件的表。只能參考程式碼模式，**RLS 部分無實例可抄**。]{.src}

::: {.callout .crit}
**🔴 它的 `main_version_id` 外鍵已被官方認錯並事實廢棄**

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` 完全不碰它）

[**對本案的意涵：應保留 FR-059 現行的 `is_current` 布林 ＋ partial unique index**。資料庫直接擋住兩筆 current；`demote_current` 先降後插的順序是被索引**強制**的，不是靠開發者自律。詳見 D2。]{.rec}
:::

::::: grid2
::: {.card .ok}
#### 該抄的模式

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

::: {.card .warn}
#### 該避的坑

不要寫成 N+1 的 `_enrich`（每列主檔各打一次 `list_versions`）；不要在 Python 端過濾／排序／分頁；**不要新增與既有 marshmallow schema 撞名的 class**——已有 500 事故前例（兩個 module 同名 schema 造成 RegistryError）。
:::
:::::

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

| \# | 現況問題 | 拆表後 |
|---:|----|----|
| 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()` |

### 🔴 派工鏈「零影響」的假設不成立

原以為拆表只影響管理面。實際 grep `app/detection_tools/service/detection_orchestration_service.py` 後發現**兩處會壞**：

::: {.callout .crit}
**🔴 破口 ① · `_load_profile()` 檢查 `profile.is_active`**

拆表後 `is_active` 在**主檔**。若 `_load_profile()` 拿到的是從檔 entity：

- 從檔 entity **沒有**該屬性 → `AttributeError` → 500
- 從檔 entity **有但預設 None** → `None is False` 為假 → **守門靜默失效，已停用的 profile 照樣派得出去**

後者是無聲的授權漏洞，比 500 更糟——不會有人發現。
:::

::: {.callout .crit}
**🔴 破口 ② · `_humanize_profile_param()` 用 `getattr(profile, "name", None)`**

`name` 拆表後在主檔。這裡有 default 值所以**不會報錯**，只會讓通知信裡的「掃描設定」**靜默退化成 `profile:<uuid>`**——而且**目前沒有任何測試會抓到這個退化**。
:::

::: callout
**解法**

domain service 新增 `get_version_with_profile(uid)`，回傳主檔＋從檔合成的 entity；orchestration 的三處改指它。**對外行為零變化**，且把「派工需要哪些欄位」這件事收斂到一個方法裡。
:::

### 確認零影響的兩處

- `common/util/detection_profile_ref.py` 的 `build_payload()`——全部讀從檔欄位，拆表後原樣可用
- `agent_file_access_service._profile_ref_resolver`——它比對的是 `upload_files` 的 uid，根本不碰 profile 表

## 資料庫現況、RLS 與遷移路徑 {#probe-c sec="2C" nav="-"}

### DEV 現況 13 筆

10 筆 SYSTEM（tenant 1）＋ 3 筆 TENANT（tenant 102）。**全部 `version = 1`，沒有任何真正的多版本鏈。**

::: {.callout .warn}
**⚠️ id 32 / 33 不是版本鏈**

這兩列同 tenant、同 name（`twgcb-02-003-google-chrome`）、同 file\_id，但**屬於不同工具**（tool 8 vs tool 4）——是使用者選錯工具後停用重建留下的痕跡。

id 33 是唯一 `is_current = f AND is_active = f` 的列；拆表後它的主檔會變成「**沒有任何 active version 的 profile**」，UI 要能處理這個狀態。

[**🔴 搬遷的 JOIN 條件必須是 `(tool_id, tenant_id, name)` 三欄**——少一欄，32 與 33 會 cross join，搬出錯誤的主從關係。]{.rec}
:::

**本表沒有任何外鍵**（進出皆為 0）。`detection_tool_id` / `file_id` / `tenant_id` 全是 soft-ref。

### 兩條 soft-ref（凍結契約等級，不可改）

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

::: {.callout .crit}
**🔴 `_profile.uid` 的語意依 source\_type 而異**

**url 型 = profile 列的 uid**；**file 型 = `upload_files` 的 uid**（agent 拿它去打 FR-058.7 取檔通道）。這兩個 uid 來自不同的表，搬遷時必須分開對待。

[這也是 **D4 建議「uid 由從檔沿用」**的直接理由——只要從檔逐列沿用舊 uid，這 10 筆既有引用零轉換、零風險。]{.rec}
:::

### RLS 四段 policy（要改寫的對象）

現行 `config.detection_tool_profiles` 的 RLS 結構：

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

::: {.callout .warn}
**⚠️ helper 型別 cast 陷阱**

`app_tenant_allowed_for_session(integer)` **只接 integer**，而 `tenant_id` 是 bigint → 必須寫 `tenant_id::integer`。**四段 policy × 兩張表 = 共 8 處**，漏任何一處都會硬失敗（不是靜默的，但會在套 migration 當下才爆）。
:::

::: {.callout .crit}
**🔴 從檔一定要自己掛 RLS**

**PostgreSQL 的 RLS 不沿外鍵繼承。**只掛主檔的話，`SELECT * FROM 從檔` 完全不受限制 → 租戶 A 能列出租戶 B 的全部 `sha256` / `file_id` / `url`。**`file_id` 洩漏搭配 FR-058.7 取檔通道，就是一條實質的檔案越權路徑。**

而且從檔**會被直接查**——派工主幹道 `_load_profile()` 走的就是從檔的 `get_by_uid`，不是先查主檔再 join。

[**建議：從檔冗餘存 `tenant_id` / `scope` / `org_unit_id`**，policy 與主檔逐字對稱。詳見 D3。]{.rec}

[**⚠️ 本專案目前沒有任何「主從兩表都帶 RLS」的前例，FR-060 是首例。**設計時要格外小心，且建議在 migration 內附上跨租戶讀取的驗證查詢。]{.con}
:::

### 遷移路徑：三段式，不用 DEFERRABLE

```{.mermaid cap="圖 3 — 三段式遷移路徑（全程在單一 transaction 內原子完成）"}
%%{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 TD
  S["psql --single-transaction<br/>-v ON_ERROR_STOP=1"] --> A1
  A1["① CREATE 主檔 detection_profiles<br/>current_version_id 欄位建好<br/>但 FK constraint 先不加"] --> A2
  A2["② CREATE 從檔 detection_profile_versions<br/>profile_id FK 立即生效<br/>冗餘 tenant_id / scope / org_unit_id"] --> A3
  A3["③ CREATE 控制項表 detection_profile_controls<br/>version_id FK · ON DELETE CASCADE"] --> B1
  B1["④ INSERT 主檔<br/>SELECT DISTINCT (tool_id, tenant_id, name)<br/>current_version_id 留 NULL"] --> B2
  B2["⑤ INSERT ... SELECT 從檔<br/>🔴 JOIN 條件三欄缺一不可<br/>🔴 uid 逐列沿用舊表"] --> B3
  B3["⑥ UPDATE 主檔回填 current_version_id"] --> C1
  C1["⑦ ALTER TABLE 主檔<br/>ADD FK current_version_id → 從檔"] --> C2
  C2["⑧ 建 RLS：四段 × 兩表 = 8 段<br/>tenant_id::integer cast 逐處確認"] --> D1
  D1["⑨ RENAME 舊表<br/>detection_tool_profiles<br/>→ _deprecated_20260803<br/>（不 DROP · 觀察期後另開 migration）"] --> V

  V{"⑩ 驗證查詢<br/>全部通過才 COMMIT"}
  V --> V1["主檔 = 13 筆"]
  V --> V2["從檔 = 13 筆"]
  V --> V3["current_version_id IS NOT NULL = 12<br/>（id 33 那條是 NULL）"]
  V --> V4["🔴 uid 零遺失<br/>NOT EXISTS 比對舊表 → 必須 0"]
  V --> V5["INSERT public.schema_migrations"]
```

::: callout
**為什麼不用 DEFERRABLE**

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

::: {.callout .warn}
**⚠️ 舊表 rename 保留、不 DROP**

專案慣例：「重構搬遷」與「刪舊表」是**兩支獨立的 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_*`）避免與舊表殘留的索引名撞名。
:::

### 🔴 STG 已服役（推翻既有記載）

::: {.callout .crit}
**🔴 這不是動 DEV 實驗表，是 rename 一張 STG 正在服役的表**

實查結果：STG（188 的 `guidant_ai_stg`）與 DEV 同為 **125 筆 migration**，FR-059 全 5 支都在 STG。`config.detection_tool_profiles` 在 STG **存在且有 10 筆**（全部 SYSTEM，無 TENANT），RLS 四段齊全。

[**意涵一**：STG **全部 SYSTEM、沒有 TENANT 資料** → 搬遷 SQL 在 STG **測不到租戶路徑**、**測不到 32/33 的三欄 JOIN 陷阱**、**測不到停用列造成的孤兒主檔**。**「STG 跑得順」不能當作驗收依據。**]{.con}

[**意涵二**：POC（189）**本次未查**（遵守環境紀律，唯讀查詢也不主動做）。上版前需另行唯讀確認 ＋ 決策者放行。]{.con}

[**🔴 開發期只套 DEV。STG / POC 一律等決策者明示放行**（環境異動鐵律；FR-058 的 11 支 migration 全同步進三環境、其中 4 支 seed 讓 POC 顯示四個 available 但 agent 無對應 connector，是殷鑑）。]{.rec}
:::

## 前端現況與可用素材 {#probe-d sec="2D" nav="-"}

好消息：分組下拉、主從展開、分類枚舉化，專案內全部有現成樣板可抄。壞消息：有一個必然觸發的靜默 bug。

### PrimeVue 3.53 分組下拉：支援，且可與 editable 並存

實查套件原始碼確認：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` 手填能力保留」，分組不能犧牲手填。

- 群組標頭**天然不可點選**，不需要用 disabled 項模擬
- `filter` 與分組的協同路徑現成（`Dropdown.vue:910-920`）
- 專案內已有實例：`RoundApAuthoringView.vue:779`

::: {.callout .crit}
**🔴 最容易漏的靜默 bug**

`DetectionConfigField.vue:82-92` 有一段 `opts.some(o => o.value === current)`——用來判斷「目前選中的值是否還在選項裡」，不在就補一個「已不可用」的 fallback 項。

**選項改成巢狀後，這個比對必然全部 miss**（它比對到的是 group 物件，不是 leaf 選項）→ **每一個已選值都會多長一個「已不可用」的 fallback 項**。這不會報錯、不會進 catch，只會讓每張已設定的任務都顯示成「這個 profile 已不可用」。

[**對策**：比對前先攤平（flatten）選項樹；fallback 項本身也要包成一個群組，否則它會混在群組之間造成渲染錯亂。]{.rec}
:::

::: {.callout .warn}
**menu API 契約建議：BE 保持扁平**

建議 BE 的 menu API 維持扁平陣列 `[{value, name, target_type, ...}]`，**由 FE 自己 groupBy**。

若 BE 改回傳巢狀，`JobExecutionDrawer.vue:279-282` 的 `for (const m of items)` 會**靜默失效**（不 throw、不進 catch）——任務抽屜的參數顯示會直接退回裸的 `profile:<uuid>`，使用者看到一串 UUID。詳見 D13。
:::

### 分類枚舉化：有現成樣板可直接抄

`DeviceManage.vue:36-57` 面對的幾乎是同一個問題（自由文字欄位要枚舉化，同時相容既有資料），做法是三層 fallback：

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

**直接照抄那 28 行即可。**

::: {.callout .warn}
**⚠️ 不要跟進 options.json**

專案內另有一份 `options.json` 集中式 enum i18n 檔，但實查 `grep "lang.options\." src/` **零命中**——**那份檔案是死的**，沒有任何地方在用。不要因為看到它就跟進那套機制。
:::

### 主從展開與大量清單

::::: grid2
::: {.card .ok}
#### 主從展開：兩個現成 pattern

**`SheetPreviewControls.vue`**——expander ＋ rowGroup subheader，是專案內**唯一同時滿足兩種需求**的實例。

**`ControlListEditor.vue`**——dataKey-keyed object 的單一展開模式，較輕量。
:::

::: {.card .ok}
#### 控制項清單（234～708 條）

專案內無現成的大量清單元件，但 **client-side DataTable ＋ `:paginator` 25 筆／頁完全撐得住**（PrimeVue 只渲染當前頁）。

建議組合：以 `ControlListEditor.vue` 為骨架 ＋ `SheetPreviewControls.vue` 的 rowGroup ＋ 複製 `DetectionProfileManageView.vue:506-513` 的 IconField 搜尋（已含 300ms debounce）。
:::
:::::

::: callout
**不建議 server-side lazy loading**

控制項是**某一版的靜態內容**——不會變、不需要即時性。載一次快取住，比每次翻頁多打 N 次 API 快得多、也簡單得多。

**VirtualScroller 專案內零使用**，除非實測超過 1MB 或 2 秒，否則不要為這個功能開一個新 pattern。
:::

### 管理頁改動量與現有缺口

| 項目 | 現況 |
|----|----|
| **改動量** | `DetectionProfileManageView.vue` 共 1,239 行，其中約 **600–700 行要重構**。建議趁機拆檔（主表格／版本子表／各 Dialog 各自獨立元件），否則會膨脹到 2,000 行以上 |
| **缺口 · 無編輯入口** | **目前完全沒有「編輯基本資料」的入口**——名稱只能在 fork / copy 時順便改，等於「改名要先複製一份」。這正是需求 ② 的直接來源 |
| **缺口 · 兩個懸空 i18n key** | `field_scope` / `hint_scope_system`——當初為「root 建公版」預留但 UI 沒做，`createDialog.scope` 恆為 `TENANT`。詳見 D18 |

### 管理頁的目標 UI 結構

```{.mermaid cap="圖 4 — 管理頁主從兩層結構（虛線為 FR-061 掛載點）"}
%%{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 TD
  L["掃描設定檔管理（列表頁）<br/>一列 = 一支基準（主檔）<br/>欄：名稱 / 標的類型 / 標的產品 / 基準體系<br/>　　所屬工具 / 範圍 / 當前版 / 控制項數<br/>篩選：分類三軸 + 工具 + 範圍<br/>開關：顯示已停用與舊版"]

  L -->|"編輯"| E["基本資料編輯 Dialog<br/>名稱 / 描述 / 分類三軸<br/>租戶限自己的 · 公版限 root<br/>不升版（D7 / D8）"]
  L -->|"展開列（expander）"| V["版本歷史子表<br/>v3 (current) / v2 / v1<br/>來源型態 / sha256 / 上傳者 / 時間<br/>抽取狀態徽章"]
  L -->|"新增版本"| U["上傳新版<br/>→ 抽取管線（圖 2）"]
  L -->|"fork / 複製到另一工具"| F["_clone_profile()<br/>只複製當前版為新 v1"]

  V -->|"點某一版"| D["版本詳情頁"]
  D --> D1["摘要區<br/>控制項總數 234<br/>🔴 人工待判 199<br/>自動檢查 35<br/>執行平台（supports 唯讀）"]
  D --> D2["控制項清單<br/>DataTable + paginator 25/頁<br/>搜尋（IconField + 300ms debounce）<br/>rowGroup 依 tags.category<br/>expander 展開單條詳情"]
  D2 --> D3["單條詳情<br/>編號 / 標題 / 說明<br/>期望值（tags.gcb_value）<br/>全部 tags<br/>人工待判標記"]

  D3 -.->|"FR-061"| X["選用範圍 / 豁免<br/>人工判定 / 自訂項"]
```

## 決策項 {#decisions sec="3"}

已定案 2 項、建議預設 1 項、待決策 D1–D18，另有 **D19（多工具擴充邊界）決策者已當場定調**、列於 D18 之後。每一項都附我的建議、利弊、以及被排除的方案與理由——請決策者覆核或推翻，不需要從零思考。

### 已拍板

::: {.callout .decided}
**✅ 抽取落點 ＝ BE 主機安裝 CINC Auditor**

抽取工作在 BE 執行，不派給 agent。成本已於 §2A 列明：**275MB 體積**（含 200MB 用不到的雲端 SDK）、**授權紅線**（必須 CINC Auditor Apache 2.0，不可用官方 InSpec 6+ 或 `inspec-core` gem）、**三台部署機 ＋ 每台開發機都要裝**、收尾要在 `host-dependencies.md` 加第三項、路徑解析走 `CINC_AUDITOR_CMD` 環境變數比照 LibreOffice 慣例。

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

::: {.callout .decided}
**✅ 版本鎖定 ＝ 鎖版 ＋ 提示（決策者已表態傾向，仍列此供覆核）**

**任務綁定版本 uid**（即現行行為）——FR-059 D2 契約零變動、10 筆既有綁定零轉換。

[**但純鎖版有合規漏洞**：TWGCB 發了新版，排程任務會無聲地繼續掃舊版，使用者以為自己合規、實際上不是。**在 GRC 系統裡這是正確性問題，不是 UX 瑕疵。**]{.con}

[**因此要配一個提示**：「此基準已有新版 v3，目前使用 v1」徽章 ＋ 一鍵換版。**換不換是使用者的決定，但必須讓他知情。**拆表後「是不是現行版」就是主檔 `current_version_id` 的一次比對，menu API 順手帶回，成本極低。**BE 側留在 FR-060；FE 的徽章與一鍵換版若範圍太肥可移到 FR-061（BE 先把資料備好）。**]{.rec}

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

### 待決策 D1–D18

::: {#d1 .callout .pending}
**D1 · 主從欄位歸屬**

哪些欄位屬「基準本身」（主檔）、哪些屬「某一版」（從檔）？這決定了改名、分類、版更各自會動到什麼。

[**我的建議**：  
**主檔**——`name` / `description` / 分類三軸（標的類型 / 標的產品 / 基準體系）/ `scope` / `tenant_id` / `org_unit_id` / `detection_tool_id` / `is_active` / `current_version_id`  
**從檔**——`source_type` / `file_id` / `url` / `sha256` / `version` / `is_current` / `supports` / `extraction_status` / 控制項統計索引（總數 / 待判數）]{.rec}

[**判準一句話**：「換一版會不會變」——會變的進從檔，不會變的進主檔。`supports`（執行平台）放從檔是因為它是從該版的 `inspec.yml` 抽出來的，不同版可能不同。]{.con}

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

::: {#d2 .callout .pending}
**D2 · 當前版指標：`is_current` 布林 vs `current_version_id` 外鍵**

兩種寫法都能表達「哪一版是現行版」，但保證強度差很多。

[**我的建議：保留 FR-059 現行的 `is_current` 布林 ＋ partial unique index**（`UNIQUE (profile_id) WHERE is_current`）。資料庫直接擋住兩筆 current，`demote_current` 先降後插的順序是被索引強制的，不是靠開發者記得。]{.rec}

[**被排除：`current_version_id` 外鍵**——這正是 `oscal.frameworks` 走過的路，而且**官方已認錯並事實廢棄**（2026-06-16 改用純字串欄，外鍵降級為冗餘、列入待 DROP）。三個具體傷害：① 循環外鍵逼得 DDL 拆兩段、Python model 退化成 plain int；② 刪除流程被綁死，不能先刪版本只能靠 cascade；③ **資料庫保證不了唯一性**，而應用層從頭到尾沒實作過切換。]{.con}

[**折衷可行**：`current_version_id` 仍**可以存在**當作查詢便利欄（避免每次列表都要 join 從檔篩 is\_current），但**唯一性的保證來源是 partial unique index，不是這個外鍵**。兩者不衝突。]{.con}
:::

::: {#d3 .callout .pending}
**D3 · 從檔 RLS 寫法：冗餘欄位 vs EXISTS 子查詢繼承**

從檔一定要自己掛 RLS（PostgreSQL 不沿外鍵繼承，理由見 §2C）。問題是租戶判定的欄位從哪來。

[**我的建議：從檔冗餘存 `tenant_id` / `scope` / `org_unit_id`**，policy 與主檔逐字對稱。]{.rec}

[**理由三條**：① **本專案零 EXISTS 繼承前例**——`detection_executions`、`job_execution_detection_tools` 等子表全部是冗餘欄位形狀，跟進既有形狀降低理解成本；② 效能——EXISTS 每一列都要跑一次子查詢；③ **子查詢本身還會再套一次主檔的 policy**，形成 policy 套 policy，除錯時極難推理。]{.con}

[**冗餘的代價**：改主檔的 scope / tenant 時要同步更新從檔。但實務上 `scope` 與 `tenant_id` 建立後幾乎不變（fork 是建新列而非改欄位），代價很低。建議在 app service 層集中處理，不要散在各處。]{.con}

[⚠️ 提醒：**本專案目前沒有任何「主從兩表都帶 RLS」的前例，FR-060 是首例**，設計時請額外附上跨租戶讀取的驗證查詢。]{.con}
:::

::: {#d4 .callout .pending}
**D4 · uid 歸屬**

拆表後主檔與從檔都需要 uid。舊表那 13 個 uid 該給誰？

[**我的建議：從檔逐列沿用舊 uid，主檔另生新 uid。**]{.rec}

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

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

::: {#d5 .callout .pending}
**D5 · 分類三軸的定義與 enum 來源**

決策者指出兩個現象：CINC 的 profile **不只作業系統，連瀏覽器都有**；**profile 一多下拉會很長、會選錯**。需要一套分類軸。

[**我的建議：三軸**  
**① 標的類型（受控 enum）**：作業系統 / 瀏覽器 / 應用程式 / 資料庫 / 網通設備 / 雲端服務 / 容器 / 其他  
**② 標的產品（自由文字）**：Ubuntu 22.04、Google Chrome、PostgreSQL 15 …  
**③ 基準體系（受控 enum）**：TWGCB / CIS / STIG / dev-sec / 自訂  
**④ 執行平台**——**不是第四軸**，從 `inspec.yml` 的 `supports` 自動抽取、**唯讀不可編**（它是事實不是分類）]{.rec}

[**為什麼標的產品用自由文字而非 enum**：產品名稱長尾極長（客戶自帶的 profile 可能是任何東西），硬做 enum 會逼使用者選「其他」，反而失去篩選價值。以自由文字 ＋ 前端 autocomplete 既有值的方式處理。]{.con}

[**待決：enum 值來源**——(a) FE 寫死常數，改一次要發版；(b) 存 DB（比照 `DeviceManage.vue` 用 `system_menus` 的做法），可由 root 維護。**我傾向 (a) FE 寫死**：這八個類型是穩定的分類骨幹、不該讓租戶隨意增刪（增刪會讓篩選語意漂移），要新增類型時發版一次是合理成本。若決策者認為需要客戶自訂，再走 (b)。]{.con}
:::

::: {#d6 .callout .pending}
**D6 · `platform_hint` 退役**

現行有一個 `platform_hint` 欄位。實查 DEV 12 筆資料，值有兩種形態：空字串 `''` 與大寫的 `Windows`——**髒值已經存在**。

[**我的建議：退役此欄位。**它的功能被 D5 的三軸完全覆蓋而且更精確：「這支基準是給什麼用的」由標的類型＋標的產品表達，「它能在哪些平台跑」由 `supports` 自動抽取表達。一個沒有約束、已經有髒值、語意含糊的自由文字欄位撐不起分類。]{.rec}

[**退役方式**：拆表時**不搬到新表**，值隨舊表一起保留在 rename 後的 `_deprecated` 表裡（觀察期內若發現有人在用，還撈得回來）。**不要在新表建一個空的 platform\_hint 欄位**——那會變成第二個沒人維護的髒欄位。]{.con}

[**被排除：保留並清洗**——清洗 12 筆很容易，但清洗完它仍然與新三軸功能重疊，只是多一個要同步維護的欄位。]{.con}
:::

::: {#d7 .callout .pending}
**D7 · 改名的語意**

使用者改了名稱，歷史版本要跟著改，還是只改當前版？

[**我的建議：整鏈一起改，且不升版。**拆表後改名就是**主檔一列 UPDATE**，所有版本天然共用同一個名稱——這個行為是拆表的自然結果，不需要額外邏輯。]{.rec}

[**理由**：名稱是「這支基準叫什麼」，不是「這一版的內容」。若歷史版本保留舊名，列表上會出現兩個看起來不同的基準其實是同一支，比不改更混亂。]{.con}

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

::: {#d8 .callout .pending}
**D8 · 「只改描述」不再算版更 → 新增編輯端點**

承 D7：既然改名 / 改描述 / 改分類都不升版，就需要一個純粹的「編輯基本資料」端點。

[**我的建議：新增 `PUT /detection-tool-profiles/<uid>`**，消費那顆**已 seed 但至今沒人用的 `detection-profile.update` capability**（FR-059 spec §12 坑 2 稱它為「預留孤兒」）。]{.rec}

[**附帶效益**：這顆 capability 從「孤兒」變成有實際消費者，權限矩陣不再有一列是死的。]{.con}

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

::: {#d9 .callout .pending}
**D9 · fork / copy\_to\_tool 的版本複製語意**

fork 一支有 3 個版本的基準，新的那支要有幾個版本？

[**我的建議：只複製當前版，成為新基準的 v1**（與現行為一致），並把 fork 與 copy\_to\_tool 抽成共用的 `_clone_profile()`。]{.rec}

[**理由**：fork 的意圖是「我要以這份為起點做我自己的」，歷史版本對新的那支沒有意義（那是原基準的演進史，不是新基準的）。且複製全部版本會讓儲存量與 file\_id 引用倍增。]{.con}

[**拆表後的簡化**：fork 與 copy\_to\_tool 的差異收斂成三個參數（`scope` / `tenant_id` / `tool_id`），版本複製邏輯完全相同 → 一個方法帶參數即可，不必兩套。]{.con}

[**控制項要不要一起複製**：建議**複製**（而非重新抽取）——內容完全相同、重抽要再等 28～117 秒沒有意義。但要注意 `extraction_status` 要一併設為 `succeeded`。]{.con}
:::

## 待決策 D10–D18（續）＋ D19 已定調 {#decisions2 sec="3" nav="-"}

::: {#d10 .callout .pending}
**D10 · 抽取非同步化與 `extraction_status` 欄位落點**

實測 708 條的 profile 抽取要 **117 秒**，遠超任何 HTTP 逾時 → 非同步是必然，不是選項。需要一個狀態欄讓前端知道進度。

[**我的建議**：`extraction_status` 四態 `pending` / `running` / `succeeded` / `failed`，**放從檔**——抽取是對**某一版**做的，不是對整支基準。再配一個 `extraction_error` 文字欄存失敗訊息（給使用者看，也給支援人員除錯）。]{.rec}

[**為什麼不放主檔**：主檔若只有一個狀態欄，上傳 v3 失敗會讓 v1、v2 的狀態一起被覆蓋成 failed，但那兩版的控制項其實好好的。]{.con}

[**前端呈現**：列表與版本子表顯示狀態徽章；`pending` / `running` 時輪詢（建議 5 秒一次，抽取本來就要幾十秒）。**不建議走 WebSocket**——為一個低頻操作接推播不划算，且專案的 SocketIO 走另一個 port。]{.con}
:::

::: {#d11 .callout .pending}
**D11 · 抽取失敗的判定條件**

承 §2A 的第七種錯誤形狀：Ruby 語法錯會導致 **exit 0 ＋ stderr 全空 ＋ JSON 合法 ＋ `controls: []`**。

[**我的建議：`exit != 0` 或 `controls == []`，兩者任一都算失敗。**]{.rec}

[**理由**：若只看 exit code，使用者會拿到一支「抽取成功但零控制項」的基準——他會以為是這份基準本來就沒東西，而不是解析壞了。**靜默的錯誤比明顯的錯誤更貴。**]{.con}

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

::: {#d12 .callout .pending}
**D12 · 控制項的存儲形式**

234～708 條控制項，存成一個大 jsonb，還是拆成一條一列？

[**我的建議：一條一列拆表**（`detection_profile_controls`），**欄位以「跨格式共通的最小集合」為正規欄，格式專屬的中繼資料整包進 `attributes` jsonb**（詳見 D19），**丟掉 `code`、丟掉與 `descriptions.default` 重複的 `desc`**（省 59.6% 體積，實測）。]{.rec}

[**拆表的理由（決定性）**：**FR-061 的豁免要能綁單一控制項**——那需要控制項是一列可被外鍵參照的資料，不是 jsonb 陣列裡的一個元素。這是不可逆的架構決定，現在做對成本很低，之後改要動 FR-061 已寫好的東西。]{.con}

[**其他理由**：搜尋 / 篩選 / 分頁走 SQL 而非 Python；「人工待判 199 條」這種統計是一句 `COUNT(*) WHERE is_pending`；未來要跨 profile 找「哪些基準有驗這條」也有路。]{.con}

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

[**格式專屬中繼資料進 `attributes` jsonb**：InSpec 放 `tags` ＋ `descriptions`；XCCDF 放 `ident`（CCE／CCI）／`fixtext`／`rationale`；未來格式放它自己的。**這一格就是「加資料不加欄位」的著力點**——第二個工具入庫時不必 ALTER TABLE。實測理由同樣成立：tag key 集合**隨 profile 變動**（TWGCB-01-011 比 -014 多一個 `service_display_name`），外來 profile 更不可能有 TWGCB 那套 key → schema 本來就不可寫死。為常用 key 建 GIN 索引（至少 `category`）。]{.con}

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

[**完整欄位建議**：正規欄 `control_id` ／ `title` ／ `description` ／ `severity_raw` ／ `severity_norm` ／ `is_pending` ／ `source_ref`（剝掉絕對路徑前綴的相對路徑）；標記欄 `extraction_format`（哪個格式抽出來的）＋ `origin`（`extracted` ｜ `custom`，FR-061 自訂項用，兩者正交）；其餘全進 `attributes` jsonb。]{.con}
:::

::: {#d13 .callout .pending}
**D13 · menu API 的形狀：扁平 vs 巢狀**

下拉要分組，分組資料由誰組？

[**我的建議：BE 保持扁平 `[{value, name, target_type, target_product, benchmark_family, ...}]`，FE 自己 groupBy。**]{.rec}

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

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

[🔴 **不論選哪個，D13 都必須配 §2D 的 fallback 修補**：`DetectionConfigField.vue:82-92` 的 `opts.some(o => o.value === current)` 在選項變巢狀後**必然全 miss**，每個已選值都會多長一個「已不可用」項。要先攤平再比對，fallback 項自己也要包成群組。]{.con}
:::

::: {#d14 .callout .pending}
**D14 · 列表語意：主列表 vs 版本歷史**

拆表後列表一列代表什麼？現有的「顯示已停用與舊版」開關語意會分裂成兩件事。

[**我的建議**：**主列表一列 = 一支基準**（主檔），預設只顯示 `is_active` 的；版本歷史走**展開列**（expander，pattern 抄 `SheetPreviewControls.vue`）＋ **另有獨立分頁 API**（給版本多的情況）。]{.rec}

[**「顯示已停用與舊版」開關的分裂**：拆表後這是兩個獨立概念——「已停用的基準」（主檔 `is_active = false`）與「非當前版」（從檔 `is_current = false`）。建議**拆成兩個開關**：主列表上是「顯示已停用基準」，版本子表上是「顯示歷史版本」（子表本來就該全顯，這個開關可能根本不需要）。]{.con}

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

::: {#d15 .callout .pending}
**D15 · `is_active` 只放主檔，還是從檔也要狀態欄**

現況 id 33 是**列級停用**（`is_active = f`）。拆表後這個語意要放哪一層？

[**我的建議：第一期 `is_active` 只放主檔**（＝停用整支基準），從檔**不另設停用欄**。]{.rec}

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

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

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

::: {#d16 .callout .pending}
**D16 · 舊表 rename 保留、觀察期後另開 migration 才 DROP**

這是專案慣例的確認項，不是新決策。

[**我的建議：本案的 migration 只做 `ALTER TABLE ... RENAME TO detection_tool_profiles_deprecated_20260803`，不 DROP。**DROP 另開一支 migration，於觀察期（建議一個 release 週期）後執行。]{.rec}

[**前例**：`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）。]{.con}

[⚠️ **rename 不會自動改索引名稱** → 新表的索引一律用新前綴（`idx_dp_*` / `idx_dpv_*` / `idx_dpc_*`），避免與舊表殘留的索引名撞名導致 migration 中途失敗。]{.con}

[**DROP 前的檢查**：比照 CLAUDE.md「DROP 退役表前安全四查」，確認沒有任何 live-wired 的 code 還在引用（拆表後應該沒有，但要實際 grep 確認，不要假設）。]{.con}
:::

::: {#d17 .callout .pending}
**D17 · 既有 13 筆的分類補登策略**

拆表後主檔多了三個分類欄位，13 筆既有資料要填什麼？

[**我的建議：migration 內做「有把握的才填，其餘留 NULL」**——名稱裡含 `twgcb-01-*` 明確是作業系統、`twgcb-02-003-google-chrome` 明確是瀏覽器，這類**從名稱可高信心推導的直接填**；推不出來的留 NULL，由使用者在管理頁補。]{.rec}

[**被排除：全部留 NULL 等人工補**——13 筆裡有 10 筆是我們自己 seed 的 TWGCB 公版，我們比使用者更清楚它們是什麼，讓使用者補等於把已知資訊丟掉。]{.con}

[**被排除：全部靠猜測規則自動填**——猜錯比留白更糟（使用者會信任錯誤的分類去篩選，篩掉他該看的東西）。**寧可空著也不要猜。**]{.con}

[**「未分類」群組的位置**：下拉分組時，未分類的項目**放最後**（不是最前）——分類明確的是常態，未分類是待補的例外。列表篩選要有「未分類」這個選項讓使用者找得到待補的。]{.con}
:::

::: {#d18 .callout .pending}
**D18 · 兩個懸空 i18n key：`field_scope` / `hint_scope_system`**

FR-059 當初為「root 建立公版」預留了這兩個翻譯 key，但 UI 沒做——`createDialog.scope` 恆為 `TENANT`，root 目前**沒有辦法從 UI 建公版**（只能靠 migration seed）。

[**我的建議：補做 root 建公版的 UI**，讓這兩個 key 活起來。]{.rec}

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

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

[**若決策者認為範圍太肥**：至少要在 FR-060 的 spec 明寫「公版建立目前仍走 migration」，不要讓這兩個 key 繼續懸空無人知曉。]{.con}
:::

::: {#d19 .callout .decided}
**✅ D19 · 多工具擴充邊界（決策者 2026-08-03 指示，已定調）**

> 要預留擴充方式，以目前有的工具為主，未來如果又有多工具有需要設定檔的，也要可以擴充，不能像這次一樣又大幅度更新，上線後很危險。 [— 決策者原話（2026-08-03）]{.attr}

**工具維度本來就在**，拆表不動搖它——`detection_tool_id` 是建表就有的 not-null 欄位，唯一索引 `(detection_tool_id, tenant_id, name)` 三欄含它，列表頁已有工具篩選、建立時必選工具、「複製到另一工具」整個功能繞著它轉。D1 建議把它放**主檔**（跟著「這支基準是什麼」走），行為零變化。

**但目前只有 InSpec 系真的入庫。**實查各工具參數 schema 的 `profile` 欄位取值來源：

| 工具 | `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，即 §2C 講的「使用者選錯工具後停用重建」殘留，**是誤操作痕跡不是 OpenSCAP 真的在用庫**。

::: {.callout .crit}
**🔴 若照原提案直接刻 InSpec 語意，第二個工具進來就要改已上線的表**

原 D12 提案的欄位（`impact` 浮點／`tags`／`descriptions`／`is_pending` 由 `impact==0.0` 推導）**是 InSpec 專有語意**。XCCDF 的對應概念完全不同：`severity` 是五級列舉不是浮點、沒有「人工待判」概念（另有 `role=unchecked`）、中繼資料是 `ident`（CCE／CCI）／`fixtext`／`rationale`。

第二個工具入庫時只剩兩條路：**加一個 `severity` 欄**，或**把五級硬塞進浮點**——兩條都是上線後改表，正是決策者指的「大幅度更新、上線後很危險」。
:::

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

**① 工具端宣告能力——沿用既有慣例，不發明新機制。**專案已有這個 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 表存正規化語意 ＋ 原始中繼資料分離**（欄位清單見 D12 改寫後版本）。正規欄只收**所有格式一定都有**的最小集合；格式差異全進 `attributes` jsonb；`extraction_format` 標記來源格式讓讀取端知道該期待什麼。

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

[**被排除：明寫「profile 庫只服務 InSpec 系」**——太樂觀。決策者已明確表示未來其他工具的設定檔也會在這裡維護，把邊界劃死等於把問題推給下一次改表。]{.con}

[**被排除：現在就完整抽象化（正規欄涵蓋預想中的 XCCDF 欄位）**——只有一種真實格式時抽象化容易抽錯，沒有第二個實例可以驗證抽象是否正確。**抽錯的抽象比沒有抽象更貴**，因為它會誤導後續實作照錯的形狀寫。]{.con}

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

## FR-061 的模型契約 {#fr061 sec="4" nav="模型契約"}

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

### 四條硬約束

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

::: {.callout .crit}
**🔴 抽取欄位必須一次抽完整**

**不是抽「目前要顯示的子集」，是抽「CINC 給的全部（扣掉 code 與重複 desc）」。**如果 FR-060 只抽 id / title / desc 三欄，FR-061 做豁免時發現需要 `tags`、做人工判定時發現需要 `descriptions.gcb_value`，就得**把所有 profile 全部重抽一次**——每支 28～117 秒，而且要寫一支資料回填 migration。

[D12 建議的欄位集合（正規欄 ＋ 完整中繼資料進 `attributes` jsonb）就是為此。**存下來不顯示，成本是幾百 KB；沒存要重抽，成本是一整輪重跑加一支 migration。**]{.rec}

[**D19 讓這條約束更強**：重抽的成本不只是「每支 28～117 秒」——多工具之後，某些格式可能**根本無法重抽**（例如工具端 API 已改版、或原始檔已不在）。`attributes` 整包存下來是唯一保險。]{.con}
:::

### 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 而非只是傳參數，複雜度較高，是它單獨成案的理由之一 |

## 階段拆分 {#phases sec="5" nav="拆分"}

切線在**管理面／執行面**之間，不是在「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>[資料模型]{.src} | 主從拆表 ＋ 控制項表建立（**欄位採 D19 跨格式形狀**：正規欄最小集合 ＋ `attributes` jsonb ＋ `extraction_format` ／ `origin` 雙標記）＋ 13 筆三段式搬遷 ＋ RLS 兩表八段 ＋ repository / domain service / app service 改寫 ＋ **派工鏈兩處破口修補**（`_load_profile` 的 is\_active 守門、`_humanize_profile_param` 的 name 退化） | **全案的地基，必須先行。**風險最高的一項——rename 的是 **STG 正在服役**的表，且 STG 資料全 SYSTEM 測不到租戶路徑。驗收必須在 DEV 完成（DEV 才有 TENANT 資料與 32/33 陷阱）。**controls 表欄位是本案最不可逆的決定**（D19）——上線後要加格式只能加資料，不能改欄位語意 |
| **FR-060.2**<br>[分類與編輯]{.src} | 分類三軸落地（D5）＋ `platform_hint` 退役（D6）＋ 編輯端點（D8）＋ 管理頁重構 ＋ 下拉分組 ＋ **fallback 比對修補**（D13）＋ 既有 13 筆分類補登（D17） | 依賴 .1 的主檔存在。與 .3 **可並行**（兩者動的是不同層：.2 動主檔與 UI 框架，.3 動從檔與詳細頁） |
| **FR-060.3**<br>[抽取與瀏覽]{.src} | BE 主機安裝 CINC（含 host-dependencies.md 補第三項）＋ **抽取器註冊表骨架**（D19，依 `profile_format` 取 extractor）＋ InSpec extractor 實作（含三種失敗分支）＋ 非同步管線 ＋ `extraction_status` ＋ 控制項清單詳細頁 ＋ 摘要區（234／199／35） | 依賴 .1 的控制項表存在。**主機依賴要提前備妥**——三台部署機 ＋ 每台開發機都要裝，這是實作前的準備動作不是實作內容。**本期只實作 InSpec 一種 extractor**，註冊表骨架是為了讓第二種格式進來時不必動管線 |
| **FR-061.4 / .5**<br>[下一階段]{.src} | 選用範圍 ／ 豁免 ／ 人工判定 ／ 自訂項 | 另案。本案只定模型契約（§4），**不實作** |

::: callout
**為什麼切線在這裡**

**.1–.3 最壞的情況是「詳細頁少一塊內容」——既有掃描零影響**，因為這三項完全不動掃描參數，agent 拿到的 payload 形狀與今天一模一樣。

**.4 開始就動掃描參數**（`--controls` / `--tags` / `--waiver-file`），改錯會讓掃描結果不正確。**在 GRC 系統裡，使用者以為自己合規但實際不是，是嚴重問題**——這種風險等級的變更值得單獨立案、單獨驗收，不該混在管理面的改動裡一起上。
:::

### 實作前的準備動作

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

::: {.callout .crit}
**🔴 環境紀律（不可省略）**

**開發期間所有 migration 只套 DEV。**STG（已服役、有 10 筆資料）與 POC 一律等決策者當次明確指示才套。

**「STG 跑得順」不能當驗收依據**——STG 資料全 SYSTEM、無 TENANT，測不到租戶 RLS 路徑、測不到 32/33 的三欄 JOIN 陷阱、測不到停用列造成的孤兒主檔。**驗收要在 DEV 做**，那裡才有完整的資料形態。
:::

::: {.callout .decided}
**下一步**

決策者審閱本稿 → 逐項裁決 D1–D18（可直接採用建議或推翻）→ 我把定案寫進 `design.md` 並拆出 .1 / .2 / .3 三張卡 → 依序開工。
:::
