# Module Frame Template Defaults — 設計文件

> **狀態：** Design — 待實作
> **建立日期：** 2026-04-25
> **作者：** raymond + Claude Code (brainstorming session)

---

## 1. 需求說明

### 1.1 背景

目前合規資源庫（`module_frames`）只存「基本資訊 + OSCAL Profile 包裝 + BPMN 工作流」。每次啟動專案後，使用者必須在 ProjectPlanning 介面為每個控制項與評估目標（AO）逐一填寫**現況說明（implementation_description）**與**程序書（reference documents）**，每個專案都重複一次。

### 1.2 目標

把「現況說明 + 程序書」抽成 module_frame 層的**模板預設值**：
- 模板維運者一次設好預設內容
- 啟動專案時自動帶入新建的 SSP（snapshot 模式）
- 大幅減少每個新專案的填寫工作量
- 為將來 OSCAL Component Definition 標準匯出鋪路

### 1.3 不在本次範圍

詳見「§9 Out of scope」。最重要的是：
- **不做** SSP-per-round / SSP 版本控制 / AR snapshot
- **不做** template → SSP re-sync 機制
- **不做** i18n 翻譯表（v1 純 TEXT 欄位）

---

## 2. 設計決策摘要（從 brainstorming Q&A 收斂）

| 決策 | 結論 | 理由 |
|---|---|---|
| 預設值粒度 | **控制項層 + AO 層**（兩層都做） | 對齊 SSP 端 `ssp_control_implementations` + `ssp_control_implementation_objectives` 兩層結構 |
| OSCAL 對應 | **Component Definition Lite**（掛在 module_frame 下） | OSCAL 標準的「跨 SSP 重用」機制是 Component Definition；不違反 OSCAL「SSP per system」核心語意 |
| 程序書儲存 | **真實檔案上傳（沿用 SSP 結構）** | 模板真的「自帶程序書」，啟動時 SSP 直接共享 file_id 不複製檔案本體 |
| 程序書池 scope | **Per-Module-Frame**（不跨模板共享） | 對齊 SSP `ssp_reference_documents` per-SSP pattern；模板自包含 |
| 啟動專案複製策略 | **Snapshot at start**（不 live reference） | 安全：稽核中不會被 template 改動意外影響；OSCAL Component Definition 模式即如此 |
| 編輯 UI 結構 | **保留 2-step Dialog 建立 + 新詳情頁編輯** | 建立流程輕、編輯流程重，分開最自然 |
| AO statement_identifier 來源 | **`oscal.catalog_control_assessments.uid`** | spike 已驗證；對拷時 join 到 `ap_task.uid` |
| Multi-AP 共用 SSP 模型 | **不影響本次設計** | 啟動時 deep copy 一次到 SSP，後續跨 round 共用，與 template 無關 |

---

## 3. 資料模型

### 3.1 新增 4 張表（compliance schema）

#### 3.1.1 `compliance.module_frame_control_defaults`

控制項層預設值，對應 OSCAL Component Definition 的 `implemented-requirements`，啟動時對拷至 SSP `oscal.system_security_plan_control_implementations`。

```sql
CREATE TABLE compliance.module_frame_control_defaults (
    id                          SERIAL PRIMARY KEY,
    uid                         UUID DEFAULT gen_random_uuid() NOT NULL UNIQUE,
    module_frame_id             INTEGER NOT NULL
        REFERENCES public.module_frames(id) ON DELETE CASCADE,
    control_identifier          VARCHAR(100) NOT NULL,           -- e.g. AC.L1-3.1.1
    implementation_status       VARCHAR(30) DEFAULT 'unknown',   -- 預設值
    implementation_description  TEXT,                             -- 現況說明預設文字
    responsible_role            VARCHAR(100),                     -- 負責角色預設
    control_origination         VARCHAR(50) DEFAULT 'organization',
    remarks                     TEXT,
    created_at                  TIMESTAMPTZ DEFAULT now() NOT NULL,
    updated_at                  TIMESTAMPTZ DEFAULT now() NOT NULL,
    created_user                VARCHAR(50),
    updated_user                VARCHAR(50),
    CONSTRAINT uq_mfcd_module_frame_control UNIQUE (module_frame_id, control_identifier)
);
CREATE INDEX ix_mfcd_module_frame_id ON compliance.module_frame_control_defaults (module_frame_id);
```

**行為：**
- **Lazy 預填**：建立 module_frame 時不預先 INSERT。使用者第一次編輯某 control 時才 upsert 一筆 row。
- 軟刪除靠 `module_frames` 的 `is_delete` flag + JOIN 過濾；hard delete 走 ON DELETE CASCADE。

#### 3.1.2 `compliance.module_frame_control_objective_defaults`

AO（Assessment Objective）層預設值，對應 SSP `oscal.ssp_control_implementation_objectives`。

```sql
CREATE TABLE compliance.module_frame_control_objective_defaults (
    id                          SERIAL PRIMARY KEY,
    uid                         UUID DEFAULT gen_random_uuid() NOT NULL UNIQUE,
    module_frame_id             INTEGER NOT NULL
        REFERENCES public.module_frames(id) ON DELETE CASCADE,
    control_default_id          INTEGER NOT NULL
        REFERENCES compliance.module_frame_control_defaults(id) ON DELETE CASCADE,
    control_identifier          VARCHAR(100) NOT NULL,
    statement_identifier        VARCHAR(100) NOT NULL,            -- = catalog_control_assessment.uid
    implementation_status       VARCHAR(50),
    implementation_description  TEXT,
    remarks                     TEXT,
    created_at                  TIMESTAMPTZ DEFAULT now() NOT NULL,
    updated_at                  TIMESTAMPTZ DEFAULT now() NOT NULL,
    created_user                VARCHAR(50),
    updated_user                VARCHAR(50),
    CONSTRAINT uq_mfcod_ctrl_default_stmt UNIQUE (control_default_id, statement_identifier)
);
CREATE INDEX ix_mfcod_module_frame_id ON compliance.module_frame_control_objective_defaults (module_frame_id);
CREATE INDEX ix_mfcod_control_default_id ON compliance.module_frame_control_objective_defaults (control_default_id);
```

**`statement_identifier` 設計：**
- 模板層存 `catalog_control_assessments.uid`（OSCAL catalog 層級的 stable key）
- SSP 端 `ssp_control_implementation_objectives.statement_identifier` 存 `assessment_plan_tasks.uid`（執行期生成）
- 啟動專案對拷時透過 join 解析：`module_frame_control_objective_defaults → catalog_control_assessments → assessment_plan_tasks → ssp_control_implementation_objectives`

**Soft FK 規範（按 CLAUDE.md 跨 schema 慣例）：**
- `statement_identifier` 是 `catalog_control_assessments.uid` 的**字串 soft reference**（無 SQLAlchemy FK constraint，跨 schema）
- API 層 PUT 時驗證該 ccai uid 屬於模板的 profile（profile.controls 的 catalog_control_assessments 集合內），失敗回 `MODULE_FRAME_CONTROL_NOT_IN_PROFILE`
- 若 catalog 後續刪除/變更該 ccai → 模板資料變孤兒，UI 顯示「目標已被 catalog 移除」並提供「移除預設值」按鈕

**denormalized `module_frame_id`：**
- 此表同時存 `module_frame_id` (FK) 和 `control_default_id` (FK)，後者可推導前者
- 故意 denormalize 理由：
  1. 大量 query「列出某模板所有 AO defaults」可省一次 JOIN
  2. RLS / tenant 過濾可直接 filter 此表（不必 JOIN module_frames）
- 一致性靠：API 層只讓使用者選 control_default，module_frame_id 由 service 層自動填入（不接受 client 傳值）

#### 3.1.3 `compliance.module_frame_reference_documents`

程序書池（per-module_frame），對應 SSP `oscal.ssp_reference_documents`。

```sql
CREATE TABLE compliance.module_frame_reference_documents (
    id                  SERIAL PRIMARY KEY,
    uid                 VARCHAR(36) DEFAULT gen_random_uuid() NOT NULL UNIQUE,
    module_frame_id     INTEGER NOT NULL
        REFERENCES public.module_frames(id) ON DELETE CASCADE,
    file_id             INTEGER NOT NULL,                          -- jedi-file-upload tenant 內 file
    title               VARCHAR(255),
    description         TEXT,
    created_at          TIMESTAMPTZ DEFAULT now() NOT NULL,
    updated_at          TIMESTAMPTZ DEFAULT now() NOT NULL,
    created_user        VARCHAR(50),
    updated_user        VARCHAR(50)
);
CREATE INDEX ix_mfrd_module_frame_id ON compliance.module_frame_reference_documents (module_frame_id);
CREATE INDEX ix_mfrd_file_id ON compliance.module_frame_reference_documents (file_id);
```

**Scope 說明：**
- 程序書 row 是 per-module_frame
- 但底層 `file_id` 是 tenant-scoped（jedi-file-upload 既有設計），故同 tenant 內不同模板可挑同一份檔案重用（FE 從既有檔案池選即可）

#### 3.1.4 `compliance.module_frame_reference_document_mappings`

多對多，讓同一份程序書可掛多個 control / objective default。

```sql
CREATE TABLE compliance.module_frame_reference_document_mappings (
    id                          SERIAL PRIMARY KEY,
    reference_document_id       INTEGER NOT NULL
        REFERENCES compliance.module_frame_reference_documents(id) ON DELETE CASCADE,
    context_type                VARCHAR(30) NOT NULL,             -- 'control_default' | 'objective_default'
    context_id                  INTEGER NOT NULL,                  -- → control_defaults.id 或 objective_defaults.id
    created_at                  TIMESTAMPTZ DEFAULT now() NOT NULL,
    created_user                VARCHAR(50),
    CONSTRAINT uq_mfrdm_doc_context UNIQUE (reference_document_id, context_type, context_id)
);
CREATE INDEX ix_mfrdm_context ON compliance.module_frame_reference_document_mappings (context_type, context_id);
CREATE INDEX ix_mfrdm_doc_id ON compliance.module_frame_reference_document_mappings (reference_document_id);
```

**對齊 SSP 端 `oscal.ssp_reference_document_mappings`：**
- SSP 端 `context_type` = `'control_implementation'` / `'objective'`
- 模板端 `context_type` = `'control_default'` / `'objective_default'`
- 啟動時對拷做 context_type 翻譯 + context_id re-map

### 3.2 修改既有 SSP 表（jedi-oscal）

**最小改動 — 只加一個欄位：**

```sql
ALTER TABLE oscal.system_security_plans
    ADD COLUMN template_module_frame_id INTEGER NULL;
```

用途：紀錄此 SSP 的源頭 module_frame（lineage tracking）。為將來 re-sync feature 預留。

> ✂️ **明確不加**（避免 YAGNI）：
> - `previous_ssp_id`（給未來 SSP-per-round 用，等那個 feature 自己加）
> - `is_user_modified` on control_impl / objectives（給未來 re-sync 用，等那個 feature 自己加）

### 3.3 SQL Migration 規範

按 CLAUDE.md 規範：
- 檔名：`scripts/sql/2026-XX-XX-module-frame-template-defaults.sql`
- 檔案開頭：`-- Date: 2026-XX-XX`
- 每條 ALTER/CREATE 加 `(2026-XX-XX)` 註解
- 每張新表必須 `GRANT SELECT, INSERT, UPDATE, DELETE ... TO cm_app;` + sequence 授權

**範例（4 張新表 + 1 ALTER）：**

```sql
-- 1. 4 張新表 CREATE 略（見 §3.1）

-- 2. ALTER SSP table (2026-XX-XX)
ALTER TABLE oscal.system_security_plans
    ADD COLUMN template_module_frame_id INTEGER NULL;

-- 3. CHECK constraint 限制 mappings 的 context_type 合法值 (2026-XX-XX)
ALTER TABLE compliance.module_frame_reference_document_mappings
    ADD CONSTRAINT chk_mfrdm_context_type
    CHECK (context_type IN ('control_default', 'objective_default'));

-- 4. GRANT 權限授予 cm_app (2026-XX-XX)
GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.module_frame_control_defaults TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.module_frame_control_defaults_id_seq TO cm_app;

GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.module_frame_control_objective_defaults TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.module_frame_control_objective_defaults_id_seq TO cm_app;

GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.module_frame_reference_documents TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.module_frame_reference_documents_id_seq TO cm_app;

GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.module_frame_reference_document_mappings TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.module_frame_reference_document_mappings_id_seq TO cm_app;
```

### 3.4 既有 module_frames 不需 backfill

按 §3.1.1 Lazy 預填策略：本次 migration **只建表 + 加欄位**，**不對既有資料做 backfill**。
- 既有 module_frames 行為不變
- defaults 顯示為「未設定」直到使用者編輯
- 既有 SSP `template_module_frame_id` = NULL（unknown source），不影響任何邏輯

### 3.5 UID 型別約定

新表的 `uid` 欄位有兩種型別：
- `module_frame_control_defaults.uid` / `module_frame_control_objective_defaults.uid` 用 PostgreSQL `UUID`
- `module_frame_reference_documents.uid` 用 `VARCHAR(36)`（**沿用 SSP `ssp_reference_documents` 既有 convention**，避免 jedi-file-upload 整合處要做型別轉換）

JOIN 時注意型別轉換（如 `ccai.uid::text = mfod.statement_identifier`）。

---

## 4. API 變更

### 4.1 既有 Module Frame API — 不動

`POST /module-frame`、`GET /module-frame/{uid}`、`PUT /module-frame/{uid}`、`DELETE /module-frame/{uid}`、Excel import endpoints **保持不變**。

新欄位都不在建立 Dialog 階段填，建立完成後在新詳情頁編輯。

### 4.2 新增：Module Frame Control Defaults

| Method | Path | 用途 |
|---|---|---|
| `GET` | `/module-frame/{uid}/control-defaults` | 列出該模板已設定的所有控制項預設值 |
| `GET` | `/module-frame/{uid}/control-defaults/{control_identifier}` | 取得單一控制項預設值（含其下 AO defaults + 程序書 mappings） |
| `PUT` | `/module-frame/{uid}/control-defaults/{control_identifier}` | Upsert（lazy 第一次 PUT 即 INSERT） |
| `DELETE` | `/module-frame/{uid}/control-defaults/{control_identifier}` | 刪除（清空回未設定狀態，CASCADE 帶走 AO defaults） |

**PUT 驗證流程（重要）：**
1. 驗證 module_frame uid 存在且使用者有 manager 權限
2. **驗證 `control_identifier` 屬於該模板 profile 的 `include_controls`**，否則回 `MODULE_FRAME_CONTROL_NOT_IN_PROFILE` (`MODULE_FRAME_400001`)
3. UPSERT row（lazy：不存在則 INSERT，存在則 UPDATE）

**Request body 範例 (PUT)：**
```json
{
  "implementation_status": "implemented",
  "implementation_description": "本公司透過 ISMS-001 程序書管理...",
  "responsible_role": "資安長",
  "control_origination": "organization",
  "remarks": "..."
}
```

### 4.3 新增：Module Frame Objective Defaults（獨立路徑，避免 3 層 nested）

| Method | Path | 用途 |
|---|---|---|
| `GET` | `/module-frame/{uid}/objective-defaults` | 列出全部 AO 預設值 |
| `GET` | `/module-frame/{uid}/objective-defaults/{objective_default_uid}` | 取得單一 |
| `PUT` | `/module-frame/{uid}/objective-defaults` | Upsert（body 含 control_identifier + statement_identifier=ccai_uid） |
| `DELETE` | `/module-frame/{uid}/objective-defaults/{objective_default_uid}` | 刪除 |

### 4.4 新增：Module Frame Reference Documents

| Method | Path | 用途 |
|---|---|---|
| `GET` | `/module-frame/{uid}/reference-documents` | 列出程序書池（含每筆 mappings 概要） |
| `POST` | `/module-frame/{uid}/reference-documents` | 上傳新檔案（multipart/form-data） |
| `PUT` | `/module-frame/{uid}/reference-documents/{doc_uid}` | 更新 title / description |
| `DELETE` | `/module-frame/{uid}/reference-documents/{doc_uid}` | 刪除 doc + CASCADE 清 mappings（檔案本體保留） |

### 4.5 新增：Reference Document Mappings（多對多）

| Method | Path | 用途 |
|---|---|---|
| `POST` | `/module-frame/{uid}/control-defaults/{control_identifier}/reference-documents` | 把現有 doc_uid 掛到此控制項 |
| `DELETE` | `/module-frame/{uid}/control-defaults/{control_identifier}/reference-documents/{doc_uid}` | 解除此 mapping |
| `POST` | `/module-frame/{uid}/objective-defaults/{objective_default_uid}/reference-documents` | 掛到 AO |
| `DELETE` | `/module-frame/{uid}/objective-defaults/{objective_default_uid}/reference-documents/{doc_uid}` | 解除 |

### 4.6 新增：Catalog AO 列舉

| Method | Path | 用途 |
|---|---|---|
| `GET` | `/oscal/catalog-controls/{control_uid}/assessments` | 列出此 catalog control 的 catalog_control_assessment 清單，供 UI 列出可填的 AO |

→ jedi-oscal 已有 `CatalogControlAssessmentRepoImpl`，本層只做 API 包裝。回傳：
```json
{ "code": 1, "data": [{ "uid": "...", "name": "Official", "description": "..." }, ...] }
```

### 4.7 新增：批次匯入/匯出（mirror SSP Excel import 結構）

| Method | Path | 用途 |
|---|---|---|
| `GET` | `/module-frame/{uid}/control-defaults/export` | 匯出當前 control + AO defaults 為 Excel |
| `GET` | `/module-frame/{uid}/control-defaults/template/download` | 下載空白 Excel 範本（含模板的 control + AO 結構） |
| `POST` | `/module-frame/{uid}/control-defaults/import/template` | 上傳 Excel + 驗證 headers/格式 |
| `POST` | `/module-frame/{uid}/control-defaults/import/verify` | 驗證資料行（顯示 diff） |
| `POST` | `/module-frame/{uid}/control-defaults/import` | 確認匯入（bulk upsert） |

→ 完全 mirror `SspImportRoute` 的 endpoint 結構與 service 邏輯（fork `ssp_control_impl_import_service.py`）。

### 4.8 既有 Project Start API 行為調整

`POST /oscal/projects/start`：
- **Request/Response 結構不變**
- 內部多一步 `_copy_template_to_ssp()`，在建立 SSP 與 AP 之後呼叫
- 對 caller 透明

### 4.9 權限與錯誤處理

按 CLAUDE.md 規範：
- 寫入 API（POST/PUT/DELETE）：須 `manager` 角色（沿用 module_frame service 既有權限規範）
- 寫入用 `@transaction` 裝飾器
- 路徑參數 uid → service 層用 `module_frame_domain_service.get_one(uid)` 驗證 + RLS 自動過濾 tenant
- 新增 ErrorCode 至 `common/code/module_frame_error_code.py`（新檔）

**Error Code 命名（按 CLAUDE.md 規範）：**

```python
class ModuleFrameErrorCode(BaseCode):
    MODULE_FRAME_CONTROL_DEFAULT_NOT_FOUND  = ("模板控制項預設值不存在", "MODULE_FRAME_404001")
    MODULE_FRAME_OBJECTIVE_DEFAULT_NOT_FOUND = ("模板 AO 預設值不存在",  "MODULE_FRAME_404002")
    MODULE_FRAME_REFERENCE_DOC_NOT_FOUND     = ("模板程序書不存在",        "MODULE_FRAME_404003")
    MODULE_FRAME_CONTROL_NOT_IN_PROFILE      = ("控制項不屬於此模板的 profile", "MODULE_FRAME_400001")
```

---

## 5. 啟動專案對拷邏輯

### 5.0 重要：與既有 Step 7.5 共存策略 — UPDATE in place

`OscalProjectService.start_oscal_project` 既有 **Step 7.5** (`oscal_project_service.py:272-318`) 已經為每個 AP control 建立 SSP control_impl 空殼 row + 為每個 AP task (AO) 建立 SSP objective 空殼 row：
- SSP control_impl: `implementation_status="unknown"`, 其餘欄位 NULL
- SSP objective: `statement_identifier = task.task_code OR task.title OR str(task.id)` (line 307)，其餘欄位 NULL

→ **我們的 `_copy_template_to_ssp` 在 Step 7.5 之後執行，必須 UPDATE 既有 row 不 INSERT 新 row**。
- 否則會產生 duplicate / 撞 `uq_ssp_obj_ctrl_impl_stmt` unique constraint
- 既有 Step 7.5 邏輯**完全不動**，保持向後相容

### 5.1 對拷流程

新增方法 `OscalProjectService._copy_template_to_ssp(module_frame_id, ssp_id, ap_id, curr_user)`，於 `start_oscal_project` 的 Step 7.5 **之後**執行。

```sql
-- Step 1: 控制項層 UPDATE in place
-- NULL 語意：模板若未填某欄位，保留 Step 7.5 的 default 值（用 COALESCE 防止 NULL 覆蓋有意義的 default）
UPDATE oscal.system_security_plan_control_implementations ci
SET
  implementation_status = COALESCE(mfcd.implementation_status, ci.implementation_status),
  implementation_description = COALESCE(mfcd.implementation_description, ci.implementation_description),
  responsible_role = COALESCE(mfcd.responsible_role, ci.responsible_role),
  control_origination = COALESCE(mfcd.control_origination, ci.control_origination),
  remarks = COALESCE(mfcd.remarks, ci.remarks),
  updated_user = :curr_user,
  updated_at = now()
FROM compliance.module_frame_control_defaults mfcd
WHERE mfcd.module_frame_id = :module_frame_id
  AND ci.system_security_plan_id = :ssp_id
  AND ci.control_identifier = mfcd.control_identifier;

-- Step 2: AO 層 UPDATE in place（透過 ccai + ap_task 找到對應 ssp objective）
-- 關鍵：obj.statement_identifier 用 Step 7.5 的 convention（COALESCE task_code/title/id），不用 ap_task.uid
-- NULL 語意：同 Step 1，模板若未填則保留 Step 7.5 預設
UPDATE oscal.ssp_control_implementation_objectives obj
SET
  implementation_status = COALESCE(mfod.implementation_status, obj.implementation_status),
  implementation_description = COALESCE(mfod.implementation_description, obj.implementation_description),
  remarks = COALESCE(mfod.remarks, obj.remarks),
  updated_user = :curr_user,
  updated_at = now()
FROM compliance.module_frame_control_objective_defaults mfod
JOIN oscal.catalog_control_assessments ccai
  ON ccai.uid::text = mfod.statement_identifier
JOIN oscal.assessment_plan_tasks ap_task
  ON ap_task.catalog_control_assessment_id = ccai.id
 AND ap_task.assessment_plan_id = :ap_id
WHERE mfod.module_frame_id = :module_frame_id
  AND obj.system_security_plan_id = :ssp_id
  AND obj.control_identifier = mfod.control_identifier
  AND obj.statement_identifier = COALESCE(ap_task.task_code, ap_task.title, ap_task.id::text);

-- Step 3a: 控制項層程序書（→ SSP mappings 表，新增 row）
-- 詳見 §5.4

-- Step 3b: AO 層程序書（→ SSP objective.reference_documents JSONB，UPDATE in place）
-- 詳見 §5.4

-- Step 4: 標記 SSP 來源
UPDATE oscal.system_security_plans
   SET template_module_frame_id = :module_frame_id
 WHERE id = :ssp_id;
```

→ **整個對拷邏輯不 INSERT 任何 SSP control_impl / objective row，只 UPDATE Step 7.5 已建好的空殼**。  
→ 沒有 unique constraint 衝突風險。  
→ 沒設模板 defaults 的控制項 / AO row 維持 Step 7.5 的空白狀態（行為與既有 100% 一致）。

### 5.2 實作要求

- 使用 `session.bulk_insert_mappings` 或 `INSERT ... SELECT` 形式批次處理（避免 100+ 單行 INSERT）
- 集中在 `OscalProjectService._copy_template_to_ssp()` method（單一入口）
- 配對 unit test：mock module_frame 含 N 控制項 + M AO + K 程序書，驗證 SSP 端產生對應 row
- 失敗策略：**fail loud** — 任何一步失敗整個 transaction rollback；不要 try/except 吞掉

### 5.3 程序書 file_id 共享策略

- 模板的 `module_frame_reference_documents.file_id` 與 SSP 的 `ssp_reference_documents.file_id` **指向同一個 file_id**
- 啟動專案 = 不複製檔案本體，只複製 metadata + mapping
- 模板若刪除 `module_frame_reference_documents` row → 只刪 mapping，**檔案本體保留**；既有專案 SSP 仍可下載

### 5.4 SSP 端程序書儲存的不對稱性（重要）

實際翻 `ssp_control_implementation_service.py` 後確認 SSP 端兩層程序書儲存方式**不一致**：

| 層級 | SSP 端儲存 | 對應 entity |
|---|---|---|
| Control 層 | `oscal.ssp_reference_documents` 池 + `ssp_reference_document_mappings` mapping 表（context_type='control_implementation'） | `obj_entity` 透過 mapping 表關聯 |
| Objective (AO) 層 | `oscal.ssp_control_implementation_objectives.reference_documents` **inline JSONB array** | `obj_entity.reference_documents` 直接賦值 |

→ **對拷邏輯需要分兩支處理**：

```
Step 3a: 控制項層程序書（→ SSP mappings 表）
  -- 模板的 module_frame_reference_documents → ssp_reference_documents (context_type='control_implementation')
  -- 模板的 module_frame_reference_document_mappings (context_type='control_default')
  -- → ssp_reference_document_mappings (context_type='control_implementation', context_id=new_ssp_ci.id)

Step 3b: AO 層程序書（→ SSP objective JSONB）
  -- 模板的 module_frame_reference_document_mappings (context_type='objective_default')
  -- → 組成 [{file_id, name, ...}] JSONB array
  -- → UPDATE ssp_control_implementation_objectives SET reference_documents = :jsonb_array
```

**理由：** 既有 SSP service 端 `add_objective_document` 直接操作 JSONB（`ssp_control_implementation_service.py:158`），改成 mappings 表會破壞既有 API。本 feature **不重構** SSP 端，只在對拷時做格式轉換。

### 5.5 launch_new_round 補一行 fix（必做，否則破壞既有 AO 程序書資料）

**既有 bug：** `_clone_ssp_objectives` (`oscal_project_service.py:563-630`) 跨 round clone SSP objective 時**沒有複製 `reference_documents` JSONB 欄位**（line 616-626 只 copy `implementation_status / implementation_description / remarks`）。

→ 任何使用者在 round 1 加的 AO 層程序書（包括既有功能填的、不只本 feature 的）都會在 round 2 launch 時**消失**。

**修法：** 在 `_clone_ssp_objectives` line 623（`remarks=...`）之後、line 624（`created_user=...`）之前補一行：
```python
reference_documents=getattr(source_obj, "reference_documents", None) or [],
```

**性質：** 純 additive bug fix，零向後相容風險。修了之後所有 AO 程序書（不論模板帶來的或使用者自填的）跨 round 都不會掉。

**列入本 feature scope** — 不修的話本 feature 的「程序書帶入」承諾在 round 2 會破功，等同沒做。

---

## 6. UI 設計

### 6.1 建立階段（保留現有 2-step Dialog）

`ModuleFrame.vue` 的「+ 新增」按鈕仍開現有 Dialog：
- Step 1：基本資訊
- Step 2：OSCAL Framework + 控制項 Tree 勾選

按「建立」→ 模板成形（預設值全空）→ Dialog 關閉 → **自動跳轉至新詳情頁**。

### 6.2 新詳情頁：`ModuleFrameTemplateEditView.vue`

**URL：** `/module-frame/:uid/template-edit`

**設計策略：高度 reuse `ProjectPlanningView.vue` 既有結構與元件**

#### 6.2.1 Layout（抄 Planning Split Panel）

```
┌─ Header ───────────────────────────────────────────────────────────┐
│ [← 返回] 模板編輯：ISO27001 v1.0                                  │
│   [stats: 已設定 35/100 控制項 / 12/250 AO]                         │
│   [批次維護 ▾] [程序書池管理]                                       │
└────────────────────────────────────────────────────────────────────┘
┌─ 左 Tree (panel-sidebar) ────┐ ┌─ 右 Content ──────────────────────┐
│ - search                       │ │ - control 編輯區：description /    │
│ - group/control/AO 三層         │ │    status / responsible_role /    │
│ - 狀態 icon (complete/partial/  │ │    control_origination / 備註     │
│   empty)                        │ │ - 程序書區塊（reuse Reference     │
│ - badge: 已設定/總數             │ │   DocumentList）                  │
│                               │ │ - AO 編輯區：相同樣式（Accordion）  │
└───────────────────────────────┘ └────────────────────────────────────┘
```

#### 6.2.2 直接重用的元件

從 `ProjectPlanningView` 直接 import：
- `SspStatusTag.vue` — 狀態 tag
- `ReferenceDocumentList.vue` — 程序書清單顯示
- `DocumentPoolPanel.vue` — 程序書池管理 Dialog
- `DocumentLinkDialog.vue` — 從池選掛
- 整個 Tree（Group → Control → AO 三層 + 狀態 icon + search + expand all）— 抄結構過來
- 整個編輯區塊（textarea + status dropdown + remarks）— 抄結構過來

#### 6.2.3 從 Planning 拿掉的部分

- ❌ Launch 按鈕
- ❌ Task Setup 區塊（`<Panel v-for="task in taskMap.get(...)">`）
- ❌ Readonly Banner
- ❌ ParticipantsPanel
- ❌ SSP Import / Job Import Dialog（用 module_frame 自己的 batch import）
- ❌ `canEditSsp` / `isPreparing` / `isApClosed` 狀態判斷（模板永遠可編輯）

#### 6.2.4 新增的部分

- **`control_origination` 欄位**（Planning 沒這欄位）— dropdown: organization | system-specific | customer-configured | inherited | shared
- **stats bar** 改成 template 角度：「已設定 X / 總控制項 Y」「已設定 AO X / 總 AO Y」
- **批次維護 menu**（含下載 Excel 範本 / 匯出當前 / 上傳 Excel）
- **`ModuleFrameTemplateImportDialog.vue`**（fork `SspImportDialog.vue`）

#### 6.2.5 儲存策略

- 文字欄位（description / responsible_role / remarks）：**debounce 800ms 自動 PUT**（沿用 audit-scope-merge feature 的「自動儲存 + toast」pattern；參考 `docs/changelog/2026-04-25-audit-scope-merge.md`）
- 程序書掛載/解除：**按按鈕即時 POST/DELETE**
- 程序書上傳：選檔後上傳完成顯示 toast

**並發/競爭寫入處理：** v1 採 **last-write-wins**，無 etag/version 控制。理由：模板維運通常是少量 manager 序列編輯，並發寫入機率低；YAGNI。後續若實際遇到衝突，可加 `If-Match: <updated_at>` header 機制。

#### 6.2.6 Lazy 預填的 UI fallback

- Tree 節點 icon：✅（有 default row）/ ⬜（lazy 未建）
- 右側面板：第一次點到未設定的 control 顯示空白欄位 + 灰色 placeholder
- 第一次填字 → debounce 後 PUT → backend 自動建立 row（upsert 模式）

### 6.3 服務層

**新增** `src/service/ModuleFrameTemplateService.js`，mirror `SspService.js` 的 API surface 但底層打 module-frame endpoint：

```js
export default {
  listControlDefaults(moduleFrameUid) { ... },
  updateControlDefault(moduleFrameUid, controlIdentifier, payload) { ... },
  listObjectiveDefaults(moduleFrameUid) { ... },
  upsertObjectiveDefault(moduleFrameUid, payload) { ... },

  // 程序書池
  listReferenceDocuments(moduleFrameUid) { ... },
  uploadReferenceDocument(moduleFrameUid, file, meta) { ... },
  deleteReferenceDocument(moduleFrameUid, docUid) { ... },

  // mappings
  attachDocToControl(moduleFrameUid, controlIdentifier, docUid) { ... },
  detachDocFromControl(moduleFrameUid, controlIdentifier, docUid) { ... },
  attachDocToObjective(moduleFrameUid, objectiveDefaultUid, docUid) { ... },
  detachDocFromObjective(moduleFrameUid, objectiveDefaultUid, docUid) { ... },

  // catalog AO 列舉
  listCatalogControlAssessments(controlUid) { ... },

  // 批次
  exportControlDefaults(moduleFrameUid) { ... },
  downloadImportTemplate(moduleFrameUid) { ... },
  importControlDefaults(moduleFrameUid, file) { ... },
}
```

### 6.4 i18n 新增 key

`src/config/locales/i18n/zh-tw/module-frame.json` + `en/module-frame.json` 新增：

```
template_edit_title / template_edit_breadcrumb
stat_controls_filled / stat_aos_filled
label_implementation_description / label_implementation_status
label_responsible_role / label_control_origination / label_remarks
placeholder_unfilled / state_filled / state_unfilled
btn_back_to_list / btn_batch_maintain / btn_document_pool
toast_autosaved / toast_document_uploaded / toast_document_attached
confirm_delete_document_with_mappings
... (大量可參考 lang.project_planning.* 並複製改名)
```

### 6.5 ProjectCreate / ProjectSettings — 不動

新功能對 ProjectCreate / ProjectSettings 完全透明：
- 啟動專案後 → 使用者進 ProjectPlanning 看到「已預填內容」
- ProjectPlanning UI 完全不需要改

### 6.6 ModuleFrame 列表頁變更

`ModuleFrame.vue` 列表每筆模板加「編輯預設值」按鈕，導向 `/module-frame/:uid/template-edit`。

---

## 7. Multi-Round 模型相容性

### 7.1 現況確認

```
Project (compliance.projects)
  ↓ 1:1
SSP (oscal.system_security_plans)         ← 系統基線，跨 round 共用、mutable
  ↑ N:1
AP_1 (Round 1) → ssp_id 軟參照同一份 SSP
AP_2 (Round 2) → ssp_id 軟參照同一份 SSP
...
```

`launch_new_round` 邏輯：必須舊 AP `closed` → 新 AP profile_id + ssp_id 沿用舊 AP → AP controls/tasks 重新從 Profile snapshot（每 round 新生 ap_task.uid）。

### 7.2 statement_identifier 跨 round 對應 — 由既有 `_clone_ssp_objectives` 處理

**重要：** SSP objectives 的 `statement_identifier` 存的是 `ap_task.uid`，每 round 新生。但 `launch_new_round` 已經有 `_clone_ssp_objectives()` 機制處理跨 round mapping（`oscal_project_service.py:563`）：

1. 抓舊 AP 與新 AP 各自的 tasks
2. 用 `task.title` 當穩定 key 建立舊→新 ap_task.uid 對照
3. 對每個新 task，從舊 SSP objective（用舊 statement_identifier=舊 ap_task.uid 索引）複製一份新 row（statement_identifier=新 ap_task.uid）

→ Round 2 啟動後，SSP 中會多出新的 objective row 對應新 task uid，舊 round 的 objective row 仍保留（為 future audit history 預留）。

### 7.3 對本次設計的影響：無（搭配 §5.5 的一行 fix）

**Template → SSP 啟動時 UPDATE in place** 在 round 1 啟動時執行一次（不是 INSERT）：
- 啟動專案時對 Step 7.5 已建好的 SSP control_impl / objective 空殼 row 做 UPDATE
- statement_identifier 沿用 Step 7.5 的 convention（`task.task_code OR task.title OR str(task.id)`）
- Round 2/3/N launch 時 → `_clone_ssp_objectives` 用 `task.title` 對應，自動把 description / status / remarks / **reference_documents** (見 §5.5 修補) 複製到新 task uid 上
- 整個 round 演進與 template 無關（snapshot 後完全自治）

→ **本設計在 multi-round 下完全成立，搭配 §5.5 的一行 fix 即可確保程序書跨 round 不消失。**

> **註**：`_clone_ssp_objectives` 用 `task.title` 當穩定 key 是既有設計選擇。理論上用 `catalog_control_assessment_id` 作為 stable key 更穩固（title 可能因為翻譯/編輯而變動），但這是既有 launch_new_round 的設計議題，不在本次 scope。

---

## 8. NEEDS SPIKE（實作前確認）

| Spike 項目 | 解開時機 | 決議 / 假設 |
|---|---|---|
| **`init_ap_controls_from_profile` 建立的「Evidence upload」fallback control（沒 catalog_control_assessment）— 對應的模板 AO 該怎麼處理** | spike DONE（design 階段已決） | **不支援**：fallback control 沒有 catalog_control_assessment，因此模板層**不允許**為其新增 AO defaults。對拷邏輯用 LEFT JOIN（不是 INNER JOIN），對沒 CCA 的 control 自然無 AO row 可拷；對拷完成後 log 一行 INFO（「N controls had no CCA, AO defaults skipped」），不算錯誤。模板 UI 對沒 CCA 的 control 不顯示 AO 編輯區塊。 |
| **模板 clone (`clone_module_frame`) 時要不要連 control_defaults / objective_defaults / reference_documents 一起 clone** | 實作 clone 擴充前 | 假設：clone 時連 defaults + 程序書 mapping 一起複製（檔案 file_id 共享）；獨立 task 不在 v1 必做。 |
| **驗證 `assessment_plan_tasks.catalog_control_assessment_id` 與 `oscal.catalog_control_assessments.uid` 的索引存在** | 實作對拷邏輯前 | 預期既有索引足夠；若不足，migration 需補。 |

---

## 9. Out of Scope（本次明確不做）

| 項目 | 原因 | 列入 |
|---|---|---|
| **SSP-per-round** | 影響範圍跨 dashboard / SSP detail / POA&M / evidence，需獨立 feature | Phase 2 backlog |
| **SSP 版本控制** | 跟 SSP-per-round 一起做 | Phase 2 backlog |
| **AP per-round scope 變化** | OSCAL `reviewed-controls` 標準功能，獨立 feature | Phase 2 backlog |
| **Re-sync from template**（Pattern C） | YAGNI；等真實「template 改了想推到 project」需求出現再做 | Phase 2 backlog |
| **AR snapshot SSP 內容** | 由 SSP-per-round 一併解決 | Phase 2 backlog |
| **i18n 翻譯表** | v1 純 TEXT 欄位，未來加 trans 表向前相容 | Phase 2 backlog |
| **Tenant 共用程序書池** | YAGNI；目前 per-module_frame 設計與 SSP 端對齊 | 視需求評估 |
| **`SspImportDialog.vue` 抽象成 generic 元件** | 過度設計，本次 fork 一份即可 | Phase 2 backlog |
| **Promote project SSP to template** | 不在心智模型內 | 已棄 |

---

## 10. 風險清單與決策（從 brainstorming 收斂）

| # | 風險 | 決策 |
|---|---|---|
| 1 | `previous_ssp_id` 預留欄位 YAGNI | ✅ 砍 |
| 2 | `is_user_modified` 預留欄位 YAGNI | ✅ 砍 |
| 3 | AO statement_identifier 來源 | ✅ A: 模板層用 `catalog_control_assessment.uid`，SSP 端對拷 join |
| 4 | `control_origination` 欄位 | ✅ 加到 `module_frame_control_defaults` |
| 5 | Lazy vs Eager 預填 | ✅ Lazy + UI fallback「未設定」 |
| 6 | 軟刪除一致性 | ✅ 新表不加 is_delete，靠 CASCADE + JOIN 過濾 |
| 7 | i18n | ✅ v1 不做，欄位 TEXT 留向前相容 |
| 8 | 對拷邏輯封裝 | ✅ `_copy_template_to_ssp()` 共用 service + unit test |
| 9 | 啟動時對拷效能 | ✅ Bulk insert |
| 10 | jedi-oscal 進版 | ✅ 確認可進版 |
| 11 | 程序書池 scope | ✅ Per-Module-Frame |
| 12 | 批次上傳元件抽象 | ✅ A: fork `SspImportDialog` |

---

## 10A. 不影響原有功能 — Backward Compatibility 保證（最高優先級）

本 feature 的核心承諾：**對既有功能、既有資料、既有 API 完全不影響**。逐項保證如下：

### 10A.1 既有 API 行為 — 100% 不變

| 既有 API | 影響 | 保證 |
|---|---|---|
| `POST /module-frame` (建立模板 Dialog) | **無** | request/response 完全不變；新欄位都不在建立階段填 |
| `GET /module-frame/{uid}` (取得模板) | **無** | 既有 response shape 不變；新增欄位走獨立新 API |
| `PUT /module-frame/{uid}` (更新基本資訊) | **無** | 不更新新表 |
| `DELETE /module-frame/{uid}` (軟刪除) | **無** | 既有軟刪除邏輯不變；新表 row 跟著 module_frame 軟刪除（query 時 JOIN filter） |
| `POST /module-frame/clone` | spike 後決定（v1 預設一起 clone） | 不破壞既有 clone 行為 |
| Excel import（`/module-frame/import/*`） | **無** | 沿用既有流程 |
| `POST /oscal/projects/start` | **request/response 完全不變** | 內部多一步 `_copy_template_to_ssp()`；對 caller 透明 |
| `POST /grc/projects/{uid}/launch-new-round` | **無** | 完全不改；既有 `_clone_ssp_objectives` 接手 round 維護 |
| 所有 SSP 端 API（`PUT /ssp/...`、程序書 CRUD、AO update） | **無** | 不重構 SSP 端任何邏輯 |
| 所有 ProjectPlanning / ProjectCreate / ProjectSettings FE 流程 | **無** | 不改任何既有 view |

### 10A.2 既有資料 — 0 backfill / 0 mutation

- 既有 `module_frames` 表資料 **完全不動**
- 既有 SSP / SSP control_impl / SSP objective / SSP reference_documents 資料 **完全不動**
- 既有 `assessment_plans` / `assessment_plan_tasks` 資料 **完全不動**
- 新欄位 `oscal.system_security_plans.template_module_frame_id` 為 NULL（對既有 SSP 是 unknown source，不影響任何既有邏輯）
- 新表為空表，等使用者自行編輯後才有 row

### 10A.3 既有專案啟動行為 — 完全保留

對於**沒有設定任何 defaults 的 module_frame**（即所有既有模板）：
- `_copy_template_to_ssp()` 內 UPDATE 的 WHERE clause 對應 0 筆 (因為 module_frame_control_defaults 表為空) → 沒任何 row 被改
- SSP 啟動結果 = 既有行為（Step 7.5 建好的空殼 row 維持不變）
- 使用者進 ProjectPlanning 看到的畫面 100% 與目前一致

→ **既有專案啟動完全不會看到差異**，只有「模板已設過 defaults」的新專案才會看到自動帶入內容。

**關鍵設計：UPDATE in place 而非 INSERT（見 §5.0）** — 因為 Step 7.5 已經建好空殼 row，我們只是把 NULL 欄位填上模板內容，不額外 INSERT 新 row，故沒有 unique constraint 衝突風險。

### 10A.4 既有 multi-round 行為 — 純加強，不破壞

- `launch_new_round` 主要邏輯**完全不改**
- 唯一動作：`_clone_ssp_objectives` 補一行 copy `reference_documents` JSONB（見 §5.5）
  - 這是**既有 bug fix**，純 additive，0 向後相容風險
  - 修了之後**所有** AO 程序書（不論模板帶來的或既有功能填的）跨 round 都不會消失
  - 不修反而會破壞既有功能（既有 user 加的 AO 程序書 round 2 也會掉）
- 對拷產生的 SSP description 一旦進 round 1 SSP，就跟一般 SSP 資料一樣由既有 multi-round 機制接手

### 10A.5 jedi-oscal 套件變更 — 加欄位 only

- 唯一改動：`OscalSystemSecurityPlan` model 加 `template_module_frame_id` 欄位（nullable）
- **不改任何既有欄位**、**不改任何既有 method**
- 既有 jedi-oscal 使用者（含本專案其他模組）**完全 backward compatible**
- 進版號用 minor bump（如 0.0.8 → 0.0.9，schema 變更）

### 10A.6 整合測試清單（必跑，確保不影響既有功能）

實作完成後必須驗證以下既有行為**完全不變**：

1. ✅ 沒設 defaults 的模板啟動專案 → SSP 全 NULL（與目前 100% 一致）
2. ✅ 既有 module_frames 列表頁渲染 + Dialog 編輯 → 行為不變
3. ✅ 既有 SSP 編輯（ProjectPlanning）→ 完全不變
4. ✅ 既有 SSP 程序書上傳/掛接（control 層 + AO 層）→ 完全不變
5. ✅ 既有 module_frame Excel import → 完全不變
6. ✅ launch_new_round Round 2 啟動 → SSP objective 跨 round clone 行為不變
7. ✅ jedi-oscal 既有 SSP query / update 流程 → 完全不變
8. ✅ Round 1 user 自填 AO 程序書 → launch_new_round → Round 2 SSP objective 仍保有程序書（驗證 §5.5 fix）
9. ✅ Step 7.5 + `_copy_template_to_ssp` 共存：模板有 defaults 啟動 → SSP control_impl / objective row 數量 = Step 7.5 該建的數量（無 duplicate row、無 unique constraint 衝突）

### 10A.7 失敗回滾

- 整個 `_copy_template_to_ssp` 在 `start_oscal_project` 的 `@transaction` scope 內
- 任何一步失敗 → 整個 project start transaction rollback
- 不會留下半完成 SSP 或半 copy defaults 狀態
- 不允許 try/except 吞掉錯誤（fail loud）

---

## 11. 影響範圍

### 11.1 Backend

**新增：**
- `compliance.module_frame_control_defaults` (table)
- `compliance.module_frame_control_objective_defaults` (table)
- `compliance.module_frame_reference_documents` (table)
- `compliance.module_frame_reference_document_mappings` (table)
- `domain/module_frame/entity/...` + `infra/module_frame/repository/...` + `mapper/...` 一整套 DDD 層
- `app/module_frame/service/module_frame_control_default_service.py`
- `app/module_frame/service/module_frame_objective_default_service.py`
- `app/module_frame/service/module_frame_reference_document_service.py`
- `app/module_frame/service/module_frame_template_import_service.py`
- `api/module_frame/routes/control_default_route.py`
- `api/module_frame/routes/objective_default_route.py`
- `api/module_frame/routes/reference_document_route.py`
- `api/module_frame/routes/template_import_route.py`
- `api/oscal/routes/catalog_control_assessment_route.py`（如尚未有）
- `common/code/module_frame_error_code.py` (新檔)
- SQL migration: `scripts/sql/2026-XX-XX-module-frame-template-defaults.sql`

**修改：**
- `oscal.system_security_plans` 加 `template_module_frame_id` 欄位（jedi-oscal 進版）
- `app/project/service/oscal_project_service.py` 加 `_copy_template_to_ssp()` 並在 `start_oscal_project` 流程中呼叫
- `app/oscal/service/...` 暴露 catalog_control_assessments 列舉 service（如尚未有）

**不動：**
- 既有 module_frame 基本 CRUD API
- SSP 端既有 update API
- launch_new_round 流程

### 11.2 Frontend

**新增：**
- `src/views/module_frame/ModuleFrameTemplateEditView.vue`
- `src/components/grc/ModuleFrameTemplateImportDialog.vue` (fork `SspImportDialog`)
- `src/service/ModuleFrameTemplateService.js`
- `src/config/router/index.js` 加新 route `/module-frame/:uid/template-edit`
- i18n key（`module-frame.json` zh-tw + en）

**修改：**
- `src/views/module_frame/ModuleFrame.vue` 列表加「編輯預設值」按鈕
- `src/config/api/api.js` 加新 endpoint 常數

**不動：**
- `ProjectCreateView` / `ProjectSettingsView` / `ProjectPlanningView`
- 既有 module_frame 建立 Dialog 流程

### 11.3 jedi-oscal（外部套件）

- **修改**：`OscalSystemSecurityPlan` model 加 `template_module_frame_id` 欄位
- **進版發佈**：依 user 慣例，需明確指示後才執行（CLAUDE.md memory: feedback_jedi_package_publish_flow）

---

## 12. 文件交付

按 CLAUDE.md 規範，本次需產出：
- ✅ `docs/features/FR-018-2604-module-frame-template-defaults/design.md`（本文件）
- 待產出：`docs/features/FR-018-2604-module-frame-template-defaults/implementation-plan.md`（writing-plans 階段）
- 待產出：`docs/changelog/2026-XX-XX-module-frame-template-defaults.md`（實作完成後）

---

## 13. 估計工作量

| 階段 | 工作量 |
|---|---|
| BE: schema migration + DDD layer (4 entities × entity/mapper/repo if/repo impl/service) | 3 天 |
| BE: 5 services + routes + import/export | 2 天 |
| BE: 對拷邏輯（含 control + objective 雙路徑 + JSONB 轉換） | 1 天 |
| BE: jedi-oscal 改欄位 + 進版發佈 | 0.5 天 |
| FE: TemplateEditView + Service + Dialog fork + i18n + route | 5.5 天 |
| 整合測試（BE unit + FE 整合 + 對拷端到端驗證） | 2 天 |
| 文件交付（changelog） | 0.5 天 |
| **總計：~14-15 天** | |

---

## 14. Open Questions for Implementation（必須在 implementation plan 階段解開）

| # | 問題 | 解法方向 |
|---|---|---|
| Q1 | 模板 clone 時 defaults / 程序書 mapping 是否一起 clone？ | 預設「是」，需在 plan 確認 |
| Q2 | Catalog 後續更新導致 catalog_control_assessment 變動 → 模板層 statement_identifier 變孤兒，UX 該如何提示？ | UI 顯示「目標已被 catalog 移除」，提供「移除預設值」按鈕 |
| Q3 | `assessment_plan_tasks.catalog_control_assessment_id` 索引是否存在？ | 實作前 `\d` 確認，缺則 migration 補 |
| Q4 | jedi-oscal 進版號 (minor / patch)？ | minor bump（schema 變更，已決） |

---

## 15. 參考

- OSCAL 標準：https://pages.nist.gov/OSCAL/
  - SSP: https://pages.nist.gov/OSCAL/concepts/layer/implementation/ssp/
  - Component Definition: https://pages.nist.gov/OSCAL/concepts/layer/implementation/component-definition/
- 既有設計：
  - SSP 表：`scripts/sql/ssp_control_implementation_migration.sql`
  - SSP 程序書池：`scripts/sql/stage5_migration.sql` + `scripts/sql/ssp_document_pool_migration.sql`
  - 啟動專案：`prompt/oscal_project_start_flow.md`
  - ProjectPlanning UI：`compliance-manager-fe/src/views/project/ProjectPlanningView.vue`
- 既有 jedi-oscal model 路徑：`~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/`
