| 項目 | 內容 |
|---|---|
| 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) |
目前資源庫(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_*)。
brainstorm 期間考慮過三條路:
module_frame 指向一份 is_template=True 的 SSP,內容存在既有 ssp_* 子表;*_defaults 退役。schema 單一、clone 重用既有版本化邏輯、不污染 jedi-oscal 的 OSCAL 純度(jedi-oscal 只認得「SSP」,樣板也是一種 SSP;是 module_frame 這邊持有指向它的 reference)。owner_type='module_frame'|'project' 泛型 discriminator:折進 SSP 表但讓 SSP 表認得「module_frame」這個非 OSCAL 概念,污染套件。被排除:跨層、污染 jedi-oscal。關鍵共識: clone 動作保留(不是改成共用參考)。樣板 SSP 與專案 SSP 從 clone 那一刻起是兩筆各自獨立的 row,改一邊不影響另一邊——這對「已結案稽核的 SSP 必須凍結、不可被事後改範本回頭污染」與「每輪 AP = 新 SSP 版本可各自修改」兩個合規需求是硬性要求。merge 不犧牲這個隔離,只是把「兩套表 + 跨表複製」換成「一套表 + 同表族複製」。
is_template 而不是 status 值status(draft / ready / archived)是 SSP 的生命週期維度;「樣板 vs 實例」是正交的另一個維度。若把 template 塞進 status,樣板 SSP 本身就無法再表達 draft / ready 等生命週期狀態。故採獨立 Boolean 欄位 is_template,status 語意維持不變。
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,維持現狀)
專案 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 僅續用於「輪到輪」版本化。*_defaults 的 AP-independent key(control_identifier / ccai uid / letter key);本質是「把 *_defaults 搬進 ssp_* 表、掛 is_template SSP」。copy() 的 AP 橋接邏輯,只把來源 re-point(compliance.module_frame_*_defaults → oscal.ssp_* WHERE ssp_id=樣板)。statement_identifier 慣例不同,以 system_security_plan_id + is_template 區隔,不衝突。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)為兩個不同方向的關係,互不衝突。profile_id = 該 module_frame 的 profile(由 module_frames.oscal_profile_uid 對到)。version_no 設 0(或 NULL,實作時定)、group_id 自成一組,不參與專案版本序列。oscal.ssps 加 is_template 欄位;list / 查詢 SSP 的進入點一律排除 is_template=True(避免樣板漏進專案 SSP 清單 / dashboard / AP / OSCAL 匯出)。走 path-dependency dev 流程,user 明示才發版。module_frames → 各建一份 is_template=True 的樣板 SSP → 把該 MF 的 *_defaults + 程序書逐欄位灌進樣板 SSP 子表 → 設 module_frames.template_ssp_id;逐欄位 / row count 驗證;dev → stg → poc 各跑。module_frame 編輯 service 從寫 *_defaults 改成寫進該 MF 的樣板 SSP 子表(透過 jedi-oscal SSP service)。FE 畫面 / API 形狀盡量不變。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。*_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 部分)。否則資源庫匯出 / 下載會看到舊資料。*_defaults 退為休眠:Phase 1 結束後不再被寫入/讀取,但先不 DROP(Phase 2 才清)。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。module_frame_*_defaults(7 張)+ module_frame_reference_documents / _mappings;移除對應 model / repo / domain service。ModuleFrameTemplateCopyService.__init__ 的 4 個 *_default domain service 注入(M4 後已不用,原本就走 raw SQL)一併移除。_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 取代)。| 類別 | 項目 |
|---|---|
| 可重用 | 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。 |
| 風險 | 緩解 |
|---|---|
| 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)。 |
is_template=True 樣板 SSP,子表內容與原 *_defaults 逐欄位一致;module_frames.template_ssp_id 正確指向。is_template=True 的樣板 SSP。*_defaults 殘影。version_no(0 vs NULL)與 group_id 生成規則定案。*_trans(翻譯表)內容搬遷(控制項 / 程序書多語)。狀態:DEV 完整收口(改源 + DROP + cleanup + 手測通過)。stg/poc 留獨立遷移弧。
| 子階段 | 內容 | 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 |
confirm_service.confirm(ssp_id=template_ssp_id)
sc_write_strategy,消除 _run_mf_default_confirm 與臨時 shell SSP 兩條路徑。FR-032 device/系統資產 鉤稽由共用的 InventoryItemWriteStrategy 沿用,不丟失。_mf_has_template_ssp gate + copy() 從樣板 SSP 灌齊。oscal.ssps 都沒有(FR-035 + M1 + M3 未套),*_defaults 是未遷移 live 資料 → DROP 嚴禁;FR-036 整支 code 在那邊也跑不起來。migration 檔頭已標明。create_all → ORM model inert, DROP 後 app 仍能啟動(DI import smoke 驗證)。version_no=0 / 自成 group_id:Phase 1 已定(migration 採用)。*_trans 翻譯表:Phase 1 M3 已隨內容遷移。mf_*_default 參數(低優先)。