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

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

§1

決策紀錄

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

§2

一句話

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

§3

這是什麼問題(白話)

系統把一份「系統安全計畫書」(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 的東西放錯家。

§4

方案: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 等)——不動,只記錄。
§5

排除的方案

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

範圍與規模(實查 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)。

§7

怎麼驗「行為零改動」

  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。
§8

拆棒建議

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

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

§9

風險與反悔條件

  • 底線函式改公開名會漏改呼叫點。 五支 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 一起進套件,本案搬的東西跟著進去當套件的「宿主呈現層」;不會因為本案搬了而更難。
§10

不在本案的(明列避免順手做)

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