# FR-036 module_frame ↔ SSP 整併（樣板 SSP 模型）— 設計

| 項目 | 內容 |
|------|------|
| FR | FR-036 |
| 提出 | 2026-06-10 |
| 狀態 | 設計（brainstorm 完成，待 implementation-plan） |
| arc 關聯 | relates to FR-018（MF 範本預設）/ FR-019（SSP 版本控制）/ FR-035（SSP 改名） |
| 影響 repo | BE（主專案）+ jedi-oscal（套件，DDL 變更）+ FE（Phase 1 視 UI 路線，本次 B 路線盡量不動）+ 3 DB 環境（dev/stg/poc） |

---

## 1. 問題陳述

目前資源庫（module_frame）的範本內容存在一整套 `compliance.module_frame_*_defaults` 表，與專案 SSP 的 `oscal.ssp_*` 子表**結構幾乎一一對應，卻是兩套獨立 schema**：

| module_frame 端（範本源頭，`compliance`） | 對應 | 專案 SSP 端（實例，`oscal`） |
|---|---|---|
| `module_frame_control_defaults` | → | `ssp_control_implementations` |
| `module_frame_control_objective_defaults` | → | `ssp_control_implementation_objectives` |
| `module_frame_component_defaults` | → | `ssp_components` |
| `module_frame_inventory_item_defaults` | → | `ssp_inventory_items` |
| `module_frame_leveraged_authorization_defaults` | → | `ssp_leveraged_authorizations` |
| `module_frame_system_characteristic_defaults` | → | `ssp_system_characteristics` |
| `module_frame_reference_documents` + `_mappings` | → | `ssp_reference_documents`（context_type='ssp'）+ `ssp_reference_document_mappings` |

**痛點：** OSCAL 改版或要加一個新欄位時，必須同時改 `*_defaults` 與 `ssp_*` 兩個地方、寫兩支 migration，且兩邊容易 drift（已實際踩過，見記憶 `feedback_mf_default_tables_schema_symmetry`）。這是一筆每次 OSCAL 異動就要繳的稅。

**目標：** 消除雙套 schema。`module_frame` 的範本內容**就用一份 SSP 來存**（標記為樣板），啟動專案時 clone 這份樣板 SSP 成專案 SSP。schema 從此只有一套（`ssp_*`）。

---

## 2. 設計取捨（為什麼是「樣板 SSP」而不是別的）

brainstorm 期間考慮過三條路：

1. **樣板 SSP 模型（採用）**：`module_frame` 指向一份 `is_template=True` 的 SSP，內容存在既有 `ssp_*` 子表；`*_defaults` 退役。schema 單一、clone 重用既有版本化邏輯、不污染 jedi-oscal 的 OSCAL 純度（jedi-oscal 只認得「SSP」，樣板也是一種 SSP；是 `module_frame` 這邊持有指向它的 reference）。
2. **保留兩套表、只共用 column 定義（mixin）**：解了 schema drift，但仍是兩套實體表、跨兩個 repo，且編輯/clone 邏輯仍分裂。被排除：沒有真正消除雙軌。
3. **在 SSP 表加 `owner_type='module_frame'|'project'` 泛型 discriminator**：折進 SSP 表但讓 SSP 表認得「module_frame」這個**非 OSCAL 概念**，污染套件。被排除：跨層、污染 jedi-oscal。

**關鍵共識：** clone 動作保留（不是改成共用參考）。樣板 SSP 與專案 SSP 從 clone 那一刻起是兩筆各自獨立的 row，改一邊不影響另一邊——這對「已結案稽核的 SSP 必須凍結、不可被事後改範本回頭污染」與「每輪 AP = 新 SSP 版本可各自修改」兩個合規需求是硬性要求。merge 不犧牲這個隔離，只是把「兩套表 + 跨表複製」換成「一套表 + 同表族複製」。

### 2.1 為什麼用獨立欄位 `is_template` 而不是 status 值

`status`（draft / ready / archived）是 SSP 的**生命週期**維度；「樣板 vs 實例」是**正交**的另一個維度。若把 `template` 塞進 `status`，樣板 SSP 本身就無法再表達 draft / ready 等生命週期狀態。故採獨立 Boolean 欄位 `is_template`，`status` 語意維持不變。

---

## 3. 目標模型

```
module_frame ──(template_ssp_id)──► 樣板 SSP（oscal.ssps, is_template=True）
                                       └ 完整 SSP 子表（取代原 *_defaults）：
                                         ssp_control_implementations
                                         ssp_control_implementation_objectives（含 reference_documents JSONB）
                                         ssp_components / ssp_inventory_items
                                         ssp_leveraged_authorizations
                                         ssp_system_characteristics
                                         ssp_reference_documents（context_type='ssp'）+ ssp_reference_document_mappings

資源庫編輯 UI（B 路線，不動）──► BE 寫入路徑改成寫進「該 MF 的樣板 SSP 子表」

啟動專案（start_oscal_project）
   ──► AP 建立後，SSP 子表骨架由 AP controls 建出（_setup_ssp_system_implementation，維持現狀）
   ──► copy() 把樣板 SSP 子表內容「AP 橋接 + 疊上」專案 SSP（來源 re-point：*_defaults → 樣板 SSP）
   ──► 專案 SSP（is_template=False、AP-keyed）；AP.ssp_id 指向它
launch_new_round（輪到輪）
   ──► SspVersioningService.clone_to_new_version（兩邊都有 AP，維持現狀）
```

### 3.0 架構修正（2026-06-10，brainstorm 後實查 code 發現）

**專案 SSP 子表 row 是「AP 的投影」**：`ssp_control_implementation_objectives.statement_identifier` 用 AP task 的 `COALESCE(task_code, title, apt_id)`、程序書 mapping 的 context_id 用 AP task/control id；系統其他部分（FR-010 程序書池、FE Planning）靠此 AP 連結讀資料。含意：

- **不可用 `SspVersioningService` 把樣板 SSP 直接 clone 成專案 SSP** — 它保留來源 key，但專案 SSP 需要「該 AP 的 task key」，搬錯 key 會讓 AP↔SSP 投影斷掉。`SspVersioningService` 僅續用於「輪到輪」版本化。
- **樣板 SSP 子表沿用 `*_defaults` 的 AP-independent key**（control_identifier / ccai uid / letter key）；本質是「把 `*_defaults` 搬進 `ssp_*` 表、掛 `is_template` SSP」。
- **啟動專案的 clone 仍走現有 `copy()` 的 AP 橋接邏輯**，只把來源 re-point（`compliance.module_frame_*_defaults` → `oscal.ssp_* WHERE ssp_id=樣板`）。
- SSP 子表 schema 在 DB 層皆 AP-independent（無 NOT NULL AP FK，2026-06-10 實查 dev 確認）；樣板 row 與專案 row 在同表內 `statement_identifier` 慣例不同，以 `system_security_plan_id` + `is_template` 區隔，不衝突。

### 3.1 欄位

- **`oscal.ssps.is_template`**（jedi-oscal 新增）：Boolean, NOT NULL, default `False`。樣板 SSP = `True`。
- **`compliance.module_frames.template_ssp_id`**（主專案新增）：Integer soft-ref（不建 SQLAlchemy FK，遵循跨 schema soft-ref 慣例），指向該 MF 的樣板 SSP。一個 module_frame 對應一份樣板 SSP（1:1）。
- 既有 `oscal.ssps.template_module_frame_id`（追蹤「專案 SSP 由哪個 MF clone 而來」）保留；與新 `module_frames.template_ssp_id`（MF → 樣板 SSP）為兩個不同方向的關係，互不衝突。
- 樣板 SSP 的 `profile_id` = 該 module_frame 的 profile（由 `module_frames.oscal_profile_uid` 對到）。`version_no` 設 0（或 NULL，實作時定）、`group_id` 自成一組，不參與專案版本序列。

---

## 4. 階段切分

### Phase 1（基本 / 本 FR 主體）

1. **jedi-oscal**：`oscal.ssps` 加 `is_template` 欄位；list / 查詢 SSP 的進入點一律排除 `is_template=True`（避免樣板漏進專案 SSP 清單 / dashboard / AP / OSCAL 匯出）。走 path-dependency dev 流程，user 明示才發版。
2. **migration（零丟失）**：掃所有現有 `module_frames` → 各建一份 `is_template=True` 的樣板 SSP → 把該 MF 的 `*_defaults` + 程序書逐欄位灌進樣板 SSP 子表 → 設 `module_frames.template_ssp_id`；逐欄位 / row count 驗證；dev → stg → poc 各跑。
3. **資源庫編輯寫入路徑（B 路線）**：`module_frame` 編輯 service 從寫 `*_defaults` 改成寫進該 MF 的樣板 SSP 子表（透過 jedi-oscal SSP service）。FE 畫面 / API 形狀盡量不變。
4. **啟動專案 clone 換源**（修正，見 §3.0）：`start_oscal_project` 的 SSP 骨架仍由 AP controls 建出（`_setup_ssp_system_implementation` 維持），`copy()` 的內容來源 re-point 為樣板 SSP（`oscal.ssp_* WHERE ssp_id=樣板` 取代 `compliance.module_frame_*_defaults`），AP 橋接邏輯保留。**不**改用 `SspVersioningService`（會搬錯 AP key）。`launch_new_round` 的輪到輪版本化維持 `SspVersioningService`。
5. **一致性陷阱處理**：資源庫一旦改寫樣板 SSP，`*_defaults` 即過時。故 Phase 1 必須**同時**把「讀 `*_defaults` 來顯示 / 匯出 / 填值」的路徑改讀樣板 SSP（`export/ssp_mf_content_loader.py`、`export/ssp_export_model.py`、`module_frame/ssp_import_template_app_service.py` 的 fill 部分）。否則資源庫匯出 / 下載會看到舊資料。
6. **`*_defaults` 退為休眠**：Phase 1 結束後不再被寫入/讀取，但**先不 DROP**（Phase 2 才清）。

### Phase 2（後續 / user 指定留待）

7. **匯入路徑改源**：`ssp_docx_import_app_service.py`、`ssp_excel_import_app_service.py` 與 MF-scoped 樣板匯入 `module_frame/ssp_import_template_app_service.py`、`module_frame_template_import_service.py` 改成匯入到樣板 SSP。
8. **清理**：驗證無誤後 DROP `module_frame_*_defaults`（7 張）+ `module_frame_reference_documents` / `_mappings`；移除對應 model / repo / domain service。`ModuleFrameTemplateCopyService.__init__` 的 4 個 `*_default` domain service 注入（M4 後已不用，原本就走 raw SQL）一併移除。
9. **`_setup_ssp_system_implementation` gate 改寫**：目前用 `module_frame_component_defaults` count 當「MF 有 defaults → skip 舊式 template-SSP-clone」的 gate（`oscal_project_service.py` ~line 665）；Phase 2 DROP 該表後此 gate 會失效，需改成檢查 `module_frames.template_ssp_id IS NOT NULL`，並移除舊式「profile 第一個 SSP 當 template」clone 概念（已被 FR-036 樣板 SSP 取代）。

---

## 5. 可重用 / 真要重寫 / 新增（依「開工前先分析」規範）

| 類別 | 項目 |
|------|------|
| **可重用** | `ModuleFrameTemplateCopyService.copy()` 的 AP 橋接邏輯（control UPDATE-in-place、objective task_map 橋接、程序書 mapping）— 啟動專案 clone 直接重用，只 re-point 來源（`*_defaults` → 樣板 SSP 子表）。`SspVersioningService.clone_to_new_version` 續用於 launch 輪到輪版本化（不動）。 |
| **真要重寫 / 改源** | 資源庫編輯寫入路徑（寫 `*_defaults` → 寫樣板 SSP 子表）；`copy()` 的內容來源（`*_defaults` → 樣板 SSP）；讀 `*_defaults` 的 export/fill 路徑。 |
| **新增** | `oscal.ssps.is_template` 欄位（jedi-oscal）；`compliance.module_frames.template_ssp_id` 欄位；遷移 migration；查詢排除樣板 SSP 的 filter。 |
| **退役（Phase 2）** | `ModuleFrameTemplateCopyService`、`*_defaults` 表 + model/repo/domain service。 |

---

## 6. 風險與緩解

| 風險 | 緩解 |
|------|------|
| migration 在三環境零丟失（最高風險） | 逐欄位 / row count 對齊驗證；dev → stg → poc 漸進；`*_defaults` 不在 Phase 1 DROP，可回溯比對。app 必須與 migration 同步上版（搬表教訓 `feedback_rebuild_docker_image_after_schema_move`）。 |
| 樣板 SSP 漏進專案 SSP 流程 | 全面 audit 所有查 SSP 的進入點（SSP list / AP / dashboard / 匯出 / 統計），一律加 `is_template=False`；jedi-oscal repo 層集中處理優先。 |
| Phase 1 改寫樣板 SSP 後 `*_defaults` 過時 | Phase 1 同步改掉所有讀 `*_defaults` 的 display/export/fill 路徑（§4.5）。 |
| jedi-oscal 改動影響其他 consumer | `is_template` 有 default、查詢排除為加嚴；走 path-dependency dev、user 明示才發版（規範 `feedback_jedi_package_dev_path_first` / `feedback_jedi_package_publish_flow`）。 |
| 跨 schema FK 字串未限定 | 新欄位 soft-ref（Integer），不建 SQLAlchemy FK；如有 relationship 字串務必 schema-qualify（`feedback_cross_schema_fk_must_qualify`）。 |

---

## 7. 驗收條件（分層）

- **BE 寫對**：migration 後每個 module_frame 有一份 `is_template=True` 樣板 SSP，子表內容與原 `*_defaults` 逐欄位一致；`module_frames.template_ssp_id` 正確指向。
- **流程對**：啟動專案 / launch 新輪後，專案 SSP 內容與樣板 SSP 一致且為獨立 row（改樣板不影響既有專案、改專案不回寫樣板）。
- **不外漏**：SSP list / AP / dashboard / 匯出 / 統計 一律不出現 `is_template=True` 的樣板 SSP。
- **資源庫一致**：資源庫編輯 → 樣板 SSP；資源庫顯示 / 匯出 / 填值讀樣板 SSP，無舊 `*_defaults` 殘影。
- **Demo-ready**：FE 資源庫畫面（B 路線）行為不變，使用者無感知底層 storage 已換。

---

## 8. 未解 / 待 implementation-plan 細化

- 樣板 SSP 的 `version_no`（0 vs NULL）與 `group_id` 生成規則定案。
- migration 是否一併處理 `*_trans`（翻譯表）內容搬遷（控制項 / 程序書多語）。
- 資源庫編輯 API 是否需在 app service 層包一層「對樣板 SSP 的 CRUD adapter」，使 FE 完全無感。
- Phase 2 匯入到樣板 SSP 的 UX（資源庫是否需要「匯入範本」入口）留 Phase 2 brainstorm。

---

## 9. Phase 2 + P2c 完工 reconciliation（2026-06-10，DEV）

**狀態：DEV 完整收口（改源 + DROP + cleanup + 手測通過）。stg/poc 留獨立遷移弧。**

### 9.1 完成項

| 子階段 | 內容 | commit |
|--------|------|--------|
| P2a-A | 樣板下載填值讀 helper 改源 | `57746a5b` |
| P2a-B | docx 匯入確認 + 控制項/AO `ModuleFrameWriteStrategy`（docx+excel 共用）改寫樣板 SSP | `bee8946c` |
| P2a-C | excel 匯入確認 `_write_mf_defaults` 改源 | `49c34073` |
| P2b | 啟動專案 gate 改查 `template_ssp_id` + copy service 移除 unused 注入 | `dc39aa45` |
| P2a-E | 模板層控制項 Excel 批次匯入/匯出改源（Phase 2 漏網的最後一個 live consumer）| `22b19ee0` |
| P2c | DROP 8 退役表（dev）+ migration | `14719775` |
| P2c cleanup | 移除 inert `*_defaults` 子系統 56 檔 + DI | `538e8524` |

### 9.2 與原計畫的差異 / 決策

- **docx/excel 匯入收斂到 project_ssp 同源路徑**：原計畫想各自 re-point；實作發現 module_frame 的樣板
  SSP 本身就是 SSP，故 module_frame 匯入確認直接走既有 `confirm_service.confirm(ssp_id=template_ssp_id)`
  + `sc_write_strategy`，消除 `_run_mf_default_confirm` 與臨時 shell SSP 兩條路徑。FR-032 device/系統資產
  鉤稽由共用的 InventoryItemWriteStrategy 沿用，不丟失。
- **Excel control 匯入 delete-all → upsert**：樣板 SSP 的 control_impl 與資源庫編輯 (M5a) / 程序書掛勾
  (M5b) 共用同一份 row，原本「整份覆蓋」會誤刪人工維護內容 + refdoc mapping → 改 upsert 與 M5a 一致
  （profile baseline 由另一流程維護，匯入只覆寫內容欄位）。
- **啟動專案 gate**：移除舊式「profile 最早 SSP 當 template」clone —— 在樣板 SSP 模型下（is_template SSP
  被查詢排除）該 heuristic 會誤抓兄弟專案 SSP；改由 `_mf_has_template_ssp` gate + `copy()` 從樣板 SSP 灌齊。
- **P2a-E（模板層控制項 Excel）是 design §4.7 列入但 handoff P2a 漏掉的 live consumer**；DROP 前的「找出所有
  live consumer」稽核才抓到（FE 確認資源庫編輯頁的匯出按鈕 + 匯入對話框有在用）。

### 9.3 P2c DROP 安全底線（實際執行）

- **找出所有 live consumer**：跨 BE + jedi-* grep，確認 8 表零 live consumer（剩餘僅 dead code）。
- **orphan audit**（dev active MF）：8 表內容皆 0 孤兒（全在 10 份樣板 SSP；855 筆 control 殘列屬已刪資源庫）。
- **dev-only**：stg/poc 連 `oscal.ssps` 都沒有（FR-035 + M1 + M3 未套），`*_defaults` 是未遷移 live 資料 →
  DROP 嚴禁；FR-036 整支 code 在那邊也跑不起來。migration 檔頭已標明。
- **inert 安全**：DTO duck-typed（不 import 被刪 entity）；無 runtime `create_all` → ORM model inert，
  DROP 後 app 仍能啟動（DI import smoke 驗證）。

### 9.4 §8 未解項結案

- 樣板 SSP `version_no=0` / 自成 group_id：Phase 1 已定（migration 採用）。
- `*_trans` 翻譯表：Phase 1 M3 已隨內容遷移。
- 資源庫 CRUD adapter：採「app service 內 re-point + DTO duck-type」，FE 無感達成，不需額外 adapter 層。

### 9.5 Follow-up

- **stg / poc 遷移 + FR-036 上線**（未排程，需 user 規劃 + 備份）：FR-035 + M1 + M3 + 驗零丟失 → 才上 code / DROP。
- excel service 殘留少數 inert `mf_*_default` 參數（低優先）。
- push（7 BE commit 未 push，等 user 明示）。
