Module Frame Template Defaults — 設計文件

狀態: Design — 待實作 建立日期: 2026-04-25 作者: raymond + Claude Code (brainstorming session)


1. 需求說明

1.1 背景

目前合規資源庫(module_frames)只存「基本資訊 + OSCAL Profile 包裝 + BPMN 工作流」。每次啟動專案後,使用者必須在 ProjectPlanning 介面為每個控制項與評估目標(AO)逐一填寫現況說明(implementation_description)程序書(reference documents),每個專案都重複一次。

1.2 目標

把「現況說明 + 程序書」抽成 module_frame 層的模板預設值

  • 模板維運者一次設好預設內容
  • 啟動專案時自動帶入新建的 SSP(snapshot 模式)
  • 大幅減少每個新專案的填寫工作量
  • 為將來 OSCAL Component Definition 標準匯出鋪路

1.3 不在本次範圍

詳見「§9 Out of scope」。最重要的是:

  • 不做 SSP-per-round / SSP 版本控制 / AR snapshot
  • 不做 template → SSP re-sync 機制
  • 不做 i18n 翻譯表(v1 純 TEXT 欄位)

2. 設計決策摘要(從 brainstorming Q&A 收斂)

決策 結論 理由
預設值粒度 控制項層 + AO 層(兩層都做) 對齊 SSP 端 ssp_control_implementations + ssp_control_implementation_objectives 兩層結構
OSCAL 對應 Component Definition Lite(掛在 module_frame 下) OSCAL 標準的「跨 SSP 重用」機制是 Component Definition;不違反 OSCAL「SSP per system」核心語意
程序書儲存 真實檔案上傳(沿用 SSP 結構) 模板真的「自帶程序書」,啟動時 SSP 直接共享 file_id 不複製檔案本體
程序書池 scope Per-Module-Frame(不跨模板共享) 對齊 SSP ssp_reference_documents per-SSP pattern;模板自包含
啟動專案複製策略 Snapshot at start(不 live reference) 安全:稽核中不會被 template 改動意外影響;OSCAL Component Definition 模式即如此
編輯 UI 結構 保留 2-step Dialog 建立 + 新詳情頁編輯 建立流程輕、編輯流程重,分開最自然
AO statement_identifier 來源 oscal.catalog_control_assessments.uid spike 已驗證;對拷時 join 到 ap_task.uid
Multi-AP 共用 SSP 模型 不影響本次設計 啟動時 deep copy 一次到 SSP,後續跨 round 共用,與 template 無關

3. 資料模型

3.1 新增 4 張表(compliance schema)

3.1.1 compliance.module_frame_control_defaults

控制項層預設值,對應 OSCAL Component Definition 的 implemented-requirements,啟動時對拷至 SSP oscal.system_security_plan_control_implementations

CREATE TABLE compliance.module_frame_control_defaults (
    id                          SERIAL PRIMARY KEY,
    uid                         UUID DEFAULT gen_random_uuid() NOT NULL UNIQUE,
    module_frame_id             INTEGER NOT NULL
        REFERENCES public.module_frames(id) ON DELETE CASCADE,
    control_identifier          VARCHAR(100) NOT NULL,           -- e.g. AC.L1-3.1.1
    implementation_status       VARCHAR(30) DEFAULT 'unknown',   -- 預設值
    implementation_description  TEXT,                             -- 現況說明預設文字
    responsible_role            VARCHAR(100),                     -- 負責角色預設
    control_origination         VARCHAR(50) DEFAULT 'organization',
    remarks                     TEXT,
    created_at                  TIMESTAMPTZ DEFAULT now() NOT NULL,
    updated_at                  TIMESTAMPTZ DEFAULT now() NOT NULL,
    created_user                VARCHAR(50),
    updated_user                VARCHAR(50),
    CONSTRAINT uq_mfcd_module_frame_control UNIQUE (module_frame_id, control_identifier)
);
CREATE INDEX ix_mfcd_module_frame_id ON compliance.module_frame_control_defaults (module_frame_id);

行為:

  • Lazy 預填:建立 module_frame 時不預先 INSERT。使用者第一次編輯某 control 時才 upsert 一筆 row。
  • 軟刪除靠 module_framesis_delete flag + JOIN 過濾;hard delete 走 ON DELETE CASCADE。

3.1.2 compliance.module_frame_control_objective_defaults

AO(Assessment Objective)層預設值,對應 SSP oscal.ssp_control_implementation_objectives

CREATE TABLE compliance.module_frame_control_objective_defaults (
    id                          SERIAL PRIMARY KEY,
    uid                         UUID DEFAULT gen_random_uuid() NOT NULL UNIQUE,
    module_frame_id             INTEGER NOT NULL
        REFERENCES public.module_frames(id) ON DELETE CASCADE,
    control_default_id          INTEGER NOT NULL
        REFERENCES compliance.module_frame_control_defaults(id) ON DELETE CASCADE,
    control_identifier          VARCHAR(100) NOT NULL,
    statement_identifier        VARCHAR(100) NOT NULL,            -- = catalog_control_assessment.uid
    implementation_status       VARCHAR(50),
    implementation_description  TEXT,
    remarks                     TEXT,
    created_at                  TIMESTAMPTZ DEFAULT now() NOT NULL,
    updated_at                  TIMESTAMPTZ DEFAULT now() NOT NULL,
    created_user                VARCHAR(50),
    updated_user                VARCHAR(50),
    CONSTRAINT uq_mfcod_ctrl_default_stmt UNIQUE (control_default_id, statement_identifier)
);
CREATE INDEX ix_mfcod_module_frame_id ON compliance.module_frame_control_objective_defaults (module_frame_id);
CREATE INDEX ix_mfcod_control_default_id ON compliance.module_frame_control_objective_defaults (control_default_id);

statement_identifier 設計:

  • 模板層存 catalog_control_assessments.uid(OSCAL catalog 層級的 stable key)
  • SSP 端 ssp_control_implementation_objectives.statement_identifierassessment_plan_tasks.uid(執行期生成)
  • 啟動專案對拷時透過 join 解析:module_frame_control_objective_defaults → catalog_control_assessments → assessment_plan_tasks → ssp_control_implementation_objectives

Soft FK 規範(按 CLAUDE.md 跨 schema 慣例):

  • statement_identifiercatalog_control_assessments.uid字串 soft reference(無 SQLAlchemy FK constraint,跨 schema)
  • API 層 PUT 時驗證該 ccai uid 屬於模板的 profile(profile.controls 的 catalog_control_assessments 集合內),失敗回 MODULE_FRAME_CONTROL_NOT_IN_PROFILE
  • 若 catalog 後續刪除/變更該 ccai → 模板資料變孤兒,UI 顯示「目標已被 catalog 移除」並提供「移除預設值」按鈕

denormalized module_frame_id

  • 此表同時存 module_frame_id (FK) 和 control_default_id (FK),後者可推導前者
  • 故意 denormalize 理由:
    1. 大量 query「列出某模板所有 AO defaults」可省一次 JOIN
    2. RLS / tenant 過濾可直接 filter 此表(不必 JOIN module_frames)
  • 一致性靠:API 層只讓使用者選 control_default,module_frame_id 由 service 層自動填入(不接受 client 傳值)

3.1.3 compliance.module_frame_reference_documents

程序書池(per-module_frame),對應 SSP oscal.ssp_reference_documents

CREATE TABLE compliance.module_frame_reference_documents (
    id                  SERIAL PRIMARY KEY,
    uid                 VARCHAR(36) DEFAULT gen_random_uuid() NOT NULL UNIQUE,
    module_frame_id     INTEGER NOT NULL
        REFERENCES public.module_frames(id) ON DELETE CASCADE,
    file_id             INTEGER NOT NULL,                          -- jedi-file-upload tenant 內 file
    title               VARCHAR(255),
    description         TEXT,
    created_at          TIMESTAMPTZ DEFAULT now() NOT NULL,
    updated_at          TIMESTAMPTZ DEFAULT now() NOT NULL,
    created_user        VARCHAR(50),
    updated_user        VARCHAR(50)
);
CREATE INDEX ix_mfrd_module_frame_id ON compliance.module_frame_reference_documents (module_frame_id);
CREATE INDEX ix_mfrd_file_id ON compliance.module_frame_reference_documents (file_id);

Scope 說明:

  • 程序書 row 是 per-module_frame
  • 但底層 file_id 是 tenant-scoped(jedi-file-upload 既有設計),故同 tenant 內不同模板可挑同一份檔案重用(FE 從既有檔案池選即可)

3.1.4 compliance.module_frame_reference_document_mappings

多對多,讓同一份程序書可掛多個 control / objective default。

CREATE TABLE compliance.module_frame_reference_document_mappings (
    id                          SERIAL PRIMARY KEY,
    reference_document_id       INTEGER NOT NULL
        REFERENCES compliance.module_frame_reference_documents(id) ON DELETE CASCADE,
    context_type                VARCHAR(30) NOT NULL,             -- 'control_default' | 'objective_default'
    context_id                  INTEGER NOT NULL,                  -- → control_defaults.id 或 objective_defaults.id
    created_at                  TIMESTAMPTZ DEFAULT now() NOT NULL,
    created_user                VARCHAR(50),
    CONSTRAINT uq_mfrdm_doc_context UNIQUE (reference_document_id, context_type, context_id)
);
CREATE INDEX ix_mfrdm_context ON compliance.module_frame_reference_document_mappings (context_type, context_id);
CREATE INDEX ix_mfrdm_doc_id ON compliance.module_frame_reference_document_mappings (reference_document_id);

對齊 SSP 端 oscal.ssp_reference_document_mappings

  • SSP 端 context_type = 'control_implementation' / 'objective'
  • 模板端 context_type = 'control_default' / 'objective_default'
  • 啟動時對拷做 context_type 翻譯 + context_id re-map

3.2 修改既有 SSP 表(jedi-oscal)

最小改動 — 只加一個欄位:

ALTER TABLE oscal.system_security_plans
    ADD COLUMN template_module_frame_id INTEGER NULL;

用途:紀錄此 SSP 的源頭 module_frame(lineage tracking)。為將來 re-sync feature 預留。

✂️ 明確不加(避免 YAGNI):

  • previous_ssp_id(給未來 SSP-per-round 用,等那個 feature 自己加)
  • is_user_modified on control_impl / objectives(給未來 re-sync 用,等那個 feature 自己加)

3.3 SQL Migration 規範

按 CLAUDE.md 規範:

  • 檔名:scripts/sql/2026-XX-XX-module-frame-template-defaults.sql
  • 檔案開頭:-- Date: 2026-XX-XX
  • 每條 ALTER/CREATE 加 (2026-XX-XX) 註解
  • 每張新表必須 GRANT SELECT, INSERT, UPDATE, DELETE ... TO cm_app; + sequence 授權

範例(4 張新表 + 1 ALTER):

-- 1. 4 張新表 CREATE 略(見 §3.1)

-- 2. ALTER SSP table (2026-XX-XX)
ALTER TABLE oscal.system_security_plans
    ADD COLUMN template_module_frame_id INTEGER NULL;

-- 3. CHECK constraint 限制 mappings 的 context_type 合法值 (2026-XX-XX)
ALTER TABLE compliance.module_frame_reference_document_mappings
    ADD CONSTRAINT chk_mfrdm_context_type
    CHECK (context_type IN ('control_default', 'objective_default'));

-- 4. GRANT 權限授予 cm_app (2026-XX-XX)
GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.module_frame_control_defaults TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.module_frame_control_defaults_id_seq TO cm_app;

GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.module_frame_control_objective_defaults TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.module_frame_control_objective_defaults_id_seq TO cm_app;

GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.module_frame_reference_documents TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.module_frame_reference_documents_id_seq TO cm_app;

GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.module_frame_reference_document_mappings TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.module_frame_reference_document_mappings_id_seq TO cm_app;

3.4 既有 module_frames 不需 backfill

按 §3.1.1 Lazy 預填策略:本次 migration 只建表 + 加欄位不對既有資料做 backfill

  • 既有 module_frames 行為不變
  • defaults 顯示為「未設定」直到使用者編輯
  • 既有 SSP template_module_frame_id = NULL(unknown source),不影響任何邏輯

3.5 UID 型別約定

新表的 uid 欄位有兩種型別:

  • module_frame_control_defaults.uid / module_frame_control_objective_defaults.uid 用 PostgreSQL UUID
  • module_frame_reference_documents.uidVARCHAR(36)沿用 SSP ssp_reference_documents 既有 convention,避免 jedi-file-upload 整合處要做型別轉換)

JOIN 時注意型別轉換(如 ccai.uid::text = mfod.statement_identifier)。


4. API 變更

4.1 既有 Module Frame API — 不動

POST /module-frameGET /module-frame/{uid}PUT /module-frame/{uid}DELETE /module-frame/{uid}、Excel import endpoints 保持不變

新欄位都不在建立 Dialog 階段填,建立完成後在新詳情頁編輯。

4.2 新增:Module Frame Control Defaults

Method Path 用途
GET /module-frame/{uid}/control-defaults 列出該模板已設定的所有控制項預設值
GET /module-frame/{uid}/control-defaults/{control_identifier} 取得單一控制項預設值(含其下 AO defaults + 程序書 mappings)
PUT /module-frame/{uid}/control-defaults/{control_identifier} Upsert(lazy 第一次 PUT 即 INSERT)
DELETE /module-frame/{uid}/control-defaults/{control_identifier} 刪除(清空回未設定狀態,CASCADE 帶走 AO defaults)

PUT 驗證流程(重要):

  1. 驗證 module_frame uid 存在且使用者有 manager 權限
  2. 驗證 control_identifier 屬於該模板 profile 的 include_controls,否則回 MODULE_FRAME_CONTROL_NOT_IN_PROFILE (MODULE_FRAME_400001)
  3. UPSERT row(lazy:不存在則 INSERT,存在則 UPDATE)

Request body 範例 (PUT):

{
  "implementation_status": "implemented",
  "implementation_description": "本公司透過 ISMS-001 程序書管理...",
  "responsible_role": "資安長",
  "control_origination": "organization",
  "remarks": "..."
}

4.3 新增:Module Frame Objective Defaults(獨立路徑,避免 3 層 nested)

Method Path 用途
GET /module-frame/{uid}/objective-defaults 列出全部 AO 預設值
GET /module-frame/{uid}/objective-defaults/{objective_default_uid} 取得單一
PUT /module-frame/{uid}/objective-defaults Upsert(body 含 control_identifier + statement_identifier=ccai_uid)
DELETE /module-frame/{uid}/objective-defaults/{objective_default_uid} 刪除

4.4 新增:Module Frame Reference Documents

Method Path 用途
GET /module-frame/{uid}/reference-documents 列出程序書池(含每筆 mappings 概要)
POST /module-frame/{uid}/reference-documents 上傳新檔案(multipart/form-data)
PUT /module-frame/{uid}/reference-documents/{doc_uid} 更新 title / description
DELETE /module-frame/{uid}/reference-documents/{doc_uid} 刪除 doc + CASCADE 清 mappings(檔案本體保留)

4.5 新增:Reference Document Mappings(多對多)

Method Path 用途
POST /module-frame/{uid}/control-defaults/{control_identifier}/reference-documents 把現有 doc_uid 掛到此控制項
DELETE /module-frame/{uid}/control-defaults/{control_identifier}/reference-documents/{doc_uid} 解除此 mapping
POST /module-frame/{uid}/objective-defaults/{objective_default_uid}/reference-documents 掛到 AO
DELETE /module-frame/{uid}/objective-defaults/{objective_default_uid}/reference-documents/{doc_uid} 解除

4.6 新增:Catalog AO 列舉

Method Path 用途
GET /oscal/catalog-controls/{control_uid}/assessments 列出此 catalog control 的 catalog_control_assessment 清單,供 UI 列出可填的 AO

→ jedi-oscal 已有 CatalogControlAssessmentRepoImpl,本層只做 API 包裝。回傳:

{ "code": 1, "data": [{ "uid": "...", "name": "Official", "description": "..." }, ...] }

4.7 新增:批次匯入/匯出(mirror SSP Excel import 結構)

Method Path 用途
GET /module-frame/{uid}/control-defaults/export 匯出當前 control + AO defaults 為 Excel
GET /module-frame/{uid}/control-defaults/template/download 下載空白 Excel 範本(含模板的 control + AO 結構)
POST /module-frame/{uid}/control-defaults/import/template 上傳 Excel + 驗證 headers/格式
POST /module-frame/{uid}/control-defaults/import/verify 驗證資料行(顯示 diff)
POST /module-frame/{uid}/control-defaults/import 確認匯入(bulk upsert)

→ 完全 mirror SspImportRoute 的 endpoint 結構與 service 邏輯(fork ssp_control_impl_import_service.py)。

4.8 既有 Project Start API 行為調整

POST /oscal/projects/start

  • Request/Response 結構不變
  • 內部多一步 _copy_template_to_ssp(),在建立 SSP 與 AP 之後呼叫
  • 對 caller 透明

4.9 權限與錯誤處理

按 CLAUDE.md 規範:

  • 寫入 API(POST/PUT/DELETE):須 manager 角色(沿用 module_frame service 既有權限規範)
  • 寫入用 @transaction 裝飾器
  • 路徑參數 uid → service 層用 module_frame_domain_service.get_one(uid) 驗證 + RLS 自動過濾 tenant
  • 新增 ErrorCode 至 common/code/module_frame_error_code.py(新檔)

Error Code 命名(按 CLAUDE.md 規範):

class ModuleFrameErrorCode(BaseCode):
    MODULE_FRAME_CONTROL_DEFAULT_NOT_FOUND  = ("模板控制項預設值不存在", "MODULE_FRAME_404001")
    MODULE_FRAME_OBJECTIVE_DEFAULT_NOT_FOUND = ("模板 AO 預設值不存在",  "MODULE_FRAME_404002")
    MODULE_FRAME_REFERENCE_DOC_NOT_FOUND     = ("模板程序書不存在",        "MODULE_FRAME_404003")
    MODULE_FRAME_CONTROL_NOT_IN_PROFILE      = ("控制項不屬於此模板的 profile", "MODULE_FRAME_400001")

5. 啟動專案對拷邏輯

5.0 重要:與既有 Step 7.5 共存策略 — UPDATE in place

OscalProjectService.start_oscal_project 既有 Step 7.5 (oscal_project_service.py:272-318) 已經為每個 AP control 建立 SSP control_impl 空殼 row + 為每個 AP task (AO) 建立 SSP objective 空殼 row:

  • SSP control_impl: implementation_status="unknown", 其餘欄位 NULL
  • SSP objective: statement_identifier = task.task_code OR task.title OR str(task.id) (line 307),其餘欄位 NULL

我們的 _copy_template_to_ssp 在 Step 7.5 之後執行,必須 UPDATE 既有 row 不 INSERT 新 row

  • 否則會產生 duplicate / 撞 uq_ssp_obj_ctrl_impl_stmt unique constraint
  • 既有 Step 7.5 邏輯完全不動,保持向後相容

5.1 對拷流程

新增方法 OscalProjectService._copy_template_to_ssp(module_frame_id, ssp_id, ap_id, curr_user),於 start_oscal_project 的 Step 7.5 之後執行。

-- Step 1: 控制項層 UPDATE in place
-- NULL 語意:模板若未填某欄位,保留 Step 7.5 的 default 值(用 COALESCE 防止 NULL 覆蓋有意義的 default)
UPDATE oscal.system_security_plan_control_implementations ci
SET
  implementation_status = COALESCE(mfcd.implementation_status, ci.implementation_status),
  implementation_description = COALESCE(mfcd.implementation_description, ci.implementation_description),
  responsible_role = COALESCE(mfcd.responsible_role, ci.responsible_role),
  control_origination = COALESCE(mfcd.control_origination, ci.control_origination),
  remarks = COALESCE(mfcd.remarks, ci.remarks),
  updated_user = :curr_user,
  updated_at = now()
FROM compliance.module_frame_control_defaults mfcd
WHERE mfcd.module_frame_id = :module_frame_id
  AND ci.system_security_plan_id = :ssp_id
  AND ci.control_identifier = mfcd.control_identifier;

-- Step 2: AO 層 UPDATE in place(透過 ccai + ap_task 找到對應 ssp objective)
-- 關鍵:obj.statement_identifier 用 Step 7.5 的 convention(COALESCE task_code/title/id),不用 ap_task.uid
-- NULL 語意:同 Step 1,模板若未填則保留 Step 7.5 預設
UPDATE oscal.ssp_control_implementation_objectives obj
SET
  implementation_status = COALESCE(mfod.implementation_status, obj.implementation_status),
  implementation_description = COALESCE(mfod.implementation_description, obj.implementation_description),
  remarks = COALESCE(mfod.remarks, obj.remarks),
  updated_user = :curr_user,
  updated_at = now()
FROM compliance.module_frame_control_objective_defaults mfod
JOIN oscal.catalog_control_assessments ccai
  ON ccai.uid::text = mfod.statement_identifier
JOIN oscal.assessment_plan_tasks ap_task
  ON ap_task.catalog_control_assessment_id = ccai.id
 AND ap_task.assessment_plan_id = :ap_id
WHERE mfod.module_frame_id = :module_frame_id
  AND obj.system_security_plan_id = :ssp_id
  AND obj.control_identifier = mfod.control_identifier
  AND obj.statement_identifier = COALESCE(ap_task.task_code, ap_task.title, ap_task.id::text);

-- Step 3a: 控制項層程序書(→ SSP mappings 表,新增 row)
-- 詳見 §5.4

-- Step 3b: AO 層程序書(→ SSP objective.reference_documents JSONB,UPDATE in place)
-- 詳見 §5.4

-- Step 4: 標記 SSP 來源
UPDATE oscal.system_security_plans
   SET template_module_frame_id = :module_frame_id
 WHERE id = :ssp_id;

整個對拷邏輯不 INSERT 任何 SSP control_impl / objective row,只 UPDATE Step 7.5 已建好的空殼
→ 沒有 unique constraint 衝突風險。
→ 沒設模板 defaults 的控制項 / AO row 維持 Step 7.5 的空白狀態(行為與既有 100% 一致)。

5.2 實作要求

  • 使用 session.bulk_insert_mappingsINSERT ... SELECT 形式批次處理(避免 100+ 單行 INSERT)
  • 集中在 OscalProjectService._copy_template_to_ssp() method(單一入口)
  • 配對 unit test:mock module_frame 含 N 控制項 + M AO + K 程序書,驗證 SSP 端產生對應 row
  • 失敗策略:fail loud — 任何一步失敗整個 transaction rollback;不要 try/except 吞掉

5.3 程序書 file_id 共享策略

  • 模板的 module_frame_reference_documents.file_id 與 SSP 的 ssp_reference_documents.file_id 指向同一個 file_id
  • 啟動專案 = 不複製檔案本體,只複製 metadata + mapping
  • 模板若刪除 module_frame_reference_documents row → 只刪 mapping,檔案本體保留;既有專案 SSP 仍可下載

5.4 SSP 端程序書儲存的不對稱性(重要)

實際翻 ssp_control_implementation_service.py 後確認 SSP 端兩層程序書儲存方式不一致

層級 SSP 端儲存 對應 entity
Control 層 oscal.ssp_reference_documents 池 + ssp_reference_document_mappings mapping 表(context_type='control_implementation') obj_entity 透過 mapping 表關聯
Objective (AO) 層 oscal.ssp_control_implementation_objectives.reference_documents inline JSONB array obj_entity.reference_documents 直接賦值

對拷邏輯需要分兩支處理

Step 3a: 控制項層程序書(→ SSP mappings 表)
  -- 模板的 module_frame_reference_documents → ssp_reference_documents (context_type='control_implementation')
  -- 模板的 module_frame_reference_document_mappings (context_type='control_default')
  -- → ssp_reference_document_mappings (context_type='control_implementation', context_id=new_ssp_ci.id)

Step 3b: AO 層程序書(→ SSP objective JSONB)
  -- 模板的 module_frame_reference_document_mappings (context_type='objective_default')
  -- → 組成 [{file_id, name, ...}] JSONB array
  -- → UPDATE ssp_control_implementation_objectives SET reference_documents = :jsonb_array

理由: 既有 SSP service 端 add_objective_document 直接操作 JSONB(ssp_control_implementation_service.py:158),改成 mappings 表會破壞既有 API。本 feature 不重構 SSP 端,只在對拷時做格式轉換。

5.5 launch_new_round 補一行 fix(必做,否則破壞既有 AO 程序書資料)

既有 bug: _clone_ssp_objectives (oscal_project_service.py:563-630) 跨 round clone SSP objective 時沒有複製 reference_documents JSONB 欄位(line 616-626 只 copy implementation_status / implementation_description / remarks)。

→ 任何使用者在 round 1 加的 AO 層程序書(包括既有功能填的、不只本 feature 的)都會在 round 2 launch 時消失

修法:_clone_ssp_objectives line 623(remarks=...)之後、line 624(created_user=...)之前補一行:

reference_documents=getattr(source_obj, "reference_documents", None) or [],

性質: 純 additive bug fix,零向後相容風險。修了之後所有 AO 程序書(不論模板帶來的或使用者自填的)跨 round 都不會掉。

列入本 feature scope — 不修的話本 feature 的「程序書帶入」承諾在 round 2 會破功,等同沒做。


6. UI 設計

6.1 建立階段(保留現有 2-step Dialog)

ModuleFrame.vue 的「+ 新增」按鈕仍開現有 Dialog:

  • Step 1:基本資訊
  • Step 2:OSCAL Framework + 控制項 Tree 勾選

按「建立」→ 模板成形(預設值全空)→ Dialog 關閉 → 自動跳轉至新詳情頁

6.2 新詳情頁:ModuleFrameTemplateEditView.vue

URL: /module-frame/:uid/template-edit

設計策略:高度 reuse ProjectPlanningView.vue 既有結構與元件

6.2.1 Layout(抄 Planning Split Panel)

┌─ Header ───────────────────────────────────────────────────────────┐
│ [← 返回] 模板編輯:ISO27001 v1.0                                  │
│   [stats: 已設定 35/100 控制項 / 12/250 AO]                         │
│   [批次維護 ▾] [程序書池管理]                                       │
└────────────────────────────────────────────────────────────────────┘
┌─ 左 Tree (panel-sidebar) ────┐ ┌─ 右 Content ──────────────────────┐
│ - search                       │ │ - control 編輯區:description /    │
│ - group/control/AO 三層         │ │    status / responsible_role /    │
│ - 狀態 icon (complete/partial/  │ │    control_origination / 備註     │
│   empty)                        │ │ - 程序書區塊(reuse Reference     │
│ - badge: 已設定/總數             │ │   DocumentList)                  │
│                               │ │ - AO 編輯區:相同樣式(Accordion)  │
└───────────────────────────────┘ └────────────────────────────────────┘

6.2.2 直接重用的元件

ProjectPlanningView 直接 import:

  • SspStatusTag.vue — 狀態 tag
  • ReferenceDocumentList.vue — 程序書清單顯示
  • DocumentPoolPanel.vue — 程序書池管理 Dialog
  • DocumentLinkDialog.vue — 從池選掛
  • 整個 Tree(Group → Control → AO 三層 + 狀態 icon + search + expand all)— 抄結構過來
  • 整個編輯區塊(textarea + status dropdown + remarks)— 抄結構過來

6.2.3 從 Planning 拿掉的部分

  • ❌ Launch 按鈕
  • ❌ Task Setup 區塊(<Panel v-for="task in taskMap.get(...)">
  • ❌ Readonly Banner
  • ❌ ParticipantsPanel
  • ❌ SSP Import / Job Import Dialog(用 module_frame 自己的 batch import)
  • canEditSsp / isPreparing / isApClosed 狀態判斷(模板永遠可編輯)

6.2.4 新增的部分

  • control_origination 欄位(Planning 沒這欄位)— dropdown: organization | system-specific | customer-configured | inherited | shared
  • stats bar 改成 template 角度:「已設定 X / 總控制項 Y」「已設定 AO X / 總 AO Y」
  • 批次維護 menu(含下載 Excel 範本 / 匯出當前 / 上傳 Excel)
  • ModuleFrameTemplateImportDialog.vue(fork SspImportDialog.vue

6.2.5 儲存策略

  • 文字欄位(description / responsible_role / remarks):debounce 800ms 自動 PUT(沿用 audit-scope-merge feature 的「自動儲存 + toast」pattern;參考 docs/changelog/2026-04-25-audit-scope-merge.md
  • 程序書掛載/解除:按按鈕即時 POST/DELETE
  • 程序書上傳:選檔後上傳完成顯示 toast

並發/競爭寫入處理: v1 採 last-write-wins,無 etag/version 控制。理由:模板維運通常是少量 manager 序列編輯,並發寫入機率低;YAGNI。後續若實際遇到衝突,可加 If-Match: <updated_at> header 機制。

6.2.6 Lazy 預填的 UI fallback

  • Tree 節點 icon:✅(有 default row)/ ⬜(lazy 未建)
  • 右側面板:第一次點到未設定的 control 顯示空白欄位 + 灰色 placeholder
  • 第一次填字 → debounce 後 PUT → backend 自動建立 row(upsert 模式)

6.3 服務層

新增 src/service/ModuleFrameTemplateService.js,mirror SspService.js 的 API surface 但底層打 module-frame endpoint:

export default {
  listControlDefaults(moduleFrameUid) { ... },
  updateControlDefault(moduleFrameUid, controlIdentifier, payload) { ... },
  listObjectiveDefaults(moduleFrameUid) { ... },
  upsertObjectiveDefault(moduleFrameUid, payload) { ... },

  // 程序書池
  listReferenceDocuments(moduleFrameUid) { ... },
  uploadReferenceDocument(moduleFrameUid, file, meta) { ... },
  deleteReferenceDocument(moduleFrameUid, docUid) { ... },

  // mappings
  attachDocToControl(moduleFrameUid, controlIdentifier, docUid) { ... },
  detachDocFromControl(moduleFrameUid, controlIdentifier, docUid) { ... },
  attachDocToObjective(moduleFrameUid, objectiveDefaultUid, docUid) { ... },
  detachDocFromObjective(moduleFrameUid, objectiveDefaultUid, docUid) { ... },

  // catalog AO 列舉
  listCatalogControlAssessments(controlUid) { ... },

  // 批次
  exportControlDefaults(moduleFrameUid) { ... },
  downloadImportTemplate(moduleFrameUid) { ... },
  importControlDefaults(moduleFrameUid, file) { ... },
}

6.4 i18n 新增 key

src/config/locales/i18n/zh-tw/module-frame.json + en/module-frame.json 新增:

template_edit_title / template_edit_breadcrumb
stat_controls_filled / stat_aos_filled
label_implementation_description / label_implementation_status
label_responsible_role / label_control_origination / label_remarks
placeholder_unfilled / state_filled / state_unfilled
btn_back_to_list / btn_batch_maintain / btn_document_pool
toast_autosaved / toast_document_uploaded / toast_document_attached
confirm_delete_document_with_mappings
... (大量可參考 lang.project_planning.* 並複製改名)

6.5 ProjectCreate / ProjectSettings — 不動

新功能對 ProjectCreate / ProjectSettings 完全透明:

  • 啟動專案後 → 使用者進 ProjectPlanning 看到「已預填內容」
  • ProjectPlanning UI 完全不需要改

6.6 ModuleFrame 列表頁變更

ModuleFrame.vue 列表每筆模板加「編輯預設值」按鈕,導向 /module-frame/:uid/template-edit


7. Multi-Round 模型相容性

7.1 現況確認

Project (compliance.projects)
  ↓ 1:1
SSP (oscal.system_security_plans)         ← 系統基線,跨 round 共用、mutable
  ↑ N:1
AP_1 (Round 1) → ssp_id 軟參照同一份 SSP
AP_2 (Round 2) → ssp_id 軟參照同一份 SSP
...

launch_new_round 邏輯:必須舊 AP closed → 新 AP profile_id + ssp_id 沿用舊 AP → AP controls/tasks 重新從 Profile snapshot(每 round 新生 ap_task.uid)。

7.2 statement_identifier 跨 round 對應 — 由既有 _clone_ssp_objectives 處理

重要: SSP objectives 的 statement_identifier 存的是 ap_task.uid,每 round 新生。但 launch_new_round 已經有 _clone_ssp_objectives() 機制處理跨 round mapping(oscal_project_service.py:563):

  1. 抓舊 AP 與新 AP 各自的 tasks
  2. task.title 當穩定 key 建立舊→新 ap_task.uid 對照
  3. 對每個新 task,從舊 SSP objective(用舊 statement_identifier=舊 ap_task.uid 索引)複製一份新 row(statement_identifier=新 ap_task.uid)

→ Round 2 啟動後,SSP 中會多出新的 objective row 對應新 task uid,舊 round 的 objective row 仍保留(為 future audit history 預留)。

7.3 對本次設計的影響:無(搭配 §5.5 的一行 fix)

Template → SSP 啟動時 UPDATE in place 在 round 1 啟動時執行一次(不是 INSERT):

  • 啟動專案時對 Step 7.5 已建好的 SSP control_impl / objective 空殼 row 做 UPDATE
  • statement_identifier 沿用 Step 7.5 的 convention(task.task_code OR task.title OR str(task.id)
  • Round 2/3/N launch 時 → _clone_ssp_objectivestask.title 對應,自動把 description / status / remarks / reference_documents (見 §5.5 修補) 複製到新 task uid 上
  • 整個 round 演進與 template 無關(snapshot 後完全自治)

本設計在 multi-round 下完全成立,搭配 §5.5 的一行 fix 即可確保程序書跨 round 不消失。

_clone_ssp_objectivestask.title 當穩定 key 是既有設計選擇。理論上用 catalog_control_assessment_id 作為 stable key 更穩固(title 可能因為翻譯/編輯而變動),但這是既有 launch_new_round 的設計議題,不在本次 scope。


8. NEEDS SPIKE(實作前確認)

Spike 項目 解開時機 決議 / 假設
init_ap_controls_from_profile 建立的「Evidence upload」fallback control(沒 catalog_control_assessment)— 對應的模板 AO 該怎麼處理 spike DONE(design 階段已決) 不支援:fallback control 沒有 catalog_control_assessment,因此模板層不允許為其新增 AO defaults。對拷邏輯用 LEFT JOIN(不是 INNER JOIN),對沒 CCA 的 control 自然無 AO row 可拷;對拷完成後 log 一行 INFO(「N controls had no CCA, AO defaults skipped」),不算錯誤。模板 UI 對沒 CCA 的 control 不顯示 AO 編輯區塊。
模板 clone (clone_module_frame) 時要不要連 control_defaults / objective_defaults / reference_documents 一起 clone 實作 clone 擴充前 假設:clone 時連 defaults + 程序書 mapping 一起複製(檔案 file_id 共享);獨立 task 不在 v1 必做。
驗證 assessment_plan_tasks.catalog_control_assessment_idoscal.catalog_control_assessments.uid 的索引存在 實作對拷邏輯前 預期既有索引足夠;若不足,migration 需補。

9. Out of Scope(本次明確不做)

項目 原因 列入
SSP-per-round 影響範圍跨 dashboard / SSP detail / POA&M / evidence,需獨立 feature Phase 2 backlog
SSP 版本控制 跟 SSP-per-round 一起做 Phase 2 backlog
AP per-round scope 變化 OSCAL reviewed-controls 標準功能,獨立 feature Phase 2 backlog
Re-sync from template(Pattern C) YAGNI;等真實「template 改了想推到 project」需求出現再做 Phase 2 backlog
AR snapshot SSP 內容 由 SSP-per-round 一併解決 Phase 2 backlog
i18n 翻譯表 v1 純 TEXT 欄位,未來加 trans 表向前相容 Phase 2 backlog
Tenant 共用程序書池 YAGNI;目前 per-module_frame 設計與 SSP 端對齊 視需求評估
SspImportDialog.vue 抽象成 generic 元件 過度設計,本次 fork 一份即可 Phase 2 backlog
Promote project SSP to template 不在心智模型內 已棄

10. 風險清單與決策(從 brainstorming 收斂)

# 風險 決策
1 previous_ssp_id 預留欄位 YAGNI ✅ 砍
2 is_user_modified 預留欄位 YAGNI ✅ 砍
3 AO statement_identifier 來源 ✅ A: 模板層用 catalog_control_assessment.uid,SSP 端對拷 join
4 control_origination 欄位 ✅ 加到 module_frame_control_defaults
5 Lazy vs Eager 預填 ✅ Lazy + UI fallback「未設定」
6 軟刪除一致性 ✅ 新表不加 is_delete,靠 CASCADE + JOIN 過濾
7 i18n ✅ v1 不做,欄位 TEXT 留向前相容
8 對拷邏輯封裝 _copy_template_to_ssp() 共用 service + unit test
9 啟動時對拷效能 ✅ Bulk insert
10 jedi-oscal 進版 ✅ 確認可進版
11 程序書池 scope ✅ Per-Module-Frame
12 批次上傳元件抽象 ✅ A: fork SspImportDialog

10A. 不影響原有功能 — Backward Compatibility 保證(最高優先級)

本 feature 的核心承諾:對既有功能、既有資料、既有 API 完全不影響。逐項保證如下:

10A.1 既有 API 行為 — 100% 不變

既有 API 影響 保證
POST /module-frame (建立模板 Dialog) request/response 完全不變;新欄位都不在建立階段填
GET /module-frame/{uid} (取得模板) 既有 response shape 不變;新增欄位走獨立新 API
PUT /module-frame/{uid} (更新基本資訊) 不更新新表
DELETE /module-frame/{uid} (軟刪除) 既有軟刪除邏輯不變;新表 row 跟著 module_frame 軟刪除(query 時 JOIN filter)
POST /module-frame/clone spike 後決定(v1 預設一起 clone) 不破壞既有 clone 行為
Excel import(/module-frame/import/* 沿用既有流程
POST /oscal/projects/start request/response 完全不變 內部多一步 _copy_template_to_ssp();對 caller 透明
POST /grc/projects/{uid}/launch-new-round 完全不改;既有 _clone_ssp_objectives 接手 round 維護
所有 SSP 端 API(PUT /ssp/...、程序書 CRUD、AO update) 不重構 SSP 端任何邏輯
所有 ProjectPlanning / ProjectCreate / ProjectSettings FE 流程 不改任何既有 view

10A.2 既有資料 — 0 backfill / 0 mutation

  • 既有 module_frames 表資料 完全不動
  • 既有 SSP / SSP control_impl / SSP objective / SSP reference_documents 資料 完全不動
  • 既有 assessment_plans / assessment_plan_tasks 資料 完全不動
  • 新欄位 oscal.system_security_plans.template_module_frame_id 為 NULL(對既有 SSP 是 unknown source,不影響任何既有邏輯)
  • 新表為空表,等使用者自行編輯後才有 row

10A.3 既有專案啟動行為 — 完全保留

對於沒有設定任何 defaults 的 module_frame(即所有既有模板):

  • _copy_template_to_ssp() 內 UPDATE 的 WHERE clause 對應 0 筆 (因為 module_frame_control_defaults 表為空) → 沒任何 row 被改
  • SSP 啟動結果 = 既有行為(Step 7.5 建好的空殼 row 維持不變)
  • 使用者進 ProjectPlanning 看到的畫面 100% 與目前一致

既有專案啟動完全不會看到差異,只有「模板已設過 defaults」的新專案才會看到自動帶入內容。

關鍵設計:UPDATE in place 而非 INSERT(見 §5.0) — 因為 Step 7.5 已經建好空殼 row,我們只是把 NULL 欄位填上模板內容,不額外 INSERT 新 row,故沒有 unique constraint 衝突風險。

10A.4 既有 multi-round 行為 — 純加強,不破壞

  • launch_new_round 主要邏輯完全不改
  • 唯一動作:_clone_ssp_objectives 補一行 copy reference_documents JSONB(見 §5.5)
    • 這是既有 bug fix,純 additive,0 向後相容風險
    • 修了之後所有 AO 程序書(不論模板帶來的或既有功能填的)跨 round 都不會消失
    • 不修反而會破壞既有功能(既有 user 加的 AO 程序書 round 2 也會掉)
  • 對拷產生的 SSP description 一旦進 round 1 SSP,就跟一般 SSP 資料一樣由既有 multi-round 機制接手

10A.5 jedi-oscal 套件變更 — 加欄位 only

  • 唯一改動:OscalSystemSecurityPlan model 加 template_module_frame_id 欄位(nullable)
  • 不改任何既有欄位不改任何既有 method
  • 既有 jedi-oscal 使用者(含本專案其他模組)完全 backward compatible
  • 進版號用 minor bump(如 0.0.8 → 0.0.9,schema 變更)

10A.6 整合測試清單(必跑,確保不影響既有功能)

實作完成後必須驗證以下既有行為完全不變

  1. ✅ 沒設 defaults 的模板啟動專案 → SSP 全 NULL(與目前 100% 一致)
  2. ✅ 既有 module_frames 列表頁渲染 + Dialog 編輯 → 行為不變
  3. ✅ 既有 SSP 編輯(ProjectPlanning)→ 完全不變
  4. ✅ 既有 SSP 程序書上傳/掛接(control 層 + AO 層)→ 完全不變
  5. ✅ 既有 module_frame Excel import → 完全不變
  6. ✅ launch_new_round Round 2 啟動 → SSP objective 跨 round clone 行為不變
  7. ✅ jedi-oscal 既有 SSP query / update 流程 → 完全不變
  8. ✅ Round 1 user 自填 AO 程序書 → launch_new_round → Round 2 SSP objective 仍保有程序書(驗證 §5.5 fix)
  9. ✅ Step 7.5 + _copy_template_to_ssp 共存:模板有 defaults 啟動 → SSP control_impl / objective row 數量 = Step 7.5 該建的數量(無 duplicate row、無 unique constraint 衝突)

10A.7 失敗回滾

  • 整個 _copy_template_to_sspstart_oscal_project@transaction scope 內
  • 任何一步失敗 → 整個 project start transaction rollback
  • 不會留下半完成 SSP 或半 copy defaults 狀態
  • 不允許 try/except 吞掉錯誤(fail loud)

11. 影響範圍

11.1 Backend

新增:

  • compliance.module_frame_control_defaults (table)
  • compliance.module_frame_control_objective_defaults (table)
  • compliance.module_frame_reference_documents (table)
  • compliance.module_frame_reference_document_mappings (table)
  • domain/module_frame/entity/... + infra/module_frame/repository/... + mapper/... 一整套 DDD 層
  • app/module_frame/service/module_frame_control_default_service.py
  • app/module_frame/service/module_frame_objective_default_service.py
  • app/module_frame/service/module_frame_reference_document_service.py
  • app/module_frame/service/module_frame_template_import_service.py
  • api/module_frame/routes/control_default_route.py
  • api/module_frame/routes/objective_default_route.py
  • api/module_frame/routes/reference_document_route.py
  • api/module_frame/routes/template_import_route.py
  • api/oscal/routes/catalog_control_assessment_route.py(如尚未有)
  • common/code/module_frame_error_code.py (新檔)
  • SQL migration: scripts/sql/2026-XX-XX-module-frame-template-defaults.sql

修改:

  • oscal.system_security_planstemplate_module_frame_id 欄位(jedi-oscal 進版)
  • app/project/service/oscal_project_service.py_copy_template_to_ssp() 並在 start_oscal_project 流程中呼叫
  • app/oscal/service/... 暴露 catalog_control_assessments 列舉 service(如尚未有)

不動:

  • 既有 module_frame 基本 CRUD API
  • SSP 端既有 update API
  • launch_new_round 流程

11.2 Frontend

新增:

  • src/views/module_frame/ModuleFrameTemplateEditView.vue
  • src/components/grc/ModuleFrameTemplateImportDialog.vue (fork SspImportDialog)
  • src/service/ModuleFrameTemplateService.js
  • src/config/router/index.js 加新 route /module-frame/:uid/template-edit
  • i18n key(module-frame.json zh-tw + en)

修改:

  • src/views/module_frame/ModuleFrame.vue 列表加「編輯預設值」按鈕
  • src/config/api/api.js 加新 endpoint 常數

不動:

  • ProjectCreateView / ProjectSettingsView / ProjectPlanningView
  • 既有 module_frame 建立 Dialog 流程

11.3 jedi-oscal(外部套件)

  • 修改OscalSystemSecurityPlan model 加 template_module_frame_id 欄位
  • 進版發佈:依 user 慣例,需明確指示後才執行(CLAUDE.md memory: feedback_jedi_package_publish_flow)

12. 文件交付

按 CLAUDE.md 規範,本次需產出:

  • docs/features/FR-018-2604-module-frame-template-defaults/design.md(本文件)
  • 待產出:docs/features/FR-018-2604-module-frame-template-defaults/implementation-plan.md(writing-plans 階段)
  • 待產出:docs/changelog/2026-XX-XX-module-frame-template-defaults.md(實作完成後)

13. 估計工作量

階段 工作量
BE: schema migration + DDD layer (4 entities × entity/mapper/repo if/repo impl/service) 3 天
BE: 5 services + routes + import/export 2 天
BE: 對拷邏輯(含 control + objective 雙路徑 + JSONB 轉換) 1 天
BE: jedi-oscal 改欄位 + 進版發佈 0.5 天
FE: TemplateEditView + Service + Dialog fork + i18n + route 5.5 天
整合測試(BE unit + FE 整合 + 對拷端到端驗證) 2 天
文件交付(changelog) 0.5 天
總計:~14-15 天

14. Open Questions for Implementation(必須在 implementation plan 階段解開)

# 問題 解法方向
Q1 模板 clone 時 defaults / 程序書 mapping 是否一起 clone? 預設「是」,需在 plan 確認
Q2 Catalog 後續更新導致 catalog_control_assessment 變動 → 模板層 statement_identifier 變孤兒,UX 該如何提示? UI 顯示「目標已被 catalog 移除」,提供「移除預設值」按鈕
Q3 assessment_plan_tasks.catalog_control_assessment_id 索引是否存在? 實作前 \d 確認,缺則 migration 補
Q4 jedi-oscal 進版號 (minor / patch)? minor bump(schema 變更,已決)

15. 參考