---
title: 套件分層與判準 — 五層、依賴只准往下
brand: Guidant AI · **架構手冊**
eyebrow: 架構手冊 · 套件分層 · living
h1: 五層分類、合併與拆分判準、四個可量指標
lede: 這頁回答「**這支套件該歸哪一層、兩支該不該合、進步有沒有量得出來**」。分層決定誰可以依賴誰（只准往下），判準決定一支套件該管多大範圍（合不合），指標決定驗收（做完有沒有變好）。來源是 [FR-080 第 2 棒分析報告](../FR-080-2609-jedi-consolidation-and-service-path/design.md)。
chips: [{text: 五層, kind: accent}, {text: 25 → 21, kind: accent}, {text: 依賴只准往下, kind: warn}, {text: 四個可量指標, kind: ok}]
---

::: {.callout .pending}
**②層的名字「橫切」待決策者定案。** 第 2 棒討論中用過「中介層」「橫切」兩個名字，決策者說先放著、之後再定。本頁一律用**橫切**，定案後全頁一次改名。層的內容與判準不受名字影響。
:::

> 這頁是 [FR-080 第 2 棒分析報告](../FR-080-2609-jedi-consolidation-and-service-path/design.md) 第 1、2 節的**長期規範版**：分析報告記的是「當時怎麼判的、討論中推翻了什麼」，這頁記的是「以後照這個判」。
> 與 [套件分類地圖](package-taxonomy.md) 的分工：那頁把套件分成**十族**（誰會裝、壞了會有什麼後果），是給「要加新能力時該照哪個先例抄」用的；這頁分**五層**（誰可以依賴誰），是給「這支能不能用那支」用的。族與層是兩個獨立的切法，同一族的套件可能分在不同層。

## 五層，依賴只准往下 {#layers nav="五層"}

「層」決定的是誰可以依賴誰。判一支套件屬於哪一層，就問兩個問題：**拔掉它會怎樣**、**換一個完全不同的產品還要不要它**。

| 層 | 業界叫法 | 它是什麼 | 壞了會怎樣 | 換一個產品 |
|---|---|---|---|---|
| ① 基座 | kernel / core library（核心函式庫） | 所有東西踩在它上面：資料庫連線與交易、例外格式、查詢底座、代表「現在是誰在操作」的資料結構 | 全停 | 一定帶著走，不能有第二套 |
| ② 橫切 | middleware / cross-cutting concern（橫切關注點） | 站在功能外面的路上，每個請求都會經過、但功能本身不會去呼叫它 | 全停或全開 | 帶著走，實作可以換 |
| ③ 平台服務 | platform / shared service（共用服務） | 每個業務都會用、但自己不含業務邏輯的「功能」：寄信、存檔案、系統設定、下拉選單、公告、資產清冊 | 該功能停，其他照跑 | 選裝 |
| ③.5 平台骨架 | task engine（任務引擎，像 Jira 的 issue 引擎、ServiceNow 的 task 表） | 本產品獨有：專案、任務、指派、流程，是所有業務模組插上去的插座 | 任務推不動 | 只有同類型的產品才要 |
| ④ 業務 | feature / domain module（功能模組） | 這個產品之所以是這個產品的東西 | 該業務停 | 只有合規稽核類的產品要 |

::: {.callout .crit}
**依賴方向是硬規則：④ → ③.5 → ③ → ② → ①，永遠不能反過來。**

①是純函式庫不是插件，也是唯一允許被任何一層直接依賴的。②③④三層共同的包裝方式叫**插件**——「插件」不是第五層，是這三層都用同一種裝法（Odoo 那套系統裡的 base、mail、account 三個模組，裝法完全一樣）。三層的差別只在依賴方向。
:::

**「middleware」這個字有兩個意思要分清楚**：狹義是指請求進來時排隊經過的攔截點（Flask 的 `before_request`）；廣義是指夾在基座與業務之間的通用層。`jedi-common` 是①基座，比 middleware 更底層，不屬於②。

## ②橫切層怎麼運作：誰寫、誰掛、誰宣告 {#cross-cutting nav="②橫切怎麼運作"}

②不是「一包公共工具箱」，是一排**各自獨立、各管一件事**的套件。它們做的動作只有三種：

| 動作 | 做什麼 | 實例 |
|---|---|---|
| 擋下 | 不符合就攔住，請求根本進不到功能裡 | iam 驗證登入權杖、license 到期後轉唯讀、integrity 的啟動閘門與鎖定機制 |
| 補資訊 | 幫請求帶上功能需要、但功能不該自己去查的東西 | iam 組出「現在是誰在操作」的資料結構、開交易時注入租戶路徑讓資料庫做隔離、跨網域請求的標頭 |
| 記錄 | 看過就好，不改也不擋 | log 的 API 存取紀錄、稽核事件、integrity 每 4 小時抽查一次 |

四個問題各有各的歸屬，**不可以互相跑到對方的位置**：

| 問題 | 歸哪裡 | 實例 |
|---|---|---|
| 中介處理本身怎麼做 | ②層，各自一支套件 | jedi-iam、jedi-log、jedi-integrity、jedi-license-runtime |
| 這個產品要掛哪幾支 | 主專案的接線盤 | `core/app_factory.py`、`common/authz/__init__.py` 的決策表、`config/app_modules.py` |
| 這支功能要被哪幾個管 | 功能自己的 API 上一行宣告 | `@auth_required`、`require_capability("xxx")`、`license_guard.require("ai-dashboard")` |
| 中介之間共用的演算法 | ①基座，或另外開一個小套件 | 建議把數位簽章驗證（Ed25519）與機器指紋計算從 license-runtime 拉出來 |

::: {.callout .crit}
**功能只能「挑」不能「自己寫」。** FR-048 之前，「這個人有沒有權限」的檢查在四個地方各寫了一套，收斂成 `common/authz` 一套之後，CLAUDE.md 就明文禁止再另立新的檢查函式，講的就是這條規則。唯一例外是**要先查出這是誰的資料才知道判誰**的那種檢查（例如判斷這人是不是這個專案的負責人），查資料那一步在功能自己的服務層，但真正的判定仍然呼叫 `common/authz`。
:::

在插件的設計裡，**中介是「接口」**（port，就是套件對外宣告「我需要有人幫我做這件事」的一組函式，由主專案接上實作）：套件只宣告自己需要有人做登入驗證、授權檢查，主專案接線時把實作塞進來。四支有 API 的系統套件都刻意**不給守衛預設值、沒接線就拒絕掛載**——寧可服務起不來，也不能默默變成沒有任何保護的 API。

## 25 → 21 支歸位表 {#placement nav="歸位表"}

整併後 21 支各屬哪一層（合併理由見下一節的判準與分析報告第 3 節）：

| 層 | 套件 |
|---|---|
| ① 基座 | jedi-common |
| ② 橫切 | jedi-iam、jedi-log（含轉發）、jedi-integrity、jedi-license-runtime |
| ③ 平台服務 | jedi-notification、jedi-file-upload、jedi-system-core、jedi-asset、jedi-bulletin、jedi-issue、jedi-remote-agent、jedi-ai-bot、jedi-ai-dashboard |
| ③.5 平台骨架 | jedi-task-platform、jedi-flow-engine |
| ④ 業務 | jedi-oscal-v2、jedi-compliance-audit、jedi-survey、jedi-detection、jedi-evidence-classification |

**四組合併**（25 → 21）：

| 合併後 | 由誰組成 | 一句理由 |
|---|---|---|
| jedi-asset | jedi-device ＋ jedi-information-system | 決策者裁定；主專案的資產清冊表早就用一個「資產型別」欄位把設備與資訊系統當成同一張表的兩種型別 |
| jedi-task-platform | jedi-task-platform ＋ jedi-participant | 整個套件庫裡唯一一組互相 import 對方的循環；DEV 環境實查 12,479 筆指派全部對得上專案表 |
| jedi-system-core | jedi-system-config ＋ jedi-system-menu | 兩支都不到 1,200 行、都沒 import 別的套件、都沒外鍵、都只是字典型的增刪改查、常常同一批改 |
| jedi-log | jedi-log ＋ jedi-log-forwarding | 決策者裁定；兩支同屬②橫切層，「記錄」與「轉發到客戶的資安監控系統」是同一條日誌管線的上下游 |

**明確不併的三組**（看起來像但不是同一件事）：oscal-v2 與 compliance-audit（前者是國際合規標準的資料模型、任何合規產品都可能用，後者是本產品的稽核流程）、integrity 與 license-runtime（一個管這台機器有沒有被竄改、一個管這個客戶有沒有買授權）、detection 與 remote-agent（一個是業務、一個只是通道）。

**新產品起手式**：裝這五支就有登入、存取紀錄、系統設定、下拉選單、寄信——**jedi-common、jedi-iam、jedi-log、jedi-system-core、jedi-notification**。其餘依產品需要選裝。

## 以後新東西怎麼判：三個問題 {#how-to-judge nav="三問"}

::: grid2
::: {.card .ok}
#### ① 拔掉它會怎樣

系統全停 → ①②
只少一個功能 → ③④
:::
::: {.card .ok}
#### ② 換一個不做稽核的產品還要不要

要 → ③
不要 → ④
:::
:::

::: {.callout .ok}
**③ 它站在哪裡**——站在功能外面的路上、功能不會呼叫它 → **②橫切**，核心寫成請求攔截；功能會主動呼叫它做事 → **③④**。
:::

判完層之後形狀就定了，剩下只有一條硬規則：**依賴只准往下**。

## 判準：一把尺，全程只用這組 {#criteria nav="判準"}

「套件要不要合併」與「要不要分開部署」是兩件事，**用兩組不同的問題判，不可以互相借用**。講合併／不動時是在講**一支套件該管多大範圍**；講同一個行程／分開部署時是在講**部署形態**。

### 兩支套件該不該合併

滿足下面任一條就傾向合併：

| 問題 | 怎麼判 |
|---|---|
| **資料是不是同生共死？** | 兩邊的表會想建外鍵、同一筆交易裡會同時寫兩邊、少了一邊另一邊就沒意義 → 合。這來自 FR-069 訂的規則：想建外鍵就代表這兩張表該住在同一支套件裡。**只存對方 id 但不建外鍵的那種弱關聯不算。** |
| **換一個產品會不會被一起拿走？** | 一個跟稽核無關的新產品要裝 A，就一定也要裝 B（或反過來）→ 合。各自能單獨用 → 不合，即使功能看起來同一類 |
| **是不是同一個人在養？**（輔助條件） | 從來沒有各自演化過、每次都同一批改版、改一邊必改另一邊 → 支持合。**前兩條都不成立時，光憑這一條不能合** |

**反過來，只要有下面任一種情況就不合併**，即使上面三條都很像：

| 不合的情況 | 說明 |
|---|---|
| 對方是**很多人都依賴的供應者** | 合了之後會害一堆本來不依賴它的套件被迫依賴它 |
| 合了會變成**雜物袋** | 套件開機時要做一堆不相干的事 |
| **兩支不在同一層** | 橫切層與功能層不混，層不同就不合 |

::: {.callout .warn}
**「跨層不合」這條是 FR-080 討論中新增的，因為犯過兩次同型的錯**：一次判「log 併進 system-core」（log 是②橫切、config 與 menu 是③功能）；一次差點拿「轉發可以失敗」這個**部署理由**來當**合併理由**。

**判「該管多大範圍」跟判「要不要分開跑」是兩把尺，不可以互相借用。**
:::

### 一支套件能不能分開部署

| 問題 | 怎麼判 |
|---|---|
| **資料交換能不能全部改成網路呼叫？** | 它跟主專案之間的資料關聯要能全部改成「網路呼叫 ＋ 只存對方 id 的弱關聯」。有跨界外鍵就不行，得先改掉 |
| **有沒有獨立的生命週期理由？** | 不同的擴縮型態（吃 CPU／吃 I/O／要跑很久）、不同的發版節奏、不同的安全邊界、不同的操作者 |
| **分開之後哪些查詢會斷？** | 主專案有哪些「一次撈很多張表」的查詢會斷掉？能不能改成呼叫 API 之後在程式裡拼起來，或另外做一份專門給查詢用的資料表？ |

::: {.callout .crit}
**沒有第二條理由（獨立的生命週期）就不分開部署。** 微服務的成本（網路、資料一致性、部署、監控）要有理由才值得付。FR-080 的結論是**這一輪只做插件，微服務降級為以後再看的路線圖**。
:::

## 指標基線（2026-09-09） {#metrics nav="指標基線"}

四個可以量的指標，由 `scripts/deliverables/plugin_metrics.py` 實際算出來。**後面每一棒做完都跑一次然後回寫**，這樣「有沒有進步」是量出來的，不是講出來的。

```bash
# 預設路徑（BE ＝ 本 repo，套件庫 ＝ jedi-python-package）
python scripts/deliverables/plugin_metrics.py

# 指定路徑 / 輸出成機器可讀的 JSON
python scripts/deliverables/plugin_metrics.py --be <BE 根目錄> --mono <套件庫根目錄> --json
```

| 指標 | 這個數字在量什麼 | 基線（2026-09-09） | 目標 |
|---|---|---|---|
| (a) 套件之間直接 import 對方的處數 | 一支套件直接 import 另一支的類別，換系統就會斷 | **120** | 只剩 compliance-audit 對 oscal-v2 那一批（刻意保留） |
| (b) 宣告了卻沒人用的接口 | 套件宣告「我需要有人幫我做這件事」但自己一次都沒呼叫 | **16**（其中樣板 10） | 0 |
| (c) 主專案接線程式的行數 | 主專案裡「把套件的接口接上實作」那些程式 | **7,457** | 下降（合併會消掉一部分，不會歸零） |
| (d) 裝上就有 API 的套件數 | 註冊一行、API 就長出來、主專案不用自己寫業務 | **17**（機械判準）／12（嚴格判準） | 19 支左右（flow-engine 與純函式庫除外） |

**(a) 分布**：compliance-audit 72、survey 18、detection 14、issue 9、participant 5、task-platform 2。計算時排除 `jedi_common`（它是①基座，允許被任何人直接依賴）與套件對自己的 import。

::: {.callout .warn}
**(b) 與 (d) 跟第 2 棒手寫的數字對不上，是「兩邊量的東西不一樣」不是「算錯」，所以兩種讀法都留著。**

- **(b) 腳本算出 16、第 2 棒寫 10**：第 2 棒是**手工列**五支有殼沒肉套件的「讀設定」與「發通知」兩個樣板接口（5×2＝10）；腳本是**系統性掃描**，那 10 個全部命中（腳本輸出會標 ★），另外多抓到 6 個：detection 的 `IEvidenceSink`／`IAgentDirectory`、participant 的 `IProjectDirectory`／`IAuditLogger`、task-platform 的 `IProjectRoleGuard`／`IIdentityGuard`。這 6 個逐一實查確認**套件源碼裡一次都沒用到**——它們的轉接器欄位寫成 `Optional[Any]`，連型別標註都沒用上那個抽象類別，所以宣告了等於沒宣告。**16 是實況，10 是它的子集。**
- **(d) 腳本算出 17、第 2 棒寫 12**：卡片訂的機械判準是「有 `api/` 目錄 ＋ 有掛路由 ＋ 主專案有掛這個插件」，這條會把「能用但要背一串」那一等（compliance-audit、detection、survey、participant、task-platform）也算進來——它們機械上確實註冊得起來，只是對別支的依賴走的是直接 import 不是接口。第 2 棒的 12 是「拿了就能用」那一等，另外含 `jedi-integrity`（它**根本沒有 `api/` 目錄**，是啟動時的閘門不是 API 插件，機械判準抓不到）。**兩個數字量的是不同東西**：17 量的是「機械上裝得起來」，12 量的是「裝起來不用連帶背一串依賴」。
:::

**(c) 怎麼算出來的**：`di_containers/` 底下全部 `.py` 共 5,554 行 ＋ 15 支轉接／接線檔共 1,903 行 ＝ 7,457。用 `wc -l` 交叉核對一致。那 15 支的清單寫死在腳本的 `HOST_WIRING_FILES` 裡，接線檔增減時要同步改。

## 這頁怎麼維護 {#maintain nav="維護"}

- **層有變動時才改這頁**：新抽一支套件要歸層、某支改層、合併案落地。
- **指標基線每棒回寫**：FR-080 各棒收口時跑一次腳本，把新值追加到「指標基線」表（舊的那列保留，看得到趨勢）。
- **②層改名定案時**：把頁首那則待決提示拿掉，全頁「橫切」一次改成定案的名字。
- **判準被推翻時**：上面那些合併與分開部署的判準，如果因為實作發現不成立，改這裡並在分析報告第 9 節記一筆。
