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 改版或要加一個新欄位時,必須同時改 *_defaultsssp_* 兩個地方、寫兩支 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_templatestatus 語意維持不變。


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_*_defaultsoscal.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-oscaloscal.sspsis_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.pyexport/ssp_export_model.pymodule_frame/ssp_import_template_app_service.py 的 fill 部分)。否則資源庫匯出 / 下載會看到舊資料。
  6. *_defaults 退為休眠:Phase 1 結束後不再被寫入/讀取,但先不 DROP(Phase 2 才清)。

Phase 2(後續 / user 指定留待)

  1. 匯入路徑改源ssp_docx_import_app_service.pyssp_excel_import_app_service.py 與 MF-scoped 樣板匯入 module_frame/ssp_import_template_app_service.pymodule_frame_template_import_service.py 改成匯入到樣板 SSP。
  2. 清理:驗證無誤後 DROP module_frame_*_defaults(7 張)+ module_frame_reference_documents / _mappings;移除對應 model / repo / domain service。ModuleFrameTemplateCopyService.__init__ 的 4 個 *_default domain service 注入(M4 後已不用,原本就走 raw SQL)一併移除。
  3. _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 明示)。