---
title: FR-080 jedi-* 套件整併與服務化路線 — design
brand: Guidant AI · **FR-080** 套件整併與服務化路線
eyebrow: FR-080 · 設計定案 · 2026-09-09（承接 FR-069 終局）
h1: jedi-* 套件整併與服務化路線：25 支收成 20 支，每支可獨立部署、預設兩支分開跑
lede: 承接 FR-069 收官後的 25 支套件。決策者提出三個要求——**朝微服務模式、套件可在其他專案隨插即用、現況粒度太細**。本檔給出定案分類（25→20）、今天與目標的差距、以及從插件走到服務要補的七階。推理過程同步歸檔於 [`docs/analysis/2026-09-09-jedi-consolidation-25-to-20.md`](../../analysis/2026-09-09-jedi-consolidation-25-to-20.md)。
chips: [{text: 定案 25→20, kind: ok}, {text: 今天 0 支是服務, kind: crit}, {text: 七階補法・第 3 階為評估點, kind: accent}, {text: Notion 未開卡, kind: warn}]
footer: 所有數據為 2026-09-09 對 monorepo 與主專案的實查結果，非引用文件。
---

## 摘要 {#summary nav="摘要"}

::: statgrid
::: stat
[25 → 20]{.v}[套件數：合併 3 組、拆環 1 組、瘦身 1 支]{.k}
:::
::: {.stat .crit}
[0]{.v}[今天以服務身分在跑的套件]{.k}
:::
::: {.stat .accent}
[15]{.v}[七階補完後可獨立部署的套件]{.k}
:::
::: {.stat .ok}
[2]{.v}[預設分開部署的套件（detection、evidence）]{.k}
:::
:::

**五個結論**

1. **分類要用兩把尺**。第一把是子領域型態（地基／通用／核心），決定它是什麼；第二把是部署剖面（函式庫／進程內／可獨立），決定它跑在哪。一把尺量到底會出現「system-menu 併進 iam」這種讓所有插件被迫依賴 iam 的錯誤。
2. **25 支收成 20 支**：system-infra 五合一（config／menu／log／log-forwarding／notification）、asset 二合一（device／information-system）、detection 吞 remote-agent、participant 拆環不合併、common 搬走 `system_logs` 表。其餘 12 支不動。
3. **今天零支是服務**。FR-069 做的是模組化不是服務化：全部在同一進程、同一 DB、同一 transaction；零 broker、零 outbox；10 支有真獨立 harness 但沒有進 CI。
4. **「可獨立部署」是設計要求，「預設分開部署」是部署選擇**。七階補完後，除 5 支函式庫外的 15 支都要能用 import 模式或 API gateway 模式跑，套件碼一行不改；但今天只有 detection 與 evidence-classification 有分開跑的收益。iam、survey 技術上都能獨立，只是要等觸發條件。
5. **前三階（harness 進 CI、契約層、資料解耦）約 20 棒完成插件模式**，達成「其他專案隨插即用」；第 3 階收完停下評估第二個產品是否出現，再決定後四階（身分可攜、outbox worker、雙 adapter、detection 切出）。

**下一步**：第 0 階套件合併，順序 asset → system-infra → participant 拆環 → detection 吞 remote-agent。每組比照 FR-069 退役慣例（死名守衛、移出 `EXTRACTED_PACKAGES`、pin 改名、Nexus 發版等令）。

## 判準 {#criteria nav="判準"}

### 兩把尺

| 尺 | 問的問題 | 取值 | 為什麼不能少 |
|----|---------|------|------------|
| **子領域型態** | 它是什麼？換第二個產品還會要嗎？ | 地基／系統基礎建設／安全與部署／平台核心／領域積木 | 決定依賴方向（只能往下）與「誰會裝它」 |
| **部署剖面** | 它跑在哪？ | 函式庫（0 route，被 import）／進程內插件／可獨立部署 | 決定要不要為它付網路稅；函式庫沒有部署身分 |

### 合併的三個條件（全部成立才合）

1. **另一個產品會整包要它**：切到「一個業務能力」為止，不切到「一張表」。1 千行、1 張表的套件單獨存在是 nanoservice。
2. **資料自己擁有**：有 FK、天天 JOIN 的表必須同單位；跨單位只留軟參照。
3. **沒有任何人受害**：同操作者、同生命週期、彼此零 import 或單向 import。

### 「可獨立部署」與「預設分開部署」是兩件事

::: {.callout .decided}
**微服務的定義是「可獨立部署」，關鍵字是「可」**

業界定義（Newman）：微服務是可獨立部署的服務，不是「必須分開部署」。因此本檔對每支套件回答兩個問題：**能不能包成服務**（套件品質要求，七階補完後 15 支全部要能）與**該不該今天就分開跑**（部署選擇，看有沒有收益）。前面版本把「不該切」講成「不能切」，是錯的表述，本版已修正。
:::

## 定案分類 {#result nav="定案"}

```{.mermaid cap="圖 1 — 定案後 20 個單位的五層；依賴只能往下，同層互不認識。★ 為本次異動"}
%%{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 TB
  subgraph L5["⑤ 領域積木（GRC 專屬，挑用或換掉）"]
    OSCAL["jedi-oscal-v2<br/>函式庫"]
    AUDIT["jedi-compliance-audit"]
    SURVEY["jedi-survey"]
    DET["jedi-detection<br/>★ 吞 remote-agent"]
    EVID["jedi-evidence-classification"]
    DASH["jedi-ai-dashboard"]
    BOT["jedi-ai-bot"]
    ISSUE["jedi-issue"]
    ASSET["jedi-asset<br/>★ device＋information-system"]
    BULL["jedi-bulletin"]
  end
  subgraph L4["④ 平台核心（插座）"]
    TASK["jedi-task-platform"]
    PART["jedi-participant<br/>★ 拆 2 處反向 import"]
    FLOW["jedi-flow-engine<br/>函式庫"]
  end
  subgraph L3["③ 安全與部署"]
    IAM["jedi-iam"]
    LIC["jedi-license-runtime"]
    INTEG["jedi-integrity<br/>函式庫"]
    FILE["jedi-file-upload"]
  end
  subgraph L2["② 系統基礎建設"]
    INFRA["jedi-system-infra<br/>★ config＋menu＋log（含 forwarding、system_logs）＋notification"]
  end
  L1["① 地基　jedi-common（★ system_logs 表搬走）"]
  L5 --> L4 --> L3 --> L2 --> L1
```

### 總表

| 層 | 單位 | 收哪些 | 異動 | 部署剖面 | 一句話理由 |
|----|------|--------|------|---------|-----------|
| ① | jedi-common | `system_logs` 表搬走 | 瘦身 | 函式庫 | 地基不該擁有資料；那張表 FK 到 tenants，是地基反向依賴上層的唯一來源 |
| ② | **jedi-system-infra** | config＋menu＋log＋log-forwarding＋notification＋`system_logs` | **五合一** | 進程內；背景動作走 worker | 「系統開起來一定要有的基本功能」，三題判準見下 |
| ③ | jedi-iam | 不變 | 不動 | 可獨立（claims 模式） | 每 request 都要碰；今天的實作綁進程，改 claims 進 token 後即可獨立 |
| ③ | jedi-license-runtime | 不變 | 不動 | 進程內 | 執法在 request guard；生命週期綁外部 License Center，不併 iam |
| ③ | jedi-integrity | 不變 | 不動 | 函式庫 | 0 表 0 route 啟動鉤子；塞進 common 會逼內部工具也帶防竄改 |
| ③ | jedi-file-upload | 不變 | 不動 | 進程內（SeaweedFS SDK） | 被三支當函式庫 import；pyproject 依賴 iam，坐不到 ② |
| ④ | jedi-task-platform | 不變 | 不動 | 進程內 | 插座。要解的是它反向依賴 survey／device／information-system |
| ④ | jedi-participant | 不變 | **拆環** | 進程內 | 2 處 import task-platform 查專案存在，改 port 即拆環；port 早已定義只差接線 |
| ④ | jedi-flow-engine | 不變 | 不動 | 函式庫 | 0 route 通用 BPMN 引擎，綁進插座就沒人能單獨用 |
| ⑤ | jedi-oscal-v2 | 不變 | 不動 | 函式庫 | 主專案 129 處直接用，是共用字典不是稽核私產 |
| ⑤ | jedi-compliance-audit | 不變 | 不動 | 進程內 | 產品核心流程，與宿主同 transaction |
| ⑤ | jedi-survey | 不變 | 不動 | 可獨立（outbox 模式） | D10 已合成完整疆界、自有 schema；最可能單獨賣 |
| ⑤ | **jedi-detection** | detection＋remote-agent | **二合一** | **預設獨立** | remote-agent 套件本體 23 處 detection 字樣，唯一實質消費者是 detection |
| ⑤ | jedi-evidence-classification | 不變 | 不動 | **預設獨立** | LLM 長任務，資源型態不同 |
| ⑤ | jedi-ai-dashboard | 不變 | 不動 | 進程內 | 各插件自註冊資料源，註冊只能在進程內發生 |
| ⑤ | jedi-ai-bot | 不變 | 不動 | 進程內 | 與 evidence-classification 子領域不同（同類型≠同疆界），共用 LLM client 該下沉成函式庫 |
| ⑤ | jedi-issue | 不變 | 不動 | 進程內 | GL／GH 整合，6 表自成疆界 |
| ⑤ | **jedi-asset** | device＋information-system | **二合一** | 進程內 | 檔案佈局鏡像、都是資產清冊、都是半體；D11 裁的是「device 不併檢測」，不涵蓋這組 |
| ⑤ | jedi-bulletin | 不變 | 不動 | 進程內 | 沒公告系統照開；租戶內容走 RLS；D14 已裁保留 |

### system-infra 收多少：三題全過才進

決策者定義「系統開起來一定要有的基本功能」，翻成三題：① 開機必要？② 只依賴 common、不依賴 iam？③ 平台管理員操作、無 GRC 語意？

| 候選 | ① | ② | ③ | 裁定 | 關鍵證據 |
|------|:-:|:-:|:-:|------|---------|
| system-config | ✓ | ✓ | ✓ | 進 | 1 表；套件側消費者只有 survey 3 處、detection 1 處 |
| system-menu | ✓ | ✓ | ✓ | 進 | 1 表；套件側消費者 0 |
| log ＋ `system_logs` | ✓ | ✓ | ✓ | 進 | 寫入點在宿主 request 中介層，任何系統的基本稽核鏈 |
| log-forwarding | 半 | ✓ | ✓ | 進，當 log 子模組 | 沒有 log 它不存在；設定開關預設關 |
| notification | ✓ | ✓ | ✓ | 進 | **iam 的 pyproject 已宣告依賴它**（登入寄驗證碼）；合併後 iam → system-infra 由上往下 |
| bulletin | ✗ | ✓ | 半 | 不進 | 沒公告照開；租戶內容走 RLS |
| file-upload | 半 | **✗** | ✓ | 不進 | pyproject 依賴 iam，放進 ② 會成環 |
| integrity | 半 | ✓ | ✓ | 不進 | 只有接授權體系的產品要 |
| license-runtime | 半 | **✗** | ✓ | 不進 | raw SQL 直打 iam 的 view |

合併後約 6.3k 行、5 表、9 route。內部保留五個子模組各自 container，`register()` 一次全掛。

::: {.callout .decided}
**命名維持 `jedi-system-infra`**

符合「系統基礎建設」本意。代價是每支套件內都有 DDD 的 `infra/` 層，會出現 `jedi_system_infra/log/infra/` 這種路徑；外觀問題，README 註明即可。替代名 `jedi-system-base`。
:::

## 現況與差距 {#gap nav="現況差距"}

### 今天實際的部署形狀

::: {.callout .crit}
**🔴 25 支套件裡，以服務身分在跑的是零支**

全部在同一個 Flask 進程、同一個 DB、同一個 transaction。三條死因：零非同步邊界（無 broker／outbox／domain event）；單一 DB 加 30 張 RLS 表，session 變數由 iam 中介層每 request 注入；跨套件直接 import 是函式庫耦合不是 API 契約。
:::

真正以獨立進程跑的東西都不是 jedi-* 套件：`RUN_MODE=socketio`（同 image 的傳輸分身）、License Center（獨立 repo，license-runtime 是它的客戶端）、客戶主機上的 agent 執行檔（remote-agent 是伺服端）、SeaweedFS／Postgres／Redis（基礎設施）。

### 獨立啟動能力：harness 三級

**harness** 是每支套件自帶的最小獨立宿主（`harness/dev_app.py` ＋ `docker-compose.yml`）：起一個空 Flask app、連自己的空 DB、`register()` 掛上、宿主該給的 port 用 Fake 頂替、`test_client` 實打 route。它證明**零隱藏耦合**——比 grep 守衛強一級，因為對空庫跑，SQL 字串裡偷 JOIN 宿主表會直接炸。FR-069 D7 列為標配。

| 等級 | 內容 | 支數 | 哪幾支 |
|------|------|-----:|--------|
| **A 真獨立** | compose 起自己的 Postgres、`create_all`、實打 route | 10 | iam（空庫 seed 後 `POST /login` 拿真 JWT）、file-upload、flow-engine、bulletin、device、information-system、license-runtime、log-forwarding、remote-agent、ai-bot |
| **B 吃假的** | 有 dev_app，port 全用 Fake，沒有自己的 DB | 7 | participant、compliance-audit、evidence-classification、notification、integrity、system-config、ai-dashboard |
| **C 沒有** | — | 8 | common（合理）、oscal-v2、**detection**、issue、log、survey、system-menu、task-platform |

::: {.callout .warn}
**兩個未兌現的承諾**

① **harness 沒進 CI**。D7 寫「由 CI 獨立執行」，monorepo 零 CI 檔。手動跑的證明三個月內壞掉沒人知道。
② **最該服務化的 detection 是 C 級**。它 147 檔、依賴四支兄弟套件，最難寫 harness，也最沒證明自己能獨立活著。服務化第一步不是切，是先補 harness，那一步會逼出它的 port 化。
:::

### 有 Flask 殼就能做事嗎：差四樣

harness 只證明「活著」，離「有用」差四樣，每樣對應七階中的一階：

| 缺的東西 | 宿主裡誰供應 | 單獨跑時 | 補法 |
|---------|------------|---------|------|
| DB 加 schema | 宿主庫、migration 已套、RLS 已建 | `create_all` 空庫——沒 RLS、沒 seed、沒 migration 歷史 | 第 3 階 |
| 身分脈絡 | iam 中介層每 request 查 users 表建 context | 沒有 users 表建不出 context，harness 用 `_FakeGuard` 放行 | 第 4 階 |
| port 實作 | 宿主接線盤接上真服務 | 只有一種實作：進程內直呼；接線盤 `requests.`／`httpx.` 為零 | 第 2、6 階 |
| 呼叫者 | 宿主 route 或其他 service 直接 call | 沒有東西打它；FE 只認宿主一個 base URL | 第 6 階 |

## 從插件到服務：七階 {#stages nav="七階"}

決策者要的效果拆成三個目標，需要的深度不同。**A、B 是插件模式，C 是微服務模式；A 不需要等 C。**

| 目標 | 一句話 | 補到第幾階 |
|------|--------|-----------|
| **A** 其他專案裝上就有 API | `pip install` 幾支、寫一支 app_factory、起得來 | 1～3 |
| **B** 插件可拔可換 | 拔一支宿主照跑；換同 port 實作宿主不改 | 2～3 |
| **C** 套件可搬到另一進程跑 | 同一份碼，宿主改一個設定就從 import 變 HTTP | 4～7 |

```{.mermaid cap="圖 2 — 七階補法：前三階完成插件模式，第 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'}}}%%
flowchart TB
  S0["第 0 階　套件合併 25→20"]
  subgraph PLUGIN["插件模式（目標 A、B）"]
    S1["第 1 階　harness 全面 A 級 ＋ 進 CI"]
    S2["第 2 階　port 收斂成契約層 jedi-contracts"]
    S3["第 3 階　資料層解耦 ＋ 宿主範本"]
  end
  EVAL{"評估點：第二個產品<br/>來了嗎？要哪幾支？"}
  subgraph MICRO["微服務模式（目標 C）"]
    S4["第 4 階　身分可攜（claims 進 token）"]
    S5["第 5 階　Postgres outbox ＋ RUN_MODE=worker"]
    S6["第 6 階　雙 adapter ＋ 宿主薄連接器"]
    S7["第 7 階　detection 第一刀"]
  end
  S0 --> S1 --> S2 --> S3 --> EVAL
  EVAL -->|要| S4 --> S5 --> S6 --> S7
  EVAL -->|不要／未定| STOP["停在插件模式<br/>已達成隨插即用"]
```

| 階 | 現況 | 補什麼 | 買到什麼 | 棒數 |
|----|------|--------|---------|-----:|
| **0 套件合併** | 25 支 | asset → system-infra → participant 拆環 → detection 吞 remote-agent | 25→20 | 4 |
| **1 harness ＋ CI** | A 10／B 7／C 8；零 CI | C 級 8 支補 compose＋dev_app；B 級 7 支補自己的 DB；monorepo 加 `.gitlab-ci.yml` 每支一 job | 每支天天證明自己活著 | 8～10 |
| **2 契約層** | port 散在 6 支套件共 27 支、語意重疊；接線盤散四處 | 開極薄套件 `jedi-contracts`（只放 Protocol 與 dataclass，零實作零依賴），收斂成約十支；跨單位 import 全改走契約；AST 守衛改按五層分組 | 換實作不改套件 | 3～4 |
| **3 資料解耦 ＋ 宿主範本** | 四條跨單位 FK；task-platform 擁有兩張認識插頭的表；只有 2 個 schema；migration 隨包只 7 支 | FK 改軟參照；插座兩張表改申報制（用既有 `register_job_type()`，只存 `(job_execution_id, plugin_key, ref_uid)` 通用表）；每單位一 schema；migration 全隨包；抽 `jedi-host-template`（最小 app_factory 掛 system-infra＋iam）與 meta-package `jedi-platform-suite` | **插件模式完成，其他專案可裝** | 4～5 |
| **4 身分可攜** | JWT 只帶 uid；每 request 查 users 表 | 用 `jwt_mw.py` 既有 `additional_claims_loader` 把 tenant／org paths／capabilities 塞進 token；改 RS256 公鑰驗簽；契約層加 claims-only 實作；撤銷走 Redis 或短 TTL | 第二進程不打 iam 就能建 context 套 RLS | 2 |
| **5 非同步邊界** | 零 outbox；`RUN_MODE` 只有 api／socketio；notification 全同步；detection 長任務用 Thread | jedi-common 加 outbox 表與 `publish()`；`main.py` 加 `RUN_MODE=worker` 輪詢 outbox（`FOR UPDATE SKIP LOCKED`，**不引 broker**）；先搬寄信、log 轉發、detection 長任務 | 第一個業務 worker，同 image | 3 |
| **6 雙 adapter ＋ 連接器** | port 只有進程內實作 | 為 detection、evidence 加 `adapters/http/`（OpenAPI 已由 flask-apispec 產出）；宿主設定 `UNIT_MODE_<name>=inproc|remote`；宿主薄連接器 `jedi-<name>-connector` 做申報與轉呼叫 | 同一份碼兩種跑法 | 2 |
| **7 detection 切出** | — | 自己的 schema、容器、worker；agent ingress 走自己的 port；宿主用 connector 接。evidence 照抄 | **微服務模式完成** | 2～3 |

**工作量**：第 0～3 階約 20 棒達成「隨插即用」；第 4～7 階約 10 棒。**第 3 階收完停下評估**——那時才知道第二個產品來不來、要哪幾支。

::: {.callout .crit}
**🔴 三個會讓整件事白做的風險**

① harness 沒進 CI——第 2～7 階建立的一切半年內靜默腐化。
② `jedi-contracts` 長成第二個 jedi-common——必須零實作零依賴，AST 守衛擋它 import 任何非 typing 的東西。
③ 為了 C 提前引 broker——這個規模 Kafka／RabbitMQ 是純負債，落地版多一個容器。Postgres outbox 撐到第二個產品出現都夠。
:::

## 終局形狀 {#final nav="終局"}

### 每支套件的部署地位

| 地位 | 誰 | 意思 |
|------|-----|------|
| **函式庫，不適用** | common、contracts、oscal-v2、flow-engine、integrity | 0 route，被 import，沒有部署身分 |
| **預設分開部署** | detection、evidence-classification | 長任務、資源型態不同、（detection）mTLS 獨立安全面，今天就有收益 |
| **有觸發條件時分開** | iam、survey、ai-bot、system-infra 的 log 轉發面 | 機制備好，觸發條件成立就切，見下表 |
| **可以但沒場景** | task-platform、participant、compliance-audit、asset、issue、bulletin、ai-dashboard、license-runtime、system-infra 本體 | 七階補完技術上都能切；與宿主同 transaction 寫入，切出去換分散式一致性問題、零收益 |

### iam 與 survey 怎麼獨立

::: grid2
::: {.card .ok}
#### iam：改 claims 模式，是 20 支裡最有資格獨立的

今天「每 request 拿 uid 查 users 表」綁死進程，那是實作不是本質。改業界標準兩段式：**發 token**時把 tenant／org paths／capabilities 塞進 claims；**驗 token**由每個消費者自己用公鑰驗簽、從 claims 建 context、注入 RLS——不打 iam；撤銷走共享 Redis；只有名冊查詢（非 request path）走 HTTP 且可快取。這就是 Keycloak 模式，改完 API 進程每 request 只做本地驗簽，延遲比查 DB 還低。
:::
::: {.card .ok}
#### survey：資料早已獨立，缺的是跨界寫入

自有 schema、14 表自成疆界。綁進程的只有兩件事：**向插座申報任務型別**——第 6 階的 `jedi-survey-connector` 在宿主進程內申報；**作答完成更新任務狀態**——今天同 transaction 寫兩邊，改成 survey 寫自己的表加 outbox 事件，task-platform 消費事件更新 job。唯一代價是最終一致的幾百毫秒延遲，問卷場景可接受。
:::
:::

### 兩種模式的實作形式

```
契約層 jedi-contracts
  IProjectDirectory（Protocol）
       ↑ 實作 A                       ↑ 實作 B
  InProcProjectDirectory           HttpProjectDirectory
  （直接 call task-platform）      （打 gateway /api/projects/{uid}）

宿主接線：UNIT_MODE_survey=inproc | remote
```

- **import 模式**：`pip install jedi-survey`，`register()` 掛 route，port 注入實作 A。今天的樣子。
- **gateway 模式**：survey 跑自己的容器，gateway（落地版用已在 stack 裡的 Traefik／nginx）把 `/api/survey/*` 路由過去；宿主只裝 connector 申報，port 注入實作 B。
- **套件碼一行不改**，它只認 Protocol。

### 預設部署的容器

| 容器 | 內容 | 性質 |
|------|------|------|
| `guidant-api` | 單體 API：進程內套件加宿主骨幹 | 模組化單體 |
| `guidant-socketio` | 同 image，傳輸進程 | 傳輸分身 |
| `guidant-worker` | 同 image，outbox 消費者 | 單體的第二進程 |
| `guidant-detection` | detection 服務加自己的 worker | 真微服務 ① |
| `guidant-evidence` | evidence-classification 服務 | 真微服務 ② |
| `license-center` | 獨立 repo | 外部真服務 |
| db／redis／seaweedfs | 基礎設施 | 現成品 |

從三個容器變七個。**一個模組化單體帶三個分身，加兩個真服務，加一個外部簽發系統**，是這個團隊規模與產品形態下的合理上限。

### 觸發條件

| 套件 | 什麼情況升格為獨立部署 |
|------|---------------------|
| iam | 第二個產品要**共用同一套帳號**。屆時先評估換 Keycloak，自建服務是次選 |
| survey | 出現「只要問卷、不要 GRC」的第二個產品且要獨立部署 |
| ai-bot | 對話量大到拖垮 API 進程延遲；它 0 表純出站，技術上最容易切 |
| system-infra log 轉發面 | 客戶要求 SIEM 轉發與主系統隔離部署；升為獨立容器但仍不是業務服務 |

## 反悔條件 {#revisit nav="反悔"}

| 定案 | 什麼情況要重議 |
|------|--------------|
| remote-agent 併入 detection | 出現第二個非檢測的 agent 任務型別，且不需要碰 `tool_id` |
| notification 併入 system-infra | 出現「只要寄信、不要設定／選單／log」的純 worker 產品 |
| ai 三支不合 | evidence-classification 與 ai-bot 開始共用 LLM client 以外的東西（同一套 prompt 模板、同一張表） |
| bulletin 獨立 | D14 反悔——決策者放棄公告的複用前景 |
| 只預設兩個獨立部署 | 某支進程內套件出現實測資源競爭（例如 SSP 匯入匯出拖垮 request 延遲） |
| 20 支不再合 | 第二個消費產品實際裝機時出現「裝 N 支才湊出一個功能」的抱怨 |
| 不引 broker | outbox 輪詢延遲或吞吐在實測中不夠用 |

---

## 附錄 A：實查數據 {#appendix-a nav="附錄 A 實查"}

### 規模與跨套件 import

| 套件 | 行數 | 表 | route | 對兄弟套件 import（非 common） |
|------|-----:|---:|------:|------|
| jedi-detection | 17,013 | 11 | 35 | remote-agent 6／flow-engine 5／iam 2／file-upload 1 |
| jedi-oscal-v2 | 15,837 | 45 | 0 | — |
| jedi-iam | 15,147 | 17 | 41 | — |
| jedi-compliance-audit | 13,604 | 9 | 5 | oscal-v2 36／task-platform 13／flow-engine 10／participant 8／iam 5 |
| jedi-survey | 11,360 | 14 | 32 | participant 7／iam 7／flow-engine 2／device 1／task-platform 1 |
| jedi-flow-engine | 6,791 | 5 | 0 | — |
| jedi-participant | 5,310 | 6 | 20 | flow-engine 3／task-platform 2 |
| jedi-evidence-classification | 3,850 | 2 | 10 | — |
| jedi-issue | 3,634 | 6 | 1 | file-upload 9 |
| jedi-license-runtime | 3,608 | 2 | 1 | raw SQL 直打 iam `v_user_capabilities` |
| jedi-common | 3,476 | 1 | 0 | — |
| jedi-task-platform | 3,450 | 5 | 10 | participant 2 |
| jedi-remote-agent | 2,824 | 3 | 1 | — |
| jedi-integrity | 2,768 | 0 | 0 | — |
| jedi-file-upload | 2,273 | 1 | 7 | — |
| jedi-ai-dashboard | 2,097 | 0 | 2 | — |
| jedi-log-forwarding | 2,065 | 1 | 1 | — |
| jedi-log | 1,315 | 1 | 2 | — |
| jedi-system-menu | 1,203 | 1 | 5 | — |
| jedi-information-system | 1,146 | 1 | 0 | — |
| jedi-system-config | 988 | 1 | 0 | — |
| jedi-notification | 800 | 0 | 1 | — |
| jedi-device | 780 | 1 | 0 | — |
| jedi-bulletin | 656 | 1 | 0 | — |
| jedi-ai-bot | 516 | 0 | 1 | — |

### 跨套件 FK 與 pyproject 相依

| 來源 | 目標 | 意義 |
|------|------|------|
| common → `tenants`、`org_units`（iam） | FK | 地基反向依賴上層 |
| task-platform → `users`×2、`org_units`×2（iam）、`devices`（device） | FK | 第 3 階改軟參照 |
| compliance-audit → `upload_files`（file-upload） | FK | 第 3 階改軟參照 |
| task-platform → survey、device、information-system | pyproject | 插座反向依賴插頭 |
| iam → notification | pyproject | notification 可坐 iam 下面 |
| file-upload → iam | pyproject | file-upload 不能坐 iam 下面 |
| participant → task-platform（2 處 import） | 程式碼 | 循環相依，見附錄 B |

### 執行期事實

- `jedi_iam/middleware/jwt_mw.py` 每 request 呼叫 user_service 查 DB 建 `UserContextDTO`；JWT 只帶 `identity=user_uid`（`login_service.py:132`）
- `api_logs` 寫入點在主專案 `common/middleware/app_mw.py`，同步 `session.add`
- 零 broker／outbox／domain event；DB 只有 `config`、`survey` 兩個 schema；30 張表有 RLS policy
- port 分兩處：jedi-common 3 支身分名冊 resolver；六支套件各自 27 支（participant 6、detection 5、compliance-audit 5、task-platform 5、survey 4、license-runtime 2）；宿主接線盤 `requests.`／`httpx.` 為零
- migration 隨包只 7 支：detection、evidence-classification、file-upload、license-runtime、log-forwarding、remote-agent、participant
- remote-agent 套件本體：`detection` 23 次、`tool_id` 13 次、`scan` 7 次；消費者 detection 5 處、主專案 `app/remote_agent` 3 處
- notification 實際內容：SMTP／Discord／Telegram 三個出站 adapter 加工廠
- 主專案 readmodel 有 22 個 JOIN 的跨疆界查詢（`ssp_control_implementation_query.py`），屬骨幹不隨套件走

### 衛生問題

- iam、file-upload、flow-engine 的 pyproject 仍宣告已退役的 jedi-auth／login／mfa／captcha
- survey、flow-engine 宣告了叫 `jedi-python-package` 的依賴，疑似誤填
- 主專案 `infra/` 仍有 `__tablename__ = "workflow_executions"` 的 model，與 flow-engine 同名
- `poams` 在 oscal-v2 與 compliance-audit 各定義一次、`roles` 在 iam 與 oscal-v2 各定義一次，需確認是同表兩 ORM 還是不同 schema

## 附錄 B：推演過程與分歧點 {#appendix-b nav="附錄 B 推演"}

### 四輪推演

| 輪 | 判準 | 結果 | 為什麼被否決 |
|----|------|------|------------|
| 09-07 提案 | 換產品原封裝 | 25→20 | 判準是插件粒度，會留六支千行級獨立單位；integrity／license-runtime／remote-agent「刻意不合」的理由框架被本檔推翻 |
| 第 1 輪 | 部署單位（FK／同步密度／擴縮型態） | 25→14 | iam 吞 config／menu 讓所有插件被迫依賴 iam，方向反了；flow-engine、oscal-v2 是函式庫，綁進插件就沒人能單獨用 |
| 第 2 輪 | 雙模式可行性 | 25→12 | 同上且更嚴重，核心與服務混成一欄 |
| 第 3 輪 | 型態＋剖面兩把尺 | 25→19 | 大方向成立，三處判錯：file-upload 判微服務（實為函式庫耦合）、notification 不併 system-infra（iam pyproject 證據推翻）、participant 併 task-platform（過重） |
| 第 4 輪（定案） | 對照外部 AI 表逐項實查 | 25→20 | — |

### 外部 AI 分類表逐項對照

決策者提供一份外部 AI 的表並明言「只信一半」。裁決依據是實查證據，不是誰說的：

| 項目 | 外部 AI | 定案 | 裁決 | 依據 |
|------|--------|------|------|------|
| system-infra 五合一 | 五合一 | 五合一 | 採納 | 三題實查全過 |
| asset 二合一 | 二合一 | 二合一 | 採納 | 三方一致 |
| ai 三支 | 各自不動（無理由） | 各自不動 | 採納結論、補理由 | 同類型≠同疆界 |
| remote-agent | 獨立，「mTLS 派工基礎設施」 | 併入 detection | **否決** | 套件本體 23 處 detection 字樣 |
| bulletin | 「獨立或併進 infra」 | 獨立 | 否決模糊 | 過不了開機必要；D14 已裁 |
| file-upload | 獨立，「issue 會被迫依賴整支 infra」 | 獨立 | 採納結論、否決理由 | issue 本來就依賴 jedi-log；真正理由是 file-upload 依賴 iam |
| participant | 不動 | 拆環 | 部分否決 | 他沒看到循環相依 |
| common | 不動 | 瘦身 | 否決 | `system_logs` FK 到 tenants |

外部表完全沒碰「能不能雙模式」的門檻（port 化、軟參照、outbox、插座反向依賴）。

### participant 為什麼當初獨立、循環相依怎麼來的

| 層次 | 理由 | 今天還成立 |
|------|------|-----------|
| 圖論 | FR-069 開案盤點的六模組核心環（760 檔）中，participant 是最大 hub（被 60+ 處 import）但 out-degree 僅 8。**能不能抽看 out-degree**——import 名不變，消費端零改動。抽掉它核心環最粗兩條邊自動消失，後續拆解才有路 | 成立，已兌現 |
| 疆界 | 不是身分（管「你是誰」）、不是流程（管「下一步」），它管「這一步歸誰」；換產品規則不變 | 成立 |
| 時序 | participant 抽出是 2026-08-31（CM-1477），當時只有 399 行掏空的舊 `jedi-project`；task-platform **隔天** 2026-09-01 才合成（CM-1492）。抽出那刻沒有任務平台可併 | 歷史事實 |

::: {.callout .crit}
**循環相依不是設計，是搬家收尾的漏網之魚**

抽出當天 `project_participant_service.py` 直接 import 舊 `jedi_project` 的 `ProjectDomainService`；**同一時刻 `domain/ports.py:84` 已定義 `IProjectDirectory` port**，README 也寫「專案在哪：走 port」。設計者知道該走 port、port 也建好了，但 `_resolve_project_id_from_uid()` 沒接。隔天 CM-1492 改名的字串替換把這句變成對 `jedi_task_platform` 的直接依賴；同棒 task-platform 反向 import participant 2 處。兩邊各自合理的一步，合起來就是環。這是「先搬家、後裝修」的已知代價，CM-1484 已用同樣方式償還過 iam 那條債。
:::

## 附錄 C：與既有文件的關係 {#appendix-c nav="附錄 C 文件"}

- **推翻**：`architecture-handbook/jedi-module-map-proposal.html`（09-07，未 commit）的 remote-agent 獨立與「刻意不合」理由框架
- **修正**：`architecture-handbook/dependency-map.md` 寫「功能套件彼此零直接依賴」——第四階段後已不成立，實況見附錄 A
- **補充**：`architecture-handbook/package-taxonomy.md` 的「同族≠同疆界」仍有效，本檔加第三個概念「同單位」
- **不推翻**：`architecture-handbook/project-platform-decision.md`（插座 vs 插頭），是第 3 階申報制的依據
- **D7 未兌現**：harness 進 CI，第 1 階補
- **D16 需改寫**：「只准依賴 common」→「單位內自由、跨單位走契約層」，隨第 2 階改 design.md
- **D6 未落實**：migration 隨包只 7 支，第 3 階補齊
