---
title: FR-106 SSP 對應層歸位 oscal——反轉 module_frame 與 oscal 的依賴方向
status: ⏸️ **決策者 2026-09-16 裁「先不動」**——與主專案牽連太多，後續釐清再處理；本稿留作紀錄，未開卡。實查 HEAD `7b017144`，引用前先重驗數字。
relates: [FR-105, FR-104, FR-102, FR-080, FR-038]
---

# FR-106 SSP 對應層歸位 oscal——反轉 module_frame 與 oscal 的依賴方向

## 決策紀錄

決策者 2026-09-16 審完本稿後裁：**先不動**。理由：oscal 與主專案牽連太多（61 條 route 每條都帶本產品守門或概念），反轉只解模組間方向、不解模組內三支千行大檔的糾纏；等新功能開發帶著真實需求回頭釐清再處理。本稿的實查數字與方案取捨留作那時的起點。

## 一句話

系統裡只有**一種** OSCAL SSP，開了兩個前門：資源庫範本（`module_frame`）與專案 SSP（`oscal`）。
SSP 的欄位對應表、Excel 範本產生器現在住在 `module_frame`，純粹因為資源庫先做。
**把它們搬回 `oscal`**，讓 `module_frame` 依賴 `oscal`、`oscal` 不再依賴 `module_frame`。
不改任何行為、不動套件、不動 DB。

## 這是什麼問題（白話）

系統把一份「系統安全計畫書」（SSP）拆成幾種子物件：參與方、元件、資產清冊、外部授權、系統特性。
套件 `jedi_oscal_v2` 存的是 OSCAL 標準格式，前端要的是自己的欄位名，中間要一張對應表翻譯。
這張表在兩個地方用到：資源庫範本與專案 SSP。**兩者底下都是同一種 SSP**——資源庫範本的資料
就是一份 SSP（`module_frames.template_ssp_id` 指向套件的 SSP 表）。

FR-038 做 v2 時先做資源庫那側，對應表就寫在 `module_frame`；後來專案 SSP 要用同一份，`oscal`
直接去 `module_frame` 拿。結果是：

- 兩倍大、概念上是 SSP 本家的 `oscal`，反過來依賴 `module_frame`（實查 app 層 6 檔對 1 檔，加 DI 容器 6 處）。
- 跨模組 import 的都是底線開頭的「私有」函式（`_build_props`、`_to_dict`），三個模組在用。
- 五支 module_frame 對應 service 的 DI 註冊放在 `oscal_containers.py`，不在 `module_frame_containers.py`。
- Excel 範本產生器（1,656 行、零套件依賴）產的是 **SSP** 匯入範本，卻住在 `module_frame` 下。

**問題不是重複（實查真正重複只有 25 行），是 SSP 的東西放錯家。**

## 方案：SSP 的東西歸 `oscal`

### 搬什麼

| 類 | 從哪來 | 搬去哪 | 規模 | 說明 |
|---|---|---|---|---|
| **① 子物件對應表的純函式** | 五支 `app/module_frame/service/module_frame_{party,components,inventory,leveraged,system_characteristic}_service.py` 裡的模組層級函式與 `@staticmethod`（`_build_props`／`_to_dict`／`_props_to_map`／`_PROP_*` 常數／`_NIL_PARTY_UUID`／`_parse_date`／`_to_email_list`／`_empty_placeholder`／`_apply_payload` 等） | `app/oscal/service/ssp_mapping/{party,components,inventory,leveraged,system_characteristic}.py` | 約 400 行 | **只搬純函式**；五支 service 本體（查 `template_ssp_id`、呼叫 `SspService`、暱稱 enrich）留在 `module_frame`，改 import oscal。函式**去掉底線改公開名**——它們本來就是跨模組契約 |
| **② SSP Excel 範本產生器** | `app/module_frame/excel_template/` 整包 7 檔 | `app/oscal/service/excel_template/`（與既有 `excel_parser/` 並列：一個產、一個讀） | 1,656 行 | 整包平移，零邏輯改動 |
| **③ 控制項 Excel 欄位契約** | `module_frame_template_import_service.py:78-107`（CM-1841 剛去重到這裡，oscal 側 `ssp_control_impl_import_service.py:44` 現在 import 它） | `app/oscal/service/excel_template/control_impl_contract.py` | 25 行 | 再搬一次；兩邊改引用。CM-1841 的落點與本案相反，但只是常數位置，本案覆蓋即可 |
| **④ SSP 資源脈絡 service** | `app/oscal/service/ssp_resources_context_service.py` | 不動 | 230 行 | 已在 oscal。反轉後 `module_frame_ssp_resources_service.py` 對它的 import 從「唯一反向依賴」變成合法方向 |
| **⑤ 通用 Excel 樣式** | `excel_template/styles.py`（38 行）與 `data_validation_builder.py`（54 行）——`app/auth/service/user_import_template_app_service.py` 只借這兩檔的三個字型／填色常數與一個下拉建構函式 | 隨 ② 進 oscal，auth 那條 import 改指 oscal | 92 行 | 兩檔只依賴 openpyxl、無業務知識。**不另抽 `common/`**：`common/` 目前沒有任何 openpyxl 工具，為三個常數開新目錄不划算；auth 依賴 oscal 是可接受方向（使用者匯入範本借 SSP 範本的視覺風格是刻意一致的） |

### 搬完後的依賴形狀

```
              jedi_oscal_v2 / jedi_common
                        ▲
                   app/oscal            ← SSP 本家：子物件對應表、Excel 產／讀、資源脈絡、匯入匯出
                   ▲   ▲   ▲
     app/module_frame  │   app/auth（借 Excel 樣式）
                       │
              app/flow_control（assessment_plan 用 party 對應表）
```

`module_frame → oscal` 現有 4 處（1 支 service、1 支 route、1 支 serializer 引 2 個 schema）反轉後全是合法方向，不用動。

### 守衛（加進 `test/test_module_boundaries.py`）

- `app/oscal/`、`api/oscal/`、`domain/oscal/`、`infra/oscal/`、`di_containers/oscal/` **不得 import `app/module_frame`、`api/module_frame`、`domain/module_frame`**。實查現況違規 16 處（app 10、di_containers 6），本案清到 0。
- 突變測試：故意在 `app/oscal` 加一行 `from app.module_frame...`，守衛要紅。

### 順手一起做（同棒）

- 五支對應 service 與 `module_frame_ssp_resources_service` 的 DI 註冊從 `oscal_containers.py`（import 在 57-88 行，Factory 在 257-285、373-376 行）搬回 `module_frame_containers.py`——它們是 module_frame 的 service。
- `FlowControlErrorCode` 被這些檔當通用錯誤碼用（`GRC_MODULE_FRAME_NOT_FOUND` 等）——不動，只記錄。

## 排除的方案

| 方案 | 為什麼不選 |
|---|---|
| **新開共用模組**（前一版設計稿） | 為同一個概念開第三個箱子，名字要硬湊。決策者問「SSP 不就是 OSCAL 的一環，為什麼獨立出來」——答不出來，就代表不該獨立 |
| **維持現狀** | 「SSP 本家依賴資源庫」會一直誘人反轉；FR-105 兩棒待裁欄都提到，代表它會反覆冒出來 |
| **塞進 `common/`** | `common/` 有「不得反向 import」守衛，且對應表帶產品業務知識（`matched-user-id` 軟參照、空值哨兵、中文欄位標題） |
| **推進套件 `jedi_oscal_v2`** | 對應表另一端是本產品前端欄位名與中文 Excel 標題，套件不該知道；空值哨兵是主專案替套件補位，塞回套件會變成套件契約 |
| **連五支 service 本體一起搬** | 本體綁 `module_frames.template_ssp_id`（資源庫概念），搬走等於把資源庫業務塞進 oscal |

## 範圍與規模（實查 HEAD `7b017144`）

| 動作 | 檔數 | 行數 |
|---|---:|---:|
| 純平移（excel_template 7 檔） | 7 | 1,656 |
| 抽出純函式成新檔（五支對應表 → oscal 五檔） | 5 新增 | 約 400 |
| Excel 契約搬成一檔 | 1 新增 | 25 |
| 改 import 路徑（不改邏輯） | 約 20 | — |
| DI 容器搬註冊 | 2 | — |
| 守衛測試加規則 | 1 | 約 40 |
| **合計** | **約 36 檔** | **邏輯零改動** |

改 import 的檔（runner 開工先重跑 grep）：
`app/module_frame/service/` 五支對應 service ＋ `ssp_import_template_app_service` ＋ `module_frame_template_import_service`；
`app/oscal/service/` 五支 `ssp_*_app_service` ＋ `ssp_control_impl_import_service` ＋ `excel_parser/sheet_handlers`；
`app/flow_control/service/assessment_plan_app_service`；`app/auth/service/user_import_template_app_service`；
`di_containers/oscal/oscal_containers`、`di_containers/module_frame/module_frame_containers`；
`scripts/archive/regenerate_reference_templates.py`；`test/` 四支（`test_excel_template_generator_required_fill`、`test_fr032_excel_device_roundtrip`、`test_fr038_b3_mf_inventory_mapping`、`test_module_boundaries`）。

## 怎麼驗「行為零改動」

1. 既有測試全綠：`pytest test/test_module_boundaries.py test/test_fr038_b3_mf_inventory_mapping.py test/test_excel_template_generator_required_fill.py test/test_fr032_excel_device_roundtrip.py test/test_import_adapter_excel_to_oscal_ssp.py test/test_party_reconciliation_v2.py -q`。
2. 端點回應比對（搬前搬後同一參數回同一 JSON）：資源庫範本與專案 SSP 各打五個子物件的 list 端點共 10 條；SSP Excel 範本下載一次，除時間戳外二進位相同；使用者匯入範本下載一次。
3. 守衛突變測試（上）。
4. `grep -rn "from app.module_frame" app/oscal api/oscal di_containers/oscal` 回 0。

## 拆棒建議

**一棒做完**（Opus，effort high）。36 檔全是同一個搬遷動作的不同面，拆兩棒會有「新路徑存在但舊路徑還在」的半套狀態。

**與 CM-1841 的關係**：CM-1841 已在跑（BE `22c65991`、`7b017144` 已進，FE 未 commit）。它的 C 段把 25 行契約去重到 module_frame 側，與本案方向相反，但只是常數落點——**不中斷 1841**，本案第 ③ 類再搬一次。本案開工前提：1841 收完並驗收。

## 風險與反悔條件

- **底線函式改公開名會漏改呼叫點。** 五支 service 裡同名函式（`_build_props`、`_to_dict`）各有一份、簽名不同，改名時要逐支對；守衛擋不到「同名不同模組」。用 `grep -rn "_build_props\|_to_dict\|_props_to_map\|_map_to_props" --include=*.py app api test` 逐一核。
- **`ssp_party_app_service.py:158,164` 有兩處方法內延遲 import**（原因是避循環）。反轉後循環消失，可提到檔頭；runner 要驗 `python -c "import app.oscal.service.ssp_party_app_service"` 無循環。
- **反悔條件**：若日後資源庫（`module_frame`）與 SSP 一起進套件，本案搬的東西跟著進去當套件的「宿主呈現層」；不會因為本案搬了而更難。

## 不在本案的（明列避免順手做）

- 三支殼與業務混住的千行大檔——FR-105 已裁不拆。
- 空值哨兵退回套件——FR-105 已裁記債不動。
- `FlowControlErrorCode` 被當通用錯誤碼——只記錄。
- `oscal` 模組改名（它裝的是「框架＋SSP」兩個產品功能，不是整個 OSCAL）——另案。
- FR-104 的三支 readmodel 候選——flow_control 的事，另案。
