---
title: "jedi-asset 插件解剖 (FR-089)"
brand: "Guidant AI · **FR-089** 套件結構統一"
eyebrow: "FR-089 · jedi-asset 插件解剖 · 21 支套件的形狀範本"
h1: "jedi-asset 插件解剖"
lede: "一支套件要同時能「插進 Guidant AI」和「離開 Guidant AI 自己跑」。整套機制只有一句話：**開機時宿主把一包東西放進袋子，之後每個 request 從袋子拿。** 本頁先用五個名詞把地基釘住，再用「袋子」的比喻把這句話拆開講，然後交代主專案那側要寫什麼、每個檔各放什麼、怎麼證明套件真的能獨立跑。"
chips: [
  {text: "21 支套件的形狀範本", kind: accent},
  {text: "SOP §4.3 為規範正本", kind: plain},
  {text: "port 雙重注入可收，不急", kind: warn}
]
footer: "來源：`jedi_asset/plugin/` 五檔、`jedi_asset/api/guards.py`、`api/routing.py`、`harness/dev_app.py`、主專案 `core/app_factory.py`、`core/plugins/asset.py`、`test/test_module_boundaries.py`。契約全文見 `docs/features/FR-069-2608-jedi-module-extraction/` 的 design.md D6／D7 段與 extraction-sop.md §4.3。本頁只描述現況，沿革見 FR-089-LOG 與 git log。"
---

> **怎麼讀這一頁：** 只想知道「怎麼接一支套件」→ 看[五個詞](#glossary)＋[新手教程](#tutorial)就夠。
> 想懂「為什麼要這樣設計」→ 從[袋子](#bag)往下順著讀。想查「某個檔放什麼」→ 直接跳[檔案](#files)。

## 先講五個詞 {#glossary nav="五個詞"}

整份文件只圍繞五個名詞打轉。讀之前先把它們釘住，後面就不會卡：

| 詞 | 一句話 | 在這裡具體是什麼 |
|---|---|---|
| **宿主** | 接零件的那個系統 | Guidant AI 主專案。另一個宿主是 harness（見[最後一節](#harness)） |
| **`register()`** | 把套件接上宿主的**唯一入口**，開機時呼叫一次 | 套件的 `plugin/__init__.py` 裡那支函式 |
| **adapters** | 宿主交給套件的一包東西：驗登入的函式、驗權限的函式、取使用者的函式、建 service 的工廠 | 一個 `AssetAdapters` dataclass，八個欄位 |
| **袋子** | 套件收下那包東西的地方，整個 app 一份、開機裝好後唯讀 | `app.extensions["jedi_asset"]`，裝的是 `_RuntimeContext` |
| **`runtime()`** | 「把袋子拿出來」的動作，每次有 request 進來就呼叫 | 套件的 `api/guards.py` 裡那支一行函式 |

一句話串起來：**開機時宿主用 `register()` 把 adapters 放進袋子，之後每個 request 用 `runtime()` 從袋子拿。** 本頁其餘內容都是這句話的細節。

再兩個詞，講「套件怎麼問宿主問題」：

| 詞 | 一句話 |
|---|---|
| **port** | 套件宣告的一個介面：「我需要有人能回答這件事」。只有方法簽名，沒有實作 |
| **adapter** | 那個介面的實作：「我來回答」。答案套件自己知道就寫在套件裡，答案在別的套件手上就寫在宿主 |

## 標準形狀：一支套件長什麼樣 {#shape nav="標準形狀"}

**這節是「檔案該放哪」的規矩，不是機制。** 想先懂機制的可以跳到[袋子](#bag)，回頭再看這張表。

jedi-asset 是 21 支套件的形狀範本，規範正本在 [SOP §4.3](../FR-069-2608-jedi-module-extraction/extraction-sop.md)。六條：

| 項 | 規定 |
|---|---|
| `plugin/` 五檔 | `__init__`（`register()` ＋ 對外 re-export）／`contract`／`runtime`／`assembly`／`migrations` |
| `api/` 四件 | `guards.py`（`runtime()` ＋ 三個 lazy decorator）／`routing.py`（URL 表＋`FROZEN_URLS`）／`routes/`（一資源一檔）／`serializers/`（一資源一檔）。**route 檔與 schema 不得平鋪在 `api/` 頂層**，只有一個資源也要開子目錄 |
| 頂層不平鋪 | 套件頂層只放 `__init__.py`（logger ＋ re-export）與分層目錄。判準：`ls jedi_<name>/*.py` 只看得到 `__init__.py`。沒有 route 的套件一樣適用 |
| port 位置 | 一律 `domain/ports.py`，不開頂層 `ports/`——兩處並存讀者分不清哪個是 port |
| 取執行期組件 | 函式一律叫 `runtime()`，不叫 `ctx()`（理由見[下方](#naming)） |
| 其餘 | logger 掛 `common.jedi_<pkg>`；entity 一律 dataclass 並用 `inspect.signature` 對照 ORM 欄位；`tests/` 在套件根不放 `jedi_<name>/tests/`（放套件內要靠 pyproject `exclude` 才不進 wheel） |

`jedi-oscal-v2` 與 `jedi-common` 沒有 route、沒有 `register()`，套用版面與 logger 規則，但**不建 `plugin/` 五檔與 `api/` 四件**——不為了形狀開空目錄。

> **對外 import 路徑是契約，子模組是實作細節。** `from jedi_<name>.plugin import X` 與 `from jedi_<name>.api import Y` 這兩條路徑不許動，內部怎麼拆都可以。

## 袋子比喻：register 放東西、runtime 拿東西 {#bag nav="袋子"}

整個機制只有兩個動作，發生在兩個不同的時間。角色四種：主專案（宿主）、套件、袋子 `app.extensions["jedi_asset"]`、每個 HTTP request。

```{.mermaid cap="圖 1 — register 放進袋子、runtime 從袋子拿"}
%%{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["動作一：開機時（一次）"]
    direction LR
    H1["主專案 app_factory.py<br/>register(app, adapters)<br/>adapters 裡裝著：驗登入函式、驗權限函式、取使用者函式、service 工廠"]
    P1["套件 plugin/__init__.py<br/>① 把 9 條 route 掛上 app<br/>② 把 adapters 塞進袋子<br/>（打包成 _RuntimeContext）"]
    H1 --> P1
  end
  subgraph req["動作二：每次有人打 API"]
    direction LR
    R1["PUT /api/1.0/device/abc<br/>Flask 找到 DeviceDetailRoute.put<br/>外面包著 @auth_required 等三層殼"]
    P2["套件 api/guards.py<br/>runtime()<br/>= 把袋子拿出來，就這一行"]
    R1 --> P2
  end
  BAG[("袋子<br/>app.extensions[&quot;jedi_asset&quot;]")]
  P1 -- 放 --> BAG
  P2 -. 拿 .-> BAG
```

袋子裡裝的東西：

| 區 | 內容 |
|---|---|
| `adapters` | `.auth_required`、`.capability_required`、`.current_user_login_name`、`.identity`、`.reference_counter` |
| `config` | `.device_update_capability` … |
| `services` | `.device_service`（工廠） |
| 方法 | `reply()` / `service()` |

主專案在開機時把它的東西交給 `register()`，套件放進袋子。之後每個 request 進來，route 呼叫 `runtime()` 把袋子拿出來，從裡面取出主專案交來的函式來用。

```python
# plugin/__init__.py  ── 開機時：放進袋子
def register(app, adapters, config=None, ...):
    context = _RuntimeContext(adapters=..., config=..., services=...)
    app.extensions["jedi_asset"] = context          # ← 放
    app.register_blueprint(bp)                        # ← 掛 route

# api/guards.py  ── request 時：拿出袋子
def runtime():
    return current_app.extensions["jedi_asset"]   # ← 拿，沒有別的
```

> **所以 runtime 是什麼：** 它不是一個「東西」，是一個「動作」。每次 route 需要主專案給的函式，就呼叫 `runtime()` 把袋子拿出來、從裡面取。`runtime().adapters.auth_required` 讀作「從袋子取出主專案給的驗登入函式」。

## 為什麼要有袋子，不直接寫進 route {#time nav="為什麼要袋子"}

因為 route 檔在 import 的瞬間就定型了，那時候主專案的東西還沒交過來。

```{.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
  T1["① import 期<br/>Python 載入 device_route.py<br/>@auth_required<br/>def put(self, uid):<br/>decorator 已經套上了，但袋子還是空的、<br/>主專案還沒把 jwt_required 交來。<br/>現在決定守門 → 決定不了"]
  T2["② register 期<br/>主專案呼叫 register()<br/>adapters 這時才到。套件放進袋子。<br/>但 ① 早就跑完了，<br/>route 上的 decorator 來不及改。"]
  T3["③ request 期<br/>有人打 PUT /device/abc<br/>decorator 的殼這時才執行：<br/>runtime().adapters.auth_required<br/>袋子裡已經有東西了，拿出來套上、執行。<br/>守門在這一刻才決定 ✓"]
  T1 --> T2 --> T3
```

解法：① 只留一個「到時候去袋子拿」的殼，把決定推遲到 ③。這個殼就叫 lazy decorator。

主專案那側直接寫 `@jwt_required()` 是在 ① 就綁死實作。套件不能這樣寫，因為它不知道宿主用什麼認證，而且這樣寫等於套件反過來 import 主專案。

拿生活的比喻：套件是一間出租店面，裝潢時（import）把「收銀機的位置」留好了，但收銀機本身（jwt_required）要等房客搬進來（register）才放上去。每次客人結帳（request），店員走到那個位置拿收銀機來用。`runtime()` 就是「走到那個位置」。

## guards.py：一個 runtime 加三個守門 {#guards nav="guards.py"}

**這節在講：套件怎麼在不認識宿主的前提下，做到「沒登入擋掉、沒權限擋掉」。** 答案是套件只寫一個空殼，真正的判斷從袋子拿。

守門集中在 `jedi_asset/api/guards.py`，60 行。route 檔從 `jedi_asset.api` import，不需要知道它在哪一個子模組。

### auth_required：殼在 import 期，守門在 request 期

```python
def auth_required(fn):                    # ① import 期：只做這層包裝
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):         # ③ request 期：這裡才執行
        guard = runtime().adapters.auth_required   # 從袋子拿主專案的 jwt_required()
        return guard(fn)(*args, **kwargs)        # 現場套上、現場執行
    return wrapper
```

```{.mermaid cap="圖 3 — auth_required 殼的內外"}
%%{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 wrapper["wrapper（套件在 import 期做好的殼）"]
    subgraph guard["guard（主專案給的，request 期才填入）"]
      G1["Guidant AI：jwt_required()<br/>harness：只看 X-Auth header"]
      PUT["put(self, uid) ← 被包住的 route 方法"]
      G1 --> PUT
    end
  end
  NOTE["殼是固定的，套件寫死。<br/>虛線框的內容每次 request 從袋子取，主專案給什麼就是什麼。<br/>route 那邊一個字都不用改，因為它從頭到尾只說「去袋子拿」。"]
  wrapper -.-> NOTE
```

### capability_required：連「哪個能力點名」都推遲

```python
@capability_required(lambda rt: rt.config.device_update_capability)
def put(self, uid): ...
```

參數不是字串 `"device.update"`，是一個「給我袋子、我回你名字」的函式。原因相同：能力點名住在 `config`（主專案可以改），而 config 在 import 期還讀不到。殼在 request 期先 `runtime()` 拿袋子，呼叫這個函式拿到名字，再交給主專案的 `require_capability(name)`。

#### 能力點名字從哪來：`CAPABILITIES` 清單

`plugin/contract.py` 宣告 `CAPABILITIES: tuple[Capability, ...]`（型別 `jedi_common.plugin.Capability`，欄位 `name`／`resource`／`action`／`description`／`default_roles`／`is_platform`），在 `plugin/__init__.py` 的 `__all__` 曝露。21 支共 80 筆，沒有能力點的套件宣告空 tuple。

三條硬規則：

- **`read` 也要列。** BE route 只守寫入類，但前端選單與 `route_capabilities` 認 read。漏了的症狀是「選單看不到這個頁面」，而且**不會有任何測試變紅**。
- **`DEFAULT_*_CAPABILITY` 常數從清單取值**（`_BY_NAME["x.create"].name`），不得寫死字面量。清單是唯一來源，常數只是相容用的別名。
- **route 只透過 `config` 取名字**，不在 route 寫死字串。

版面守衛除了凍結名字集合、逐項檢查型別，還有一條 **AST 驗 `DEFAULT_*` 不是字面量**——🔴 這條不能在執行期用 `is` 比物件：CPython 會把同模組相同字面量摺疊成同一物件，改回寫死字串照樣綠。只有在原始碼層驗得出來。

### current_user_login_name：同樣去袋子拿，只是回值不是殼

```python
def current_user_login_name():
    getter = runtime().adapters.current_user_login_name
    return getter() if getter is not None else None
```

寫入審計欄位 `created_user` 時呼叫。沒注入回 None，審計欄位留空，但寫入不擋。它不是安全的門，安全的門是 `auth_required`。

### 為什麼叫 runtime()，不叫 ctx() {#naming}

runtime 在軟體界有三個常見用法，這裡取第三個：

| 用法 | 意思 | 例子 |
|---|---|---|
| 執行期（run time） | 程式「跑起來之後」的那段時間，相對於寫程式時（design time）和編譯時（compile time） | 「這個錯誤要 runtime 才會出現」 |
| 執行環境 | 讓程式跑起來所需的那層東西 | Node.js runtime、JVM、.NET runtime |
| **執行期組件** | 程式跑起來後才存在、用來支撐運作的物件 | `_RuntimeContext` 就是這個 |

袋子的 class 叫 `_RuntimeContext`，意思是「register 期才組出來、request 期才用到的東西」，import 期不存在。取用它的函式叫 `runtime()`，讀作「拿執行期組件」，語意對得上。

> **為什麼不叫 ctx：** ctx 是 context 的縮寫，業界直覺是「這個 request 的環境」：Koa 的 `ctx`、Go 的 `ctx` 都是每個 request 一份、裝 request 資料。但這裡的袋子是**整個 app 一份、開機裝好後只讀**，跟 request 無關。叫 ctx 會讓人問「這是哪個 request 的 context」，答案是「不是」。有 plugin 的 19 支都用同一個名字，是全系統唯一的取法。

> **為什麼不用 dependency_injector 的 @inject：** `@inject` 加 `Provide[Containers.asset_container.device_service]` 裡的 `Containers` 是主專案的 DI container，在 import 期就要寫死路徑。套件一旦這樣寫，就等於套件 import 主專案，正是抽套件要消滅的東西。`runtime().service("device_service")` 讓套件只認一個名字，工廠從哪來是主專案的事。

## 檔案：每個檔放什麼 {#files nav="檔案"}

`plugin/` 五檔、`api/` 四件，對外 import 路徑只有 `plugin/__init__.py` 與 `api/__init__.py` 兩個。

```{.mermaid cap="圖 4 — plugin 五檔與 api 四件的關係"}
%%{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 plugin["jedi_asset/plugin/"]
    PI["__init__.py<br/>register() 本體 ＋ re-export<br/>主專案只需要認識這一個檔。117 行。"]
    CT["contract.py<br/>宿主要填的表格<br/>AssetAdapters 等 5 個 dataclass<br/>＋ CAPABILITIES 能力點清單"]
    RT["runtime.py<br/>袋子本體 _RuntimeContext<br/>service() / reply() 兩方法"]
    AS["assembly.py<br/>組裝：build_services<br/>_assert_api_wiring / create_blueprint"]
    MG["migrations.py<br/>iter_migrations()<br/>讀隨包的 001 / 002 SQL"]
    PI --- CT
    PI --- RT
    PI --- AS
    PI --- MG
  end
  subgraph api["jedi_asset/api/"]
    AI["__init__.py<br/>只 re-export，20 行<br/>route 檔一律 from jedi_asset.api import …"]
    GD["guards.py<br/>runtime() 拿袋子<br/>三個 lazy decorator"]
    RO["routing.py<br/>mount_routes() 9 條 URL<br/>FROZEN_URLS 凍結清單"]
    RS["routes/<br/>device_route.py（5 條）<br/>information_system_route.py（4 條）<br/>serializers/ request / response schema"]
    AI --- GD
    AI --- RO
    AI --- RS
  end
  RT -. "guards.runtime() 拿的就是 runtime 的袋子" .-> GD
```

`runtime.py` 與 `guards.py` 是一對：runtime.py 定義袋子長什麼樣，guards.py 負責拿。主專案與 harness 只接觸 `plugin/__init__.py`；route 只接觸 `api/__init__.py`。

### plugin/ 五檔逐個講

#### `contract.py`（132 行）— 宿主要讀的東西全在這

五個 dataclass ＋ 能力點清單 ＋ 常數，**只有型別宣告、沒有任何邏輯**。接這支套件的人打開它就知道要填什麼：

| dataclass | 是什麼 | 關鍵 |
|---|---|---|
| `AssetAdapters` | **宿主要填的八格表**——三個守門（`auth_required`／`capability_required`／`current_user_login_name`）、四個選填 port（`identity`／`reference_counter`／`user_directory`／`response_builder`）、一個 service 名冊 | 守門三件**刻意無預設值**：預設放行會讓「忘記傳」變成九條無聲的公開端點，而設備清冊含主機名、IP、OS 版本 |
| `AssetServices` | service provider 名冊 | 🔴 **存 provider 不存實例**——service 內含 session-bound repo，存實例會讓第一個 request 建的物件用到天荒地老，平常看不出來、併發下才爆 |
| `AssetPluginConfig` | 純參數值（URL 前綴、blueprint 名、六個能力點名） | **不含行為**，行為一律在 adapters。六個能力點名的預設值是對外契約（FE 權限矩陣與 DB `capabilities` 表都認這些字串），開成欄位是讓宿主可覆寫，不是要換掉預設 |
| `SchemaExtensions` | 宿主要多收／多吐欄位時的 hook | 插槽開齊，目前無產品使用 |
| `PluginHandle` | `register()` 的回傳值 | `mount_api=False` 時 `blueprint` 為 `None` |

🔴 **這個檔不得 import flask**（有 AST 守衛盯著）：契約型別是 consumer 讀的東西，要能在沒裝 web framework 的情境下 import 得起來。需要 `Blueprint` 型別走 `TYPE_CHECKING`。

#### `runtime.py`（52 行）— 袋子本體

一個 `_RuntimeContext` dataclass，裝四樣：`adapters`、`config`、`schema_extensions`，以及**解析後**的 `services`。兩個方法：

- **`service(name)`** — 依名字取一個 service 實例（呼叫 provider）。provider 沒接線時**當場炸**而不是回 None——回 None 會讓錯誤延後到 `None.get_device()` 那行，訊息看起來像「service 有 bug」而不是「你漏接線」。
- **`reply(ok, data)`** — 組回應信封，宿主沒指定就退回 jedi-common 內建（形狀一致）。

> ⚠️ `services` 欄位存的是**解析後**的名冊，不可改成直接讀 `adapters.services`——那樣「宿主沒給 provider」就等於 service 不存在，而套件自己組得出來。

#### `assembly.py`（118 行）— 把 adapters 變成能用的東西

三支函式，`create_blueprint()` 是主線，另外兩支是它的步驟：

- **`build_services(adapters)`** — 宿主沒給 service provider 時，用 adapters 的 port 自己組一份。🔴 **這支不可以省**：三張選填 port 真正的消費者是兩支 service 的建構子，而 service 通常由宿主 DI 容器建；少了這條路，只照 README 接 port 的 consumer 會得到「`register()` 成功、九條 API 全綠，但暱稱恆空、引用計數恆 0、負責人存不進去」——三者降級都是安靜回空值，症狀像資料掉了而不是漏接線。
- **`_assert_api_wiring(adapters)`** — 掛 route 前先確認守門三件齊備，缺了**當場拒絕掛載**。🔴 不可改成「有就用、沒有就跳過」：跳過等於九條端點裸奔，而服務照常起得來、健康檢查照樣綠燈，是看不見的失效。
- **`create_blueprint(...)`** — 建 blueprint 但**不掛載**，供 consumer 自行 `register_blueprint`。執行期組件用 `record_once` 延後到掛上 app 那一刻才寫進 `app.extensions`，所以這支不吃 app 參數——同一套件掛到多個 app 時各自一份 context，不互相污染。

> **`Blueprint()` 第二參數寫死 `"jedi_asset.plugin"` 不用 `__name__`：** blueprint 的 `root_path` 吃這個值，不釘住的話拆檔會讓資源路徑跟著搬家。

#### `migrations.py`（18 行）— 隨包 SQL 的出口

`MIGRATIONS_DIR` ＋ `iter_migrations()`，回 `[(檔名, SQL), ...]` 依檔名序（三位數序號決定套用順序）。**套件只提供、不執行**：何時套、套進哪個庫、怎麼記錄已套過，全是宿主的維運決定。

> `MIGRATIONS_DIR` 要 `parent.parent`——SQL 住在套件根的 `migrations/`，不在 `plugin/` 底下，子套件深一層。

#### `__init__.py`（119 行）— `register()` 本體與唯一對外門面

`register(app, adapters, config, schema_extensions, mount_api)` ＋ 對外 re-export（`__all__` 列齊，版面守衛會比對）。`register()` 本身很短：組 adapters 與 config，然後分兩條路——

- `mount_api=True`：`create_blueprint()` 後 `app.register_blueprint(bp)`
- `mount_api=False`（把插件當 library 用）：🔴 **仍然必須寫 runtime context**。掛路由那條路靠 blueprint 的 `record_once` 寫入，而 `record_once` 只在 blueprint 真被掛上時才觸發；少了這段，「不掛路由」與「拿得到 `runtime()`」會變成綁死的二選一。

### api/ 四件逐個講

#### `guards.py`（64 行）— `runtime()` ＋ 三個 lazy decorator

本頁[前面那節](#guards)整節在講它：`runtime()` 一行把袋子拿出來，`auth_required`／`capability_required`／`current_user_login_name` 三個殼在 import 期做好、request 期才去袋子取主專案給的實作。

#### `routing.py`（59 行）— URL 的唯一真相

`mount_routes(bp)` 把九條 route 掛上 blueprint，**這份對照表是對外 URL 的唯一真相**；`FROZEN_URLS` 是對應的凍結清單，測試拿它與實際掛載結果做**集合比對**。

- ⚠️ **要比集合不比數量**：數量對但拼錯一條，症狀是該 API 對前端憑空消失 404，而 route 表上看不出任何異常。
- 九條路徑逐字凍結（FE 常數對著它們），含看起來不對稱的 `/devices` 與 `/information-systems/list`——那是實況，統一即為 breaking change。
- ⚠️ 裸路徑 `/device` 只承載 POST：get／put／delete 都要 uid，打裸路徑必 500。

#### `routes/`（一資源一檔）— flask-restful Resource

`device_route.py` 五條、`information_system_route.py` 四條。每個 class 命名 `<Type><動作>Route`，方法上疊三層殼（登入／能力點／取操作者），本體只做「驗欄位 → 叫 service → 包信封」。

#### `serializers/`（一資源一檔）— marshmallow schema

`device.py`、`information_system.py`，request 與 response schema 都在。**欄位是 FE 契約**，改欄位名等同改 API。

> **只有一個資源也要開子目錄。** route 檔與 schema 不得平鋪在 `api/` 頂層——讀者要能用同一條路徑在 21 支套件裡找到東西。

### 完整目錄

```text
jedi_asset/
├── plugin/                       ← 宿主接觸面
│   ├── __init__.py               register() ＋ re-export 全部公開名字
│   ├── contract.py               AssetAdapters / AssetPluginConfig / AssetServices / SchemaExtensions / PluginHandle ＋ CAPABILITIES ＋ DEFAULT_* 常數
│   ├── runtime.py                _RuntimeContext（袋子）
│   ├── assembly.py               build_services / _assert_api_wiring / create_blueprint
│   └── migrations.py             iter_migrations()
├── api/
│   ├── __init__.py               re-export
│   ├── guards.py                 runtime() ＋ auth_required / capability_required / current_user_login_name
│   ├── routing.py                mount_routes() ＋ FROZEN_URLS
│   ├── routes/                   DeviceListRoute / DeviceDetailRoute / DeviceCreateRoute / DeviceMenuRoute / DeviceReferenceRoute；InformationSystem 同型四支
│   └── serializers/              marshmallow schema，欄位是 FE 契約
├── app/
│   ├── dto/                      DeviceDTO / InformationSystemDTO / 兩個 MenuDTO
│   └── service/                  DeviceService / InformationSystemService（@transaction）；_audit_names.py 補暱稱共用一份
├── domain/
│   ├── entity/                   六個 @dataclass entity
│   ├── repository/               IDeviceRepo / IInformationSystemRepo
│   ├── service/                  兩支 DomainService，屬性統一 self._repo
│   └── ports.py                  IAssetReferenceCounter / IUserDirectory / _NullUserDirectory
├── infra/
│   ├── models/                   Device（public.devices）、InformationSystem（compliance.information_systems）
│   ├── repository/               兩支 RepoImpl，session 用 lazy property
│   └── mapper/                   model ↔ entity
├── common/
│   ├── error_code.py             ErrorCode（BaseCode 不是 Enum；字串凍結）
│   └── enum/                     AssetType ＋ 四個 FIPS-199 enum
└── migrations/                   001 建表（冪等）、002 RLS＋GRANT（需宿主生態）
harness/
├── dev_app.py                    最小宿主：假 adapters ＋ 兩張樁表 ＋ --migrate / --smoke
└── docker-compose.yml            專屬 postgres 5493
```

> SSP 序列化不在本套件，屬 jedi-oscal-v2。

## 主專案怎麼接 {#host nav="主專案接法"}

**這節是宿主那一側：主專案要寫什麼，套件才會活起來。**

接觸面只有**一個檔**：`core/plugins/asset.py`——adapter、填表、掛載三段都在裡面，
接手的人打開它就知道這支套件接了什麼。

### core/plugins/asset.py：一個檔三段

```python
# ① 這支套件開的 port，主專案怎麼答
class AssetReferenceAdapter(IAssetReferenceCounter): ...
class AssetUserDirectory(IUserDirectory): ...

# ② 填表
def build_adapters(container):
    asset = container.asset_container
    return AssetAdapters(
        services=AssetServices(
            device_service=asset.device_service,               # 🔴 沒括號：存工廠不存實例
            information_system_service=asset.information_system_service,
        ),
        identity=container.identity_container.identity(),
        reference_counter=asset.asset_reference_counter(),
        user_directory=asset.user_directory(),
        **host_defaults(),        # 守門三件＋信封，六支套件共用的那組答案
    )

# ③ 掛載
def mount(app, container, host):
    from jedi_asset.plugin import register
    register(app, build_adapters(container), mount_api=True)

PLUGIN = Plugin(
    name="asset",
    mount=mount,
    capabilities=jedi_asset.plugin.CAPABILITIES,   # 傳引用，不抄字串
)
```

> 🔴 **`capabilities=` 必帶。** `Plugin.capabilities` 預設 `None` 專門表達「沒填」並判紅；沒有能力點的套件傳**空 tuple**。這個區分是必要的：能力點列表 seed 在主專案 `scripts/init/04-seed-core.sql`，套件宣告了卻沒登記進 seed，症狀是客戶裝好後對應操作永遠 403，**沒有任何錯誤訊息**。`test/test_module_boundaries.py` 的五條守衛把 21 支宣告的 80 筆對 seed 的 127 筆分四類（套件 80／主專案 36／疑廢 11／待裁 `report` 1）並要求零未歸類；第五條守的是豁免名單本身，否則新抽出來的套件會靜靜落在掃描範圍外。

> **存工廠不存實例：** `asset.device_service` 是 `providers.Factory`，每 request 呼叫一次建一個新的 service，內含 session-bound repo。加了括號變成傳實例，第一個 request 建出來的物件會服務到天荒地老，平常看不出來、併發下才爆。

### core/plugins/__init__.py：清單就是掛載順序

```python
PLUGINS: list[Plugin] = [
    identity.PLUGIN, file_upload.PLUGIN, integrity.PLUGIN, notification.PLUGIN,
    system_core.PLUGIN, issue.PLUGIN, api_log.PLUGIN, asset.PLUGIN,
    participant.PLUGIN, bulletin.PLUGIN, survey.PLUGIN,
]
```

### core/app_factory.py：一個迴圈掛全部

```python
container.wire(modules=di_modules)      # 一定要先跑
...
for plugin in PLUGINS:
    plugin.mount(app, container, plugin_host)
```

必須在 `container.wire()` 之後，因為 adapters 要從 DI container 取工廠。把某支從 `PLUGINS` 拿掉就是「拔掉測試」：該模組端點全 404，但資料和 domain service 不受影響。

### host_defaults()：六支套件重複填的那四樣

`jwt_required()`、`require_capability`、`current_user_login_name`、`return_response` 這四樣多數套件填的內容逐字相同，統一收在 `core/plugins/_host.py` 的 `host_defaults()`。

它存在的理由不是少打字，是**改授權模型時只有一個落點**——各填各的話，漏改一支的症狀是那支套件的端點守門與別人不一致，而且不會有任何錯誤訊息。

> **不是每支都收這四樣，所以不能無腦 `**host_defaults()`**：`SystemCoreAdapters` 沒有 `current_user_login_name`、`FileUploadAdapters` 沒有 `capability_required`，多塞一個 key 就是 TypeError。收得齊的那兩支（asset／bulletin）用 splat，其餘按 key 取。

## port 與 adapter：介面在 domain、實作在 infra，但分兩邊 {#ports nav="port 與 adapter"}

**這節在講：套件需要「別的套件才知道的答案」時怎麼辦。** 例如資產套件想知道「這台設備被幾個稽核任務用到」——答案在任務套件手上，而套件之間不准互相 import。

規則是「介面在 domain、實作在 infra」，套件和主專案都適用。差別只有一個：**答案套件自己知道的，實作寫在套件；答案在別的套件裡的，實作寫在主專案。**

```{.mermaid cap="圖 5 — port 在套件 domain，adapter 分兩邊"}
%%{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 pkg["套件 jedi_asset/"]
    direction TB
    DOM["domain/ — 只有「我要什麼」，全是 abstract<br/>repository/device.py：IDeviceRepo（get_by_uid / add / update / get_menu …）<br/>ports.py：IAssetReferenceCounter（count_references(asset_type, id) → int）<br/>ports.py：IUserDirectory（get_id_by_uid(uid) / get_display_by_ids(ids)）"]
    INF["infra/ — 套件自己答得出來的，實作在這<br/>repository/device_repo_impl.py<br/>DeviceRepoImpl(IDeviceRepo)：設備存在 public.devices，套件知道"]
    GAP["套件答不出來的兩張 port<br/>「這台設備被幾個稽核任務引用」→ 答案在 jedi-task-platform 的三張表<br/>「負責人的 uid 對應哪個 users.id、暱稱是什麼」→ 答案在 jedi-iam<br/>套件不准 import 那兩支，只能留空等主專案填"]
    INF -- 實作 --> DOM
  end
  subgraph host["主專案"]
    direction TB
    OTHER["別的套件<br/>jedi_task_platform：三張 job↔device 關聯表<br/>jedi_iam：users 表、UserDomainService"]
    HINF["core/plugins/asset.py 的 ① 段<br/>AssetReferenceAdapter(IAssetReferenceCounter) → 問 jedi_task_platform.DeviceReferenceQuery<br/>AssetUserDirectory(IUserDirectory) → 問 jedi_iam"]
    HINF -- adapter 去問 --> OTHER
  end
  HINF -- 實作 --> GAP
```

主專案可以認識所有套件（它是組裝根），套件之間不准互相 import。所以「跨套件的膠水」只能住在主專案。

三個介面都在套件的 domain 層。DeviceRepoImpl 在套件的 infra，因為答案是套件自己的表。另外兩個 adapter 在**主專案**，因為答案在 task-platform 與 iam，套件不准認識它們。

**它們住哪：`core/plugins/asset.py` 的 ① 段，與接線同一個檔。** 按分層純度，adapter 是接外部系統的膠水、該歸 `infra/`；但拆兩處的代價是接手的人要翻兩個地方才知道這支套件接了什麼。決策者裁定：**一個檔看完，優先於分層純度**。

**唯一的例外是「別的層也在 import 它」**：搬進 `core/plugins/` 會讓 `app/` 或 `di_containers/` 反向 import 組裝根，形成循環。例如 `infra/upload_file/remote_agent_adapter.py` 被 `app/upload_file/service/` 直接用，就留在 infra。這類在守衛的白名單裡逐支登記，寫明誰在用它。

| 介面（domain） | 實作（infra） | 實作住哪 | 為什麼 |
|---|---|---|---|
| `IDeviceRepo` | `DeviceRepoImpl` | 套件 | 設備存在哪張表，套件自己知道 |
| `IAssetReferenceCounter` | `AssetReferenceAdapter` | **主專案 `core/plugins/asset.py`** | 答案在 task-platform，套件不准認識它 |
| `IUserDirectory` | `AssetUserDirectory` | **主專案 `core/plugins/asset.py`** | 答案在 jedi-iam，同上 |

三個都是「介面在 domain、實作在 infra」，repository 和 port 是同一個模式。叫 port 只是為了標記「這扇門通到套件外面」。判準：有 `@abstractmethod` 的是介面；答案要跨套件的，實作寫在主專案。

### core/plugins/asset.py 的 ② 段是什麼

那一段是主專案側的**組裝表**。套件在 `plugin/contract.py` 宣告了一張 `AssetAdapters` 表格（八個欄位：三個守門、一個工廠名冊、三張 port、一個信封建造器），`build_adapters()` 負責把主專案的東西一格一格填進去，回傳填好的表格給 `register()`。

```{.mermaid cap="圖 6 — build_adapters 把主專案材料填進 AssetAdapters 表格"}
%%{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 mat["主專案手上的材料"]
    direction TB
    M1["jwt_required()"]
    M2["common.authz.require_capability"]
    M3["get_user_context().login_name"]
    M4["同檔 ① 段的兩支 adapter"]
    M5["DI container 的 Factory"]
  end
  W["core/plugins/asset.py ② 段<br/>build_adapters(container)<br/>把左邊的材料一格一格填進套件宣告的表格<br/>全是「欄位＝材料」，沒有邏輯"]
  subgraph tbl["AssetAdapters（套件 plugin/contract.py 宣告）"]
    direction TB
    F1["auth_required"]
    F2["capability_required"]
    F3["current_user_login_name"]
    F4["reference_counter"]
    F5["user_directory"]
    F6["identity"]
    F7["services"]
    F8["response_builder"]
  end
  M1 --> W
  M2 --> W
  M3 --> W
  M4 --> W
  M5 --> W
  W -- 填 --> tbl
```

填好的表格交給 `register(app, adapters)`。之後套件裡的 route 用 `runtime()` 從袋子拿的，就是這張表格。

每支套件在主專案都有一支 `core/plugins/<pkg>.py`，形狀都一樣：① adapter ② 填表 ③ 掛載。它放 `core/` 不放 `api/` 或 `app/`，因為它是組裝根的一部分，被 app_factory 的迴圈呼叫、又要 import 守門與 DI container。

所以主專案接一支套件，寫的東西只有兩種：**adapter**（回答套件答不出來的問題）和**填表**（把 adapter 與其他材料裝進表格），兩者住同一個檔。runtime、register、袋子都在套件裡，主專案不碰。

## 新手教程：主專案接一支套件要動哪 3 個地方 {#tutorial nav="新手教程"}

以 jedi-asset 為例，照這個順序做，做完 API 就長出來。每一步寫「檔案在哪、加什麼、為什麼」。

> **為什麼是三處：** adapter 與填表同住一個檔、掛載是清單加一列，於是必做的只有這三處，第四處看情況。

```{.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 LR
  S1["① pyproject.toml<br/>宣告依賴"]
  S2["② core/plugins/asset.py<br/>① adapter ② 填表 ③ 掛載<br/>一個檔看完"]
  S3["③ core/plugins/__init__.py<br/>PLUGINS 加一列"]
  S4["（④ 看情況）di_containers/<br/>只有主專案別的模組<br/>要直接用套件 domain service 才加"]
  LOOP["core/app_factory.py<br/>for p in PLUGINS: p.mount(...)<br/>已經寫好，不用改"]
  PKG["jedi-asset<br/>register(app, adapters)"]
  S1 --> S2
  S4 -.-> S2
  S2 --> S3
  S3 --> LOOP
  LOOP --> PKG
```

### ① pyproject.toml：宣告依賴

```toml
# [project] dependencies
"jedi-asset==0.1.0",

# 開發期改 path dependency（不 commit），改動套件源碼重啟 BE 即生效
# [tool.poetry.dependencies]
jedi-asset = { path = "/Users/…/jedi-python-package/jedi-asset", develop = true }
```

然後 `poetry update`。不要用 `poetry lock`，會卡很久。

### ② core/plugins/asset.py：三段寫完

照抄任一支現成的，改名字就好。三段分別是：

**① 這支套件開的 port，主專案怎麼答。** 套件在 `domain/ports.py` 宣告抽象介面，主專案給實作——這是「主專案告訴套件它不知道的事」。

```python
class AssetReferenceAdapter(IAssetReferenceCounter):
    def __init__(self, device_reference_query=None):
        self._q = device_reference_query          # 來自 jedi-task-platform
    def count_references(self, asset_type, asset_id):
        if asset_type != AssetType.HARDWARE: return 0   # 資訊系統無外鍵引用，實查 0 條
        return self._q.count_by_device_id(asset_id)


class AssetUserDirectory(IUserDirectory):
    def __init__(self, user_domain_service):     # 來自 jedi-iam
        self._users = user_domain_service
    def get_id_by_uid(self, uid): ...
    def get_display_by_ids(self, ids): ...
```

宿主 import 其他套件（task-platform、iam）是合法的，宿主是組裝根，它的工作就是把各套件接起來。疆界規則管的是套件之間。

**② 填表。** 把材料裝進套件宣告的 `AssetAdapters`：

```python
def build_adapters(container):
    asset = container.asset_container
    return AssetAdapters(
        services=AssetServices(
            device_service=asset.device_service,               # 工廠，不加括號
            information_system_service=asset.information_system_service,
        ),
        identity=container.identity_container.identity(),
        reference_counter=asset.asset_reference_counter(),
        user_directory=asset.user_directory(),
        **host_defaults(),        # 守門三件＋信封
    )
```

**③ 掛載。** 呼叫套件的 `register()`，檔尾宣告 `PLUGIN`（`capabilities=` 必帶，見[主專案接法](#host)）：

```python
def mount(app, container, host):
    from jedi_asset.plugin import register
    register(app, build_adapters(container), mount_api=True)

PLUGIN = Plugin(name="asset", mount=mount, capabilities=jedi_asset.plugin.CAPABILITIES)
```

> **為什麼 `mount()` 自己呼叫 register，而不是讓迴圈組裝？** 實查十一支後發現六支的 `register()` 形狀都不一樣：身分要讀 `app.config`、上傳與操作日誌要多一個 config 物件、完整性要兩個具名參數且沒有 `mount_api`、人員指派的簽名是 `(app, container)`、問卷只在 socketio 模式掛。**逃生口若有一半的人要走，主路徑就是錯的形狀**——改成每支自己說怎麼掛，特殊處留在自己那個檔裡看得到，不散成迴圈上的一串 if。

### ③ core/plugins/__init__.py：清單加一列

```python
PLUGINS: list[Plugin] = [
    identity.PLUGIN, file_upload.PLUGIN, ..., asset.PLUGIN, ...
]
```

順序就是掛載順序。已知有實質依賴的兩處：`identity` 必須早於 `file_upload`（上傳的身分名冊用到 auth_container），`api_log` 必須晚於 `configure_logging()`（否則 `dictConfig` 會把轉發鏈的 handler 丟掉）。其餘沒有理由不要重排。

`core/app_factory.py` 的迴圈已經寫好，不必改。

### ④（看情況）di_containers/：只有別的模組要用才加

**只有在「主專案其他模組要直接用套件的 domain service」時才做這一步。** 純掛 API 不需要。

jedi-asset 需要，因為 flow_control、module_frame、associations 共 6 處直接
`Provide[asset_container.device_domain_service]`。

```python
class AssetContainer(containers.DeclarativeContainer):
    auth_container = providers.DependenciesContainer()     # 要用到 jedi-iam 的 user_domain_service

    identity = providers.Singleton(IdentityContext, user_name_resolver=…)   # 補暱稱用
    device_repo = providers.Singleton(DeviceRepoImpl)
    device_domain_service = providers.Factory(DeviceDomainService, device_repo=device_repo)
    asset_reference_counter = providers.Factory(AssetReferenceAdapter, device_reference_query=…)

    device_service = providers.Factory(                     # ← 這個就是之後交給套件的「工廠」
        DeviceService,
        device_domain_service=device_domain_service,
        identity=identity,
        reference_counter=asset_reference_counter,
    )
```

再掛進總 container：

```python
asset_container = providers.Container(AssetContainer, config=config.asset, auth_container=auth_container)
```

> **陷阱：identity 必須在這裡注入。** 補暱稱是套件的 app service 做的，所以建 service 時就要給。只放進 ② 的 adapters 不放這裡，症狀是 `created_user_name` 恆為 null 而 API 照常 200——沒有錯誤訊息，只能手測看得出來。

### 驗「接對了」

重啟 BE，九條 `/api/1.0/devices*` 端點就在了。打 `GET /api/1.0/devices/menu` 帶 token 回 200、不帶回 401；新增一筆看 `created_user_name` 有暱稱。

> **拔掉測試：** 把該支從 `PLUGINS` 拿掉重啟，九條端點應全部 404，其他功能照常。這是 D6 契約的驗收條件：套件拔掉不能拖垮宿主。

**守衛**（`test/test_module_boundaries.py`）會擋五種走鐘：adapter 放回 `infra/`、套件上移後主專案目錄長回來、插件檔缺 `mount()` 或 `PLUGIN`、`PLUGIN.name` 與檔名對不上、`capabilities` 沒填或對不上 seed。

## DI 與 runtime 的關係：同一個容器，最後一哩路換個遞法 {#di nav="DI 與 runtime"}

**這節回答一個常被問的問題：「套件是不是自己搞了一套 DI？」不是。** 整個系統只有一個 DI 容器，在主專案；套件沒辦法直接拿到它，所以主專案把容器建好的東西打包遞過去。差別只在最後一步怎麼遞。

### 先講清楚三個詞

| 詞 | 意思 | 誰在用 |
|---|---|---|
| 依賴反轉（DIP） | 原則。高層不依賴低層，兩者都依賴抽象介面。Java 圈的「介面在上、實作在下、外面塞進去」就是它 | 主專案與套件都用，一路到底 |
| 依賴注入（DI） | 做法。物件需要的東西由外面給，不自己建。建構子收參數就是最基本的形式 | 主專案與套件都用 |
| DI 容器 | 工具。幫你算依賴順序、建實例。這裡是 dependency_injector 的 Container | **只有主專案有**，套件不知道它存在 |

所以「套件不用 DI」這句話是錯的。套件用 DIP 也用 DI，只是沒有 DI 容器。harness 沒容器就三行手寫，效果一樣。

### 為什麼套件不能直接用主專案的容器

主專案的 route 拿 service 是這樣寫的：

```python
from di_containers.containers import Containers          # 主專案的類別

class DeviceRoute(MethodResource):
    @inject
    def put(self, uid, service=Provide[Containers.asset_container.device_service]):
```

`Provide[Containers...]` 在 Python 載入檔案的那一刻就要求 `Containers` 存在。套件的 route 檔若這樣寫，套件就 import 了主專案，「套件不認識宿主」這條線立刻破，抽套件的意義就沒了。

```{.mermaid cap="圖 8 — @inject 過不了套件邊界，register 遞包裹"}
%%{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 host["主專案"]
    direction TB
    DI["DI 容器（唯一一個）<br/>建主專案自己的 service、repo<br/>也建套件要的 adapter 與套件 service 的工廠"]
    HR["主專案自己的 route<br/>@inject + Provide[Containers.xxx]<br/>直接認識容器，沒問題"]
  end
  subgraph pkg["套件（不准 import 主專案）"]
    direction TB
    BAD["route 寫 @inject？<br/>Provide[Containers...] 要 import 主專案<br/>→ 過不了那條線，守衛紅"]
    GOOD["route 寫 runtime()<br/>從袋子拿主專案遞過來的包裹<br/>不需要認識容器"]
  end
  DI -- "register(app, adapters)<br/>把容器建好的東西打包遞過去" --> GOOD
  DI -. "@inject 過不了邊界" .-x BAD
```

左邊主專案的 route 直接用 `@inject`，右邊套件的 route 不能。同一個容器建的東西，差別只在最後怎麼遞到 route 手上。

### 試過的三條路，為什麼都不行

| 做法 | 為什麼不行 |
|---|---|
| 套件自己定義一個 Container，主專案 `override()` 它 | 套件就依賴 dependency_injector 這個套件。第二個產品若用別的 DI 框架或不用框架，裝不了。而且 override 要主專案認識套件容器的內部結構 |
| `Provide["asset.device_service"]` 用字串路徑 | 字串還是綁定某個容器的命名，且 `container.wire()` 要列出套件內部的 route 模組名，等於主專案認識套件內部結構 |
| 主專案在 `before_request` 把 service 塞進 Flask 的 `g` | 這就是 Service Locator，跟 `runtime()` 是同一件事，只是換個袋子 |

第三條看清楚就知道：**現在的做法就是「用 DI 一起處理」的結果**。容器照樣建所有東西，只是最後遞給套件的那一步不能用 `@inject`，改用 `register()` 遞一個包裹。差別只在遞送方式。

### 為什麼 Java 沒這問題

Spring 是整個 Java 生態的事實標準，套件和宿主用同一個容器是預設。Python 沒有這種標準：dependency_injector、injector、punq、不用框架都常見。套件要「隨插即用」就不能假設宿主用哪一個，所以只能收建構子參數、不綁容器。

> **一句話：** 同一個 DIP 原則、同一個 DI 容器、多一個包裹讓套件不用認識容器。`register()` 是主專案遞包裹、`runtime()` 是套件拆包裹，兩個加起來就是那最後一哩路。

## 複雜度評估：接一支套件算不算複雜 {#complexity nav="複雜度"}

**這節回答「這套東西會不會太複雜」：不會，接一支要寫的只有三個地方、約 270 行，其中大半是註解。**

**3 個必做的地方**（第四個看情況），jedi-asset 這支約 270 行。

| 地方 | 行數 | 可不可以省 |
|---|---|---|
| ① pyproject.toml | 1 | 不能 |
| ② core/plugins/asset.py（① adapter ② 填表 ③ 掛載） | 167（含大量註解） | 不能，這是主專案的知識 |
| ③ core/plugins/\_\_init\_\_.py 加一列 | 1 | 不能 |
| （④）di_containers/asset/ ＋ containers.py | 約 100 | **只有別的模組要用套件 domain service 才需要** |

`core/app_factory.py` 那一行不再需要——迴圈已經寫好。全部十一支插件檔合計約 2160 行，
其中身分那支佔 646 行（登入編排、租戶開通、政策來源都在那條路上），其餘十支平均約 150 行。

**收斂本身的帳**：`core/app_factory.py` 從 662 行降到 450 行（11 段 register、222 行
收成一個四行迴圈）；散在 `infra/` 的九支 adapter 檔與六支 `core/*_wiring.py` 併成十一個檔。
行數總量沒有明顯下降（註解與 docstring 都逐字保留），**省的是「要翻幾個地方」**：
從三到五處變成一處。

### 發現：三張 port 給了兩次

看 ④ 和 ②，`identity`、`reference_counter`、`user_directory` 三樣東西**兩邊都有**：DI container 把它們注進 service 建構子，`build_adapters()` 又把它們放進 adapters。`core/plugins/asset.py` 自己的註解承認「兩處給同一支 resolver」。

```{.mermaid cap="圖 9 — port 雙重注入"}
%%{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 c3["④ DI container"]
    C3["device_service = Factory(DeviceService,<br/>    identity=identity,            ← 用到<br/>    reference_counter=counter)    ← 用到<br/><br/>主專案有給 services 工廠，所以套件用的是這裡建的 service"]
  end
  subgraph c5["② core/plugins/asset.py 填表段"]
    C5["AssetAdapters(<br/>    identity=IdentityContext(…),     ← 沒人讀<br/>    reference_counter=…,             ← 沒人讀<br/>    user_directory=…)                ← 沒人讀<br/><br/>只有「主專案沒給工廠」時 build_services 才會讀"]
  end
```

Guidant AI 走的是左邊那條路，右邊三行在本產品是死的。它們存在是因為 README 教第二個產品「只給 port、不給工廠」時要走右邊。兩邊都填是保險，不是需要。

### 刪掉那三行會怎樣（實際追過程式碼）

套件內唯一讀 `adapters.identity`／`reference_counter`／`user_directory` 的地方是 `plugin/assembly.py` 的 `build_services()`，邏輯是「宿主給了工廠就用宿主的，沒給才用這三個 port 自組」。Guidant AI 在 `core/plugins/asset.py` 的 `build_adapters()` 裡**有給工廠**，所以自組那條路永遠不會跑，三個欄位存進袋子後沒有任何人讀。主專案側也沒有任何地方從袋子讀它們。

| 刪掉後 | 結果 |
|---|---|
| Guidant AI 行為 | 一模一樣，零影響 |
| 開機檢查 | `_assert_api_wiring` 只查守門三件，不查這三項，不會炸 |
| 失去的保險 | 若日後有人把 `services=` 那段也拿掉，套件退回自組路徑，這三項缺了會讓暱稱、引用數、負責人**安靜地變空**，API 照常 200 |
| 測試覆蓋 | 目前沒有任何測試守「主專案有沒有給 services」。要刪就該補一支：斷言 `build_adapters(container).services.device_service is not None` |

> **結論：可以刪，但不急。** 實際內容是「刪三行＋補一支守衛測試＋README 明寫『有 DI 的宿主給工廠，沒 DI 的宿主給 port，不要兩邊都給』」。它不是 bug、不影響行為、不影響資安掃描。真正的價值是那條規則本身：**有 DI 的宿主給工廠，沒 DI 的宿主給 port，不要兩邊都給**。

另一個方向「主專案不給工廠、讓套件自組」不可行：主專案 flow_control、module_frame、associations 共 6 處直接 `Provide[asset_container.device_domain_service]` 用 domain service，那些 provider 還是得留在 DI container，省不到多少。

### app_factory.py 為什麼只有一個迴圈

`core/app_factory.py` 掛全部插件只有四行：一個 `for` 逐支呼叫 `mount()`。共通說明
（順序就是掛載順序、兩處實質依賴）只寫一份在迴圈上方；各支特有的陷阱（例如任務平台
兩半掛法不同、問卷只在 socketio 模式登記）留在自己那個檔的 `mount()` docstring
裡——**那才是下一個改它的人會打開的地方**。

`Plugin(name, mount)` 讓**每支自己說怎麼掛**，而不是由迴圈組裝參數。理由見
[新手教程 ②](#tutorial) 的註：六支的 `register()` 簽名各不相同，統一組裝的形狀套不進去。

## 設計模式對照：主框架與零件 {#patterns nav="設計模式"}

**這節給想確認「這是不是自己發明的怪東西」的人看：不是。** 主軸是一個業界標準框架（六角架構），其餘六樣是它落地時必然長出來的零件，每個都有出處。

### 主框架：六角架構（Ports and Adapters）

業務核心在中間，所有與外界的接觸都經過「port」（介面），外界透過「adapter」（實作）接上來。DB、HTTP、認證系統、別的套件，全部是外界。Alistair Cockburn 2005 年提出，與 Clean Architecture、Onion Architecture 是近親。

```{.mermaid cap="圖 10 — 六角架構對照 jedi-asset"}
%%{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 hex["六角（套件 jedi_asset/）"]
    direction TB
    IN["api/ 進入 adapter<br/>HTTP → 呼叫核心"]
    CORE{{"app/ ＋ domain/<br/>業務核心<br/>ports.py 在這<br/>不知道 HTTP、DB、宿主是誰"}}
    OUT["infra/ 出去 adapter<br/>核心 → 呼叫 DB"]
    IN --> CORE --> OUT
  end
  A1["主專案 core/plugins/asset.py ①<br/>AssetReferenceAdapter"]
  A2["主專案 core/plugins/asset.py ①<br/>AssetUserDirectory"]
  W1["主專案 core/plugins/asset.py ②<br/>jwt_required 等守門"]
  W2["harness dev_app.py<br/>假守門、假 port"]
  A1 -- 實作 port --> CORE
  A2 -- 實作 port --> CORE
  W1 -- 填 adapters --> CORE
  W2 -- 填 adapters --> CORE
```

DDD 的四層與六角架構是同一件事的兩種畫法：DDD 講 api／app／domain／infra 是分層，六角講「核心在中間、adapter 在外圈」是同心圓。主專案本來就是這樣，套件只是把同一結構縮小成可拆的一塊。

### 零件對照表

| 這裡的東西 | 對應的模式 | 在六角架構裡的角色 | 有沒有選擇 |
|---|---|---|---|
| `domain/ports.py` 兩個 ABC ＋ 主專案 `core/plugins/asset.py` 實作 | **Ports and Adapters**（Cockburn 2005） | 框架本身 | 這就是主軸 |
| `AssetAdapters` 這張表格 | **Parameter Object** ＋ DDD 的 **Anti-Corruption Layer** 精神（Fowler；Evans） | 把一堆 adapter 打包成一個參數，宿主在邊界把自己的世界翻譯成套件認得的形狀 | 不打包就要傳八個參數 |
| `register(app, adapters)` | **Plugin** 註冊入口（Fowler《企業應用架構模式》） | 套件接上宿主的唯一入口 | 任何插件系統都有一個 |
| `_RuntimeContext` 存進 `app.extensions`，`runtime()` 取 | **Service Locator**（Fowler 2004） | route 層拿 adapter 的方法 | **唯一有替代方案的**，但 flask-restful 的方法簽名固定塞不進 DI，Flask 生態慣例就是這樣 |
| `runtime().service("device_service")()` 傳工廠不傳實例 | **Abstract Factory** 簡化版（GoF） | 每 request 建新的 service | service 內含 session，Flask ＋ SQLAlchemy 的既定限制 |
| `_NullUserDirectory` | **Null Object**（Woolf 1996） | 選填 adapter 缺了時的預設實作 | 不用它就要到處 `if x is None` |
| `contract.py` 五個 dataclass 只放型別不放行為 | **Interface Segregation**（SOLID 的 I） | 契約與實作分離，consumer 不裝 flask 也能 import | 不分就得為了讀型別裝整套 web framework |

所以不是「混了七種模式」，是「一個框架加上它落地時必然長出來的六個零件」。每個零件拿掉都會在別的地方付出代價。

### 業界是不是這樣做

| 同型的東西 | 對照 |
|---|---|
| Flask 擴充套件慣例（Flask-SQLAlchemy、Flask-Login、Flask-JWT-Extended） | 全部是 `ext.init_app(app)` 一個入口、狀態存 `app.extensions[name]`、用時從那裡取。`register()` 與 `runtime()` 就是這個慣例，只是多了一張 adapters 表格讓宿主填。 |
| pytest plugin、Django app | 「套件宣告需要什麼、宿主在註冊時提供」。 |
| Spring Boot Starter、Go Wire | starter 就是「一個套件附帶自己的 auto-configuration，宿主只要加依賴」，與 D6 契約「安裝後就有這個 API」是同一個目標。 |
| Clean Architecture、Onion Architecture | 六角架構的近親，同心圓、依賴向內。 |

真正業界少見的是「同一支套件同時要能插進大宿主、又要能零宿主獨立起來」這個要求。harness 是為了驗證這件事而加的，多數 Flask 擴充不會做到這步。

### 該知道的一個限制

- **綁 Flask。** `app.extensions` 是 Flask 的全域字典，等於套件狀態放在全域變數裡。Flask 生態接受這件事（`current_app`、`request` 本身就是全域）。換到 FastAPI 或 Django 要重做 `register()` 與 `runtime()` 那層，但 app／domain／infra 三層不用動。這正是六角架構的承諾：換外圈不動核心。

> **建不建議在程式碼裡改名去「符合模式」：不建議。** 現在的名字（adapters、ports、register、build_services）已經是這些模式的慣用詞。模式是用來溝通的，不是用來遵守的。這張表放進 SOP §4.3 或套件 README 讓新人查出處就夠。

## harness 是什麼 {#harness nav="harness"}

**這節在講：怎麼證明「套件真的能離開 Guidant AI 自己跑」。** 光用說的沒有人能確認，所以套件自己附帶一個假宿主，跑起來就知道。

harness 是一個 100 多行的 Flask 程式，不是測試框架。它扮演主專案的角色把套件接起來——如果套件偷偷 import 了主專案的東西，它就起不來。

```{.mermaid cap="圖 11 — 同一個套件、兩個宿主"}
%%{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
  H1["宿主一：Guidant AI<br/>core/plugins/asset.py<br/>認證：jwt_required()<br/>授權：require_capability<br/>身分：jedi-iam 查暱稱<br/>引用數：問 task-platform 三張表<br/>service：DI container 工廠<br/>DB：DEV 開發庫 25432<br/>正式產品，真的一切"]
  PKG["jedi-asset 套件<br/>同一份程式碼<br/>只認 register(app, adapters)"]
  H2["宿主二：harness<br/>harness/dev_app.py<br/>認證：只看 X-Auth header<br/>授權：一律放行<br/>身分：回「帳號（測試暱稱）」<br/>引用數：固定回 7<br/>service：不給，逼套件自組<br/>DB：專屬 postgres 5493<br/>全部是假的，但形狀一樣"]
  H1 -- 接 --> PKG
  H2 -- 接 --> PKG
```

兩個宿主填的是同一張 AssetAdapters 表格。套件分不出誰在接它，這就是「插頭」的意思。如果套件偷偷 import 了主專案的東西，或偷偷連了主專案的 DB，右邊那個宿主就起不來。harness 的價值就是把這種隱性耦合逼出來。

### 它證明什麼、不證明什麼

| 證明（跑 --smoke 全綠代表這五件事成立） | 不證明（誠實聲明） |
|---|---|
| 套件 import 得起來，不需要主專案任何一個檔 | 沒有真的認證，`X-Auth` 是假守門 |
| 隨包的建表 SQL 套得起來（走 `iter_migrations()`） | 沒有 RLS，只套 001 建表、不套 002（RLS 和 GRANT 需要 `cm_app` 角色與租戶判定函式，那是宿主生態） |
| `register()` 掛得上一個乾淨的 Flask app，9 條 route 真的出現 | 不測業務邏輯正確性，那是 `tests/` 的事 |
| 端點實打走得通（缺 header 401、給 header 200） | |
| 三張 port 真的傳到 service 手上，不靠主專案的 DI container | |

### 三個指令

```bash
docker compose -f harness/docker-compose.yml up -d    # 起專屬 postgres，port 5493；刻意不連開發庫
python harness/dev_app.py --migrate                   # 自建 tenants/org_units 兩張樁表，再套套件的 001
python harness/dev_app.py --smoke                     # 不起服務，用 test client 打三下就結束；給 CI 用
python harness/dev_app.py                             # 真的起在 5093，另開終端 curl 打
```

### 兩道宿主前提：這是 harness 最有價值的產出

寫 harness 的過程逼出兩件「套件對宿主的隱性要求」，不寫 harness 永遠不會知道，因為主專案剛好都滿足了：

- **`ENABLE_MULTI_TENANT` 必須在 import model 之前設好。** `TenantScopedMixinModel` 在 class 定義的瞬間決定要不要長 `tenant_id` 欄位，晚設等於沒設。症狀是寫入時 NotNullViolation，看起來像資料問題。
- **`tenants` 與 `org_units` 兩張表要在 SQLAlchemy metadata 裡。** mixin 的外鍵在 mapper 設定期就要解析得到，只在 DB 建表沒用。harness 用 `Base` 宣告只有 id 和 name 的最小版本。宣告越少，「套件對宿主要求多少」的答案越誠實。

> **為什麼 port 刻意避開 5432 和 25432：** 連了開發庫就測不出隱藏耦合。如果套件某處偷偷 hardcode 了主專案的表名，用開發庫跑會過，用空庫跑才會炸。harness 要的就是那個炸。

## 一個 request 的旅程 {#request nav="request 旅程"}

以 `PUT /api/1.0/device/abc` 為例，看哪幾步去袋子拿了東西。

```{.mermaid cap="圖 12 — request 經過五層，六次從袋子取東西"}
%%{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 C as Client
  participant API as api 層<br/>DeviceDetailRoute.put
  participant BAG as 袋子<br/>app.extensions["jedi_asset"]
  participant APP as app 層<br/>DeviceService.update_device
  participant DOM as domain 層<br/>DeviceDomainService
  participant INF as infra 層<br/>DeviceRepoImpl
  C->>API: PUT /api/1.0/device/abc {"name","ip"}
  API-->>BAG: ① 拿 auth_required → jwt_required 驗 token，沒 token 401
  API-->>BAG: ② 拿 capability_required ＋ config 名字 "device.update" → require_capability 驗
  API-->>BAG: ③ current_user_login_name() → 拿操作者帳號
  API-->>BAG: ④ rt.service("device_service") → 拿工廠建 service（DeviceRequest().load(body) 驗欄位）
  API->>APP: update_device(entity)
  Note over APP: @transaction 開 session；組 DeviceEntity；找不到 uid 就 raise NotFound
  APP->>DOM: update_device → self._repo.update(entity)
  DOM->>INF: update
  Note over INF: self.session 這時才拿到；查 model、更新、flush
  INF-->>DOM: entity
  DOM-->>APP: entity
  APP-->>API: entity→DTO，fill_user_names 補暱稱 → DeviceResponse().dump
  API-->>BAG: ⑤ rt.reply() → 拿 return_response 組信封
  API-->>C: 200 {status: true, data}
```

袋子裡這趟被拿到的東西：`auth_required`、`capability_required`、`config.device_update_…`、`current_user_login_name`、`services.device_service`、`reply → return_response`。（`identity` / `reference_counter` 在建 service 時一起注入，不再經袋子。）

虛線就是「去袋子拿」。一個 request 拿了六次，全在 api 層的頭尾；中間三層（app / domain / infra）完全不知道袋子存在，它們需要的 port 在建 service 時就注入好了。把宿主換成 harness，這條路一步都不用改。
