# FR-069 抽取 SOP checklist——「把一個模組抽成 jedi 插件套件」逐步指南

> 狀態：P1（jedi-ai-bot）實跑驗證後定稿｜建立日期：2026-08-29｜對應卡：CM-1437
> 適用範圍：FR-069 P2–P5 的每一支套件抽取。**照著做，遇到不一致以本文為準、並回頭更新本文。**

## 變更紀錄

| 日期 | 變更 | 對應 |
|------|------|------|
| 2026-08-29 | 初版——P1 jedi-ai-bot 實跑一遍後寫成，含 D6 插件契約、D7 harness／README、四道防線簽名 | CM-1437 |
| 2026-08-29 | P4 jedi-remote-agent 實跑後補：**§4.2 migration 隨包機制定案**（首例）、§1 盤點加「消費端建構點」與「signature 變更」兩項、§6.1 harness 三道前提、§5 大模組的 DI container 保留判準 | CM-1440 |
| 2026-08-30 | P3 jedi-log-forwarding 實跑後補：**§3.4 logger 名三連坑**（抽走 logging 相關模組必踩）、§1 盤點加「模組有沒有背景常駐元件」、§5 加「core/ 不可 import api/」、§6 加「跑 logging 的模組要驗『真的送到對面』」 | CM-1439 |
| 2026-08-30 | P5 jedi-license-runtime 實跑後補：§1 盤點加「共用 error code 歸屬」、§3.2 斷法表加「多語系文案」、§4 加 `HANDLE_KEY` 慣例、§4.2 加「seed 列與非 RLS 表」兩條、§6.2 加「平行 session 共用檔」的 staging 手法、§8 加「schema 對照 DEV 實況」 | CM-1441 |

---

## 0. 先讀這段（30 秒版）

抽一支套件＝**七個階段、一條不可倒的順序**：

```
① 盤點  →  ② 建套件骨架  →  ③ 搬檔＋斷線  →  ④ 定 register 簽名
      →  ⑤ 主專案改接線  →  ⑥ 本地驗證（path dependency）  →  ⑦ 發版＋pin
```

**⑥ 之前不要碰 ⑦，⑦ 之前不要 commit「主專案移除目錄」**——順序倒了會出現
「committed 狀態沒源碼、又裝不到套件」的死結。

三個「等 user 明示才做」的動作：**發版推 Nexus**、**push**、**收尾類文件**。
其餘（commit、Notion 回寫子卡）做完就做，不用問。

---

## 1. 階段 ①：盤點（動手前，約 20 分鐘）

- [ ] **列出模組全部檔案**：`find app/<mod> api/<mod> infra/<mod> domain/<mod> common/<mod> -type f -name '*.py'`
- [ ] **找出所有 outgoing import**（模組往外依賴誰）——這是要斷的線：
      `grep -rn "^from \(common\|config\|app\|domain\|infra\)\." app/<mod>/ | grep -v __pycache__`
      ⚠️ 也要抓**縮排的 lazy import**（函式內 import），grep 錨定行首會漏。保險做法是用
      AST 掃（`test/test_module_boundaries.py` 的 `_imported_modules` 可直接複用）。
- [ ] **找出所有 incoming import**（誰依賴這個模組）——import 名不變的話這些不用改，
      但要知道有哪些，驗收時抽測：`grep -rn "app\.<mod>\|api\.<mod>" --include="*.py" .`
- [ ] **查 DI 註冊點**：`di_containers/containers.py` 有沒有這個模組的 container、
      `config/app_modules.py` 有沒有註冊行。
- [ ] **查 DB 表**：有表就走 §4.2 的「migration 隨包」（P4 已定案）。主專案既有的
      `scripts/sql/` 腳本**不動、不重複登記**——套件的 migration 是給其他 consumer 用的。
- [ ] 🔴 **列出所有「會被改 signature」的公開函式與類別建構子**，再 grep 全部呼叫點。
      抽套件幾乎必然改到兩種簽名：①「原本讀 env 的」改成收參數（如
      `jwt_util.mint(..., settings=)`）；②「原本直接持有主專案 service 的」改成收 port。
      **正式碼的呼叫點與測試一樣多、一樣容易漏**——P4 就漏了四支正式碼的 `mint()`
      呼叫（detection_result_handler／agent_cancel_client／agent_probe_client／
      remote_agent_adapter），是測試跑起來才抓到。這些路徑（agent 取消、探測、檔案
      上傳）平常不在手測動線上，漏了會是 live 故障。
- [ ] 🔴 **列出消費端的「建構點」，不只 import 點**：import 名不變不代表建構不用改。
      DI container、測試的 helper 工廠、其他模組的手動 `Service(...)`，只要建構子多了
      參數就全要改。
- [ ] 🔴 **查該模組的 error code 有沒有被主專案的執法點共用**。`common/code/<mod>_error_code.py`
      常是「套件拋的碼」與「宿主執法拋的碼」混在同一個 class（license 就是：400/404/409
      在套件、403 在宿主的 authz 與 middleware）。**兩邊各留一份會靜默漂移**——而字串值
      是對外契約（FE `error-code.json` 靠它比對做 i18n，值一變整批錯誤訊息掉回英文
      fallback，且沒有任何錯誤）。作法：**定義搬進套件，主專案那支改成 re-export shim**
      （`from jedi_<name>.common.code import XxxErrorCode`），既有 import 路徑一字不用改。
- [ ] 🔴 **這個模組有沒有「背景常駐元件」**（執行緒 / 排程 / 掛在全域單例上的東西）？
      有的話它的**啟動時機與生命週期**要一起搬，而且要盤出：誰啟動它、依賴什麼先就緒、
      fork 之後要不要重建、宿主的哪些後續動作會影響它（見 §3.4 ③）。純 CRUD 模組沒有
      這個問題，一旦有就是抽取風險最高的部分——它不像 route 那樣「壞了立刻 404」，
      而是**靜默地不動作**。
- [ ] **確認落點編號**：monorepo `~/Projects/Jedicogy/module/jedi-python-package/`
      新建 `jedi-<name>/`；dist 名連字號、import 名底線。

**盤點產出**：一張「要搬哪些檔 / 要斷哪幾條線 / 每條線怎麼斷」的清單。斷法選擇見 §3.2。

---

## 2. 階段 ②：建套件骨架

在 monorepo 根目錄下 `mkdir jedi-<name>` 後建立：

```
jedi-<name>/
├── jedi_<name>/
│   ├── __init__.py                  # logger = logging.getLogger("jedi_<name>")
│   ├── api/                         # ★ 插件模式：route 進套件（與舊 jedi-* 慣例不同）
│   ├── app/service/
│   ├── common/                      # 套件自帶的小工具（如回應信封）
│   ├── domain/repository/           # port（ABC）放這
│   ├── infra/                       # 有 DB 表才需要（ORM model / mapper / repo impl）
│   └── plugin.py                    # ★ register() / adapters / config，見 §4
├── harness/                         # ★ D7：standalone 最小宿主
│   ├── dev_app.py
│   └── docker-compose.yml
├── tests/                           # ★ D7：零外部相依，能離開主專案獨立跑
├── README.md                        # ★ D7：接入說明書，見 §6
├── pyproject.toml
├── poetry.toml                      # [virtualenvs] in-project = true
└── publish.sh                       # rm -rf dist/ && poetry publish --build -r nexus
```

**pyproject 要點**（照抄 jedi-ai-bot）：

- [ ] `[project]` runtime dependencies **只放真的需要的**。插件模式因為自帶 api 層，
      至少要 `Flask` / `flask-restful` / `flask-apispec`。
- [ ] ⚠️ **`marshmallow>=3.26,<4`**：flask-apispec 0.11 讀 `marshmallow.__version__`，
      marshmallow 4 拿掉了那個屬性，不鎖上界會在 import 時炸 `AttributeError`
      （P1 harness 首跑實際踩到）。
- [ ] `[tool.poetry.group.dev.dependencies]` 放 `pytest` 與 harness 專用相依
      （如 `redis`）——**不進 runtime**，消費端 pip install 不會被拖進來。
- [ ] `[tool.pytest.ini_options] testpaths = ["tests"]`
- [ ] `[[tool.poetry.source]] name = "nexus"`（照抄既有套件）

---

## 3. 階段 ③：搬檔＋斷線

### 3.1 搬檔

- [ ] 把 app service（與 domain / infra 如適用）搬進套件對應層。
- [ ] **route 也搬**（插件模式）——但 route 內對主專案的 import 全部要斷（見下）。
- [ ] 搬完主專案對應目錄先**留著別刪**，等 ⑦ 發版後才刪（順序鐵律）。

### 3.2 斷線：四種手法，按差異性質選

| 主專案的東西 | 斷法 |
|-------------|------|
| 基礎設施（Redis / 檔案系統 / 外部服務 client） | 套件定**窄 ABC port**（只放真的會用到的方法），主專案寫 adapter 注入 |
| 設定值（API key、host、開關） | 進 `<Pkg>Config` dataclass，由 consumer 傳入。**套件永遠不讀 `os.getenv`、不 import `config.config`** |
| 認證 / user context | 注入 decorator 與取值函式（見 §4 `adapters`） |
| 小工具（回應信封、常數字典） | **套件自帶一份最小複製**（如 `common/response.py`、`AUTH_PARAMS`），不 import 主專案。只複製真的會用到的部分 |
| 多語系文案（信件模板、通知內容） | **套件自帶預設字串 + config 可整組覆寫**。不可讀宿主的 `config/translations/`。字串**逐字照搬不趁機潤稿**——那是客戶看得到的東西 |

**port 要窄**：jedi-ai-bot 的 `IChatHistoryStore` 只有 `get` / `set` / `delete` 三個方法。
port 越窄，換後端越便宜、測試 mock 越省事。

### 3.4 🔴 logger 名三連坑（抽走任何「會發 log 或掛 log handler」的模組必踩）

P3 抽 log 轉發時**連續踩到三個**，症狀全是「功能其實在跑，但相關訊息全部消失」或
反過來「根本沒跑，而你也看不到任何錯誤」。三個都源自同一件事：**模組搬進套件後，
它的 logger 名從 `common.xxx` 變成 `jedi_xxx.common.xxx`，而宿主的 logging 設定
只認得原本那幾棵樹。**

| # | 坑 | 症狀 | 解法 |
|---|----|------|------|
| ① | 套件模組 logger 被 `disable_existing_loggers` 關掉 | 套件自己的維運訊息（啟用/失敗/重試）**整個消失**，連壞掉都不出聲 | `config` 開一個 `ops_logger_name`，宿主傳自己樹裡的名字 |
| ② | 稽核事件用套件 logger 發 | 事件掉出 `system_logs`，也不會被轉發 | `adapters` 開一個 `audit_logger_name`，同上 |
| ③ | 宿主在掛載**之後**才跑 `dictConfig` | 它**整批替換** handlers 清單，把套件掛上去的 handler 默默丟掉 | 套件端每輪自檢「handler 還在不在」，不在就重掛 |

第 ③ 個最陰險：`dictConfig` 是**替換**不是附加，而套件若只比「設定有沒有變」就會
判定「沒變、不用動」，於是永遠不會發現 handler 已經被拔掉——**狀態查詢函式仍回
「運作中」**。主專案必然踩到（`jedi_issue` 在 import 期呼叫 `configure_logging()`，
而模組註冊迴圈跑在 `create_app()` 之後）。

**通則**：套件若要在宿主的 logging 樹上掛東西或發訊息，
**logger 名一律做成可注入、預設值只是 fallback**；且**掛上去的東西要能自我檢查還在不在**。

### 3.3 守衛測試（斷線的證明）

- [ ] 在主專案 `test/test_module_boundaries.py` 的 `EXTRACTED_PACKAGES` 加一列
      `("jedi_<name>", "FR-069 P<n>")`。該測試會掃**安裝後的套件實際位置**，
      斷言 0 處 `api/app/domain/infra/di_containers/common/config/core` import。
- [ ] **做突變測試**：故意在套件內加一行 `from common.util.xxx import yyy`，
      跑測試確認**變紅**，再還原。沒紅過的守衛測試等於沒有。

---

## 4. 階段 ④：定 register 簽名（四道防線）

**P1 已定案，P2–P5 直接照抄這個形狀**：

```python
register(app, adapters, config=None, schema_extensions=None, mount_api=True) -> PluginHandle
```

四個插槽對應「不同產品接同一支套件時，API 可能差在哪」，差異由小到大：

| 插槽 | 解什麼差異 | 做法 |
|------|-----------|------|
| **1. `config`** | 參數值差異（預設值 / 上限 / 開關） | dataclass `<Pkg>Config`，API 形狀不變 |
| **2. `adapters`** | 行為差異（背後邏輯不同） | port 注入，即 D4 既有機制 |
| **3. `schema_extensions`** | 欄位差異（多收 / 多吐欄位） | base schema 只認共通欄位＋多給不炸；宿主註冊 `request_context` / `response_enricher` hook，擴充欄位經 context dict 傳遞 |
| **4. `mount_api=False`** | 端點形狀差異的逃生門 | 不掛路由、只回 service，宿主自己寫薄 route |

**四個插槽第一版就要開齊**，即使該套件暫時用不到第 3 道——P2–P5 照抄同一簽名，
之後才不用回頭改五支套件的入口。

### 4.1 實作要點（P1 踩過的坑）

- [ ] **`adapters` 的認證欄位不給預設值**——預設放行會讓「忘記傳」變成無聲的公開端點。
- [ ] **auth decorator 拒絕時回 `dict` 或 `(dict, status)`，不可回 `jsonify(...)`**：
      route 掛在 flask-restful 的 Resource 下由它序列化，收到 Response 物件會炸成
      **500 而不是 401**（harness 首跑抓到，已寫成迴歸測試）。README 要寫明這條。
- [ ] **route 不要用 `@inject` + `Provide[Container.x.y]`**：那需要在 import 時寫死具體
      container 路徑，等於套件反依賴主專案。改由 `register()` 把組件放進
      `app.extensions[EXTENSION_KEY]`，route 在 request 期取用——consumer 用什麼 DI
      框架（或不用）都能接。
- [ ] **執行期組件用 `Blueprint.record_once` 在掛載當下寫入 extensions**，不要在建
      blueprint 時就要求有 app。好處：同一套件掛到多個 app 時各自一份 context。
- [ ] **認證 decorator 用動態子類套上**（`type("XxxGuarded", (XxxRoute,), {...})`），
      不要直接改 class attribute——會污染跨 app 的共用類別。
- [ ] 提供兩個入口：`create_blueprint()`（只建不掛，給有既有載入迴圈的 consumer）
      與 `register()`（建完順手掛）。兩者共用同一組裝路徑，不產生兩套行為。

---

### 4.2 migration 隨包（P4 定案，有 DB 表的套件適用）

**套件 ship 建表腳本，但絕不自己執行。**

```
jedi-<name>/jedi_<name>/migrations/
├── 001-<模組>-tables.sql      # 建表 + 索引 + COMMENT
└── 002-<模組>-rls.sql         # RLS policy + GRANT（依賴宿主生態）
```

`plugin.py` 提供讀取入口，宿主自己決定何時套：

```python
def iter_migrations() -> List[Tuple[str, str]]:
    """回 [(檔名, SQL), ...]，依檔名序（三位數序號決定套用順序）。"""
    return [(p.name, p.read_text(encoding="utf-8"))
            for p in sorted(MIGRATIONS_DIR.glob("*.sql"))]
```

五條規則（P4 實跑得出，P3／P5 照抄）：

1. **套件只提供、不執行**。何時套、套進哪個庫、怎麼記錄已套過，全是宿主的維運決定。
   主專案有 `schema_migrations` + `manifest.tsv` + `migrate.sh` 一整套，套件不該取代它。
   **主專案的既有 `scripts/sql/` 腳本不動、不重複登記**——套件版是給其他 consumer 用的。
2. 🔴 **每支 SQL 必須冪等**（`CREATE TABLE IF NOT EXISTS` / `DO $$` 包 policy 建立）。
   宿主很可能在**已有這些表的既有庫**上執行——主專案就是這種情況（表早就建好，
   套件版全程 no-op）。裸 DDL 會讓第二次執行整支失敗。**寫個測試把這條焊死**：
   ```python
   def test_ddl_is_idempotent():
       for name, sql in iter_migrations():
           for line in sql.splitlines():
               s = line.strip()
               if s.startswith(("CREATE TABLE ", "CREATE INDEX ")):
                   assert "IF NOT EXISTS" in s, f"{name} 有非冪等 DDL：{s}"
   ```
3. **按「前提」拆檔，不是按「表」拆**。001 只需要 schema 存在；002 需要宿主生態
   （RLS 判定函式、應用角色）。不吃那套生態的 consumer 可以只套 001——harness 就是
   這樣跑的，也因此證明了拆分是真的有意義而非形式。
4. 🔴 **多租戶欄位在 001 建 nullable，由 002 收緊成 NOT NULL**。`TenantScopedMixinModel`
   只在 `ENABLE_MULTI_TENANT=true` 時才**產生**這兩個欄位——單租戶宿主的 ORM 根本不會
   在 INSERT 帶它們，欄位若一開始就 NOT NULL 則每一筆都寫不進去（P4 harness 首跑實際
   踩到 `NotNullViolation`）。
5. 🔴 **`pyproject.toml` 要顯式 `include`**，否則 wheel 裡只有 `.py`，`iter_migrations()`
   在安裝後**靜默回空清單**（沒有任何錯誤訊息，是最難查的那種）：
   ```toml
   include = [{ path = "jedi_<name>/migrations/*.sql", format = ["sdist", "wheel"] }]
   ```
6. 🔴 **表若有「必須存在的 seed 列」，seed 要寫進 001**（P5 新增）。license 的時鐘回撥
   浮水印是全域單例（恆一列 `id=1`），repo 讀的是 `WHERE id = 1`、寫的是 UPDATE 該列——
   沒有那一列的話浮水印永遠讀到 NULL（不判回撥）、推進 UPDATE 到 0 列，**整道防護靜默
   失效**。用 `INSERT ... ON CONFLICT DO NOTHING` 保持冪等，並**寫一條測試斷言 SQL 內含
   該 INSERT**——這種缺失沒有任何執行期症狀，只有測試抓得到。
7. 🔴 **不是每張表都該掛 RLS**（P5 新增）。全域單例／跨租戶事實表（如時鐘浮水印）掛了
   租戶隔離，語意會直接壞掉（「系統見過的最大時間戳」變成每租戶一份）。002 對這種表
   **只 GRANT、不 `ENABLE ROW LEVEL SECURITY`**，並寫測試把「002 不含該表的 ENABLE RLS」
   焊死——照抄別張表的形狀是最容易犯的錯。

**DDL 從哪來**：`pg_dump --schema-only -t <表>` 撈 DEV 的實況（唯讀操作，不算環境異動），
再改寫成冪等形式。不要憑 ORM model 反推——model 與實際 schema 常有落差（欄位型別、
索引、policy 形狀），照抄實況才不會出現「裝出來的庫跟現有的不一樣」。

## 5. 階段 ⑤：主專案改接線

- [ ] **`api/<mod>/__init__.py` 瘦成 adapters 組裝**：保留 `create_module()` 的名字與
      回傳型別（`main.py` 與 `tests/conftest.py` 的載入迴圈靠它，**不要改那條共用契約**），
      內部改成組 adapters + `create_blueprint(adapters, config)`。
- [ ] **infra adapter 落在 `infra/<mod>/`**（如 `infra/ai/redis_chat_history_store.py`）。
- [ ] **DI container 留不留，看有沒有其他模組反向依賴它**：
      - **可以移除**（P1 jedi-ai-bot）：adapters 已涵蓋全部相依、沒有別的模組要用它的
        domain service。移除時同步清掉 `di_containers/containers.py` 的 import 與宣告。
      - **必須保留**（P4 jedi-remote-agent）：主專案有六個 container 反向依賴
        `remote_agent_container.remote_agent_domain_service`（detection_tools、
        flow_control、oscal、upload_file、detection_orchestration——它們要直接用 domain 層
        挑可派工的機器）。此時 container 保留，**只是它現在建的是套件的類別**。
        硬要移除的代價是那六處全改成從 Flask extensions 拿，那才是把 DI 打散。
      - 判準：`grep -rn "<mod>_container\." di_containers/` 看有幾個別的 container 用它。
- [ ] 🔴 **adapters 內若要用 DI 物件，必須延遲解析，不可在 `create_module()` 當下解**。
      兩個理由（P4 實測踩到）：① `Containers.*` 是**類別層**存取，拿不到 `create_app()`
      設定好的那個實例（`core/app_factory.py` 早有這條註解）；② 會在 blueprint 載入期
      強制建構整條相依鏈，缺一個環境變數就整個服務起不來（P4 因 `DRIVE_TOKEN_ENCRYPTION_KEY`
      在該時機未備妥而炸）。做法是包一層薄代理，方法被呼叫時才從
      `current_app.extensions["di_container"]` 解（見 `app/remote_agent/adapter/lazy.py`）。
- [ ] `config/app_modules.py` 的註冊行**保持不動**（模組名不變）。
- [ ] 🔴 **`core/` 與 `main.py` 需要的東西不可放 `api/`**：組裝根 import HTTP 邊界是
      反向依賴。P3 的 `build_forwarding_config()` 一開始放 `api/log_forwarding/__init__.py`，
      被 `core/app_factory.py` import——方向錯了，改落 `infra/<mod>/`。
      判準：**誰會用它？只有 route 用 → 放 api；core/main 也要用 → 放 infra。**
- [ ] 刪掉搬走的舊檔（`app/<mod>/`、`api/<mod>/routes/`）——但**這個 commit 要等
      ⑦ 發版後才做**，本階段先在工作區改、驗證通過再說。

---

## 6. 階段 ⑥：本地驗證（path dependency）

### 6.1 套件端獨立驗證（D7 的核心價值）

> ⚠️ **先 `echo $VIRTUAL_ENV` 確認是空的。** Poetry 會沿用已啟用的 venv——若你剛從
> 主專案 venv 過來，`poetry install` 會把套件相依裝進**主專案的** venv 並升掉它的套件
> （P1 實際踩到兩次：marshmallow 被升到 4.x，弄壞 flask-apispec，得回主專案
> `poetry install` 才修好）。已在別的 venv 裡就先 `deactivate`，或每個指令前綴
> `env -u VIRTUAL_ENV`。

- [ ] `docker compose -f harness/docker-compose.yml up -d`
      （**用套件自己 compose 起的實例，不要連開發環境現有的**——連了就測不出隱藏耦合。
      port 也要避開常用值，jedi-ai-bot 用 6399 而非 6379）
- [ ] `poetry install` → `poetry run pytest -q` **全綠**
- [ ] `poetry run python harness/dev_app.py` 起得來，打得通第一個 API

🔴 **有 DB 表的套件，harness 還要補三道前提**（P4 首跑逐一炸出來，每個症狀都不直觀）：

| 前提 | 不做會怎樣 | 做法 |
|------|-----------|------|
| `ENABLE_MULTI_TENANT=true` **在 import model 之前**設好 | `NotNullViolation`：mixin 用 `@declared_attr` 在 class 定義**當下**決定要不要產生 tenant 欄位，晚設等於沒設 | `os.environ.setdefault(...)` 放在檔案最上方 |
| 宿主生態的 `tenants` / `org_units` 兩張表**要在 SQLAlchemy metadata 裡**（不是只在 DB 裡） | `NoReferencedTableError: could not find table 'org_units'`——mixin 的 FK 在 **mapper 設定期**就要解析得到；只在 DB 建樁表沒有用 | harness 用 `Base` 宣告最小等價 model（見 `declare_host_tables()`） |
| `register_error_handlers(app)` | 套件拋的業務例外（401／403／404）**全部變成 500**，看起來像套件壞了 | `from jedi_common.handler.handler import register_error_handlers` |

這三道都是**真實宿主本來就有**的東西（產品有 jedi-auth 的 model、有錯誤處理器、有多租戶
開關），harness 補上不是作弊——反而正是它的價值：把「套件對宿主的隱性要求」一條條逼出來
寫進 README，下一個 consumer 才不用重新踩一遍。

### 6.2 主專案端驗證

- [ ] 主專案 `pyproject.toml` 的 `[tool.poetry.group.dev.dependencies]` 加一行 path override：
      `jedi-<name> = { path = "/Users/.../jedi-<name>", develop = true }`
- [ ] `poetry update jedi-<name>`（**不要 `poetry lock`，會卡死**）
- [ ] `poetry show jedi-<name>` 看得到
- [ ] **重啟 BE**（`lsof -ti:8000 | xargs kill -9` 再起，見 CLAUDE.md）
- [ ] **功能手測**：該模組主要流程逐項打過（不是只看啟動成功）
- [ ] 🔴 **有背景元件的模組，必須驗「行為真的發生」而不是「狀態說它在跑」**：
      P3 的 `is_active()` 回 True、設定也對，但 handler 早被洗掉、一筆都沒送出去。
      驗法是**架一個最小接收端**（`nc -lu` 或十行 python UDP sink）看東西有沒有真的到。
      「查詢函式說它好」永遠不算驗證。
- [ ] `pytest test/test_module_boundaries.py` 全綠（**跑之前先確認自己那列還在**——見下）
- [ ] 🔴 **有平行 session 在跑別的 P 時，共用檔要逐 hunk 分離**（P5 實際遇到）。
      `config/app_modules.py`、`pyproject.toml`、`di_containers/containers.py`、
      `test/test_module_boundaries.py` 是每支抽取都會動的檔案，兩個 session 同時改就會
      互相覆蓋。兩個具體教訓：
      - **`EXTRACTED_PACKAGES` 加的那一列可能被對方的寫入蓋掉**，而症狀是「守衛測試全綠」
        ——因為根本沒有你那一列可跑。跑突變測試前先 `pytest --collect-only` 確認
        `[jedi_<name>-FR-069 P<n>]` 這個 param 真的被收集到。
      - **commit 前用「還原成 HEAD → 只套自己那段 → `git add` → 工作區還原」把自己的 hunk
        單獨 stage**，不要 `git add <整個檔>`（會把對方未完成的改動一起帶走）。

### 6.3 拔掉測試（D6 驗收核心）

- [ ] 註解 `config/app_modules.py` 的該模組註冊行
- [ ] 重啟 BE → **正常啟動、log 無 FATAL / Traceback**
- [ ] 打該模組端點 → **404**
- [ ] 抽驗其他兩三支端點 → **200**（不受影響）
- [ ] 還原註冊行 → 重啟 → **功能回來**

### 6.4 接入 README 走查（D7）

- [ ] README 含：四道防線簽名說明、port 清單（表格）、註冊寫法、（如適用）migration
      套法、10 分鐘 quickstart
- [ ] **照 README 從零走一遍**（刪 `.venv`、`docker compose down` 後重來），
      每個指令逐條實跑。**寫完不跑等於沒寫**——P1 就是照走時才發現 venv 污染那條坑，
      補進 README 警語。

---

## 7. 階段 ⑦：發版＋pin（**等 user 明示**）

順序不可倒：

1. [ ] **等 user 明示** → `cd jedi-<name> && ./publish.sh`（推 Nexus）
2. [ ] 主專案 `[project] dependencies` 加 `"jedi-<name>==<版號>"`，
       **移除** dev group 的 path override
3. [ ] `poetry update jedi-<name>` → `poetry show` 確認裝的是 Nexus 版本
4. [ ] 重啟 BE + 再手測一輪（確認裝 Nexus 版與 path 版行為一致）
5. [ ] **此時才 commit「主專案移除舊目錄 + pin 版號」**
6. [ ] 補 `docs/claude/jedi-packages.md` 該套件一段
7. [ ] **等 user 明示** → push

---

## 8. 每支套件都要交的東西（收工檢查表）

- [ ] 套件目錄（含 `plugin.py` 四道防線簽名、harness、tests、README）
- [ ] 主專案 `api/<mod>/__init__.py` 瘦成 adapters 組裝
- [ ] `infra/<mod>/` adapter
- [ ] `test/test_module_boundaries.py` 加一列守衛（且做過突變測試）
- [ ] 拔掉測試通過（§6.3 五個步驟逐項）
- [ ] **有 DB 表的套件：把 migration 套進一顆空庫，schema 逐項 diff 對照 DEV 實況**
      （P5 新增）。比對四類：欄位（`information_schema.columns`）、索引（`pg_indexes`）、
      policy（`pg_policies`）、RLS 開關（`pg_class.relrowsecurity`）。CHECK 約束的
      `pg_get_constraintdef` 文字**會因 PG 正規化而不同**（cast 位置），要比就比
      「允許值集合」而非字串。做這件事的理由：`iter_migrations()` 是憑 DDL 手寫的，
      跟 ORM model 有落差不會有任何症狀，直到別的 consumer 裝出一個「跟主專案不一樣」
      的庫為止。**空庫用 harness 那顆，不要碰 DEV**（唯讀 dump 可以，寫入不行）。
- [ ] README quickstart 實走過
- [ ] 兩 repo 各自 commit（**顯式 `git add <檔名>`，禁 `-am`、禁 `git add -A`**）
- [ ] Notion 子卡 append 白話補充 + 狀態改「修正待驗證」

## 9. 尚未定案（留給後續套件）

- ~~**migration 隨包機制**~~ → **P4（CM-1440）已定案，見 §4.2**。首例為 jedi-remote-agent
  的三張表；P3／P5 有表時照 §4.2 五條規則辦理。
- **schema_extensions 的實戰驗證**：插槽已開齊且有測試覆蓋，但尚無真實產品用到第 3 道。
  第一個實際使用的套件要回頭確認這個形狀夠不夠用。

## 10. 名詞對照

| 詞 | 意思 |
|----|------|
| 插件（plugin） | 自帶 api 層 + 註冊入口的套件，裝上註冊一行就長出功能；拔掉一行就消失 |
| 拔掉測試 | 驗證「拔掉註冊行後產品照常跑」的手測流程（§6.3） |
| harness | 套件自帶的最小宿主，讓套件不靠主產品也能跑起來 |
| port | 套件定義的窄介面（ABC），由 consumer 提供實作 |
| adapter | consumer side 對 port 的實作 |

> **邊界誠實聲明**：做到的是**安裝時可插拔**（install-time pluggable），
> 不是執行中熱插拔——DB schema / RLS 不可能執行中掛載。對齊業界（GitLab / Odoo 模組）等級。
