---
title: 流程疆界三方設計 — flow-engine／flow_control／project
brand: Guidant AI · **FR-069.4.2** 流程疆界
eyebrow: FR-069 第四階段 · CM-1478 設計稿 · 2026-08-31
h1: 24,600 行要切在哪裡
lede: 現在的 flow_control 把兩種東西混在一起——一種是**任何產品都用得到的流程通則**（任務有狀態、狀態怎麼推進、要通知誰），一種是**只有稽核產品才有的規則**（例如 POA&M 要全部關閉才能結案）。這份稿子逐塊盤點，畫出兩者的分界線，並把三件懸而未決的歸屬問題（flow-engine 的 route 該歸誰、app/project 該歸誰、資料庫 view 該歸誰）一次定案。拿不準的做成 D-1～D-9 待決項，請決策者逐項裁。**設計沒拍板前，一支檔案都不搬。**
chips: [{text: 設計稿待拍板, kind: warn}, {text: 通用 12／稽核 22／待判 12, kind: accent}, {text: 待決 9 項, kind: warn}]
footer: FR-069.4.2 · CM-1478 · 盤點以 2026-08-31 codebase 為準
---

## 30 秒版 {#tldr nav="30 秒版"}

1. **要做的事**：flow_control 這包程式（24,047 行／226 個檔）現在把「流程通則」和「稽核規則」焊在一起。這份稿子把每一塊逐支判給其中一邊，讓下一步搬遷有圖可照。
2. **判準只有一句話**：**「換一個產品，這段還會一樣嗎？」**——會一樣的，是**通用流程部分**（最後併入 task 平台包，給別的產品重複使用）；不一樣的，是**稽核專屬部分**（自己獨立成一個功能套件）。
3. **盤點結果**：app 層可判的 46 個物件裡，**通用 12 支／稽核 22 支／還看不準的 12 支**。看不準的這稿不硬判，列成待決項請決策者裁。
4. **有個好消息**：讓功能自己申報任務型別的機制，雛形已經存在。`FlowControlJobType`（general／survey／detection_tool）加上 `JOB_TYPE_REQUIRED_MODULES`，就是一張手寫死的任務型別登記表。要改成「功能自己申報」是**換個形態，不是從零造新東西**。
5. **動工分四步**（見 §9）：搬出來 → 分家 → 改成申報制 → 打包。**每一步都能單獨驗收，也能停在任何一步。**
6. **這份稿子不動任何程式碼**（唯一例外是 jedi-project 的虛掛依賴清理，純粹的依賴整理，已經 commit）。

### 各階段完成判定 {#done-criteria nav="-"}

| 步 | 做什麼 | 完成怎麼判定（決策者檢查法） |
|---|---|---|
| **0** | 本稿拍板（9 項 D 待決逐項裁） | 每項 D 有裁決結論，寫入 design.md §3 決策表 |
| **1 拆出** | flow_control 整包搬成套件（import 名不變、業務邏輯原樣） | 請 Claude 跑 URL 集合比對（搬前搬後 route 表逐字相同）＋守衛測試綠；稽核主流程（建專案→建輪次→啟動稽核→結案）手測一遍 |
| **2 蒸餾**（分家） | 通用部分下沉（＝往下移到共用的底層）成 task 平台層，稽核部分留在插件 | 手測同上；另外請 Claude 證明「平台層 grep 不到 poam／audit_round／assessment_plan」 |
| **3 平台化** | 任務型別改申報制、問卷申報審核狀態機 | 問卷任務的「送審／核可／退回」走得通；把設定關掉後審核步驟消失（＝設定不是客製） |
| **4 打包** | task 平台包上 Nexus、稽核插件上 Nexus | `poetry show` 看得到兩個版號；拔掉稽核插件後系統仍起得來（只是沒有稽核功能） |

::: {.callout .warn}
**這四步不必一次做完，也不該一次做完。** 第 1 步做完，系統就是可以交付的狀態；第 2、3 步各自也是。**停在任何一步都不會留下半成品。** 這是刻意的安排——「哪些算通用」的判斷，會隨著第二個真實流程（問卷審核）落地而修正，留餘地比一次押死好。
:::

---

## 為什麼現在要切 {#why nav="為什麼切"}

**這節在講什麼：不分家的代價，以及分家換來什麼。**

::: grid2
::: {.card .warn}
#### 不切會怎樣
問卷想要「送審→核可／退回」，就得重寫一整套狀態機——因為現在這套是稽核專用的，推進條件裡直接寫著「POA&M 全部 closed」。**每多一種流程，就多一套客製程式。**
:::
::: {.card .ok}
#### 切完會怎樣
問卷申報自己的狀態機，走同一套推進引擎。客戶 A 要審核、客戶 B 不要，變成一個設定開關（D18 設定 schema 申報制）。**流程差異的交付成本，從「開發案」降為「勾選項」。**
:::
:::

**分家不是限制重複使用，分家正是重複使用的前提。** 整包 24,600 行拔去別的產品，會夾帶 POA&M 檢查、稽核輪次、覆核 clone——用不到又拆不掉。（決策全文見 [專案平台化決策](../architecture-handbook/project-platform-decision.md)）

---

## 現況：三方的實際依賴 {#current nav="現況依賴"}

**這節在講什麼：現在 flow-engine、flow_control、project 三邊互相亂穿，圖 1 標出三個病灶。**

```{.mermaid cap="圖 1 — 現況：三方互相穿透，方向混亂（實線＝import，數字＝出現次數）"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart TD
  subgraph MAIN["主專案"]
    FC["flow_control<br/>24,047 行／226 檔<br/>稽核業務層"]
    FE_APP["app/flow_engine<br/>9,183 行<br/>（BPMN 編排／階段推進）"]
    PROJ_APP["app/project<br/>974 行<br/>（專案成立編排）"]
    RM["infra/readmodel<br/>1,149 行<br/>（跨模組唯讀查詢集中區，4.A 已正名）"]
  end
  subgraph PKG["既有套件"]
    JFE["jedi-flow-engine<br/>5,465 行（BPMN 引擎）"]
    JP["jedi-project<br/>399 行（被掏空的 CRUD）"]
  end
  FC -->|"150 domain 自引<br/>+ 25 jedi_flow_engine"| JFE
  FC -->|"9 infra + 9 domain<br/>（穿透 infra）"| JP
  FE_APP -->|"stage_rollback_route<br/>直接 import AuditRoundAppService"| FC
  FC -->|"oscal_stage_handlers<br/>實作 flow_engine 的 registry 介面"| FE_APP
  PROJ_APP -->|"6 條 route 走 api/project<br/>但 service 在 flow_control"| FC
  RM -.->|"純讀，不隨任何套件走"| FC
```

::: {.callout .crit}
**圖 1 要看的三個病灶**

1. **flow_engine 和 flow_control 互相依賴**：`stage_rollback_route.py:19` 直接 import `AuditRoundAppService`——引擎的 route 認識了稽核業務。而 `oscal_stage_handlers.py` 反過來實作引擎的 registry 介面。**這兩條線是同一個死結的兩端。**
2. **api/project 只是資料夾路徑，不代表歸屬**：AP docx 匯入、AR xlsx 匯入、稽核輪次的 route 全掛在 `api/project/`，但實際做事的 service 在 `app/flow_control/`——**路徑放哪裡跟程式屬於誰，對不上。**
3. **jedi-project 被掏空了，卻還反過來依賴主專案**：只剩 399 行，卻被引用 75 次；而且 `jedi_project/app/dto/*.py` 有三支 import 主專案的 `app.common.dto.base_dto`（這是本棒實查才發現的，boundary-map 沒記載）——**套件依賴主專案，方向完全反了。**
:::

---

## 目標：三層 {#target nav="目標三層"}

**這節在講什麼：最終要長成的樣子——所有功能都指向平台，平台反過來不認識任何功能。**

```{.mermaid cap="圖 2 — 目標：所有箭頭指向平台，平台不認識任何業務"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart TD
  AUDIT["稽核插件<br/>（flow_control 的稽核專屬部分）<br/>申報 task type: general/detection<br/>＋輪次狀態機＋POA&M 規則"]
  SURVEY["問卷插件<br/>（jedi-survey ＋ task_survey）<br/>申報 task type: survey<br/>＋審核狀態機（作答→送審→核可/退回）"]
  DETECT["檢測插件<br/>（jedi-detection，P9）<br/>申報 task type: detection_tool"]
  PLATFORM["🔌 task 平台包<br/>（jedi-project 種子 ＋ flow_control 通用部分）<br/>專案／任務／狀態機／指派／綁人綁附件綁頁面<br/><b>不認識任何業務</b>"]
  ENGINE["jedi-flow-engine<br/>BPMN 執行引擎（純技術）"]
  HOST["主程式 Guidant AI<br/>接線盤＋組裝設定＋跨模組唯讀查詢櫃檯"]
  AUDIT -->|register| PLATFORM
  SURVEY -->|register| PLATFORM
  DETECT -->|register| PLATFORM
  PLATFORM --> ENGINE
  HOST -.->|"組裝：裝哪些插件、設定值存哪"| PLATFORM
  HOST -.->|"跨模組唯讀查詢"| AUDIT
```

**跟現況只差兩處，但兩處都是方向性的**：① 平台層不再 import 任何業務程式（現在 `app/flow_engine/stage_rollback_service` 認識 `AuditRoundAppService`）；② 任務型別從寫死的清單，改成由插件自己申報。

---

## 切線盤點：flow_control 逐塊判 {#cutline nav="切線盤點"}

**這節在講什麼：把 flow_control 的每一支檔案，逐支判給通用或稽核，附上判定證據。**

判準：**「換一個產品，這段還會一樣嗎？」**判定用三類證據——① 檔案內稽核專屬詞的密度（poam／audit_round／assessment_plan／ssp／oscal 這幾個字的 grep 命中數）；② 對外依賴的方向；③ 這支檔案實際在做什麼事。

::: statgrid
::: {.stat .ok}
[12]{.v}[通用（可重複使用）]{.k}
:::
::: {.stat .crit}
[22]{.v}[稽核專屬（不可重複使用）]{.k}
:::
::: {.stat .warn}
[12]{.v}[還看不準（本稿不硬判）]{.k}
:::
::: stat
[24,047]{.v}[總行數／226 檔]{.k}
:::
:::

### app 層 service（41 支＋dto 15 支） {#cutline-app nav="app 層"}

**🟢 通用——換個產品還會一樣**

| 檔 | 行 | 判定證據 | 最後要放哪 |
|---|---|---|---|
| `service/job_service.py` | 1,144 | 稽核詞 poam/round/ap/ssp/oscal **全 0**；做的是任務 CRUD＋指派＋通知 | ⚠️ **有分歧**，見下方 G-1 |
| `service/job_comment_service.py` | 79 | 稽核詞全 0；純留言 CRUD | task 平台層 |
| `service/job_batch_complete_service.py` | 193 | 稽核詞全 0；批次完成任務 | task 平台層 |
| `service/task_execution_service.py` | 139 | 稽核詞全 0；啟動任務執行＋進度 | task 平台層（但吃跨模組唯讀查詢，見 D-4） |
| `service/task_setup_service.py` | 18 | 純轉發 domain service | task 平台層 |
| `service/dashboard_service.py` | 16 | 薄殼（16 行）；聚合在 readmodel | 主程式（readmodel 已在 4.A 正名） |
| `service/auditor_dashboard_service.py` | 14 | 薄殼；聚合在 `auditor_dashboard_query` | 主程式（該 query 尚未搬 readmodel，見 D-5） |
| `dto/job_dto.py` | 361 | 稽核詞 6（ap/ar），其餘是任務欄位 | task 平台層（6 處稽核欄位需拆，見 D-6） |
| `dto/job_comment_dto.py` | 32 | 稽核詞全 0 | task 平台層 |
| `dto/dashboard_dto.py` | 74 | 稽核詞全 0 | 主程式 |
| `dto/review_dto.py` | 32 | 稽核詞全 0 | task 平台層 |
| `dto/control_group_dto.py` | 162 | 稽核詞全 0（但 control group 是 OSCAL 概念，見下方待判區） | 待判→稽核專屬（見 D-7） |

**🔴 稽核專屬——別的產品沒有這些東西**

| 檔 | 行 | 判定證據 |
|---|---|---|
| `service/audit_round_app_service.py` | 925 | round 76／ssp 49／poam 17／ap-ar 34——**輪次狀態機本體** |
| `service/assessment_result_app_service.py` | 829 | ap-ar 88／control 46／oscal 31／poam 12 |
| `service/assessment_plan_app_service.py` | 742 | ap-ar 69／oscal 33／control 32／round 21 |
| `service/ar_import_app_service.py` | 610 | control 20／ap-ar 10；亞航 CMMC L1 xlsx 匯入 |
| `service/poam_app_service.py` | 441 | poam 31 |
| `service/ap_docx_import_app_service.py` | 375 | ssp 9／control 7／ap-ar 6 |
| `service/poam_service.py` | 172 | poam 22／ap-ar 18 |
| `service/prep_job_generation_service.py` | 217 | round 9／control 10；per-AO 收證據 job 生成（AO＝OSCAL 概念） |
| `service/oscal_stage_preconditions.py` | 129 | ap-ar 20／poam 10——「POA&M 全 closed 才能推進」正是稽核專屬前置條件 |
| `service/oscal_stage_handlers.py` | 142 | round 16／poam 4 |
| `service/oscal_stage_rollback_handlers.py` | 71 | round 7／ssp 3 |
| `service/reverify_inheritance_service.py` | 73 | round 10——覆核輪承襲 |
| `service/planning_readiness_checker.py` | 128 | round 2＋flow_engine 7；規劃階段推進前置條件 |
| `service/project_current_ssp_service.py` | 69 | ssp 23／oscal 4 |
| `service/audit_service.py` | 146 | ap-ar 25 |
| `service/ap_report_parser/*`（4 檔） | 474 | 亞航 CMMC L1 報告格式 adapter |
| `service/ar_report_parser/*`（4 檔） | 283 | 同上 AR 版 |
| `service/ar_import/*`（4 檔） | 157 | AO 對齊／佐證分級配對 |
| `service/ar_framework_profile/cmmc_l1.py` | 37 | CMMC L1 → NIST 控制對照 |
| `service/ao_derivation.py` | 42 | control 10；AO 推導 |
| `dto/poam_dto.py` / `audit_dto.py` / `control_detail_dto.py` / `control_dto.py` / `project_dto.py` / `assessment_plan_dto.py` / `assessment_object_dto.py` / `task_setup_dto.py` | 998 | 稽核詞密集 |
| `service/project_service.py` | 812 | ⚠️ **列在這裡但其實看不準**，見 G-2 |

**🟡 還看不準——本稿不硬判**

| 檔 | 行 | 為什麼看不準 | 對應待決 |
|---|---|---|---|
| `service/job_service.py` | 1,144 | 稽核詞 0，但檢測工具詞 88／問卷詞 73／指派詞 33——它其實把**三種任務型別的處理全塞在同一支**。算通用？還是該切三份？ | **G-1／D-1** |
| `service/project_service.py` | 812 | 專案 CRUD 是通用動作，但 `get_project()` 會去拼 SSP inventory 分類、participant 出現 55 處——它是個**跨模組的聚合窗口**（盤點 #5） | **G-2／D-2** |
| `service/job_import_service.py` | 464 | 任務批次匯入是通用動作，但欄位含 AO／控制項對應（participant 34／device 29） | D-3 |
| `service/review_service.py` | 123 | 「審閱標記」看起來通用，但被標記的東西是 control／AO（稽核概念） | D-7 |
| `service/control_service.py` / `control_group_service.py` / `assessment_object_service.py` | 225 | **route 已經停用**（2A dark stub 回空），只剩 DI 註冊還活著 | D-8（要不要退役？） |

### domain 層（3,008 行／58 檔） {#cutline-domain nav="domain 層"}

同一把尺量下來，domain 層**比 app 層好分**：

| 分類 | 檔 | 證據 |
|---|---|---|
| 🟢 通用 | `flow_control_job_*`（entity／domain service／repo 介面）、`flow_control_review_*`、`flow_control_task_setup_*`、`flow_control_dashboard_entity`、`code/flow_control_job_type_enum.py` | 稽核詞 0–2 |
| 🔴 稽核專屬 | `flow_control_audit_*`（entity 17／domain service 25／repo 21）、`poam_*`（17／9／5）、`project_audit_round_*`、`round_rollback_supersession_*`、`round_stage_transition_*`、`ssp_reference_document_*`、`i_ssp_document_pool_query`、`ap_docx_parse_job_*`／`ar_xlsx_parse_job_*`、`flow_control_control*` | 稽核詞密集 |
| 🟡 還看不準 | `flow_control_project_*`、`project_extension_*` | 專案主檔歸誰，會牽動 jedi-project（見 D-2） |

### infra 層（6,293 行／52 檔） {#cutline-infra nav="infra 層"}

**這一層最危險**——SQL 字串裡的跨表依賴，程式碼掃描工具看不到（4.A 建 `infra/readmodel/` 正是為了這件事）。實查 6 支重量級 query 的讀寫混合狀況：

| 檔 | 行 | 寫入語句 | 表數 | 跨 schema | 判定 |
|---|---|---|---|---|---|
| `flow_control_job_repo_impl.py` | 1,015 | **21** | 13 | compliance／oscal／survey | 🔴 **讀寫混合**——不能整支放進 readmodel（那裡只收純讀的）。見 D-4 |
| `flow_control_project_repo_impl.py` | 720 | **1** | 13 | compliance／oscal／public | 🟡 幾乎純讀，但有一處寫。見 D-4 |
| `task_execution_query.py` | 277 | **1** | 12 | compliance／oscal／public | 🟡 同上 |
| `reverify_clone_query.py` | 175 | **6** | 8 | compliance／public／survey | 🔴 讀寫混合，而且是**跨模組搬運**（複製任務＋證據＋問卷作答） |
| `job_batch_complete_query.py` | 271 | 0 | 3 | compliance／oscal | 🟢 純讀跨模組 → 該進 readmodel |
| `auditor_dashboard_query.py` | 106 | 0 | 4 | compliance／oscal | 🟢 純讀跨模組 → 該進 readmodel（4.A 漏收，見 D-5） |

其餘 infra 檔（mapper／單表 repo／model）跟著它的 entity 歸屬走，不需要獨立判斷。

**infra/flow_control/model 的 14 張表**（表歸誰，決定 migration 跟著哪個套件走）：

| 表 | schema | 判定 |
|---|---|---|
| `job_execution_comments` / `job_execution_devices` / `job_execution_org_units` / `job_execution_surveys` | compliance | 🟢 通用（任務綁留言／設備／組織／問卷＝「任務可以綁東西」） |
| `review_marks` | compliance | 🟡 還看不準（被標記的是 control／AO） |
| `poams` / `project_audit_rounds` / `round_rollback_supersessions` / `round_stage_transitions` | compliance | 🔴 稽核專屬 |
| `ap_docx_parse_jobs` / `ar_xlsx_parse_jobs` / `ssp_reference_documents` / `ssp_reference_document_mappings` | oscal | 🔴 稽核專屬（而且已經在 oscal schema，P13 要一起看） |

---

## flow-engine 19 條 route 歸屬（3.4 懸置案） {#fe-routes nav="flow-engine route"}

**這節在講什麼：3.4 當初煞車沒解決的 route 歸屬，這裡逐條判完；其中 4 條是整個死結的實體。**

3.4（CM-1470）當初煞車的原因：19 條 route 有 17 條吃主專案的 service，route 搬進套件會把主專案業務一起拖走。實查後的歸屬判定（**還在用的 16 條＋已停用 6 條**，卡片寫「19 條」是含已註解掉的）：

| # | Path | Route class 的 service 在哪 | 歸屬判定 |
|---|---|---|---|
| 1 | `GET/POST /flow-engine/flow-templates` | `app/flow_engine/flow_template_app_service` | 🟦 **引擎**（範本管理是純 BPMN） |
| 2 | `/flow-engine/flow-templates/validate` | 同上 | 🟦 引擎 |
| 3 | `/flow-engine/flow-templates/<uid>` | 同上 | 🟦 引擎 |
| 4 | `.../duplicate` | 同上 | 🟦 引擎 |
| 5 | `.../publish` | 同上 | 🟦 引擎 |
| 6 | `.../unpublish` | 同上 | 🟦 引擎 |
| 7 | `GET /flow-engine/stage-objects` | `app/flow_engine/stage_object_app_service`（16 行薄殼） | 🟦 引擎 |
| 8 | `/job-evidences`（POST list） | `app/flow_engine/job_evidence_service` | 🟩 **平台**（證據＝任務可綁附件） |
| 9 | `/job-evidence`（POST 建立） | 同上 | 🟩 平台 |
| 10 | `/job-evidence/<uid>` | 同上 | 🟩 平台 |
| 11 | `/flow-engine/task/complete/<id>` | `app/flow_engine/workflow_execution_service`（1,679 行，吃 participant／task_survey／notification／associations） | 🟩 平台（但該 service 要先拆，見 D-9） |
| 12 | `/flow-engine/task/revert/<id>` | 同上 | 🟩 平台（同 D-9） |
| 13 | `/flow-engine/process/comments/<id>` | 同上 | 🟩 平台（同 D-9） |
| 14 | `/flow-engine/process-definition/<uid>` | 同上 | 🟩 平台（同 D-9） |
| 15 | `/job-execution/task/link` | 同上＋`jedi_flow_engine.JobExecutionService` | 🟩 平台 |
| 16 | `/flow-engine/task/queue` | `app/readmodel/my_jobs_app_service`（4.A 已正名） | 🟨 **主程式**（跨模組唯讀） |
| 17 | `/project/<p>/audit-round/<r>/stage/info` | `app/flow_engine/stage_advance_service`（702 行，吃 `domain.flow_control.RoundStageTransitionEntity`） | 🔴 **稽核**——路徑就寫著 audit-round |
| 18 | `.../stage/advance` | 同上 | 🔴 稽核 |
| 19 | `.../stage/rollback` | `app/flow_engine/stage_rollback_service` **＋直接 import `AuditRoundAppService`** | 🔴 稽核 |
| 20 | `.../stage/transitions` | 同上 | 🔴 稽核 |

::: {.callout .crit}
**最關鍵的一條線：#17–20 這 4 條 stage route**

它們掛在 `api/flow_engine/`、service 也在 `app/flow_engine/`，看起來像引擎的東西。但實際上：**URL 路徑寫著 `audit-round`、程式碼直接 import `AuditRoundAppService`（`stage_rollback_route.py:19`）、還吃 `domain.flow_control.RoundStageTransitionEntity`（`stage_advance_service.py:43`）**。這 4 條做的是「稽核輪次的階段推進」，卻被放錯在引擎目錄下——**前面說的那個「同一個死結」，實體就是它們。**

判定：**這 4 條 route，加上 `stage_advance_service`（702 行）和 `stage_rollback_service`（189 行），歸稽核專屬**，跟著稽核插件走。引擎只留下通用的「階段推進機制」抽象（`stage_completion_registry` 已經在 `domain/flow_engine/`，形狀是對的）。

**API 路徑不動**（D5）——路徑是對外契約，前端已經在用。
:::

**`app/flow_engine` 這 9,183 行歸誰**（卡片寫 6,375 行，實查是 9,183——多的是 `util/bpmn_generator.py` 2,428 行）：

| 檔 | 行 | 歸屬 | 證據 |
|---|---|---|---|
| `service/camunda_service.py` | 2,852 | 🟦 引擎 | 純 BPMN／Camunda |
| `util/bpmn_generator.py` | 2,428 | 🟦 引擎 | 純 BPMN XML 生成 |
| `util/bpmn_topology_validator.py` | 330 | 🟦 引擎 | 同上 |
| `service/workflow_execution_service.py` | 1,679 | 🟡 **還看不準** | 吃 participant／task_survey／notification／associations／module_frame 五個模組，D-9 |
| `service/stage_advance_service.py` | 702 | 🔴 稽核 | 吃 `domain.flow_control` |
| `service/stage_rollback_service.py` | 189 | 🔴 稽核 | import `AuditRoundAppService` |
| `service/flow_template_app_service.py` | 264 | 🟦 引擎 | 純範本 |
| `service/job_evidence_service.py` | 181 | 🟩 平台 | 任務綁附件 |
| `service/workflow_template_snapshot_service.py` | 85 | 🔴 稽核 | snapshot 是輪次凍結的語意 |
| 其餘（dto／小 service） | ~473 | 跟著使用它的人走 | — |

---

## app/project 974 行歸屬 {#app-project nav="app/project"}

**這節在講什麼：app/project 只有 3 支 service，逐支判完；另外 api/project 底下 28 條 route 其實是稽核的。**

卡片寫大約 1,300 行，實查是 **974 行／3 支 service**（`api/project` 另有 1,658 行的 route 和 serializer）：

| 檔 | 行 | 做什麼 | 歸屬 |
|---|---|---|---|
| `project_start_app_service.py` | 476 | 專案成立：clone 資源庫三件組（catalog／profile／SSP）＋建 projects 主檔＋participants＋per-AO 準備期 job | 🟨 **主程式編排**——它是 OSCAL、專案、flow_engine 三者的接縫（盤點 #16：直接 import 6 支 jedi_oscal_v2 RepoImpl，穿透了 infra 層） |
| `oscal_audit_service.py` | 409 | 舊的 AP-scoped 稽核生命週期（activate／submit_for_review／launch_audit／confirm_audit／close_round） | 🔴 稽核專屬，**但很可能已經是退役品**——見 D-8 |
| `project_system_info_service.py` | 35 | 專案系統資訊 | 🟢 通用（很薄） |

**api/project 底下還在用的 34 條 route**：其中 **28 條是稽核輪次／AP／AR／POA&M**（`/audit-round/*`、`/ap/*`、`/ar-import/*`、`/ap-docx-import/*`），service 全都在 `app/flow_control/`——**路徑掛在 project，程式卻在 flow_control。** 判定：這 28 條跟著**稽核插件**走（路徑不改，D5）。剩下 6 條（`/oscal-project/start` 等）留在主程式。

---

## 資產盤點：DB view 歸屬 {#views nav="DB view"}

**這節在講什麼：資料庫裡的 view 也是跨模組查詢，但程式掃描掃不到，所以必須人工列冊。**

全庫只有 4 張自建的 view。**view 就是存在資料庫裡的跨模組查詢，同樣受 D17 三律管；但守衛測試只掃 Python，掃不到 view，所以必須人工列出來。**（CM-1474 有實證：`.12` 那次的雙寫守衛就漏掉了 `vw_user_job_queue`）

| view | JOIN 範圍 | 歸屬 | 本稿動作 | 程式面誰在用（實查） |
|---|---|---|---|---|
| `vw_user_job_queue` | 8 表／3 schema／六個模組 | 主專案的 **跨模組唯讀查詢櫃檯** | 標註歸屬，不動 | `infra/readmodel/my_grc_jobs_query.py:33`（4.A 已搬）；`infra/flow_control/repository/flow_control_job_repo_impl.py:307` 只在註解裡提到 |
| `v_role_routes` | 身分模組 | **身分模組的資料庫面資產**（建在 jedi-iam 的表上，由主專案 migration 建立） | 標「iam 相關表異動時必查」；不搬不改 | Python 零命中 |
| `v_user_capabilities` | 身分模組 | 同上 | 同上 | ⚠️ `jedi-license-runtime/infra/tenant_admin_notify_query.py:32` 用 raw SQL 直接打（見下方已知債） |
| `v_user_routes` | 身分模組 | 同上 | 同上 | Python 零命中 |

::: {.callout .warn}
**已知債（這一棒不還）**：`jedi-license-runtime` 的 `tenant_admin_notify_query.py` 用 raw SQL 直接打 `public.v_user_capabilities`——這是**套件穿越身分模組的 view 版變體**。FR-062 當時還沒有名冊 port 可用，屬於合理的權宜做法。正確做法是改走主程式接線的 port，等下次動到 license-runtime 時順手還掉（已記在 STATE backlog）。
:::

---

## 問卷審核走查：「客製」變「設定」 {#survey nav="問卷走查"}

**這節在講什麼：拿問卷當第一個案例，證明切完之後，第二種流程不必再寫客製程式。**

### 好消息：申報制的雛形已經存在 {#survey-existing nav="-"}

`domain/flow_control/code/flow_control_job_type_enum.py` 現在長這樣：

```python
class FlowControlJobType(StrEnum):
    GENERAL = "general"
    SURVEY = "survey"
    DETECTION_TOOL = "detection_tool"

JOB_TYPE_REQUIRED_MODULES = {          # 任務型別 → 所需授權模組
    "survey": ("survey",),
    "detection_tool": ("remote-agent-manage", "detection-profile", "plugin"),
}
```

**這已經是一張任務型別登記表了**，只是被寫死在稽核模組裡。改成讓插件自己申報，是**換個形態，不是從零造新東西**——每加一種任務型別就得回頭改這支枚舉，正是「平台去認識功能」那個病的小樣本（跟 AI 儀表板那份 525 行手工登記簿是同一種病，CM-1476 已經拆掉了）。

### 目標形態 {#survey-target nav="-"}

```{.mermaid cap="圖 3 — 問卷插件申報自己的任務型別與狀態機"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
  participant HOST as 主程式啟動
  participant SURVEY as 問卷插件
  participant PLAT as task 平台層
  participant USER as 使用者
  HOST->>SURVEY: register(platform)
  SURVEY->>PLAT: 申報 task type "survey"<br/>＋狀態機（作答→送審→核可/退回）<br/>＋設定規格（審核開關，D18）<br/>＋頁面路由
  PLAT-->>SURVEY: ok（平台不認識「問卷」是什麼）
  Note over PLAT: 平台只存：型別代號／狀態圖／設定規格
  USER->>PLAT: 完成一個 survey 任務
  PLAT->>PLAT: 查設定：這個租戶要不要審核？
  alt 審核開啟
    PLAT->>PLAT: 狀態 作答完成 → 待審核
    PLAT->>SURVEY: 通知審核者（走平台的指派與通知）
  else 審核關閉
    PLAT->>PLAT: 狀態 作答完成 → 完成
  end
```

**現況離目標比想像中近**：`app/task_survey/common/task_survey_code.py` 已經有 `TaskSurveyStatusCode`（UNFILLED／EDITING／UNDER_REVIEW／ADD_NOTES／COMPLETED）——**審核狀態本來就在**。差別只在於，推進這些狀態的邏輯目前散在 `workflow_execution_service`（1,679 行）裡，而不是由問卷插件自己申報。

::: {.callout .decided}
**「客戶 A 要審核、B 不要」怎麼走**

走 D18 設定 schema 申報制：問卷插件申報一個租戶層級的開關（`survey.review_required`，預設值寫在程式裡），值存在主程式的 `system_configs`。這是**現成的表、現成的 `(tenant_id, group, key)` 唯一鍵，不需要新開表**。平台推進狀態時查這個開關，決定要不要走 UNDER_REVIEW。

**交付成本：從「兩套客製程式」降為「一個勾選項」。**
:::

---

## 待決項（D 編號，請逐項拍板） {#pending nav="待決項"}

**這節在講什麼：9 個看不準的地方，每個附建議與理由，請決策者逐項裁。**

::: {.callout .pending}
**D-1｜job_service.py 這支 1,144 行的大檔案要怎麼分家？**

**現況**：稽核詞全 0（看起來是乾淨的通用候選），但檢測工具詞 88／問卷詞 73／指派詞 33——它其實把三種任務型別的處理全塞在同一支：`_replace_detection_tool_binding`（檢測）、`_reconcile_task_surveys`／`_replace_survey_snapshots`（問卷）、`_resolve_job_assignees`（指派）。

[**建議：切成三份，因為這三種型別的程式碼已經物理上相鄰，切開就直接變成三個 handler。** ① 任務 CRUD＋指派＋通知 → task 平台層；② `_replace_detection_tool_*`（約 400 行）→ 檢測插件申報的 handler；③ `_reconcile_task_surveys`／`_replace_survey_snapshots`（約 150 行）→ 問卷插件申報的 handler。**這支正是「任務型別 handler 申報制」最好的第一個示範。但這是第 3 步（平台化）的工，不是第 1 步（搬出來）的工**：第 1 步整支帶走，第 3 步再切三份。]{.rec}
:::

::: {.callout .pending}
**D-2｜project_service.py（812 行）跟 jedi-project 之間該怎麼理？**

**現況**：專案 CRUD 是通用動作，但 `get_project()` 會去拼 participants、living SSP 的 inventory 分類、devices/audit_systems、owner nickname（盤點 #5：教科書級的套餐窗口，DTO 的四個欄位來自四個不同模組）；participant 這個字出現 55 處。同時 `jedi-project`（399 行）已經被掏空，而且**反過來 import 主專案**（`jedi_project/app/dto/*.py` 有三支 import `app.common.dto.base_dto`，本棒實查才發現）。

[**建議：拆成兩支，因為 CRUD 和聚合查詢本來就是兩件事，混在一起才讓歸屬看不準。** ① 專案 CRUD／狀態 → 併入 task 平台包（吸收 jedi-project 當種子，同時把反向 import 修掉）；② `get_project()` 的聚合部分 → `infra/readmodel/` 或 `app/readmodel/`。這樣也順勢解掉 D15「jedi-project 疆界待決」——路 B 確立後，它不再是孤兒，而是平台包的種子。]{.rec}
:::

::: {.callout .pending}
**D-3｜job_import_service.py（464 行）該歸通用還是稽核？**

**現況**：任務批次匯入／匯出是通用動作，但 Excel 欄位裡含 AO 和控制項對應（participant 34／device 29／ap-ar 7）。

[**建議：算通用，但欄位定義改成申報制，因為機制和規格是可以分開的。** 平台提供匯入匯出的機制（讀 xlsx、驗證、批次建立），欄位規格由各任務型別的插件自己申報。如果覺得現在判斷太早，第 1 步就整支帶走留在稽核那邊，第 3 步再議。]{.rec}
:::

::: {.callout .pending}
**D-4｜4 支讀寫混合的 infra query 該怎麼處置？**

**現況**（實查）：`flow_control_job_repo_impl`（1,015 行／21 處寫入／13 表／3 schema）、`flow_control_project_repo_impl`（720／1／13）、`task_execution_query`（277／1／12）、`reverify_clone_query`（175／6／8）。readmodel 的規則寫死「會寫入的一律不收」，所以這 4 支進不去；但它們的 SQL 跨 3–4 個 schema，誤搬進套件就會靜默出錯（4.A 當初建 readmodel 就是為了防這個）。

[**建議：逐支切成兩半，因為讀和寫的搬遷風險完全不同。** 純讀的跨模組查詢方法抽出來放進 `infra/readmodel/`，會寫入的方法留在原模組。`reverify_clone_query` 是例外：它整支在做「跨模組搬運」（複製任務＋證據＋問卷作答＋設備關聯），本質是主程式的編排工作，**整支留在主程式**。逐支評估切法是第 2 步的工，**第 1 步先原地不動，並在套件的 README 誠實寫明「這 4 支不隨包走」**。]{.rec}
:::

::: {.callout .pending}
**D-5｜auditor_dashboard_query.py（106 行）要不要補進 readmodel？**

**現況**：純讀，跨 compliance／oscal 兩個 schema 四張表，形狀跟 4.A 已經搬過去的那五支完全一樣，只是當時漏收，還留在 `infra/flow_control/repository/`。同型的還有 `job_batch_complete_query.py`（271 行／純讀／compliance＋oscal）。

[**建議：兩支都補進 `infra/readmodel/`，因為它們跟已搬的五支同型，留在原地只是不一致。** 同時更新該目錄 `__init__.py` 的成員表。這是**低風險的機械工作，可以在第 1 步之前先單獨做掉**（純搬檔＋改 import，守衛測試就能證明）。]{.rec}
:::

::: {.callout .pending}
**D-6｜job_dto.py（361 行）裡那 6 處稽核欄位怎麼辦？**

**現況**：整體是任務 DTO（通用），但裡面含 6 處 ap／ar 欄位。

[**建議：整支當通用帶走，6 處稽核欄位改由插件擴充，因為為了 6 個欄位把整支判成稽核並不划算。** 做法比照 D8 的 JSONB 擴充容器／`project_extensions` 模式。第 1 步不動，第 2 步再處理。]{.rec}
:::

::: {.callout .pending}
**D-7｜review_service.py（123 行）和 review_marks 這張表該歸誰？**

**現況**：「審閱標記」這個機制本身是通用的（標記某個東西已審閱、記下誰在什麼時候標的），但被標記的東西是 control／AO，而且權限走 `ParticipantRole.MANAGER/REVIEWER`。

[**建議：算通用，因為「東西可以被標記為已審閱」是任何流程產品都會有的能力。** 被標記的對象用軟參照（D17 律②：只存識別碼、不建外鍵），由插件決定標的是什麼。如果反對意見成立（覺得現在就通用化太早），退回稽核那邊也不影響第 1 步。]{.rec}
:::

::: {.callout .pending}
**D-8｜已停用的東西怎麼處理：3 支 dark stub service，加上 oscal_audit_service（409 行）？**

**現況**：`control_service`／`control_group_service`／`assessment_object_service`（225 行）的 route 都已經在 `api/flow_control/__init__.py` 註解停用（2A dark stub 回空、前端沒有人在呼叫），只剩 DI 註冊還活著。`app/project/oscal_audit_service.py`（409 行）是舊的 AP-scoped 生命週期，新模型走 `/audit-round/*`——**但它仍然被 `AuditService` 和 `stage_advance_service` 引用**（實查有活的引用鏈），不是純粹的死程式。

[**建議：分兩類處理，因為「route 停了」和「程式沒人接線」是兩回事。** ① 3 支 dark stub：**搬遷前先退役**（route 已停、只剩 DI），省下 225 行的搬家成本——但**退役前必須先驗證拔掉 DI 註冊不會炸**（`flow_control_containers.py` 有 713 行的交叉引用）。② `oscal_audit_service`：**不動**，跟著稽核那邊走；它到底是不是殭屍需要另派一棒查證——route 面看起來停了，程式面卻還接著線，屬於「上了膛的槍」那一型。]{.rec}
:::

::: {.callout .pending}
**D-9｜workflow_execution_service.py（1,679 行）要怎麼切？**

**現況**：它是 flow-engine 這邊最大的一塊未定案——26 個依賴橫跨 participant／task_survey／notification／associations／module_frame 五個模組（盤點的「假聚合清單」#4 判定其中 12 個是 jedi_flow_engine 自家的，真正跨模組的只有 4 條線）。而 #11–14 這四條還在用的 route 全都吃它。

[**建議：算通用（歸 task 平台層），因為真正跨模組的只有 4 條線，改成 port 注入就乾淨了。** 四條線的改法：`TaskSurveyService`→改由任務型別 handler 申報、`NotificationService`→改走 `INotifier` port（D17 律③已經有樣板）、`ProcessParticipantService`／`ProjectControlParticipantService`→改用平台自己的指派能力（P12 完成後 participant 已經是套件）、`UserService`→改走身分名冊 port（D8 標準件）。**這支是第 2 步的主戰場**，第 1 步不動它。]{.rec}
:::

---

## 動工分步計畫（草案） {#plan nav="動工計畫"}

**這節在講什麼：四步怎麼走、每步做什麼、怎麼驗收。**

::: {.callout .warn}
**這是草案，四步的實際範圍以拍板後的 D 裁決為準。** 每一步各自都能交付、都能停下。
:::

### 第 1 步：搬出來（機械搬家，不做任何分家） {#plan-1 nav="-"}

**做什麼**：`app/flow_control/`＋`domain/flow_control/`＋`infra/flow_control/`＋`api/flow_control/` 整包搬成套件（照 jedi-iam「先搬家、後裝修」的先例），import 名稱不變、業務邏輯原樣帶走。D-4 那 4 支讀寫混合的 query **原地不動**，並在套件 README 誠實寫明不隨包走。

**驗證**（照 3.1 全額）：① URL 集合比對——搬前搬後 `flask routes` 的輸出逐字相同；② 守衛測試 `python -m pytest test/test_module_boundaries.py` 全綠（目前基準 **64 綠**）；③ 拔掉測試（D6 驗收核心）；④ 稽核主流程手測。

**風險**：這是整個案子最大的一次搬遷（226 個檔）。**建議先做 D-5（兩支純讀 query 補進 readmodel）和 D-8①（3 支 dark stub 退役）當熱身**——兩件都是低風險的機械工作，而且能把第 1 步要搬的面積縮小。

### 第 2 步：分家（通用部分下沉） {#plan-2 nav="-"}

**做什麼**：把 §5 判為通用的那 12 支（加上拍板後從「看不準」轉進來的）下沉（往下移到共用的底層）成 task 平台層；`workflow_execution_service` 那四條真正跨模組的線改成 port（D-9）；stage_advance／stage_rollback 從 flow_engine 移到稽核那邊。

**驗證**：手測同上；**另外加一條**——請 Claude 證明「平台層 grep 不到 poam／audit_round／assessment_plan／ssp」。這條是分家成功與否的機械判準。

### 第 3 步：改成申報制 {#plan-3 nav="-"}

**做什麼**：`FlowControlJobType` 從寫死的枚舉改成申報制；`job_service` 切成三個 handler（D-1）；問卷插件申報審核狀態機和設定規格（D18）。

**驗證**：問卷任務的「送審／核可／退回」走得通；**把設定開關關掉之後審核步驟消失**——這一條是「設定不是客製」的驗收核心。

### 第 4 步：打包 {#plan-4 nav="-"}

**做什麼**：task 平台包（吸收 jedi-project 種子）和稽核插件各自上 Nexus，照 [extraction-sop](extraction-sop.md) 的 D6/D7 標準（register 簽名／standalone harness／接入 README／拔掉測試）。

**驗證**：`poetry show` 看得到兩個版號；**拔掉稽核插件之後系統仍然起得來**（只是沒有稽核功能）。

::: {.callout .decided}
**順序刻意安排成「先分家、後打包」**——第三階段已經證明：邊界切乾淨之後再補外殼，是機械工。反過來先打包，會把「哪些算通用」的判斷錯誤鎖進套件版號裡，之後要改，貴十倍。
:::

---

## 邊界與凍結事項 {#limits nav="邊界"}

**這節在講什麼：哪些東西一個字都不能動，以及這份稿子刻意不碰的範圍。**

::: {.callout .crit}
**這些東西一個字都不能動（重申 D5）**

1. **error code 的字串值**（`GRC_404001` 等）——這是對外契約，前端 `error-code.json` 靠字串比對做多語系。改類別名稱可以（`GrcErrorCode`→`FlowControlErrorCode` 就做過），**改值不行**。
2. **對外 API 的 URL 路徑**——前端已經在用。所以 §6 判定「4 條 stage route 歸稽核」時，路徑 `/project/<p>/audit-round/<r>/stage/*` 維持原樣；§7 那 28 條 `api/project` 路徑也一樣。
3. **資料庫內容**。
:::

**其他邊界**：

- **project 本體不動**（那是最終要長成的樣子，不是這個階段的事）。
- **既有的跨模組外鍵不回頭全面拆除**（D17）——拆到誰才判誰。
- **這份稿子只做盤點與設計**，除了 jedi-project 虛掛依賴清理（純粹的依賴整理，已 commit）之外，不動任何程式碼。
- **看不準的一律寧可多列、不武斷**：這個 arc 已經有五次「卡片斷言被實查推翻」的紀錄，所以本稿把拿不準的全部列進待決，不自行拍板。

---

## 本稿與既有決策的關係 {#relations nav="決策關係"}

**這節在講什麼：這份稿子跟先前七項決策各是什麼關係。**

| 既有決策 | 本稿的關係 |
|---|---|
| **D5**（改名 flow_control，error code／API path 凍結） | 全篇遵守，§10 重申 |
| **D12**（oscal → flow_control 單向依賴） | 本稿把 flow_control 的形狀定下來，4.3（CM-1479）才有對象可驗 |
| **D15**（jedi-project 疆界待決） | **路 B 確立**：D-2 建議它成為 task 平台包的種子 |
| **D17**（資料關聯三律＋view 條款） | §8 的 view 歸屬表，就是該條款要求的人工列冊 |
| **D18**（設定 schema 申報制） | §9 的問卷審核開關，是該制的第二個實例（第一個是 `security_policy.py`） |
| **4.A 跨模組聚合正名**（`infra/readmodel/`） | D-4／D-5 是它的延伸——本稿實查發現兩支同型的漏收、四支讀寫混合待切 |
| **3.4 煞車裁決**（CM-1470 (a)+(c)） | §6 就是該裁決承諾的「route 歸屬併入第四階段流程疆界三方設計」，本稿交付 |
