FR-053 輪次隔離任務執行(Round-Scoped Jobs)— Design

建立:2026-07-22 | 狀態:第 1 刀實作中 Notion case:3a5346da-4cd0-810c-a777-ec5610048d5c 相關:FR-038(engagement 模型 / per-AO prep job)、FR-050(輪次階段)

1. 問題陳述

病根:收證據 job 全專案僅一批。 app/project/service/project_start_app_service.py:_generate_prep_jobs 只在專案啟動跑一次:per-AO clone 專屬流程範本 → 起 workflow_execution → 生 job_execution → 寫 public.workflow_execution_control_mapping(wecm, PK=(workflow_execution_id, control_id),另有 version_id / ao_part_id 欄, FK→compliance.workflow_executions)。

任務狀態、證據(compliance.job_evidences)、聊天、問卷 (survey.task_surveys.task_id→job)全部掛在 job 上被跨輪共用。覆核輪 launch_reverify(app/grc/service/audit_round_app_service.py:547 起)只 clone AP、 不生新 job(STG 實證:execution 全 1:1,第二輪操作直接覆寫第一輪歷史)。

AR / 凍結 SSP / 程序書池已按輪隔離健康,不動。

2. 三刀修法(user 已拍板)

  1. wecm 加 round_id——接線 truth 從 catalog AO props 轉移至 wecm (props 保留不動,向後相容)。
  2. 覆核輪生 job:對 narrowed scope(母輪 not_met)復用 _generate_prep_jobs 核心,生成新輪 execution + job + wecm(round_id)。
  3. 讀取端按輪過濾:control-tree(get_jobs_by_wf_uids caller 鏈)、dashboard、 job 匯出改經 wecm by round_id 篩 execution;凍結輪範圍改讀本輪 AP reviewed_controls(拆 profile 共用潛伏雷)。

不做:歷史資料遷移(user 拍板後續全刪)、合規資源庫調整(範本工廠層不受影響)、 catalog/profile 每輪快照。

3. 六項前提查證(2026-07-22 逐一讀碼確認)

3.1 _generate_prep_jobs 核心迴圈可抽 helper 參數化復用 — ✅ 可行,但 BPMN uid 注入必須「per-round 再 clone」

讀碼結論(project_start_app_service.py:338-437):

  • 核心迴圈輸入面天然可參數化:control_ids 子集、catalog_id(專案 cloned catalog, living、不 per-round,覆核輪可直接沿用)、template 來源。輸出面(execution / job / wecm add)與輪次無關,加 round_id 參數即可。
  • 「沿用專案已 clone 範本」模式成立:專案啟動時 _generate_prep_jobs 已把 cloned template uid 回填進 cloned catalog AO part.props(:399-402),所以覆核輪走 _part_wf_uid(part.props) 取到的現值就是專案專屬 cloned uid——以它為 source 再 clone,不需回到資源庫 source template(也繞開 clone_cache 對資源庫 uid 的語意)。
  • ⚠️ BPMN job uid 注入段(:417-427)不可對同一份 cloned xml 二次注入。 查證 jedi_flow_engine.common.utils.bpmn_uilts.BpmnUtils.inject_job_execution_uid (bpmn_uilts.py:572-608):注入前會剔除既有同名 job_execution_uid property 再 append(:603-604 props = [p for p in props if p.get("@name") != "job_execution_uid"]) ——即「覆蓋」語意。若覆核輪復用第一輪的 cloned template 直接注入第二輪 job uid, 第一輪 job uid 會從 xml 消失 → BPMN 編輯器對第一輪任務失去辨識(重存時重複建 job, 正是當初注入要防的 bug)。 處置:覆核輪對每個 in-scope AO 再 clone 一份 per-round template (source = 專案 cloned template,source_template_uid 指向它),對新副本注入 新輪 job uid——不跳過注入(跳過會復發重複建 job bug)、不動第一輪 xml。 另外不回填 AO props(props 維持指向專案 cloned uid 供舊讀路徑向後相容; 新輪接線 truth 全靠 wecm.round_id,第 3 刀讀取端據此改查)。
  • 附帶驗證:DEV 實證 template↔︎execution 現況 1:1(0 個 template 有多 execution), per-round clone 維持這個不變量,get_jobs_by_wf_uids 的 wt→we→je JOIN 不會混輪。

Helper 形狀(第 2 刀落地):抽 _generate_prep_jobs_core(...),參數含 control_ids、round_id、template_source_mode(resource_library = 現行 clone 資源庫 範本 + 回填 props;project_clone = 以 AO props 現值為 source 再 clone、不回填 props)。 start_project 與 launch_reverify 各以自己的模式呼叫。

3.2 覆核輪生成點 — ✅ 放 launch_reverify(能當場算 narrowed scope)

讀碼結論:

  • narrowed_control_ids_for(assessment_result_app_service.py:331-352)依賴三件事: 母輪 parent.ar_result_id(findings 來源)、母輪 parent.assessment_plan_id (in-scope 母體 + AO→control 映射)、finding_repo.get_by_ar_result 的 target_status_state == not_met。
  • launch_reverify 前置斷言 parent.status == pending_reverify (audit_round_app_service.py:565)。到達 pending_reverify 的唯一路徑是 finalize_audit(auditing→remediation,AR findings 已 finalize 落庫)→ close_round(remediation→pending_reverify)。即 launch_reverify 時母輪 AR/AP/findings 全部齊備,narrowed scope 能當場算。
  • start_auditing 的 close-out 分支(:438-442)也呼叫同一 narrowed_control_ids_for 建 AR 矩陣——兩處同源(母輪 AP+AR),不會漂移。
  • 語意上也應放 launch_reverify:覆核輪建立後 status=planning,planning 階段語意 就是收證據——job 必須在 planning 一開始就存在,PM 才能指派、受檢方才能傳證據。 放 start_auditing(稽核已開始)太晚。
  • 前置條件補充:job 生成需要 cloned catalog 的 AO parts——catalog 是專案層 clone (living,不隨輪凍結),launch_reverify 時可直接用(經 project_extensions.living_ssp_id → import_profile → source_catalog_id 導航, 與 start_project 同鏈)。

結論:生成點 = launch_reverify,在建輪 + 綁主工作流之後呼叫 _generate_prep_jobs_core(control_ids=narrowed, round_id=新輪.id, mode=project_clone)。 narrowed 為空(母輪無 not_met——理論上不會走到 launch_reverify,防禦處理)時 fallback 全 in-scope 或 skip,第 2 刀實作時定案(傾向 skip + log warning)。

3.3 wecm 全部 caller 逐一判定

Caller 用法 判定
app/project/service/project_start_app_service.py:428 _wecm_domain.add(...) 寫入 per-AO prep job 綁定 第 1 刀改:帶 round_id=initial round
app/grc/service/ar_import_app_service.py:_build_evidence_pool →_get_allowed_wf_ids_by_control(經 WfControlMappingLookupQuery) 依 control_id 撈授權 wf id 集合,剔除證據池跨控制污染 第 3 刀要加 round 過濾。AR 匯入本身 per-round(resolve_round_for_import),現行查詢跨輪撈——第 2 刀後同 control 會有多輪 wf,覆核輪匯入會把第一輪 job 的證據污染進池。改 get_wf_ids_by_control_ids(control_ids, round_id=None),round_id 有值時 AND m.round_id = :rid;沿用既有 best-effort fallback(查無 → 不過濾)
infra/grc/repository/wf_control_mapping_lookup_query.py 上列的 infra 查詢實作 第 3 刀改(同上,加 optional 參數)
app/flow_engine/service/workflow_execution_service.py:320 legacy BPMN process property framework_control producer(version_id 路線,非 per-AO prep job) 不動。legacy 路線無輪次概念,round_id 留 NULL(nullable 欄位天然相容)
app/flow_engine/service/workflow_execution_control_mapping_service.py get_ref_control_workflow_executions — FR-038 2A stub,一律回 [] 不動(死碼 stub,等 canonical 修復另案)
infra/grc/repository/grc_job_repo_impl.py:507 僅 docstring 提及「控制歸屬可由 wecm 回推」,實際 query 未 join wecm 不動
infra/grc/repository/job_import_lookup_query.py:get_job_control_uid_map job_uid 白名單 → join wecm 取 control_id 不動。輸入已是特定 job_uid 集合(隱含輪次——第 2 刀後 job 本身 per-round),join 只為取 control token;PK (we_id, control_id) 且 execution↔︎job 1:1,不會因多輪炸出多列
infra/flow_engine/repository/workflow_execution_control_mapping_repo_impl.py CRUD 實作 第 1 刀改:add() 帶 round_id(entity/mapper/model 同步)
domain/flow_engine/.../workflow_execution_control_mapping_domain_service.py + query entity pass-through 第 1 刀改:entity / query entity 加欄位(domain service 本體免改)
api/flow_engine/routes/workflow_execution_control_mapping_route.py 呼叫上面的 stub service 不動

3.4 control-tree 任務查詢鏈改法 + round 解析規則 — ✅ resolver 能力「夠組合、缺一個現成 method」

現行鏈(app/oscal/service/ssp_control_implementation_service.py:265-321): build_control_tree_by_ssp_id 讀 cloned catalog AO part.props 的 workflow-template-uid(專案 cloned uid)→ infra/oscal/repository/ssp_control_implementation_query.py:get_jobs_by_wf_uids (wt.uid → we → je JOIN,無輪次維度)。第 2 刀後同一 AO 會有多輪 template/execution ——props 只指得到第一輪,新輪 job 完全查不到;即使改 props 又會蓋掉舊輪。

改法(第 3 刀):jobs 查詢改「經 wecm by round_id 篩 execution」—— get_jobs_by_wf_uids 加 round_id 參數,JOIN public.workflow_execution_control_mapping m ON m.workflow_execution_id = we.id AND m.round_id = :rid(或改由 wecm 起頭反查 wt/we/je;實作時取改動小者)。 AO 對位從「props wf uid」換成 wecm 的 (control_id, ao_part_id)。

Round 解析規則(GET /ssp/<ssp_uid>/control-tree 只有 ssp_uid):

  • 凍結輪 ssp_uid → 經 project_audit_rounds.ssp_id 反查該輪(=本輪 round_id)。
  • living SSP → 取當前輪 = 該專案 round_no 最大的輪次。

查證 resolver 現有能力(domain/oscal/service/ssp_project_resolver.py):

  • frozen 鏈已有:_resolve_project_id 走 audit_round_domain_service.get_by_ssp_id(ssp_id) (repo project_audit_round_repo_impl.py:49 實作存在)✅——但 resolver 只回 project_id + source,round 本身沒帶出來。
  • living「當前輪」pattern 已有:is_writable 內 list_by_project → max(round_no) (:129-132)✅——但同樣沒封裝成可複用的 round 解析。

結論:能力足夠組合,缺一個現成 method。 第 3 刀在 resolver(或 domain service)補 resolve_round_for_ssp(ssp_id) -> Optional[round]:frozen 命中 get_by_ssp_id 直接回 該輪;living 命中 get_by_living_ssp_id 則 list_by_project 取 round_no 最大者; 皆無(資源庫範本 SSP)回 None → control-tree 走「無輪次」舊行為(jobs 空/props 舊路)。

3.5 凍結輪範圍改讀本輪 AP reviewed_controls — ✅ 形狀已驗、解析器可複用

DEV 實查一筆 oscal.ap_reviewed_controls.control_selections(LIMIT 1):

[
  {"include-controls": [
    {"control-id": "AC.L1-3.1.1"},
    {"control-id": "AC.L1-3.1.2"},
    "..."
  ]}
]

既有解析能力(grep 實證):

  • app/grc/service/assessment_result_app_service.py:147 _parse_control_ids—— 逐 selection 取 include-controls[].control-id,與上列形狀完全對齊 ✅
  • app/grc/service/assessment_plan_app_service.py:105 有一份同款 static method (既存的雙份現況,本案不合併,複用 AR 側即可——凍結輪範圍讀取落在 AR/round 語境)。
  • 包裝層 _inscope_control_ids(ap_id)(assessment_result_app_service.py:157: reviewed_repo.get_by_ap → _parse_control_ids)可直接複用。

結論:可複用,不新寫解析器。 第 3 刀凍結輪 control-tree / 匯出的控制母體從 「profile resolve(living,跨輪共用、會被後續改動污染)」改為 「本輪 round.assessment_plan_id → ap_reviewed_controls → _parse_control_ids」。

3.6 資源庫 end-to-end 驗證項(手測步驟,只列不改資源庫)

「新資源庫 → 啟動專案 → 第一輪 wecm 有 round_id」:

  1. 合規資源庫建立並 publish 一個新 module_frame 三件組(catalog/profile/範本 SSP + AO workflows 初始化,走既有資源庫 UI 流程,不改資源庫程式)。

  2. 以該資源庫啟動新專案(POST /project/start),確認 response job_count > 0、first_round_created = true。

  3. DEV DB 驗證:

    SELECT m.round_id, r.round_no, r.round_type, count(*)
    FROM public.workflow_execution_control_mapping m
    JOIN compliance.workflow_executions we ON we.id = m.workflow_execution_id
    LEFT JOIN compliance.project_audit_rounds r ON r.id = m.round_id
    WHERE we.created_user = '<測試帳號>'    -- 或依新專案 cloned template 時間窗過濾
    GROUP BY 1,2,3;

    期望:本次新專案的全部 wecm 列 round_id 非 NULL 且指向 round_no=1 / round_type='initial' 的輪次。

  4. 規劃頁 control-tree 正常顯示任務(第 1 刀不改讀取端,此步為回歸確認)。

  5. (第 2 刀後補測)走完整輪:launch_audit → start_auditing → finalize(含 not_met) → close_round → launch_reverify,驗證新輪 wecm 只含 narrowed 控制且 round_id=新輪。

4. 第 1 刀落地細節(本次實作)

  • Migration scripts/sql/2026-07-22-fr053-wecm-round-id.sql: ADD COLUMN round_id integer(nullable、不加 FK——比照 project_audit_rounds.parent/ssp/poam 等 soft-ref 慣例)+ partial index WHERE round_id IS NOT NULL + INSERT public.schema_migrations。
  • Model / Entity / Mapper / RepoImpl.add / QueryEntity 加 round_id。
  • round 建立時機查證:start_project 現行順序是 7b _generate_prep_jobs → 7c _create_first_round(round 後建)。 選「調換順序」(7c 移到 7b 前)而非事後 UPDATE:兩步互不依賴 (job 生成不讀 round、create_round 不讀 job;_create_first_round best-effort 失敗回 None → job 照生、round_id=NULL,nullable 相容),改動最小且 wecm 單點寫入。 create_round 對外 dto 只有 uid 無 int id(uid-only API 慣例,不動 response shape), 故 start_project 內以 round_uid 反查一次 int id(本檔既有 get_session()+sa.text 慣例,如 _resolve_source_triple)再傳入 _generate_prep_jobs(round_id=...)。

5. 第 2 / 3 刀切工建議

  • 第 2 刀(覆核輪生 job):抽 _generate_prep_jobs_core + project_clone 模式 (per-round clone、注入新 job uid、不回填 props)+ launch_reverify 接線 (narrowed scope 當場算)。牽動檔案:project_start_app_service(抽 helper)、 audit_round_app_service(呼叫點 + DI 注入 flow-engine domain services 或改注入 project_start_app_service 的 helper——實作時二選一,傾向把 core 抽到獨立 service 由兩邊注入)。
  • 第 3 刀(讀取端):resolver 補 resolve_round_for_ssp → control-tree get_jobs_by_wf_uids 改 wecm-by-round → WfControlMappingLookupQuery 加 round 參數 (AR 匯入證據池)→ dashboard(grc_project_repo_impl.get_ap_dashboard / count_incomplete_prep_jobs 的 catalog→props→wt JOIN 改 wecm-by-round)→ job 匯出(job_import_service.export_excel 鏈)→ 凍結輪控制母體改讀本輪 AP reviewed_controls(§3.5)。