---
title: 插件架構指南 — 外部套件怎麼「插」進主產品
brand: Guidant AI · **架構手冊**
eyebrow: 架構手冊 · 插件模式 · 2026-09-01
h1: 外部套件怎麼「插」進主產品——註冊、設定、通知的運作模式
lede: 這頁用白話與圖解回答三個問題：**插件怎麼掛上主產品**、**設定值歸誰管**、**像「寄通知」這種跨套件的合作怎麼運作**。看完你可以判斷「新產品要接這些能力，要做哪些事」。2026-08-31 增補：使用流程、接線 QA、資料關聯三律與查詢實戰問答。2026-09-01 增補：**申報制**（插件自己來報到）與「申報書是選配」鐵則，以及收官整理的 **D6 設計規則六條**（arc-review 正面清單）。
chips: [{text: 給 PM 的白話版, kind: accent}, {text: 詳細規格見 design.md, kind: plain}]
---

## 一句話：什麼是「插件模式」 {#what nav="是什麼"}

想像主產品是**一間房子**，插件是**家電**：冷氣（身分登入）、對講機（通知）、保險箱（檔案儲存）。家電自己就是完整的功能，房子只要有**插座**（註冊點）和**電線**（接線方式），插上就能用；拔掉某台家電，房子照樣能住，只是少了那個功能。

| 比喻 | 對應到我們的系統 | 白話說明 |
|------|------------------|----------|
| 房子 | Guidant AI 主產品 | 住人的地方——真正面對客戶的產品 |
| 家電 | jedi-* 套件（插件） | 各自完整的功能包：身分登入、通知、檔案… |
| 插座 | 模組清單裡的一行註冊 | 家電要插哪裡——一行一台，加一行就多一台 |
| 電線 | port（後面 §3 詳細講） | 家電和房子之間約定好的接線方式 |

::: {.callout .decided}
**這不是空想——身分套件已經通過「拔掉測試」**

身分套件（jedi-iam）已在 2026-08-30 完成合併，並實際做過拔掉測試：把註冊那一行註解掉，產品照常開機，只是登入功能整組消失（呼叫會得到 404），**其他功能完全不受影響**。這證明插件真的是「插上就有、拔掉就沒有」，不會牽一髮動全身。
:::

## 插件怎麼掛上主產品（註冊） {#register nav="註冊"}

主產品開機時，會照著一份**模組清單**（`config/app_modules.py`，一行代表一個模組）逐一「點名」：對清單上的每個插件呼叫它的 `register()`（自我介紹＋掛載），插件就把自己的東西掛進來。

```{.mermaid cap="圖 1 — 開機時的註冊流程：主產品照清單點名，插件各自掛上自己的功能"}
%%{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 LR
  A[主產品開機] --> B[讀模組清單<br/>一行＝一個模組]
  B --> C[對每個模組<br/>呼叫套件的 register]
  C --> D[套件把自己的<br/>API 路由／預設接線／<br/>資料表定義掛進來]
  D --> E[完成<br/>功能上線]
```

呼叫 `register()` 時，主產品可以塞四個「插槽」進去，決定這個插件在**這個產品裡**長什麼樣：

| 插槽 | 白話解釋 | 例子 |
|------|----------|------|
| config（參數值） | 每個產品不一樣的**數字與開關** | OTP 驗證碼幾分鐘過期、密碼要幾碼 |
| adapters（行為差異） | 「**寄信這件事你們家怎麼做**」——同一件事各產品做法不同，由主產品提供做法 | A 產品用內建通知系統寄、B 產品接客戶的郵件主機 |
| schema_extensions（欄位差異） | 「**你們家要多記什麼欄位**」——套件基本表之外，本產品額外要存的資料 | 這個產品的使用者要多記「員工編號」 |
| mount_api（API 總開關） | 要不要對外開放這個插件的 API 端點 | 有些產品只想用它的內部邏輯、不開放端點 |

全文唯一的一段示意代碼——註冊真的就是一行：

```python
register(app, adapters=..., config=...)
```

::: {.callout .pending}
**新產品要接一支插件，要做的事就三件**

| 步驟 | 做什麼 | 白話 |
|------|--------|------|
| ① 加一行 | 在模組清單加上這個插件 | 「插上插座」 |
| ② 接線 | 實作它宣告的 port（通常 2–4 張，見 §3） | 「照插頭形狀接電線」 |
| ③ 建表 | （插件有自己資料表的話）跑它隨包附的資料庫遷移 | 「家電要的專屬櫃子照說明書組起來」 |
:::

## 申報制：插件自己來報到，平台不去認識功能 {#declaration nav="申報制"}

前面講的是「插件怎麼掛上去」。還有一個反過來的問題：**平台怎麼知道有哪些功能？**

錯誤的做法是平台自己列一張清單（「任務型別有：一般、問卷、檢測工具…」）。那等於
**平台認識了每一個插件**——每加一種功能就要回頭改平台，插件也就不再是「插上就有、
拔掉就沒有」了。

正解是反過來：**平台只開一個登記處，插件自己來報到**。這叫「申報制」。

| | 平台自己列清單（❌） | 申報制（✅） |
|---|---|---|
| 誰知道有哪些型別 | 平台寫死 | 每個插件申報自己的 |
| 加一種新功能 | 要改平台的程式碼 | 只加一支插件，平台零改動 |
| 拔掉某功能 | 清單裡的死項目留著 | 選單自動少一項 |

實例（FR-069 4.2 第 3 步／CM-1490）：任務型別登記表只保留 `general`（任務骨幹），
問卷型別由問卷插件申報、檢測工具型別由檢測插件申報。實測拔掉問卷插件的申報，
下拉選單當場少一項——**沒有殘留的死選項**。

::: {.callout .decided}
### 🔴 申報書是選配——核心能力不得依賴任何插件有沒有申報

**規則**：平台的核心能力**不可以**寫成「要先有人申報才能運作」。沒有任何插件的空
平台，也必須跑得起來、跑得完整——只是選單上少了那些型別而已。

**為什麼要明文寫死這條**：申報制很容易滑向「平台反過來依賴申報」。典型的滑法是
平台某段程式寫成「查登記表拿到型別 → 依型別分岔」，一旦登記表是空的就走進沒人
處理的分支，於是**空平台反而壞掉**。那等於平台把自己的正常運作押在「有插件」上，
插件從選配變成必要，前面拆的耦合又長回來。

**先例**：問卷（survey）就是這樣運作的——它是選配插件，拿掉之後任務平台照常派工、
留言、看儀表板，只是不能派問卷任務。這個先例在 2026-09-01 由決策者定調為**通則**，
往後所有 audit 型積木必須依附平台，而 survey 型核心能力必須能獨立存在。

**怎麼驗**（不是宣稱，是可執行的檢查）：
1. **拔掉測試**：把插件的申報註解掉，平台開機、核心端點照常回 200／401（不是 500）
2. **空登記表測試**：登記表只有 `general` 時，平台的每條核心路徑都要走得通
3. 兩者都要**實跑**——「應該沒問題」不算數，靜默降級的症狀正是看起來一切正常
:::

## port 是什麼——插件之間「不直接認識」的合作方式 {#port nav="port"}

這是整個模式最重要的一個觀念：**插件之間不互相打電話，全部透過主產品這個「總機」轉接。**

用寄 OTP 驗證信當例子。身分套件（jedi-iam）要寄信，但它**不認識**通知套件（jedi-notification）——它只公開宣告：「我需要一個**會寄信的東西**」。這個宣告就叫 **port**（可以想成「插頭形狀」：規定好幾個孔、什麼電壓，但不管插上來的是誰家的電器）。主產品開機時，把通知套件包裝成符合這個形狀的插頭（這層包裝叫 **adapter**，轉接頭），塞給身分套件。

```{.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 LR
  A["jedi-iam 身分套件<br/>（只知道：我需要<br/>一個會寄信的東西）"] -. 宣告 INotifier port<br/>（插頭形狀） .-> B["主產品<br/>宿主接線盤"]
  B -. 注入 adapter<br/>（符合形狀的轉接頭） .-> C["jedi-notification<br/>通知套件<br/>（實際負責寄信）"]
```

這樣繞一圈的好處，白話講有三條：

1. **換掉通知系統，身分套件一行都不用改**——只要新系統也能包成同一個插頭形狀。
2. **新產品沒裝通知套件，身分套件照樣能用**——信寄不出去，但登入照常運作（這叫「優雅降級」：缺一角不會整組垮）。
3. **哪天要接 LINE 通知**，只要多寫一個轉接頭（adapter），不用動任何套件。

::: {.callout .warn}
**「安靜降級」有風險——所以配了守衛測試**

降級的另一面是：忘了接線**不會報錯**，信就是默默不寄。為此我們有「接線守衛測試」：哪張 port 忘了接，CI（自動測試）直接亮紅燈，**不會等到客戶抱怨收不到信才發現**。
:::

目前定下的**標準 port 有四張**（規格編號 D16），每支插件只准跟這四種標準插頭與地基套件（jedi-common）打交道，不准私下認識別的套件：

| 標準 port | 白話用途 |
|-----------|----------|
| 身分名冊 | 「這個 user id 是誰？叫什麼名字？」 |
| INotifier（通知） | 「幫我把這則訊息寄出去／推出去」 |
| 檔案存取 | 「幫我存這個檔／把那個檔拿來」 |
| 設定讀取 | 「這個租戶的某某設定值是多少？」 |

## D6 設計規則六條——寫新套件時照這個做 {#d6-rules nav="D6 六條規則"}

FR-069 抽了 12 支套件之後，arc-review 從實際做出來的東西裡挑出**六個值得當成標準的做法**
（不是事後想的原則，是**已經在跑的碼**）。寫新套件或改既有套件時照這六條。

### ① 守門三件套：無預設 ＋ 建構期拒絕掛載 ＋ `mount_api=False` 逃生門

需要認證／授權／加密才安全的插槽，**三件事一起做**：

1. **不給預設值**——`auth_required` / `license_guard` / `crypto` 這類欄位不寫 `= None` 的預設。
2. **`register()` 當場檢查，缺就拋例外拒絕掛載**——不是「有就用、沒有就跳過」。
3. **另給 `mount_api=False`**——真的不想要 API 的宿主走這個明確的出口，而不是靠「不接線」偷偷關掉。

**為什麼**（jedi-detection 的 `plugin.py` 原文寫得最清楚）：預設放行會讓「忘記接線」變成
**一組無聲的公開端點**——服務照常起得來、健康檢查照樣綠燈，而任何人都能讀寫別的租戶的
檢測工具設定（含連線憑證）。`crypto` 缺了更陰：**明文落庫與明文下發**，而功能表面上完全正常。

🔴 **判準**：這個插槽缺了之後，**壞掉的樣子是「安靜」還是「大聲」？** 安靜 → 三件套；
大聲（當場 500）→ 可以給預設值降級。

⚠️ **逃生門要看實作不看簽名**（CM-1500 的教訓）：`mount_api=False` 這個分支**也要把
runtime context 寫進 `app.extensions`**。jedi-survey 曾經只做 `_configure_runtime()` 就返回，
於是「不掛路由」與「拿得到 context」在套件裡變成綁死的二選一——socketio 模式（不載 REST）
的問卷共編整組失效，而宿主端無論怎麼接線都補不出來。

### ② ABC vs Protocol 的選型規則

兩種寫法在 codebase 裡都有，**不是隨便挑**：

| 用哪個 | 什麼時候 | 實例 |
|---|---|---|
| **ABC**（`abstractmethod`） | **fail-closed 的守門類**、以及「缺了就該炸」的必要能力 | jedi-detection 的 `ICrypto` / `IEvidenceSink`、task-platform 的 `ILicenseGuard` / `IProjectRoleGuard` |
| **Protocol**（結構型別） | **純降級輔助**、以及只是「跟宿主要個資料」的名冊型 port | jedi-participant 的 `IUserDirectory` / `IJobNotifier`、evidence-classification 的 `IProjectDirectory` |

**理由**：ABC 會在**建構的當下**就因為沒實作抽象方法而炸——這正是守門類要的（不合格的
adapter 根本進不了門）。Protocol 是鴨子型別，宿主拿任何長得像的東西都能塞——對「缺了只是
少個功能」的輔助 port 很方便，但守門類用它等於**把不合格的守衛放進來**，而它會在
第一次被呼叫時才壞，那時已經在線上了。

> 實況（收官盤點）：compliance-audit / detection / task-platform / survey / integrity /
> license-runtime 走 ABC；participant / evidence-classification 走 Protocol。
> 兩制並存本身沒問題，**問題是以前沒有選型規則**，本條補上。

### ③ 角色守門統一 `bool` 出口形

問「這個人是不是管理者」的 port，**回 `bool`，不要回 `None` 也不要直接拋例外**：

```python
def is_project_manager(self, project_id: int, user_id: int) -> bool: ...
def is_any_project_manager(self, user_id: int) -> bool: ...
```

**為什麼**：拋什麼例外、回哪個 error code、要不要降級——**那是呼叫端的語意決策**，
不是守衛的。port 只負責回答事實，判斷怎麼處理留給用它的人。
canonical 形見 `jedi_evidence_classification/domain/ports.py`。

### ④ 兩個方法不要合成一個回 tuple

同一個 port 裡，「取 A」與「取 B」**分兩支方法**，不要合成一支回 `(A, B)`。

jedi-task-platform 的原文理由：`get_workflow_execution_id` 與 `get_internal_id`
分開兩支而不是回一個 tuple，是因為**兩處呼叫端各只要其中一個，合成一支會逼 repo 層去
認識它不需要的欄位**。

順帶一提同一段的第二條規則：**不存在就回 `None`，不要在 port 裡拋例外**——
「拋什麼例外是呼叫端的語意決策」（留言拋 `GRC_JOB_NOT_FOUND`，別的呼叫端可能想靜默跳過）。

### ⑤ 工廠反轉：宿主的實體型別不進套件

套件需要「產生一個宿主的東西」時（例如檢測套件要產生一份**佐證**），
**不要 import 宿主的實體類別**，改在 port 上開一支**工廠方法**：

```python
@abstractmethod
def build_evidence(self, **fields: Any) -> Any:
    ...
```

套件把它知道的事（哪個任務、哪個檔、雜湊、來源是 DETECTION_TOOL）交出去，
**由宿主決定裝進什麼型別**。

**效果**：「佐證實體長什麼樣」完全留在宿主，套件**不建構也不認得**它的型別。
抽出前這裡是直接 `from domain.flow_engine.entity.job_evidence_entity import JobEvidenceEntity`
——那一行就是套件反向依賴宿主。

### ⑥ `__init__.py` 檔頭三件套：沿革表 ＋ 不留殼理由 ＋ 守衛座標

每支套件的 `__init__.py`（或 `plugin.py`）檔頭寫三樣東西：

1. **沿革表**——這支從哪來、原本叫什麼、哪張卡搬的。
2. **不留轉發殼的理由**（如果舊名死了）——「留殼等於舊名沒退場，之後新寫的 code
   兩個名字都 import 得動，半年後又變成同一個東西兩條路徑」。
3. **守衛座標**——哪幾支測試在守這個邊界，讓下一個人知道改壞了誰會叫。

範本：`jedi-compliance-audit` 與 `jedi-task-platform` 的 `__init__` / README 檔頭。

::: {.callout}
**這六條的共同精神**：FR-069 arc-review 的主題是「**綠著的守衛不代表在守**」。
六條裡有四條（①②③⑤）都在講同一件事的不同面向——**讓錯誤大聲地壞，而不是安靜地壞**。
:::

## 設定（config）歸誰管——「規格書進套件、值留主產品」 {#config nav="設定"}

原則一句話：**套件管規格書，主產品管實際的值。**

- 套件知道：「有哪些設定、什麼型別、預設多少、填錯怎麼擋」——這是**規格書**（schema），隨套件出貨。
- 主產品知道：「值存在哪張表、目前填多少」——存在主產品的 `system_configs` 資料表。

比喻：家電說明書寫「電壓 110V、定時可設 1–12 小時」——說明書隨家電來；但**你家定時實際設幾小時，記在你家**，不會寫回說明書。

```{.mermaid cap="圖 3 — 設定的分工與讀取順序：規格書隨套件、值存主產品，讀取時三層 fallback"}
%%{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
  A["套件申報 schema（規格書）<br/>鍵名／型別／預設值／驗證規則"] --> B["主產品 system_configs 表（值）<br/>一張表兩層：系統級＋租戶級"]
  B --> C{"讀取時怎麼挑？"}
  C --> D["① 租戶有設 → 用租戶的"]
  C --> E["② 租戶沒設 → 用系統級"]
  C --> F["③ 都沒設 → 用套件內建預設值"]
```

設定分**三種作用域**（誰的設定、誰能改）：

| 作用域 | 描述的是什麼 | 例子 | 誰改 |
|--------|--------------|------|------|
| 系統級 | **這個部署**（整套裝在客戶機房的產品） | LDAP 連線、儲存後端 | 平台管理員 |
| 租戶級 | **這個客戶**（同一套產品裡的某一家公司） | 通知偏好 | 租戶管理員 |
| 租戶可覆寫系統級 | 平台給預設、客戶可以換自己的 | SMTP 信箱伺服器——客戶可用自己的，沒設就用平台預設 | 兩者皆可 |

::: {.callout .decided}
**已成正式規格（D18，2026-08-31 拍板）——第四階段起照辦**

此模式已有第一個落地實例（安全政策七個設定鍵，2.5 階段完成），並於 2026-08-31 升格為正式規格 **D18 設定 schema 申報制**：schema 進套件、值與存放留宿主、**模組開關表永遠留宿主**（「哪些模組對這個租戶開啟」是宿主的組裝決策，套件不該知道自己有沒有被啟用）。

三項執行細節同時定案：①**作用域是 schema 的必填欄位**（上表三種），儲存機制現成、不需新表；②**預設活在 code、DB 只存差異**——讀無列就回套件內建預設，套件裝上就有預設值可用、不必等 seed；真要 seed 也**只補缺鍵、絕不覆蓋**（覆蓋會把客戶調過的設定打回原廠）；③上圖的三層 fallback 由套件申報、宿主執行，**全鏈皆無則明確報錯**，不靜默回空值。

附帶好處仍在：**設定頁可以直接從申報的規格書自動長出表單**，不必每加一個設定就手刻一次畫面（加分項，非必辦）。既有套件於第三階段補殼時回頭套用（有設定的才做）。全文見 [design.md](../FR-069-2608-jedi-module-extraction/design.md) §3 D18。
:::

## 實際走一遍：使用者登入收到 OTP 信，背後發生什麼 {#walkthrough nav="走一遍"}

把前面三節串起來，看一次真實流程：

```{.mermaid cap="圖 4 — 登入寄 OTP 驗證信的完整流程：身分套件全程只對著兩個插頭講話"}
%%{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 U as 使用者
  participant M as 主產品 API
  participant I as jedi-iam（身分套件）
  participant S as 設定 port
  participant N as INotifier port
  participant T as jedi-notification（通知套件）
  U->>M: 登入（帳號＋密碼）
  M->>I: 交給身分套件處理
  I->>I: 密碼對、這個帳號該走 MFA
  I->>S: 這個租戶 SMTP 用哪組？
  Note over S: 租戶沒設 → 回系統級的
  I->>N: 把這封驗證碼信寄出去
  N->>T: （主產品的接線）實際寄信
  I->>U: 回「請輸入驗證碼」
```

一句收攏：**身分套件全程不知道「信怎麼寄、SMTP 設定存在哪」——它只對著兩個插頭講話。**寄信的細節在通知套件，設定的存放在主產品，三方各管各的、靠插頭形狀合作。

## 插件的使用流程——從拿到套件到上線 {#howto nav="使用流程"}

每支插件唯一「露在外面」的，是一個叫 **plugin.py** 的檔——可以想成套件的「**插座面板＋說明書**」。套件裡其他幾百個檔都是電器內部，新產品要接入，**理論上只需要讀這一個檔**。

面板上有什麼：

| 面板上的東西 | 白話說明 |
|--------------|----------|
| 幾個常數 | 掛上後叫什麼名字、API 網址前綴是什麼 |
| 四張 port 宣告 | 「我需要外界提供這幾種形狀的服務」——插頭規格書（見 §3） |
| 四個插槽 | config（參數值）／adapters（把 port 的實作塞進來，皆選填）／schema_extensions（要多記的欄位）／mount_api（要不要開放 API 端點） |
| register() | **唯一入口**，開機時呼叫一次 |

拿到套件之後，接入流程五步：

```{.mermaid cap="圖 5 — 從拿到套件到上線的五步流程：讀一個檔、寫幾個轉接頭、加一行、跑遷移"}
%%{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
  A["① 裝套件<br/>（poetry add，一行指令）"] --> B["② 讀 plugin.py<br/>看它要哪幾張 port"]
  B --> C["③ 照規格寫 adapter<br/>通常 2–4 個小類別<br/>（例：「會寄信的東西」）"]
  C --> D["④ 模組清單加一行<br/>開機時呼叫 register()<br/>把 config 與 adapters 塞進去"]
  D --> E["⑤（有資料表的）<br/>跑它隨包附的資料庫遷移"]
  E --> F["完成——API 就能用"]
```

一句實證：身分套件的**獨立測試環境**（harness，套件自己帶的迷你主產品）就是照這五步接的——**空資料庫十分鐘跑起來**。

::: {.callout .warn}
**接好之後，開機時有兩道自動檢查**

塞進來的東西**形狀不對** → 開機當場報錯，不會等到執行期才炸；**該塞的沒塞** → 接線守衛測試紅燈。所以接錯或漏接都不會默默溜到客戶手上——細節見下一節 Q3。
:::

## 口袋裡裝的是誰？——接線的四個常見疑問 {#wiring-qa nav="接線 QA"}

前面講完整套模式，這節整理四個最常被問到的問題（Q1–Q3 是 2026-08-31 與決策者實際問答的整理，Q4 補於 2026-09-01——由一次真實故障逼出來的契約）把「接線」這件事講到底。

### Q1：套件怎麼知道要用哪一個通知服務？

**它不知道，是主產品開機時「指名塞給它」的。**

套件只留一個**空口袋**（建構子參數——建立套件服務時預留的欄位）；主產品的**接線盤**（DI 容器，dependency injection，統一管「誰用誰」的接線總表）在開機時，把包好的通知器放進這個口袋。之後套件每次要寄信，動作都一樣：**摸口袋、按按鈕**——按下去實際走的是 SMTP 郵件還是 LINE 推播，它不知道也不在乎。

要換通知管道？改接線盤那一行就好，**套件零改動**。

```{.mermaid cap="圖 6 — 接線分兩段：開機期把東西放進口袋，執行期只管摸口袋按按鈕"}
%%{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 LR
  subgraph BOOT["開機期（接線一次）"]
    A["主產品接線盤<br/>（DI 容器）"] -->|"把包好的通知器<br/>放進口袋"| B["套件的空口袋<br/>（建構子參數）"]
  end
  subgraph RUN["執行期（每次寄信）"]
    C["套件要寄信"] -->|"摸口袋、按 send"| D["口袋裡的通知器<br/>實際寄出<br/>（SMTP？LINE？套件不知道）"]
  end
  BOOT --> RUN
```

### Q2：套件不就還是用到了別的套件的功能嗎？

**用到「功能」，但不認識「人」。**

中間永遠隔一層**主產品寫的皮**（adapter，轉接頭）。所以：通知套件改版、換成 LINE、甚至這個產品**根本沒裝**通知套件——都只動那層皮，或者什麼都不用動；身分套件**一行不改**。

「用到功能」和「認識套件本身」是一字之差，但正是**拔插自由的來源**：依賴的是「插頭形狀」（port），不是插上來的那台電器。

### Q3：忘了塞會怎樣？

**不會炸，會「安靜降級」**——信默默不寄、人名顯示成帳號。方便，但也危險：最嚴重的實際案例是**設定口袋沒塞，人機驗證（CAPTCHA）會退回測試模式、對任何人都放行**。

所以配了**兩道閘**：

| 閘 | 驗什麼 | 白話 |
|----|--------|------|
| ① 開機驗形狀 | 塞進來的東西**形狀對不對** | 插頭孔數不對，開機當場報錯，不讓你帶病上線 |
| ② 接線守衛測試 | **該塞的口袋都塞了沒** | 哪張 port 忘了接，CI（自動測試）直接紅燈 |

兩道閘都過，漏接**在 CI 就被抓到**，不會等客戶抱怨收不到信才發現。

### Q4：「不開放 API 端點」的模式，套件還能正常運作嗎？

**能，而且這一點是硬性契約——2026-09-01 才被一次真實故障逼著補齊的。**

前面提過面板上有個 `mount_api` 開關：關掉就代表「我只想用這個套件的內部邏輯，不要
對外開放它的 API 端點」。聽起來只是少掛幾條網址，實際上曾經藏了一個很難自救的坑。

問題出在套件的**執行期口袋**（前面 Q1 說的那個口袋，套件靠它拿到主產品塞進來的東西）
——原本的實作把「把東西放進口袋」這個動作，**綁在掛 API 端點的那一步上**。於是關掉
API 端點時，口袋根本沒被放東西進去，套件內部一去摸就摸到空的。

**「不開放端點」與「內部功能可用」變成綁死的二選一**，而且主產品端**無論怎麼接線都
補不出來**——因為那個口袋只有套件自己放得進去。

真實故障（問卷套件，2026-09-01）：主產品的即時協作服務刻意不載入任何 REST 端點
（減少對外暴露面），而問卷的多人共編處理程序需要摸口袋拿服務。結果每一次「加入共編」
「更新答案」都在後端當場出錯，使用者看到的是「更新失敗，請稍後再試」——**問卷多人
共編整組失效**，而系統健康檢查一路綠燈。

所以定為契約：

::: {.callout .warn}
**關掉 API 端點時，套件一樣要把東西放進執行期口袋。**

「不開放端點」與「內部功能可用」是兩件事，不可以綁在一起。**主產品端補不了這個洞**，
只有套件自己能負責。

放的語意也要與掛端點那條路一致：**後放的蓋掉先放的**（同一個應用先後接兩份設定時，
兩條路要給出相同結果；若寫成「已經有就跳過」，同一件事會因為走哪條路而有不同答案，
是那種平常看不出、換場景才爆的分歧）。
:::

八支插件已全數補齊並各配一條契約測試（問卷、身分、檔案、檢測、通知、系統選單、
議題、API 日誌）。測試都做過「故意寫壞看會不會紅」的驗證，確認它們真的守得住。

## 資料表的關聯怎麼辦？——三條判斷律 {#data-decouple nav="資料三律"}

前面講的口袋、port，解的都是**程式碼**的解耦。但資料庫還有一種綁法：表與表之間的**外鍵**（FK，foreign key——資料庫層的強制關聯：A 表指著 B 表，B 的資料不能隨便刪，刪了 A 會指到空的）。外鍵是資料庫自己執行的規則，**沒辦法「塞口袋」**。

所以資料關聯改用**三條判斷律**來拆——而且這不是我們發明的，業界（Shopify、GitLab、微服務、DDD）走的全是同一套：

| 律 | 白話判準 | 本案實例 | 業界對應 |
|----|----------|----------|----------|
| **① 想建外鍵＝兩張表該住一起** | 天天要連表查、要同生共死的資料，代表本來就是**同一個能力被切錯了**——正解是合併疆界，不是硬拆 | 問卷設計表與作答表有跨界外鍵 → 裁定**合併成問卷疆界套件**（D10） | DDD：「兩個領域老要互摸對方的表＝邊界劃錯」 |
| **② 真的跨疆界＝只存編號、不建外鍵**（軟參照） | A 表記 B 的**編號**就好；要顯示名字透過名冊 port 問，查不到就**降級顯示編號、絕不炸** | 日誌套件的表存使用者帳號但**不外鍵到 users 表**；「禁止 JOIN 宿主身分表」是明文禁令 | GitLab 的 loose foreign keys（還配背景清孤兒）、微服務 database-per-service、DDD reference-by-ID、Shopify 禁跨模組 JOIN（CI 工具強制掃） |
| **③ 只是「B 發生事、A 要知道」＝連編號都不用存** | 那是**流程通知**不是資料關聯——走 port 呼叫或事件，事過境遷不留關聯 | 任務完成要發通知 ＝ INotifier port | 事件驅動架構（event-driven） |

```{.mermaid cap="圖 7 — 兩張表有關聯時的判斷樹：先問要不要住一起，再問要不要記得對方"}
%%{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
  A{"兩張表有關聯？"} --> B{"天天連表查／<br/>同生共死？"}
  B -->|是| C["律①：合併疆界<br/>（外鍵留著——本來就是一家人）"]
  B -->|否| D{"只是要指到<br/>對方的資料？"}
  D -->|是| E["律②：存編號＋透過 port 問<br/>（軟參照，查不到就降級）"]
  D -->|否| F["律③：事件通知<br/>（連編號都不存）"]
```

一句收攏：**該住一起就合併、真跨界就記編號、只是通知就走事件**——三條律把「外鍵塞不進口袋」這件事收乾淨。

::: {.callout .decided}
**已成正式規格（D17，2026-08-31 拍板）——第四階段每支候選依此判疆界**

三律於 2026-08-31 升格為正式決策 **D17 資料關聯三律**，第四階段每支候選（人員指派／問卷合併／flow_control 拆出／OSCAL）判斷疆界切分時一律依此。**既有的跨界外鍵不回頭全面拆除**——隨各疆界抽取時逐案處理（拆到誰才判誰）。

軟參照有代價：資料庫不再幫你擋「指到已刪除的資料」。這由兩件事收拾——「查不到必降級」（顯示編號、絕不炸，已寫進律②）與**定期清理孤兒資料**（背景作業，比照 GitLab loose foreign keys＋清理器；**本案尚未建置**，隨第四階段各疆界落地時補）。

反悔條件：若未來出現「跨疆界一致性必須由資料庫保證」的硬需求（如金額結算類），該處回到單一疆界內處理，而不是把外鍵加回跨界。全文見 [design.md](../FR-069-2608-jedi-module-extraction/design.md) §3 D17。
:::

## 那查詢會不會變慢、變複雜？——六個實戰問答 {#query-qa nav="查詢 QA"}

上一節講完「資料怎麼拆」，最自然的下一個疑問是：**拆完之後，查東西會不會變慢、變麻煩？** 這節整理六個實際被問到的查詢情境（2026-08-31 與決策者實際問答的整理），從最簡單到最複雜逐一走過。

先立一個貫穿整節的比喻：把系統想成**美食街**——每個插件是一個**檔口**（各有自己的廚房＝自己的資料表），主產品是**櫃檯**。規則只有一條：**櫃檯不進別人廚房炒菜（不直接摸插件的表），要菜就跟檔口點（呼叫插件的服務）；檔口自己廚房內愛怎麼炒怎麼炒（疆界內的表隨便 JOIN）**。

### Q1：查「某人的日誌」怎麼查？

**日誌檔口自己就能出餐。** 日誌表本來就存著操作者的編號——「查編號 17 的日誌」是單表查詢，跟以前一模一樣。**九成的查詢是這型，完全無感。**

唯一的小步是「雷門」這個名字要先換成編號——問一次身分名冊（port）就有。而且業界日誌系統的標配是**寫入當下就把名字快照存進日誌**（日誌本來就該記「當時他叫什麼」，之後改名不影響歷史紀錄），存了快照連名冊都不用問。

### Q2：要用「對面檔口的條件」過濾怎麼辦？（如：查資安部所有人的操作）

**兩步**——先問名冊「資安部有誰」拿回一串編號，再用編號查自己的表。原本的「連表過濾」改寫成「**先換算、再過濾**」，還是兩句查詢的事。

```{.mermaid cap="圖 8 — 跨疆界條件的兩步查法：先把「部門名」換算成編號清單，再回自己的表過濾"}
%%{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 LR
  A["條件：資安部<br/>（部門名）"] --> B["問身分名冊 port：<br/>資安部有誰？"]
  B --> C["拿回編號清單<br/>（17、42、58…）"]
  C --> D["用編號清單<br/>過濾自己的日誌表"]
  D --> E["結果"]
```

### Q3：首頁儀表板要湊 N 個模組的數字，不會超慢嗎？

儀表板歸**櫃檯**（宿主）管，做法是「**各檔口出各自的統計小菜、櫃檯擺盤**」——每個插件對自己疆界提供摘要，宿主拼裝。不慢，三個原因：

| # | 原因 | 白話 |
|---|------|------|
| ① | 各插件算的是**自己的小範圍** | 每盤小菜都好算，沒有誰要炒全場的菜 |
| ② | 儀表板標配**快取** | 算一次，大家看 60 秒，不是每個人開頁面都重算 |
| ③ | 量大時**預先算好存一張統計表** | 業界所有 BI／GA 類產品都這樣做 |

補一句業界視角：以往「一句 500 行 JOIN 的巨型報表 SQL」才是慢的元凶——拆成疆界後每段查詢變小，反而**好加索引、好診斷**。

### Q4：深查詢呢？「專案內指派給雷門且未完成的任務＋控制項群組＋控制項＋AO＋名稱過濾」——跨這麼多東西不會拆成 N 段嗎？

**不會，因為這些東西根本沒有被拆開。** 任務、控制項、AO 全是「稽核執行」**同一個生活圈**——疆界劃分的第一原則就是「常一起查的資料住同一個檔口」。所以這條查詢在檔口內**一句連表照舊**，速度跟以前完全相同。唯一跨界的只有「雷門→編號」那一小步。

收攏成一條鐵則：**熱門查詢不准跨疆界**——老是要跨，代表疆界劃錯了，正解是**重劃（合併）**，不是硬寫慢查詢（業界 Shopify、GitLab 同做法）。

### Q5：真的跨疆界的組合頁呢？（如「我的問卷任務」：指派給我的任務＋我填答的題目）

**三步舞**——①任務檔口查「指派給我的」；②收集問卷編號，**一次批量**跟問卷檔口點「這批問卷＋我的作答」（它廚房內自己連表）；③櫃檯拼盤回給畫面。**總共兩句查詢，不是 N 句。**

```{.mermaid cap="圖 9 — 跨疆界組合頁的三步舞：各拿各的、批量點菜、櫃檯拼盤——總共兩句查詢"}
%%{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 LR
  A["① 任務檔口：<br/>查「指派給我的任務」<br/>（第 1 句查詢）"] --> B["② 收集問卷編號，<br/>一次批量點菜：<br/>「這批問卷＋我的作答」<br/>（第 2 句查詢，<br/>它廚房內自己連表）"]
  B --> C["③ 櫃檯拼盤<br/>回給畫面"]
```

「放櫃檯」是什麼意思？就是主專案裡**一支普通的組裝程式**——不是新機制，現有系統裡本來就有這層，架構收斂後它的角色被正名而已。ORM 從頭用到尾，被禁止的只有「櫃檯直接連表查插件的表」這一件事。

### Q6:跨疆界的「深」查詢呢?也是主產品聚合嗎?

對,但「深」要拆開看:**深的部分留在各檔口、櫃檯永遠只做淺拼裝**——①A 檔口深查自己的(內部多表連查,愛多深多深)②B 檔口拿 A 的編號批量深查自己的③櫃檯拼裝回傳。「深」發生在①②,主產品只做③。

**如果「拼裝本身」也變深呢?**(過濾/排序條件橫跨兩邊,例:按問卷完成率排序任務清單)——按嚴重度三個出口,依序試:

| 順位 | 出口 | 適用 |
|---|---|---|
| ① | 條件先換算成編號(Q2 模式) | 大多數場景夠用 |
| ② | 預先同步一張投影表,查詢單表(讀模型) | 跨界排序/統計的常規解 |
| ③ | **重劃疆界**——這種查詢是天天跑的熱路徑?代表兩塊資料該住一起(三律第一條) | 訊號,回決策桌 |

::: {.callout .warn}
**永遠不做的一項**

主產品直接跨疆界寫巨型連表查詢當**日常手段**——那是留給一次性重報表的合法後門,不是常規路徑。常規永遠是:各檔口深查自己＋櫃檯淺拼裝;拼裝變深就照上表逐級升級。
:::

### 總表:哪種查詢的 API 開在哪、誰來查?

| 查詢類型 | API 開在哪 | 查詢寫在哪 | 例子 |
|---|---|---|---|
| **簡易(單一檔口)** | **插件自己開**——client 直接打插件掛出的 API,**主產品零參與** | 插件內 | 日誌清單、問卷 CRUD、登入(身分插件的 39 條 API 就是實例) |
| 看似複雜、實為同檔口的深查詢 | 該檔口自己 | 檔口內一句連表 | 任務＋控制項＋AO(同屬稽核疆界) |
| **真跨檔口的組合** | 主產品開「套餐窗口」(聚合 API) | 主產品組裝——**首選調各插件的服務拼盤**,直接連表查是留給重報表的合法後門 | 「我的問卷任務」、儀表板 |

一句話:**各檔口的菜,檔口自己賣(client 直接跟檔口買);要湊成套餐的,才由櫃檯開一個套餐窗口。** 如果連簡易查詢都要主產品代寫,「隨插即用」就假了——裝了插件還得幫它寫查詢。

::: {.callout .decided}
**查詢問答一句收攏**

查**自己疆界**＝照舊；跨疆界的**條件**＝先換算成編號；跨疆界的**組合**＝各拿各的、批量、櫃檯拼盤。會慢的訊號＝**熱查詢老跨界**＝疆界劃錯，重劃。
:::

## 這個模式對產品的意義（給 PM 的收益表） {#value nav="意義"}

| 情境 | 沒有插件模式 | 有插件模式 |
|------|--------------|------------|
| **新產品上市** | 登入、通知、檔案這些「每個產品都要」的功能重寫一遍 | 裝套件＋接線（三步驟，見 §2），核心功能直接到位 |
| **客製交付** | 客戶要換通知管道 → 動到產品裡面的代碼，風險大 | 加一個轉接頭（adapter）就好，**套件本身一行不動** |
| **品質** | 共用邏輯散在產品裡，改壞了要等出事才知道 | 每支套件自帶獨立測試環境天天跑，主產品改壞**當天**知道 |
| **退場** | 功能下架要小心翼翼拆，怕扯斷別的東西 | 拔掉註冊那一行即可，「拔掉測試」保證其他功能無感 |

各套件的抽取進度看 [路線圖](modularization-roadmap.md)；技術規格（D6 插件契約、D16 port 通則）見 [design.md](../FR-069-2608-jedi-module-extraction/design.md) §3。
