# FR-080 批次 A 盤點：system-infra 候選七支

盤點日期 2026-09-09。證據路徑：monorepo 根 `~/Projects/Jedicogy/module/jedi-python-package/`（記為 `<mono>`）、
主專案根 `~/Projects/Billows/Audit-Manager/compliance-manager-be/`（記為 `<be>`）。
DB 證據來自 DEV（`192.168.50.188:25432` / `guidant_ai_dev`），全部唯讀 SELECT。

## 規模速覽（先給尺度，後面逐支展開）

| 套件 | import 名 | 版本 | 套件源碼行數 | 自有表 | route | migrations | harness |
|---|---|---|---|---|---|---|---|
| jedi-common | `jedi_common` | 0.0.34 | 3,453 | 1（`system_logs`） | 0 | 0 | 無 |
| jedi-iam | `jedi_iam` | 0.1.0 | 15,098 | 16 | 39 | 0 | 有 |
| jedi-system-config | `jedi_system_config` | 0.0.16 | 921 | 1 | 0 | 0 | 有 |
| jedi-system-menu | `jedi_system_menu` | 0.0.13 | 1,199 | 1 | 5 | 0 | 無 |
| jedi-notification | `jedi_notification` | 0.0.11 | 774 | 0 | 1 | 0 | 有 |
| jedi-log | `jedi_api_log` | 0.0.14 | 1,305 | 1 | 2 | 0 | 無 |
| jedi-log-forwarding | `jedi_log_forwarding` | 0.0.1 | 2,065 | 1 | 2 | 2 | 有 |

行數證據：`find <pkg>/<import名> -name '*.py' | xargs wc -l`（2026-09-09 實跑）。
版本證據：各 `pyproject.toml:3`（log-forwarding 在 `:3`，iam 在 `:3`）。

---

## jedi-common

**Q1 它是什麼**

「所有 jedi 套件與宿主共用的地基」——實際做四件事：① DB session 與交易（`session_scope` / `@transaction` /
RLS session 變數注入 / `BaseRepositoryImpl` 查詢基底）；② 例外類與 Flask error handler 註冊；
③ logging 設定（含把系統 log 寫進 DB 的 `system_logs`）；④ 一組共用的 DTO / entity / schema 骨架
與身分名冊 port。

README 與程式碼**不一致，且落差很大**：`<mono>/jedi-common/README.md:26-50` 的目錄樹寫的是
`jedi_handler` / `jedi_logger` / `jedi_session` / `jedi_utils` 四個頂層包——這些名字在源碼中
**一個都不存在**（實際是 `jedi_common/handler/`、`jedi_common/logger/`、`jedi_common/session/`、
`jedi_common/utils/`）。README 也完全沒提 `identity/`（身分名冊 port 三件套）、`interfaces/`
（共用 DTO/entity/spec）、`enums/`（error code / schema code）這三個現役子包，更沒提它**自帶一張
DB 表**。README 沒有任何接入指引段落（`grep -qi "register(|接進你的產品|quickstart"` 無命中），
54 行裡有 24 行是一張過時的相依表（列了 pytest / testcontainers 為 runtime，但 `pyproject.toml:28-36`
已把它們搬進 dev group）。

**Q2 資料**

自有表 **1 張**：

| 表 | 存什麼 | 關鍵欄位 | 證據 |
|---|---|---|---|
| `system_logs` | 系統日誌——由 logging handler 自動寫入的每一條 app/api/domain/infra 層 log | `act_time` / `level` / `event_code` / `message` / `user_uid` / `path`+`func_name`+`line_no` | `<mono>/jedi-common/jedi_common/logger/db_log/system_log/infra/models/system_log.py:6` |

live 實況：DEV `system_logs` 有 **663,249 列**，是 RANGE(act_time) 月分區表（5 個分區），
BRIN 索引，**無任何 FK、無 `tenant_id`**（`psql \d public.system_logs` 2026-09-09）。
主專案的分區化 migration 在 `<be>/scripts/sql/2026-06-03-log-tables-partitioning.sql:73-118`。

對別人的表的參照：

- **(a) 真 FK：0 條。** 全套件唯一的 `ForeignKey` 宣告在 `<mono>/jedi-common/jedi_common/session/database/model/tenant_mixin_model.py:14,22`
  ——`tenant_id → tenants.id`（NOT NULL）與 `org_unit_id → org_units.id`（nullable）。
  **但這不是 jedi-common 自己的表在參照，是 mixin 的定義**：任何 `class X(BaseModel, TenantScopedMixinModel)`
  的表才會長出這兩欄。`system_logs` 自己**沒有繼承這個 mixin**（`system_log.py:5` 是 `class SystemLog(Base)`）。
- **(b) ORM relationship 跨包：0 條**（全套件 `grep relationship(` 只在 `base_repository_impl.py` 出現字串，非宣告）。
- **(c) 軟參照**：`system_logs.user_uid`（varchar 36）存的是 `users.uid`，由
  `<mono>/jedi-common/jedi_common/logger/custom_formatter.py:48` 從 user context 取 `user_context.uid` 填入，
  **不建 FK**。同理 `user_name` 存 nickname（`custom_formatter.py:49`）。
- **(d) raw SQL 直打別人的表：0 條。** `session_scope()` 有大量 raw SQL，但全是 `SET LOCAL app.*`
  的 session 變數設定，不碰任何表（`<mono>/jedi-common/jedi_common/session/database/db.py:93-118`）。

🔴 **決策者特別指定要查的「`system_logs` 對 tenants 的 FK」——實查結論：不存在，兩層都不存在。**
① ORM 層：`system_log.py` 沒繼承 `TenantScopedMixinModel`，12 個欄位裡沒有 `tenant_id`。
② DB 層：`\d public.system_logs` 顯示的索引只有 `pk_system_logs_p` 與 BRIN，**沒有任何 FK 約束、也沒有 tenant_id 欄位**。
所以 jedi-common 的表**對 jedi-iam 的表零耦合**。真正產生 `→ tenants.id` FK 的是那支 mixin，
而 mixin 的消費者是**別人**：monorepo 內 14 支套件（bulletin / compliance-audit / detection ×6 /
device / flow-engine ×3 / information-system / license-runtime / remote-agent ×3 / survey ×2 /
system-config / task-platform）＋ 主專案 6 個 model（`<be>/infra/feedback/model/feedback_issue.py:12`、
`<be>/infra/oscal/model/{framework,ssp_docx,ssp_excel}_parse_job.py`、`<be>/infra/module_frame/models/module_frame.py:11`、
`<be>/infra/bulletin/models/bulletin.py:29`）。DEV live 實查 `→ tenants` 15 條 FK、`→ org_units` 23 條 FK，
全部來自這些消費者的表，沒有一條來自 jedi-common 自己。

表名產品詞彙：**無**。`system_logs` 是通用詞。

**Q3 Port**

- **需要宿主提供的 port：0 張**（本套件是地基，沒有 `register()`、沒有 adapters dataclass）。
  唯一「宿主要給」的東西是**環境變數**：`ENABLE_MULTI_TENANT`（`tenant_mixin_model.py:13,21`，
  必須在 import model 之前設好，因為 `@declared_attr` 在 class 定義當下決定要不要產生欄位）、
  `RUN_ENV`（`config_logger.py:44`）、`DEFAULT_SCHEMA`（`declarative_base.py:5`）、`TZ`（`db_mw.py:11`）。
  **這四支是本套件僅有的 `os.getenv`，也是它與「不讀環境變數」這條 D16 通則的差異點**——地基層被允許。
- **提供給別人的能力入口（實測主專案 import 分佈，`grep -rhn "from jedi_common\.[a-z_.]*"` 去重統計）**：

| 模組 | 主專案引用處數 | 給什麼 |
|---|---|---|
| `jedi_common.handler.exception` | 184 | `NotFound` / `ConflictError` / `BadRequestError` 等 6 個例外類 |
| `jedi_common.session.database.db` | 97 | `@transaction` / `session_scope` / `init_db` |
| `jedi_common.session.auth.auth_context` | 87 | `get_user_context` / `set_user_context` |
| `jedi_common.utils.response_util` | 78 | `return_response`（全站回應信封） |
| `jedi_common.session.database.repository.base_repository_impl` | 20 | `BaseRepositoryImpl` |
| `jedi_common.session.database.model.base_model` | 20 | `BaseModel` |
| `jedi_common.enums.schema_code` | 19 | `AUTH_PARAMS`（Swagger 參數） |
| `jedi_common.session.database.model.tenant_mixin_model` | 7 | `TenantScopedMixinModel` |
| `jedi_common.utils.audit_log` | 7 | `audit()` 稽核事件格式 |
| `jedi_common.logger.db_log.db_handler` | 3 | `DBLogHandler` |

- **它自己提供、給套件生態用的 port（新標準件）**：`jedi_common.identity` 的
  `IUserNameResolver` / `ITenantResolver` / `IOrgUnitResolver` ＋ 聚合包 `IdentityContext`
  （`<mono>/jedi-common/jedi_common/identity/resolvers.py:63,83,92`、`context.py:60`）。
  **但目前只有 1 個消費者**：jedi-file-upload（`<mono>/jedi-file-upload/jedi_file_upload/plugin.py:129,140`）。
- **宣告了但零使用的 port**：無（identity 三件套雖只有一個消費者，但那個消費者是真的在用）。

**Q4 消費者**

- **(b) 其他 jedi-* 套件：23 支全部 import 它**（`grep` 統計，排除 tests）——
  jedi_iam 183 處、jedi_oscal_v2 144、jedi_survey 115、jedi_compliance_audit 114、jedi_detection 101、
  jedi_participant 52、jedi_task_platform 46、jedi_flow_engine 40、jedi_issue 27、jedi_license_runtime 22、
  jedi_remote_agent 21、jedi_file_upload 16、jedi_ai_dashboard 16、jedi_system_menu 15、
  jedi_information_system 14、jedi_evidence_classification 13、jedi_device 12、jedi_api_log 11、
  jedi_bulletin 10、jedi_system_config 9、jedi_log_forwarding 8、jedi_notification 3、jedi_integrity 2。
- **(a) 主專案**：全 repo 幾乎每個目錄都 import（上表 700+ 處）。
- 方向：**所有人依賴 jedi-common，jedi-common 依賴任何人＝0**。

**Q5 執行期形狀**

- route：**0 條**（無 api 層、無 blueprint）。
- 背景執行緒/排程：**套件自己不起**，但它的 `dictConfig` 掛的 `DBLogHandler` 會在每一次 log 呼叫時
  **同步寫 DB**（`<mono>/jedi-common/jedi_common/logger/db_log/db_handler.py:30` → `system_log_service.add_system_log()`），
  而 `system_log_service` 是**模組層單例**（`system_log_service.py:44`）。
- 對外 I/O：**有，而且是 import 期副作用** —— `<mono>/jedi-common/jedi_common/logger/custom_formatter.py:22-40`
  在 **模組 import 當下**無條件設定全域 OTel `TracerProvider` / `MeterProvider`，
  並建立指向 `localhost:4317` 的 OTLP gRPC exporter（`:27` `OTLPSpanExporter(endpoint="localhost:4317")`、
  `:35` `OTLPMetricExporter(endpoint="localhost:4317")`），還呼叫 `LoggingInstrumentor().instrument()`。
  這不是 lazy、不是可關閉的——只要 logging config 被載入就會發生。檔案系統 I/O：
  `config_logger.py:75` `os.makedirs("log")` ＋ `config_dev.py:37` 寫 `log/app.log`。
- 快取 / Redis：`<mono>/jedi-common/jedi_common/session/redis/redis.py` 只有兩行——
  `redis_client = FlaskRedis()`，一個未初始化的 extension 物件，套件自己不用它。
- **能不能單獨起成 process：不能，也不該。** 它沒有 route、沒有 `register()`、
  沒有 main，是純 library。

**Q6 通用性**

(a) 能直接用。工單系統要的 session / 例外 / 回應信封 / BaseRepository 全部通用，
且 `db.py:15-37` 的 `_is_postgresql()` 已明確為非 PostgreSQL（SQLite）留了逃生門
（註解點名 evidence-agent 這個 consumer）。

(b) 寫死 Guidant 產品知識的地方：

| 位置 | 寫死了什麼 | 影響 |
|---|---|---|
| `<mono>/jedi-common/jedi_common/logger/config_dev.py:53-95` | logger 名單硬編為 `api` / `app` / `infra` / `domain` / `common` / `middleware` / `error_handler` / `tests` 八個 | 這是 jedi 生態的分層命名，不是 Guidant 專屬，但**任何不照這套命名的 consumer，log 一律不落 DB 也不進檔**（`propagate: False`）。jedi-log-forwarding 就因此被迫要求宿主傳 `ops_logger_name="common.log_forwarding"`（見該支 Q6） |
| `<mono>/jedi-common/jedi_common/logger/custom_formatter.py:27,35` | OTLP endpoint 硬編 `localhost:4317` | 無法設定，且無論宿主要不要 OTel 都會起 |
| `<mono>/jedi-common/jedi_common/utils/gen_comment.py:8` | `OPENAI_API_KEY = os.getenv(...)` ＋ runtime 依賴 `openai>=2.8.0`（`pyproject.toml:25`） | 一個「產生註解」的開發輔助工具，讓**每一個 consumer 都被迫安裝 openai SDK**。與稽核無關的工單系統同樣被迫裝 |
| `<mono>/jedi-common/jedi_common/utils/audit_log.py` | `audit()` 產出 `[AUDIT:<name>] k=v` 格式並寫進 `system_logs.message` | 格式本身通用（event_code 由呼叫端定義，檔頭 `:11` 明說不預設任何碼表），**但函式名與檔名帶「audit / 稽核」字樣**，是本套件唯一的產品詞彙命中 |
| `db.py:102` | `is_super = (not user_ctx.tenant_id) or _session_paths_have_root(...)` | 「最上層租戶成員＝平台管理者」是 Guidant 的租戶模型判定，寫死在地基的 session 開啟路徑上 |

**Q7 插件完整度**

| 項目 | 有/無 | 證據 |
|---|---|---|
| ① api 層隨包 | **無** | 無 `api/` 目錄 |
| ② `register()` 註冊入口 | **無** | 無 `plugin.py`（`find` 只命中 `.venv` 內 coverage 套件的同名檔） |
| ③ migrations 隨包 | **無** | 0 支 `.sql`。`system_logs` 的建表與分區在主專案 `<be>/scripts/sql/` |
| ④ DI container 預設 | **無** | 無 container 檔 |
| ⑤ 獨立 harness | **無** | 無 `harness/` |
| ⑥ 接入 README | **無** | README 54 行且與源碼結構不符（見 Q1） |

它是**七支裡唯一一支「連 D6 插件契約的殼都沒有」的**——這與它的定位一致（地基不是插件），
但也意味著它沒有任何「拔掉就消失」的可驗證邊界。

**Q8 糾纏對象**

- **與 jedi-log（`jedi_api_log`）：曾經是同一件事的兩半，已被切開。**
  `<mono>/jedi-log/pyproject.toml:54-60` 記載：jedi-log 原本另 ship 一個 `jedi_system_log` import 名，
  **與 jedi-common 宣告同一張 `system_logs` 表**，在 jedi-common 已載入時 import 即拋
  `InvalidRequestError: Table 'system_logs' is already defined`；且該表 61 萬列全部由
  jedi-common 的 `DBLogHandler` 寫入，`jedi_system_log` 寫過 0 列、model 漂移成 9 欄。
  FR-069 P3.6（CM-1472）整包刪除。**現況兩支表已分家**（`system_logs` 在 jedi-common、
  `api_logs` 在 jedi-log），兩表之間**無 FK、無 relationship**，但**同一次 HTTP 請求會同時寫兩張表**
  ——`api_logs` 由 `<be>/common/middleware/app_mw.py:41,52` 的 before/after_request 寫，
  `system_logs` 由同一請求內任何 logger 呼叫寫。兩者在 DEV 都做了月分區、同一支 migration
  處理（`<be>/scripts/sql/2026-06-03-log-tables-partitioning.sql:2` 檔頭寫「api_logs / system_logs 改
  RANGE(act_time) 月分區」），且 retention 天數設在同一張 VALUES 表（`:136` `('api_logs', 90), ('system_logs', 180)`）。
- **與 jedi-log-forwarding：`system_logs` 的內容與轉發鏈是同一條 logging 鏈的兩端。**
  jedi-common 的 `dictConfig` 把 `DBLogHandler` 掛上 7 個 logger，jedi-log-forwarding 把
  `QueueHandler` 掛上**同一批 logger**；`<be>/core/app_factory.py:67-69` 明文標記位置紅線
  ——`configure_logging()` 必須在 `start_forwarding_chain()` 之前，因為 `dictConfig` 會**整批替換**
  handlers 清單、把轉發鏈的 QueueHandler 丟掉。**一邊的行為直接決定另一邊能不能運作。**
- **與 jedi-iam：透過 `TenantScopedMixinModel` 單向依賴——jedi-common 定義 FK 指向 jedi-iam 的表。**
  `tenant_mixin_model.py:14,22` 硬編字串 `"tenants.id"` / `"org_units.id"`，而這兩張表的 ORM 定義在
  `<mono>/jedi-iam/jedi_iam/infra/models/{tenant,org_unit}.py:15`。後果寫在
  `<mono>/jedi-system-config/README.md`「兩道宿主前提」：consumer 必須讓 `tenants` / `org_units`
  兩張表**在 SQLAlchemy metadata 裡**，否則 mapper 設定期就炸 `NoReferencedTableError`。
  也就是說**任何用 mixin 的套件都被迫把 jedi-iam 一起裝上**（或自己偽造這兩張表，
  jedi-license-runtime / jedi-remote-agent 的 harness 就是這樣做的：`<mono>/jedi-license-runtime/harness/dev_app.py:195` 註解明說）。
- **看起來像但不是一件事**：`jedi_common.session.redis` 與 jedi-iam 的 redis 使用。
  前者只是一個 `FlaskRedis()` 空殼（2 行），後者自己有 `jedi_iam/common/utils/redis_client_util.py`
  獨立實作 OTP 節流。**兩者沒有共用任何連線或 key 命名**，不是同一件事。

---

## jedi-iam

**Q1 它是什麼**

管「人的身分」：帳號、租戶、組織單位、角色與能力點（RBAC）、登入（密碼 / LDAP / Google）、
二階驗證（TOTP / Email OTP）、人機驗證（Cloudflare Turnstile），以及授權守門的**主體域**
（platform-admin / super-admin / capability / signed-token）。

README 與程式碼**一致**，且是七支裡寫得最準的一份。`<mono>/jedi-iam/README.md:21-33` 的疆界表
逐條可驗：說「授權守門主體域在疆界內、資源域留宿主」→ 實查 `<mono>/jedi-iam/jedi_iam/authz/`
確有 `platform.py` / `admin.py` / `capability.py` / `signed_token.py` / `decorators.py` 五檔，
而資源域（project / ssp / workflow）確實只在主專案 `<be>/common/authz/` 有實作。
說「39 條身分 API 已上移」→ `grep -c "api.add_resource" <mono>/jedi-iam/jedi_iam/api/__init__.py` = **39**，數字吻合。
說「OTP 信的文案與寄送走 port 由宿主注入」→ `<mono>/jedi-iam/pyproject.toml:51-53` 明文記載
「jedi-mfa 原本相依 `jedi-notification`，本棒**刻意斷掉**」，且實查 `jedi_iam/` 全目錄
**零處 import jedi_notification**。

**Q2 資料**

自有表 **16 張**（15 張主體 ＋ 1 張 mfa）：

| 表 | 存什麼 | 關鍵欄位 | 證據（`<mono>/jedi-iam/jedi_iam/infra/models/`） |
|---|---|---|---|
| `users` | 帳號 | `login_name` / `nickname` / `uid` / `tenant_id` / `org_unit_id` | `user.py:23` |
| `tenants` | 租戶（自參照樹） | `parent_id` / `path` | `tenant.py:15` |
| `org_units` | 組織單位（樹，掛租戶） | `tenant_id` / `parent_id` | `org_unit.py:15` |
| `roles` | 角色 | `tenant_id`（可空＝全域角色） | `role.py:23` |
| `capabilities` | 能力點字典 | `name` / `resource_type` / `action` / `is_platform` | `capability.py:15` |
| `role_capabilities` | 角色↔能力點 | 複合 PK | `role_capability.py:14` |
| `user_roles` | 使用者↔角色（帶授權範圍） | `user_id` / `role_id` / `tenant_id` / `org_unit_id` | `user_role.py:20` |
| `user_tenants` / `user_org_units` | 使用者↔租戶 / 單位 | 複合 PK | `user_tenant.py:14` / `user_org_unit.py:14` |
| `ui_routes` | 前端路由樹 | `parent_id` | `ui_route.py:16` |
| `route_capabilities` | 路由↔能力點 | 複合 PK | `route_capability.py:18` |
| `user_auth_providers` | 帳號的認證來源（LDAP/Google） | `user_id` / provider | `user_auth_provider.py:16` |
| `login_tokens` | 登入 token | — | `login_token.py:12` |
| `login_logs` | 登入紀錄（**已死鏈**，見下） | — | `login_log.py:21` |
| `user_change_pwd_logs` / `user_change_password_request` | 改密紀錄 / 請求 | `user_id` | `user_change_password_log.py:12` / `..._request.py:13` |
| `totp_secrets` | TOTP 種子 | — | `mfa/infra/totp/models/totp_secret.py:8` |

⚠️ `login_logs` 是**已標記的死鏈**：四個檔案（model / mapper / repo / dto）的檔頭都寫同一句
「登入稽核實際走的是 `EventCode.USER_LOGIN_SUCCESS`（主專案 logger extra），與本鏈無關」
（`infra/models/login_log.py:5`、`infra/mapper/login_log_mapper.py:5`、
`infra/repository/login_log_repo_impl.py:5`、`app/dto/login_log.py:5`）。

對別人的表的參照：**(a)(b)(c)(d) 全部 0 條。** 全套件的 FK 與 relationship（`grep ForeignKey|relationship(`
命中 60+ 行）**全部指向自己的 16 張表**，沒有一條指向套件外。這是七支裡唯一做到「資料完全自足」的。

**它是被別人參照的那一端**：DEV live 實查入向 FK 統計——
`org_units` 被 23 條 FK 指、`tenants` 15 條、`users` 7 條、`roles` 4 條、`ui_routes` 1 條。
指過來的表分佈在 bulletin / compliance（information_systems / job_executions / workflow_*）/
config（tenant_licenses）/ devices / feedback_issues / oscal（4 支 parse_job）/ survey 等，
**橫跨整個產品**。

表名產品詞彙：**無**。16 張表全是通用身分詞彙。

**Q3 Port**

需要宿主提供的 port（`<mono>/jedi-iam/jedi_iam/plugin.py`）：

| port | 簽名 | 用在哪 | 套件內使用處數 |
|---|---|---|---|
| `INotifier` | `send(*, to, subject, body) -> None` | OTP 信、帳號建立通知信 | 10 |
| `ISettingsReader` | `get(key, default=None) -> Any` | 全套件取設定值（redis 連線 / SYSTEM_NAME / Turnstile secret） | 8 |
| `IOtpMessageBuilder` | `build(*, otp_code, ttl_minutes) -> tuple` | OTP 信文案（i18n） | 11 |
| `IAccountMessageBuilder` | `build(*, login_name, password) -> tuple` | 批次匯入建帳號的通知信文案 | 6 |
| `ISubTenantQuota` | `assert_can_add_sub_tenant() -> None` | 建子租戶前的額度檢查 | 2（`ports/tenant_provisioning.py:36`、`app/service/tenant_provisioning_service.py:108-109`） |
| `ILicensedModules` | `licensed_resource_types() -> Optional[frozenset]` | 建租戶時過濾未授權模組的能力點 | 3（同上 `:123-133`） |
| `ITenantStorageSeeder` | `seed_from_root(tenant_id) -> None` | 新租戶複製 ROOT 的儲存設定 | 1（同上 `:98-100`） |
| `IAuthenticateProvider`（ABC） | `authenticate / test_connect / verify_user` | 認證來源抽象（password / ldap / google 三個內建 adapter 實作它） | `ports/auth_provider/auth_provider.py:7` |
| `IMFAProvider`（ABC） | `verify(user_id, code) -> bool` | MFA 管道抽象 | `mfa/ports/mfa_provider/mfa_provider.py:7` |

route 層另外要四樣（`plugin.py:238-253`）：`services`（21 個 service provider 名冊，
`IdentityServices` 於 `:147-168`）、`auth_required`、`capability_required`、`refresh_auth_required`、
`response_builder`、`empty_data_error_factory`；以及 4 個產品 hook（`IdentityHooks`，`:188-191`）。
🔴 認證欄位刻意無預設，缺就 `_assert_api_wiring()` 拒絕掛載（`plugin.py:382`）。

提供給別人的能力入口：`jedi_iam.authz`（守門 decorator，7 處主專案引用＋jedi-survey 6 處）、
`jedi_iam.domain.entities.user_query_entity`（19 處）、`jedi_iam.domain.service.user_domain_service`（12 處）、
`jedi_iam.infra.models.user`（9 處）、`jedi_iam.plugin`（6 處）、`jedi_iam.middleware.socketio_auth`、
`jedi_iam.security_policy`。

宣告了但零使用的 port：**無**（三張「看起來可能是樣板」的 tenant_provisioning port 實查都有呼叫點）。

**Q4 消費者**

- **(a) 主專案：269 處引用（排除 test），橫跨 40+ 個目錄**——auth(10) / module_frame(8) /
  flow_control(7) / oscal(6) / bulletin(6) / authz(6) / associations(4) / user_auth_provider(3)，
  以及 core/app_factory.py、core/iam_wiring.py、common/iam_ports.py、common/middleware/jwt_mw.py 等接線點。
  **`<be>/common/authz/` 的五個主體域檔案已是 re-export shim**——例如
  `<be>/common/authz/platform.py:1-10` 整檔就是「[deprecated shim] 已上移至 `jedi_iam.authz.platform`」
  ＋ 一行 re-export；`capability.py:1-15` 同形。
- **(b) 其他 jedi-* 套件：3 支，共 14 處**——
  jedi-survey 7 處（`api/routes/survey_*.py` 各 `from jedi_iam.authz import require_capability`、
  `app/handler/fill_survey_socketio_handler.py:8` 用 `socketio_auth.AuthenticatedNamespace`、
  `app/service/survey_discussion_service.py:1` 用 `UserDomainService`）、
  jedi-compliance-audit 5 處（`review_service.py:3` / `audit_round_app_service.py:25` /
  `poam_service.py:9,10` / `poam_app_service.py:219`）、
  jedi-detection 2 處（`detection_profile_service.py:50` / `detection_orchestration_service.py:26`）。
  方向：**這 3 支依賴 jedi-iam**。
- jedi-iam 自己依賴：只有 jedi-common（`pyproject.toml:18`），**零依賴其他 jedi-* 業務套件**。

**Q5 執行期形狀**

- route：**39 條**，blueprint 名 `iam`、prefix `/api/1.0`（`plugin.py:47,55`）。
  涵蓋 `/login` `/logout` `/refresh` `/users` `/user/<uid>` `/roles` `/tenants` `/org-units`
  `/capabilities/menu` `/ui-routes` `/otp-*` `/totp-*` `/forget-password` 等
  （`<mono>/jedi-iam/jedi_iam/api/__init__.py:165-220`）。
- 背景排程 / 長駐 thread：**套件自己沒有**。但宿主的 `INotifier` 實作會另開 thread
  （`<be>/common/iam_ports.py:59-62` `threading.Thread(...).start()`），那是宿主的選擇。
- 對外 I/O：**四種** ——① LDAP（`infra/adapter/authenticate_adapter/ldap/ldap_adapter.py:7,105,125`，
  `ldap3.Connection.open()`）；② 對外 HTTP（`turnstile/verifier.py:15,87`，
  POST `https://challenges.cloudflare.com/turnstile/v0/siteverify`，timeout 有設）；
  ③ Google OAuth（`google_auth/google_auth_adapter.py`）；④ 檔案系統
  （Excel 批次匯入上傳目錄，`plugin.py:207` `excel_upload_dir: str = "file/user/upload/"`）。
  **寄信不是它做的**——走 `INotifier` port 交給宿主。
- 快取 / Redis：**有，自己的一份**。`jedi_iam/common/utils/redis_client_util.py` 的 `RedisClient`
  被 OTP 節流與 TOTP 失敗計數使用（`mfa/app/service/email_service.py:61,78,80,90,101`、
  `totp_service.py:40,43,52,56`、`mfa/infra/email/adapter/email_adapter.py:11-15`）。
  `redis==5.2.1` 明列在 runtime 相依（`pyproject.toml:44`）。
- 另有 **繞 RLS 的提權唯讀 session**（`infra/elevated_session.py`），用於「這個帳號是不是受保護 root admin」
  這類系統層事實查詢，檔頭 `:1-33` 有 DEV 實測數據佐證（子租戶 session 查得 0 列、提權查得 1 列）。
- **能不能單獨起成 process：能。** harness 已實走驗證——`<mono>/jedi-iam/README.md:44-51` 記錄
  2026-08-30 實測：空庫建出 16 張表、`register()` 掛 blueprint、`/healthz` 回 200、
  **全程未 import 主專案任何模組**，353 個測試 pass。

**Q6 通用性**

(a) **能直接用**，是七支裡插件化最完整的。工單系統要的帳號/角色/RBAC/登入全都通用。

(b) 寫死 Guidant 產品知識的地方——**很少，而且都在註解或預設值層，不是邏輯層**：

| 位置 | 寫死了什麼 | 性質 |
|---|---|---|
| `plugin.py:207` | `excel_upload_dir: str = "file/user/upload/"` | 預設值，`IdentityConfig` 可覆寫（主專案就覆寫成 `app.config["USER_EXCEL_UPLOAD_DIR"]`，`<be>/core/app_factory.py:309`） |
| `plugin.py:47,55` | `DEFAULT_URL_PREFIX = "/api/1.0"` / `DEFAULT_BLUEPRINT_NAME = "iam"` | 預設值，可覆寫 |
| `turnstile/verifier.py:15` | Cloudflare siteverify URL 硬編 | 是 Cloudflare 的固定端點，非產品知識 |
| `plugin.py:125,138,175,246,330` | 註解裡點名「Guidant AI」 | **只是註解**，不影響行為 |
| `authz/error_code.py` | 四個守門 error code 的字串值凍結為 `GRC_*` | **這一條是真的產品洩漏**——`GRC` 是 Guidant 的前綴。凍結理由是 FE i18n key 相容（見 `<be>/CLAUDE.md` 記載），但對新產品而言就是一組帶別的產品前綴的錯誤碼 |

`security_policy.py:31` 特別做了「`build_defaults` 收 `env_reader` 參數讓宿主餵，套件自己不碰 `os.getenv`」，
實查 `jedi_iam/` **零處實際 `os.getenv` 呼叫**（grep 命中的 5 行全是註解/docstring）。

**Q7 插件完整度**

| 項目 | 有/無 | 證據 |
|---|---|---|
| ① api 層隨包 | **有** | `jedi_iam/api/`，39 條 route |
| ② `register()` | **有** | `register(app, adapters=None, config=None, schema_extensions=None, mount_api=True) -> PluginHandle`（`plugin.py:402-407`）；另有 `create_blueprint()`（`:352`） |
| ③ migrations 隨包 | **無** | 0 支 `.sql`。16 張表的建表全在主專案 `<be>/scripts/sql/` |
| ④ DI container 預設 | **無** | 套件不含 container；宿主用 `IdentityServices` 名冊注入 21 個 provider |
| ⑤ 獨立 harness | **有** | `harness/dev_app.py` ＋ `harness/docker-compose.yml`（port 5499） |
| ⑥ 接入 README | **有** | 244 行，含 quickstart 與實走結果 |

**五件套做到 4/5，缺的是 migrations。** 這是它與 jedi-log-forwarding 的唯一差距。

**Q8 糾纏對象**

- **與 jedi-notification：pyproject 宣告與 port 形式的分界，決策者指定要查——實查結論如下。**
  ① **pyproject 層：無依賴。** `<mono>/jedi-iam/pyproject.toml` 的 dependencies 清單（`:10-54`）
  **沒有 jedi-notification**，且 `:51-53` 有明文註記：「⚠️ 注意：jedi-mfa 原本相依 `jedi-notification`，
  本棒**刻意斷掉**——OTP 信的文案與寄送改走 INotifier / IOtpMessageBuilder 兩張 port 由宿主注入（D16）。
  這條邊斷掉是本卡的目的之一，日後不要因為『寄信方便』把它加回來。」
  ② **源碼層：零 import。** `grep -rn "jedi_notification" <mono>/jedi-iam/jedi_iam/` = 0 命中。
  ③ **但執行期供應鏈是實際接通的**：`<mono>/jedi-notification/README.md:40-45` 自己寫明
  「jedi-iam 定了一張 `INotifier` port，宿主的實作（`common/iam_ports.py::NotificationServiceNotifier`）
  **背後就是這支套件**。也就是說 jedi-iam → 宿主 adapter → jedi-notification 是一條實際在跑的供應鏈。」
  主專案端證據：`<be>/common/iam_ports.py:46-62` 的 `NotificationServiceNotifier` 收
  `notification_service` 並在 `send()` 內另開 thread 呼叫；接線在
  `<be>/core/app_factory.py:301-311`，把 `container.notification_container.notification_service()`
  餵進 `build_identity_adapters()`。**所以是「靜態解耦、動態耦合」——套件互不認識，宿主當中介。**
- **與 jedi-common：單向依賴 ＋ 被 mixin 反向指名。** jedi-iam import jedi-common 183 處（最大戶）；
  反過來 jedi-common 的 `tenant_mixin_model.py:14,22` 硬編 `"tenants.id"` / `"org_units.id"`
  ——**那是 jedi-iam 的表**。這是一條**地基指向上層**的隱性依賴：jedi-common 自己不 import jedi-iam，
  但它產生的 FK 需要 jedi-iam 的表在 metadata 裡才 resolve 得動（`NoReferencedTableError`）。
- **與 jedi-system-menu：兩張表都叫「menu」但不是一件事。**
  jedi-iam 有 `ui_routes` / `route_capabilities` / `user_menu_entity` / `role_menu_entity` /
  `tenant_menu_entity` / `org_unit_menu_entity`（`domain/entities/`），這些是**前端路由樹與權限選單**；
  jedi-system-menu 的 `system_menus` 是**下拉選項字典**（DEV live：`AUDIT_METHOD` 6 筆、`DEVICE_TYPE` 11 筆、
  `USER_STATUS` 4 筆等 16 個 group、74 列）。兩者**無 FK、無 relationship、無共用查詢**。
  **看起來像但不是一件事。**
- **與 jedi-system-config：登入路徑上有一條實際相依，但方向是宿主層而非套件層。**
  `<be>/app/auth/service/login_service.py:19,28,32` 的宿主 `LoginService` 建構子同時收
  jedi-iam 的 `JediLoginService` **與** jedi-system-config 的 `SystemConfigDomainService`。
  另外 `<be>/core/app_factory.py:144-146` 在 `register_identity()` **之前**就先
  `app.config.update(get_runtime_config())`，而 `get_runtime_config()` 讀的是
  `system_configs`（ROOT / `RUNTIME_CONFIG`）——**JWT 效期、登入鎖定次數、密碼政策這些 iam 參數，
  值存在 system-config 的表裡**。且政策的**定義**（有哪些鍵/型別/預設/範圍）已在
  `jedi_iam/security_policy.py`（`<be>/infra/system_config/runtime_config.py:23-27` import 它）。
  **一邊定義 schema、另一邊存值，兩邊必須同時在場才有意義。**

---

## jedi-system-config

**Q1 它是什麼**

一張 `system_configs` 表的 CRUD 與分組查詢——用 `(tenant_id, group, key) → JSONB value` 的形式
存系統設定。

README 與程式碼**一致，而且異常誠實**。`<mono>/jedi-system-config/README.md:13-22` 有一段
「🔴 驗收邊界誠實聲明」明列 D6 五件套只做到三項、自帶 api 層「❌ 未做」、拔掉測試「❌ 做不到——
沒有 route 就沒有『拔掉註冊行 → API 消失』可驗。**沒有拿假端點充數**」。實查全部屬實
（無 `api/` 目錄）。README `:5-7` 也自曝舊版是整段複製 jedi-issue 的，已於 FR-069 P3.5 重寫。

**唯一與程式碼不符的一點**：README `:100-105` 的 port 表寫「`ISettingsReader` 零使用——
本套件**自己就是設定的家**，不需要再向外讀設定」「`INotifier` 零使用」。這個描述是對的，
但 README 沒提到 **service 層有 SMTP / THIRD_PARTY_LOGIN 的硬編產品邏輯**（見 Q6）——
一個宣稱「只是設定表 CRUD」的套件，實際上認得「SMTP 這一組的密碼欄位叫 secret、
changePwd=false 時要沿用舊值」。

**Q2 資料**

自有表 **1 張**：

| 表 | 存什麼 | 關鍵欄位 | 證據 |
|---|---|---|---|
| `system_configs` | 系統設定（租戶級） | `uid` / `group` / `key` / `value`(JSONB) / `tenant_id` / `org_unit_id` | `<mono>/jedi-system-config/jedi_system_config/infra/models/system_config.py:14` |

DEV live 實況（`SET app.is_super_admin='t'` 後查）：**20 列**，7 個 group——
`SMTP` / `NOTIFY_CONFIG` / `RUNTIME_CONFIG` / `STORAGE_CONFIG` / `THIRD_PARTY_LOGIN` /
`WEB_IDEL_CONFIG` / `ISSUE_INTEGRATE_CONFIG`。唯一約束 `uq_system_configs_tenant_group_key (tenant_id, group, key)`，
**掛了完整 4 條 RLS policy**（select/insert/update/delete，條件 `is_super_admin='t' OR app_tenant_allowed_for_session(tenant_id)`）。

對別人的表的參照：

- **(a) 真 FK：ORM 層有 2 條、DB 層 0 條——這是一個落差，值得記下。**
  ORM：`system_config.py:12` `class SystemConfig(Base, TenantScopedMixinModel)` 繼承 mixin，
  於是在 `ENABLE_MULTI_TENANT=true` 時（`<be>/.env:53` 確實是 true）產生
  `tenant_id → tenants.id`（**NOT NULL**）與 `org_unit_id → org_units.id`
  （`<mono>/jedi-common/.../tenant_mixin_model.py:14,22`）。
  DB：`SELECT conname FROM pg_constraint WHERE conrelid='system_configs'::regclass`
  只回 `pk_system_configs` 與 `uq_system_configs_tenant_group_key`——**沒有任何 FK**；
  且 `\d` 顯示 `tenant_id | integer | 能否為 NULL:（空）`＝**nullable**，與 ORM 宣告的 NOT NULL 相反。
  對照組：同樣掛 mixin 的 `bulletins` 在 DB 有 `bulletins_tenant_fk` / `bulletins_org_fk` 兩條真 FK。
  **也就是說 system_configs 這張表的 ORM 宣告與 live schema 對不上**（ORM 較嚴、DB 較鬆）。
- **(b) ORM relationship 跨包：0 條。**
- **(c) 軟參照**：`tenant_id` 實際上就是軟參照 `tenants.id`（DB 無 FK），且 RLS policy
  `app_tenant_allowed_for_session(tenant_id)` 依賴它與 jedi-iam 的租戶樹對得起來。
- **(d) raw SQL 直打別人的表：套件內 0 條。但宿主端有 4 支直打這張表的 raw SQL**——
  `<be>/infra/system_config/system_config_root_reader.py:64,99,125,189,200,246,303`
  （`SELECT value FROM public.system_configs ...`、`UPDATE public.system_configs SET ...`、
  `INSERT INTO public.system_configs ...`），全部繞 RLS（`:60` `SET LOCAL app.is_super_admin='t'`）。
  **這是「套件的表被宿主繞過套件直接讀寫」的實例**——理由寫在 `:6-9`：ROOT tenant 的列
  對子租戶而言是祖先、RLS 看不到，fallback 必須繞。

表名產品詞彙：**表名無**。但**資料層有**：DEV 的 7 個 group 裡 `ISSUE_INTEGRATE_CONFIG`
（GitLab/GitHub 整合）、`STORAGE_CONFIG`、`WEB_IDEL_CONFIG` 都是產品概念——不過那是資料不是 schema。

**Q3 Port**

- 需要宿主提供的 port：**宣告 2 張、實際使用 0 張**——
  `ISettingsReader`（`get(key, default=None) -> Any`，`plugin.py:80`）與
  `INotifier`（`send(...)`，`plugin.py:90`）。`plugin.py:118-119` 自己註明「目前零使用」，
  grep 全套件（排除 plugin.py 本身）**確認 0 處呼叫**。
  README `:100-105` 給的理由：「本套件自己就是設定的家，不需要再向外讀設定」「設定變更要不要告警是產品規則」。
  **這是七支裡唯一一支「port 全部是樣板殘留」的**（README 誠實承認：「插槽開齊是為了日後真需要時
  簽名不用改，**不是現在有東西被 port 化了**」，並用兩條測試焊死此結論——`tests/unittest/test_plugin_contract.py`）。
- 提供給別人的能力入口：`SystemConfigService`（app 層）、`SystemConfigDomainService`（domain 層）、
  `SystemConfigRepoImpl`（infra 層）、`SystemConfigDTO`、`common.enum.error_code.ErrorCode`、
  `common.utils.common_util.generate_uuid`。

**Q4 消費者**

- **(a) 主專案：27 處，橫跨 8 個模組**——
  `<be>/app/auth/service/login_service.py:19`（登入路徑）、
  `<be>/app/system_config/service/guarded_system_config_service.py:21,22`、
  `<be>/app/system_config/service/storage_config_restore_app_service.py:36`、
  `<be>/app/notification/service/notification_service.py:6`、`test_mail_service.py:29`、
  `<be>/app/feedback/service/{feedback,issue}_service.py:8,10`、
  `<be>/app/upload_file/service/managed_file_upload_service.py:16`、
  `<be>/app/user_auth_provider/service/{ldap_service,user_auth_provider_service}.py:19,14`、
  `<be>/infra/system_config/tenant_config_seed_writer.py:35`、
  `<be>/api/system_config/routes/system_config_route.py:6,7`（**route 仍在宿主**）、
  以及 5 個 DI container（`di_containers/{auth,system_config,notification,user_change_password,login}/`）。
- **(b) 其他 jedi-* 套件：0 處。** 除了套件自己與 harness，monorepo 內無人 import 它。
- 它自己依賴：只有 jedi-common（`pyproject.toml:23`，9 處 import）。

**Q5 執行期形狀**

- route：**0 條**（`mount_api=True` 掛出來的 blueprint 是空的——README `:82-84` 明說
  「兩種模式的對外行為完全相同，差別只在 `app.extensions` 有沒有 `jedi_system_config` 這個 key」）。
  對外的 5 條 system_config route 仍在宿主 `<be>/api/system_config/routes/`，
  且 `<be>/config/app_modules.py:19` 的 `REGISTERED_APPS` 仍有 `"system_config"`。
  README `:26-38` 給了不能上移的理由：3 條 route 的 handler 同時吃套件的 service **與**主專案的
  `SharedConfigAppService`（多租戶合併規則），另 2 條純吃主專案的 `StorageConfigRestoreAppService`(81 行)
  與 `SecurityPolicyAppService`(207 行)；主專案 `app/system_config/` 合計 585 行全是產品知識
  （實查：`guarded_system_config_service.py` 85 + `security_policy_app_service.py` 207 +
  `shared_config_app_service.py` 88 + `storage_config_restore_app_service.py` 81 +
  `tenant_storage_config_seeder.py` 118 = **579 行**，與 README 的 585 大致吻合）。
- 背景排程 / thread：**無**。
- 對外 I/O：**無**（`os.getenv` 0 處，plugin.py:48 自述且實查屬實）。
- 快取 / Redis：**無**。
- **能不能單獨起成 process：harness 能起，但沒有端點可打。**
  `harness/dev_app.py` ＋ `docker-compose.yml`（port 5492），README `:120-124` 說它驗的是
  「建表＋CRUD 斷言＋register 掛載」。

**Q6 通用性**

(a) **能直接用，但會連帶吃到 SMTP 的產品邏輯。**

(b) 寫死 Guidant / 產品知識的地方——**這是本支最大的問題，且 README 沒提**：

| 位置 | 寫死了什麼 |
|---|---|
| `<mono>/jedi-system-config/jedi_system_config/app/service/system_config_service.py:87-88` | `if existing_config.group == "SMTP": if existing_config.key != "*":` ——認得 group 名 `SMTP` 與萬用 key `"*"` |
| 同上 `:93-97` | `if not data.get("changePwd", False): data["value"]["secret"] = existing_config.value["secret"]` ——認得 value JSONB 裡有一個叫 `secret` 的欄位、以及一個叫 `changePwd` 的旗標 |
| 同上 `:98-106` | `elif existing_config.group == "THIRD_PARTY_LOGIN":` ——第二個硬編 group 名，同樣的 secret 沿用邏輯 |
| 同上 `:126-127` | `if config.group == "SMTP" and config.key == '*': raise NotFound(...)` ——刪除保護，硬編 |
| 同上 `:131-139` | `def update_smtp_config(self, group, key, value)` ——**一個以 SMTP 命名的公開 method** |
| `<mono>/.../domain/service/system_config_domain_service.py:111` | `if existing_config.group == "SMTP" and existing_config.key == '*':` ——domain 層也有一份 |
| 同上 `:12-16,91-92,121-125,141-144` | `changePwd` 處理散在 4 處，`:12` docstring 寫「changePwd=False 時把既有列的 secret 沿用進本次要寫入的 value」 |
| `<mono>/.../infra/models/system_config.py:12` | 繼承 `TenantScopedMixinModel` → 強制 consumer 提供 `tenants` / `org_units` 兩張表在 metadata 裡（README `:127-133` 的「兩道宿主前提」） |

一個工單系統裝上這支，會得到一個「認得 SMTP 密碼欄位叫 secret」的設定表 CRUD。
**這些是 grep `"SMTP"|changePwd` 的全部命中（14 處），不是抽樣。**

**Q7 插件完整度**

| 項目 | 有/無 | 證據 |
|---|---|---|
| ① api 層隨包 | **無**（README 誠實標 ❌） | 無 `api/` 目錄 |
| ② `register()` | **有** | `register(app, adapters=None, config=None, schema_extensions=None, mount_api=True)`（`plugin.py:196-201`）；另有 `create_blueprint()`（`:171`）。四插槽齊 |
| ③ migrations 隨包 | **無** | 0 支 |
| ④ DI container 預設 | **無** | 宿主自己 wire（5 個 container） |
| ⑤ 獨立 harness | **有** | `harness/dev_app.py` + `docker-compose.yml`（5492） |
| ⑥ 接入 README | **有** | 173 行，含誠實邊界聲明 |

**做到 3/5**（自己承認）。

**Q8 糾纏對象**

- **與 jedi-notification：一邊存 SMTP 設定、一邊用它寄信，且套件 A 認得套件 B 的資料形狀。**
  `<be>/app/notification/service/notification_service.py:6,7` 同時 import 兩支；
  `:58-60` `get_notifier()` 先 `read_root_config_value("SMTP", "*")` 拿設定，
  再組 jedi-notification 的 `SmtpEmailConfigDTO`。而 **system-config 套件內部本身就認得
  `group == "SMTP"` 且知道它的 value 有 `secret` 欄位**（見 Q6）——
  也就是 jedi-system-config 的程式碼**已經知道 jedi-notification 的設定長什麼樣**，
  只是沒有 import 它。同一份資料（SMTP 帳密）在兩支套件的認知裡各有一半。
- **與 jedi-iam：登入路徑上一交易同時碰兩邊，且 iam 的政策 schema 存在 config 的表裡。**
  ① `<be>/app/auth/service/login_service.py:28` 建構子同時收 iam 的 `JediLoginService`
  與 config 的 `SystemConfigDomainService`。
  ② `<be>/infra/system_config/runtime_config.py:23-27` `from jedi_iam.security_policy import
  RUNTIME_CONFIG_GROUP, build_defaults, coerce`——**「有哪些營運參數、型別、預設值、合法範圍」
  的定義在 jedi-iam，值存在 jedi-system-config 的表裡**（`system_configs` group=`RUNTIME_CONFIG`，
  DEV live 確有此 group）。`<be>/core/app_factory.py:139-146` 在 `register_identity()`
  **之前**就把它讀進 `app.config`，註解 `:141-142` 明說「必須在發 token 之前——JWT 效期由
  flask_jwt_extended 讀 app.config」。
  ③ `system_configs` 的 RLS policy 呼叫 `app_tenant_allowed_for_session(tenant_id)`，
  而該 session 變數由 jedi-common 依 jedi-iam 的租戶 path 設定。
- **與 jedi-common：資料層被 mixin 綁死。** `system_config.py:12` 繼承 `TenantScopedMixinModel`，
  README `:127-133` 因此列出兩道硬性宿主前提（`ENABLE_MULTI_TENANT` 要在 import 前設、
  `tenants`/`org_units` 要在 metadata 裡）。
- **看起來像但不是一件事**：`jedi-system-config` 與 `jedi-system-menu`。兩者名字都是 `system-*`、
  表結構長得幾乎一樣（都是 `group` + `key` + `value` 三欄字典），但
  ① **租戶語意相反**：`system_configs` 有 `tenant_id` ＋ 4 條 RLS policy（租戶各有一份）；
  `system_menus` **無 tenant_id、無 RLS**、唯一約束是 `(group, key)` 全域唯一（全站共用一份）。
  ② **消費方向相反**：config 是後端讀來決定行為；menu 是前端讀來畫下拉選項。
  ③ 兩表之間**無 FK、無 join、無共用查詢**（grep 確認）。

---

## jedi-system-menu

**Q1 它是什麼**

一張 `system_menus` 表——**全站共用的下拉選項字典**（`group` → 多個 `key`/`value`/`sort`），
給前端畫 select 用。裝上註冊一行就長出 5 條選單 API。

README 與程式碼**一致**。`<mono>/jedi-system-menu/README.md:3` 說「完整體插件——裝上註冊一行
就長出選單 API，拔掉一行就消失」→ 實查 `jedi_system_menu/api/` 確有 api 層，
`<be>/core/app_factory.py:390` `register_system_menu(app, ..., mount_api=True)` 就是那一行，
`<be>/config/app_modules.py:21-26` 對應的 `"system_menu"` 已註解掉、`api/system_menu/` 目錄已刪。
README `:120-127` 的端點表 6 行與 `api/__init__.py:154-160` 的 5 條 `add_resource` 對得上
（6 行是因為 `/system-menu` 與 `/system-menu/<int:id>` 分列）。README `:5-7` 同樣自曝舊版抄自 jedi-issue。

**Q2 資料**

自有表 **1 張**：

| 表 | 存什麼 | 關鍵欄位 | 證據 |
|---|---|---|---|
| `system_menus` | 下拉選項字典 | `group` / `key` / `value` / `sort` / `enable` / `public` | `<mono>/jedi-system-menu/jedi_system_menu/infra/models/system_menu.py:8` |

DEV live 實況：**74 列、16 個 group**——`AGENT_TYPE`(1) / `ANS_TYPE`(7) / `AUDIT_METHOD`(6) /
`BULLETIN_CATEGORY`(4) / `COMPLIANCE_FRAMEWORK_PUBLISH_STATUS`(3) / `DEVICE_TYPE`(11) /
`ENABLE_STATUS`(2) / `FEEDBACK_TYPE`(4) / `PDF_PARSER`(4) / `PERMISSION_OPTION`(3) /
`PROJECT_SELECT_RULE`(4) / `ssp_party_role`(9) / `STORAGE_TYPE`(4) / `SYSTEM_TYPE`(3) /
`TASK_TYPE`(5) / `USER_STATUS`(4)。
索引：`pk_system_menus` ＋ `uq_system_menus_group_key (group, key)`。
**無 tenant_id、無 RLS policy、無任何 FK**（`\d public.system_menus` 2026-09-09）。

對別人的表的參照：**(a)(b)(c)(d) 全部 0 條。**
model 是 `class SystemMenu(Base)`（`:6`）——**刻意不繼承 `TenantScopedMixinModel`**，
所以它是七支裡唯一一支「資料完全零耦合、連 mixin 都不吃」的表。
`plugin.py:38-40` 給了理由：`system_menus` 是全站共用的系統字典，公開等於任何人都能改到全站 UI 的下拉選項。

表名產品詞彙：**表名與套件源碼皆無**——grep `AUDIT|ssp|COMPLIANCE|DEPARTMENT|"[A-Z_]{4,}"`
在 `jedi_system_menu/` **零命中**。
**但資料層命中很重**：live 的 16 個 group 裡 `AUDIT_METHOD`（稽核方法）、`ssp_party_role`（SSP 當事人角色）、
`COMPLIANCE_FRAMEWORK_PUBLISH_STATUS`（合規框架發布狀態）、`PROJECT_SELECT_RULE` 全是 Guidant 產品詞。
**這是 schema 通用、資料產品化的典型**——換個產品只要換資料，程式碼一行不用改。
主專案有兩處硬編對照這些 group：`<be>/app/oscal/service/export/ssp_docx_generator.py:31`
（註解「對齊 C1 system_menus group='ssp_party_role' 9 個 OSCAL 標準角色」）、
`<be>/test/test_ap_report_parser_airasia.py:5`（「AUDIT_METHOD codes 來源：DEV DB public.system_menus」）。

**Q3 Port**

需要宿主提供的（`<mono>/jedi-system-menu/jedi_system_menu/plugin.py:117-138`）：

| port | 簽名 | 預設 | 用在哪 |
|---|---|---|---|
| `services.system_menu_service` | `Callable[[], Any]`（provider，不是實例） | ❌ | route 取 service |
| `auth_required` | `Callable[[Callable], Callable]` | ❌ **刻意無預設** | 全部 5 條 route |
| `capability_required` | `Callable[[str], Callable]` | ❌ **刻意無預設** | 寫入 3 條 |
| `platform_admin_required` | `Callable[[Callable], Callable]` | ❌ **刻意無預設** | 管理 5 條（消費端點例外） |
| `response_builder` | `Callable[..., Any]` | ✅ 退回 jedi-common `return_response` | 全部 |

🔴 三個守門無預設的理由（`plugin.py:38-40` ＋ README `:112-117`）：預設放行會讓「忘記傳」
變成一整組無聲的公開端點，而 `system_menus` 是全站共用字典（無 tenant_id、無 RLS、
`(group,key)` 全域唯一），公開等於**任何人都能改到全站 UI 的下拉選項**，同時服務照常起、
健康檢查照樣綠。缺欄位時 `_assert_api_wiring()`（`plugin.py:206`）當場拒絕掛載。
config 另有三個能力點名可覆寫：`system-menu.create/update/delete`（`plugin.py:77-79,111-113`）。

- 提供給別人：`SystemMenuService` / `SystemMenuDomainService` / `SystemMenuRepoImpl` /
  `plugin.register` / `SystemMenuAdapters` / `SystemMenuServices` / `api.FROZEN_URLS`。
- **宣告了但零使用的 port：無**（五張全部在用）。`schema_extensions` 插槽開齊但無產品使用（`plugin.py:143-147`）。

**Q4 消費者**

- **(a) 主專案：5 處，全是接線** ——
  `<be>/infra/system_menu/system_menu_plugin_wiring.py:35,50`（組 adapters）、
  `<be>/core/app_factory.py:388`（`register`）、
  `<be>/di_containers/system_menu/system_menu_containers.py:2,3,4`（DI wire 三層）。
  **沒有任何業務程式碼直接呼叫它**——這是七支裡宿主耦合最薄的一支。
- **(b) 其他 jedi-* 套件：0 處。**
- 它自己依賴：只有 jedi-common（`pyproject.toml:35`，floor `>=0.0.33`，15 處 import）。

**Q5 執行期形狀**

- route：**5 條**，blueprint `system-menu`、prefix `/api/1.0`（`plugin.py:66,74`）：
  `GET /system/menu/<group>`（僅登入，**刻意不加 platform 守門**）、
  `POST /system/menus`、`GET /system/category-menu`、`POST /system-menu`、
  `PUT|DELETE /system-menu/<int:id>`（後四條 = 登入＋platform-admin，寫入再加能力點）。
  凍結清單 `FROZEN_URLS`（`api/__init__.py:166-172`），測試做集合比對。
- 背景排程 / thread：**無**。
- 對外 I/O：**無**（`os.getenv` 0 處，`plugin.py:49` 自述且實查屬實）。
- 快取 / Redis：**無**。
- **能不能單獨起成 process：不能——它是七支裡唯一「有 route 卻沒有 harness」的**
  （`ls jedi-system-menu/` 無 `harness/`）。README `:15-19` 的表格聲稱「D6 五件套全做到（含拔掉測試）」，
  **但那張表沒把 harness 列進五件套**；實查 harness 確實不存在。

**Q6 通用性**

(a) **能直接用，而且是七支裡最乾淨的一支。** 一張 `group/key/value/sort/enable/public` 的字典表
＋ CRUD API，工單系統要「工單類型」「優先級」下拉直接可用。

(b) 寫死 Guidant 產品知識：**套件源碼零命中**（grep `AUDIT|ssp|COMPLIANCE|DEPARTMENT` = 0）。
唯一「像產品知識」的只有三個能力點預設名 `system-menu.create/update/delete`
（`plugin.py:77-79`），而它們**是可覆寫的 config 欄位**（`:111-113`），不是硬編。
`DEFAULT_URL_PREFIX = "/api/1.0"` / `DEFAULT_BLUEPRINT_NAME = "system-menu"` 同樣可覆寫。

**唯一的通用性缺口不在程式碼、在守門設計**：`CM-779` 的裁決（選單管理收歸 root tenant，
但前台消費端點是例外不加守門，`api/__init__.py:118-127`）是 Guidant 的多租戶政策。
新產品若是單租戶，`platform_admin_required` 就無意義卻仍是必填欄位——只能傳一個恆真的 decorator。

**Q7 插件完整度**

| 項目 | 有/無 | 證據 |
|---|---|---|
| ① api 層隨包 | **有** | `jedi_system_menu/api/`，5 條 route ＋ `FROZEN_URLS` |
| ② `register()` | **有** | `register(app, adapters=None, config=None, schema_extensions=None, mount_api=True)`（`plugin.py:256-261`）；`create_blueprint()`（`:227`）；`_assert_api_wiring()`（`:206`） |
| ③ migrations 隨包 | **無** | 0 支 |
| ④ DI container 預設 | **無** | 宿主 wire（`<be>/di_containers/system_menu/`） |
| ⑤ 獨立 harness | **無** | 無 `harness/` 目錄 |
| ⑥ 接入 README | **有** | 169 行，含端點表、port 表、常見坑 |

**做到 4/6**（README 自稱五件套全做到，但 harness 這項實查不存在）。

**Q8 糾纏對象**

- **與 jedi-iam：兩張都叫「menu」但不是一件事**（同 jedi-iam Q8 那條的反向敘述）。
  jedi-iam 的 `ui_routes` / `route_capabilities` / `*_menu_entity` 是前端路由樹與權限選單；
  本支的 `system_menus` 是下拉選項字典。**無 FK、無 relationship、無共用查詢**。
  唯一真實關聯是**執行期的守門依賴**：本支的 3 個守門 port 由宿主接成
  `jedi_iam.authz.require_capability` / `require_platform_admin_route`
  （`<be>/infra/system_menu/system_menu_plugin_wiring.py`），且能力點
  `system-menu.create/read/update/delete` 4 筆確實存在於 jedi-iam 的 `capabilities` 表
  （DEV live 實查）。**一邊定義能力點名、另一邊存能力點資料。**
- **與 jedi-system-config：看起來像但不是一件事**（理由已在 system-config 的 Q8 詳列：
  租戶語意相反、消費方向相反、兩表零關聯）。
- **與 jedi-common：只有 library 依賴，無資料耦合。**
  是七支裡唯一**不繼承 `TenantScopedMixinModel`** 的有表套件，所以不吃「tenants/org_units
  必須在 metadata 裡」那道宿主前提。README `:150-155` 記載的坑只有版本 floor（`>=0.0.33`，
  因為 route 用 `jedi_common.enums.schema_code.AUTH_PARAMS` 與 `utils.response_util`，
  兩者是 FR-069 P7 才上移的）與 marshmallow `<4`。
- **與產品的糾纏在資料不在程式碼**：live 的 `ssp_party_role` 9 筆被
  `<be>/app/oscal/service/export/ssp_docx_generator.py:31` 對齊、`AUDIT_METHOD` 6 筆被
  AP 報告 parser 使用。**拔掉這張表的資料會壞掉 SSP 匯出與 AP 解析，但拔掉這支套件的程式碼
  只會讓 5 條 API 消失**（`<be>/core/app_factory.py:381-384` 註解明說：「選單**資料**本身不受影響
  （表還在、別的模組若直接用 SystemMenuService 也照常），消失的只有 HTTP 端點」）。

---

## jedi-notification

**Q1 它是什麼**

「怎麼把一則訊息送出去」的三條管道實作——SMTP 寄信 / Discord webhook / Telegram bot。
**不管「什麼時候寄、寄給誰、信裡寫什麼」**，那些留在宿主。

README 與程式碼**一致**。`<mono>/jedi-notification/README.md:3-9` 說「裝上、註冊一行，產品就長出
『測試信』端點；拔掉那一行，端點消失、產品其餘功能照常跑（因為 12 個消費點走的是 library 用法）」
→ 實查 `api/__init__.py:104,110` 確實只有 1 條 route（`/mail/test`），
`<be>/core/app_factory.py:364-369` 的註解逐字印證同一件事：「註解掉 → `POST /api/1.0/mail/test` 404，
但**通知功能本身完全不受影響**——12 個消費點（flow_control / task_survey / flow_engine /
notify_config / license / jedi-iam 的 OTP 信）走的是套件的 library 用法，不經 route」。
README `:40-45` 對 jedi-iam 供應鏈的描述也已交叉驗證屬實（見 jedi-iam Q8）。

**Q2 資料**

**自有表：0 張。** `grep __tablename__` 零命中，全套件無 `infra/models/`、無 ORM model、
無 `Base` import。它是七支裡唯一**完全無狀態**的。

對別人的表的參照：**(a)(b)(c)(d) 全部 0 條。** 它連 SQLAlchemy 都不在 runtime 相依裡
（`pyproject.toml:10-32` 只有 Flask 三件套 + marshmallow + pydantic + requests + jedi-common）。

表名產品詞彙：N/A（無表）。

**Q3 Port**

- 需要宿主提供的（`<mono>/jedi-notification/jedi_notification/plugin.py:99-117`）：

| port | 簽名 | 預設 |
|---|---|---|
| `services.test_mail_service` | `Callable[[], Any]`（provider） | ❌ |
| `auth_required` | `Callable[[Callable], Callable]` | ❌ 刻意無預設 |
| `capability_required` | `Callable[[str], Callable]` | ❌ 刻意無預設 |
| `response_builder` | `Callable[..., Any]` | ✅ |

config 帶一個能力點名 `test_mail_capability`（`plugin.py:96`）。缺守門 → `_assert_api_wiring()`（`:188`）拒絕掛載。
🔴 `test_mail_service` 之所以是 port 而非套件內建：README `:37` 說明
「依收件人網域挑 SMTP 設定、查無退回 `*`」是**宿主**的事，因為那是 Guidant AI 設定頁的資料模型
（group=SMTP、key=網域）長出來的政策。實作在 `<be>/app/notification/service/test_mail_service.py`（121 行）。

- **它自己的插件點（provider 註冊表，不是宿主 port）**：`register_adapter(provider, adapter, config_dto)`
  ＋ `get_notification_adapter(provider, config)`（`ports/notification_provider/notification_factory.py:42,51`）。
  `ports/` 內的 `TYPE` 表**刻意是空的**（`:39`），由 `infra/__init__.py` 在 import 時
  自己送上門登記三個 adapter。`:1-28` 有完整的依賴反轉說明——原本 `ports/` 直接 import `infra/` 三個 adapter，
  四條架構測試都抓到方向反了；搬檔案解不掉（`test_no_circular_dependencies` 按模組名比對），
  真正的問題是「介面層認得所有實作」本身。**要加第四條管道只要在 `infra/__init__.py` 加一行，`ports/` 不動。**
- 提供給別人：`NotificationService`（`app/service/notification_service.py:7`）、
  四支 DTO（`MailRequestDTO` / `TelegramRequestDTO` / `DiscordRequestDTO` / `SmtpEmailConfigDTO` /
  `DiscordConfigDTO` / `TelegramConfigDTO`）、`plugin.register` / `NotificationAdapters` / `NotificationServices`。
- **宣告了但零使用的 port：無。**

**Q4 消費者**

- **(a) 主專案：14 處，3 個模組 ＋ 1 個接線點**——
  `<be>/app/notification/service/notification_service.py:7,8,11,12,13`（**主要 wrapper**，110 行）、
  `<be>/app/notification/service/test_mail_service.py:26,27,28`、
  `<be>/app/notify_config/service/notify_config_test_service.py:18,19,22,23`（測 Discord/Telegram 連線）、
  `<be>/infra/notification/notification_plugin_wiring.py:30` ＋ `<be>/core/app_factory.py:373`（接線）。
  README `:33-40` 列的 12 個消費點（flow_control / task_survey / flow_engine / notify_config /
  license / jedi-iam）**是透過 `<be>/app/notification/service/notification_service.py` 這層 wrapper 間接消費的**，
  不是直接 import 套件。
- **(b) 其他 jedi-* 套件：0 處。** 特別是 **jedi-iam 零 import**（該邊已於 FR-069.15 刻意斷開，
  `<mono>/jedi-iam/pyproject.toml:51-53`）。
- 它自己依賴：只有 jedi-common（`pyproject.toml:31`，**僅 3 處 import**——是全 monorepo 對 jedi-common
  依賴最輕的套件）。

**Q5 執行期形狀**

- route：**1 條** ——`POST /api/1.0/mail/test`（`api/__init__.py:104`，`FROZEN_URLS` 於 `:110`）。
- 背景排程 / thread：**套件自己沒有**。非同步是宿主做的
  （`<be>/common/iam_ports.py:59-62` `threading.Thread(target=send_mail_notification).start()`）。
- **對外 I/O：三種，全部是網路** ——
  ① SMTP：`infra/smtp_mail/smtp_mail_adapter.py:1,39` `smtplib.SMTP(host, port, timeout=60)`；
  ② Discord webhook：`infra/discord/discord_adapter.py:1` `import requests`；
  ③ Telegram bot API：`infra/telegram/telegram_adapter.py:1` `import requests`。
  **無 DB、無檔案系統、無 subprocess。**
- 快取 / Redis：**無**。
- **能不能單獨起成 process：能，而且是七支裡最容易的。**
  `harness/dev_app.py` **不需要資料庫、不需要任何主產品程式碼**（README `:47-49` 明說），
  178 個單元測試不需 DB 不需網路（`:58`）。`NotificationService` 本體只有 17 行
  （`app/service/notification_service.py`，建構子收 `provider: str` + `config: dict`，
  `send_notification()` 三行）——**無狀態、可水平擴充、天生適合服務化**。

**Q6 通用性**

(a) **能直接用，是七支裡通用性最高的。** 「怎麼跟 SMTP 講話」「Discord webhook 的 JSON 長怎樣」
每個產品完全一樣（README `:7-9` 的立論）。

(b) 寫死 Guidant 產品知識：**程式碼零命中**。grep `CMMC|Guidant|POAM|ssp|稽核` 在
`jedi_notification/` 只命中 **註解與 docstring**：
`plugin.py:6`（「產品各處（稽核流程通知、問卷指派…）」）、
`plugin.py:37`（「『依收件人網域挑 SMTP 設定』| **宿主** | 那是 Guidant AI 設定頁的資料模型」）、
`harness/dev_app.py:35,61`（說明 harness 不提供真的 TestMailService，因為那裝的是 Guidant AI 的產品知識）。
**這三處全是在解釋「什麼不在疆界內」，不是洩漏。**
`os.getenv` 0 處（`plugin.py:46` 自述，實查屬實）。

唯一可議：`DEFAULT_TEST_MAIL_CAPABILITY`（`plugin.py:96`）與 `DEFAULT_URL_PREFIX="/api/1.0"`，
兩者都是可覆寫 config。

**Q7 插件完整度**

| 項目 | 有/無 | 證據 |
|---|---|---|
| ① api 層隨包 | **有** | `jedi_notification/api/`，1 條 route |
| ② `register()` | **有** | `register(app, adapters=None, config=None, schema_extensions=None, mount_api=True)`（`plugin.py:237-242`）；`create_blueprint()`（`:208`）；`_assert_api_wiring()`（`:188`） |
| ③ migrations 隨包 | **無**（也不需要——無表） | 0 支 |
| ④ DI container 預設 | **無** | 宿主 wire |
| ⑤ 獨立 harness | **有** | `harness/dev_app.py`（**無需 DB**，故也無 docker-compose） |
| ⑥ 接入 README | **有** | 276 行，含 quickstart / port 表 / 端點表 / 常見坑 |

**做到 5/5**（migrations 對無表套件不適用）。與 jedi-log-forwarding 並列最完整。

**Q8 糾纏對象**

- **與 jedi-system-config：一邊存 SMTP 帳密、一邊拿去連 SMTP——最像「同一件事的兩半」的一對。**
  證據鏈：`<be>/app/notification/service/notification_service.py:22` 建構子收
  `SystemConfigService`；`:58` `get_notifier()` 呼叫 `read_root_config_value("SMTP", "*")`
  拿設定後組 `SmtpEmailConfigDTO`；`:41-47` `get_channel_config(channel)` 讀
  `NOTIFY_CONFIG/<channel>` 決定 Discord/Telegram 的 webhook。
  **更強的證據是反向的**：jedi-system-config 的**套件內部**已經認得
  `group == "SMTP"`、`key == "*"`、`value["secret"]`、`changePwd` 旗標
  （`<mono>/jedi-system-config/.../system_config_service.py:87-106,126`，14 處硬編）——
  也就是**設定套件已經知道通知套件的資料形狀，只是沒有 import 它**。
  DEV live 佐證：`system_configs` 的 7 個 group 裡 `SMTP` 與 `NOTIFY_CONFIG` 兩個
  （佔 2/7）純粹是為通知而存在。
- **與 jedi-iam：靜態解耦、動態耦合的供應鏈**（詳見 jedi-iam Q8）。
  `<mono>/jedi-notification/README.md:40-45` 自己記載這條鏈；宿主的中介在
  `<be>/common/iam_ports.py:46-62` ＋ `<be>/core/app_factory.py:301-311`。
  **兩支套件互不 import，但拔掉 jedi-notification，jedi-iam 的 OTP 信就寄不出去**
  （會優雅降級成「產碼但不寄信」，`<be>/common/iam_ports.py:91-92`）。
- **看起來像但其實不是一件事：與 jedi-log-forwarding。** 兩者都是「把東西送到外部系統」、
  都用 socket/HTTP、都要求宿主注入守門與 `response_builder`。但
  ① **資料流向與觸發者不同**：notification 是**業務事件觸發的單筆推送**（有人做了某事 → 寄一封信）；
  log-forwarding 是**logging 管線的旁路鏡像**（每一條 log 自動複製一份），
  掛在 `QueueHandler` 上、由背景 listener thread 送。
  ② **失敗語意相反**：notification 送失敗會回 `False` 讓呼叫端知道（`notification_service.py:15-17`）；
  log-forwarding **一律靜默吞掉**（`common/gelf_handler.py:71,163-166` 明說「所有 socket 例外一律吞掉並降級」）。
  ③ **無任何共用程式碼、無共用設定、無共用表**（一個無表、一個有自己的表）。
- **與 jedi-common：最輕的依賴**（僅 3 處 import——例外類、回應信封、`AUTH_PARAMS`）。

---

## jedi-log（import 名 `jedi_api_log`）

**Q1 它是什麼**

API 存取紀錄——每一筆 HTTP 請求的 URL / 參數 / 使用者 / 來源 IP / 回應 / 耗時，
寫進 `api_logs` 表，並提供「操作日誌」頁的分頁查詢與 Excel 匯出。

README 與程式碼**一致，且開宗明義處理了最大的誤解**。`<mono>/jedi-log/README.md:11-16` 的表格
說這支 dist 曾 ship 兩個 import 名，`jedi_system_log` 已於 FR-069 P3.6（CM-1472）**整包刪除**
→ 實查 `ls jedi-log/` 確實只剩 `jedi_api_log/`，`pyproject.toml:61-63` 的 `packages` 只 include 一個。

🔴 **決策者指定要查的「jedi-log 剩下哪一半」——實查結論：剩 `jedi_api_log`，1,305 行，活的。**
刪掉的那半（`jedi_system_log`）為什麼非刪不可，`pyproject.toml:54-60` ＋ README `:22-30` 給了三層理由，
且**全部可交叉驗證**：
① 它與 jedi-common 宣告**同一張 `system_logs` 表**，在 jedi-common 已載入時（也就是必然）
`import` 即拋 `InvalidRequestError: Table 'system_logs' is already defined`
——「不是『暫時沒人用』，是『用不了』」。
② 該表的資料全部由 jedi-common 的 `DBLogHandler` 寫入，`jedi_system_log` 對它寫過 0 列。
證據可驗：`path` / `func_name` / `line_no` 三欄只存在於 jedi-common 那份 model
（`<mono>/jedi-common/.../models/system_log.py:14-17`），而 DEV live `\d public.system_logs`
確有這三欄且 663,249 列——README 說「實查全部帶值」。
③ 其 model 已漂移成 9 欄、不符 live schema 的 12 欄。
**系統日誌的 canonical 落地點是 `jedi_common.logger.db_log/`。**

**Q2 資料**

自有表 **1 張**：

| 表 | 存什麼 | 關鍵欄位 | 證據 |
|---|---|---|---|
| `api_logs` | API 存取紀錄 | `act_time` / `user_uid`+`user_name` / `url`+`method` / `source_ip`+`server_ip` / `params`+`request`+`response` / `duration` | `<mono>/jedi-log/jedi_api_log/infra/models/api_log.py:7` |

DEV live 實況：**116,319 列**，RANGE(act_time) 月分區（5 個分區）＋ BRIN 索引，
**無 FK、無 tenant_id、無 RLS**（`\d public.api_logs`）。
model 是 `class ApiLog(Base)`（`:6`）——**不繼承 `TenantScopedMixinModel`**。

⚠️ **model 與 live schema 有一處型別落差**：`api_log.py:27` 宣告 `duration = Column(Float, ...)`，
但 DB 實際是 `duration | numeric | 預設 0`（`\d` 實查）。Float(double precision) vs numeric 不同型別。

對別人的表的參照：**(a)(b)(c)(d) 全部 0 條。**
軟參照：`user_uid` / `user_name` 由宿主 middleware 填入
（`<be>/common/middleware/app_mw.py:52-60` 的 `CreateApiLogDTO`），值來自 `get_user_context()`，
**不建 FK**。

表名產品詞彙：**表名無**。但**匯出檔名有**：
`<mono>/jedi-log/jedi_api_log/app/service/api_log_service.py:58,76` 硬編
`f"user_log_{timestamp}.xlsx"` 與 `f"user_log_{timestamp}.zip"`——`user_log` 是 Guidant 前端頁面的叫法。

**Q3 Port**

需要宿主提供的（`<mono>/jedi-log/jedi_api_log/plugin.py:118-136`）：

| port | 簽名 | 預設 |
|---|---|---|
| `services.api_log_service` | `Callable[[], Any]`（provider） | ❌ |
| `auth_required` | `Callable[[Callable], Callable]` | ❌ 刻意無預設 |
| `platform_admin_required` | `Callable[[Callable], Callable]` | ❌ 刻意無預設 |
| `response_builder` | `Callable[..., Any]` | ✅ |

🔴 **本套件是 P3.5 七支裡外洩後果最嚴重的一支**（`plugin.py:122-126` ＋ README `:96-101`）：
預設放行會讓「忘記傳」變成無聲的公開端點，而這兩條端點吐的是**全站 API 存取紀錄**
——每一筆請求的 URL、參數、使用者、來源 IP，**等於把整份稽核軌跡攤開給任何人**；
同時服務照常起、健康檢查照樣綠。缺欄位時 `_assert_api_wiring()`（`:204`）當場拒絕掛載。
🔴 **用 platform-admin 而非能力點是刻意的**（README `:88-92`）：搬遷前就是
`@jwt_required()` ＋ `@require_platform_admin_route`，沒有能力點——操作日誌是全域共用資源。
故 `ApiLogPluginConfig`（`:104-114`）**無能力點欄位**，只有 `url_prefix` / `blueprint_name`。

- 提供給別人：`ApiLogService`、`CreateApiLogDTO` / `UpdateApiLogDTO`、
  `common.enum.code.LogTypeCode`、`plugin.register` / `ApiLogAdapters` / `ApiLogServices`、`api.FROZEN_URLS`。
- **宣告了但零使用的 port：無**（四張全在用）。

**Q4 消費者**

- **(a) 主專案：10 處，2 個用途** ——
  ① **寫入路徑（middleware，最重要）**：`<be>/common/middleware/app_mw.py:5,6,7,8`
  import `CreateApiLogDTO` / `UpdateApiLogDTO` / `ApiLogService` / `LogTypeCode`，
  在 `before_request`（`:41`）建立紀錄、`after_request` 回填回應與耗時。
  **這是套件最主要的消費形式，而且不經 route**——`<be>/core/app_factory.py:410` 註解明說
  「日誌**寫入**本身不受影響——那是 middleware 幹的事，不經這兩條 route」。
  ② **接線**：`<be>/infra/api_log/api_log_plugin_wiring.py:34` ＋ `<be>/core/app_factory.py:419`。
  對應的 `<be>/config/app_modules.py:48-54` 的 `"log"` 已註解掉、`api/log/` 目錄已刪。
- **(b) 其他 jedi-* 套件：0 處。**
- 它自己依賴：只有 jedi-common（`pyproject.toml:39`，floor `>=0.0.33`，11 處 import）。

**Q5 執行期形狀**

- route：**2 條**，blueprint `api-log`、prefix `/api/1.0`（`api/__init__.py:101-102`）：
  `POST /log/api-logs`（分頁查詢）、`GET /log/api-logs/export`（匯出 zip）。
  `FROZEN_URLS` 於 `:108-111`。README `:129-131` 記載「CM-1461 死路徑：本模組零命中」（已寫成測試焊死）。
- 背景排程 / thread：**無**。
- **對外 I/O：檔案系統（記憶體內）** ——匯出路徑用 pandas + openpyxl 產 xlsx、再用 `zipfile`
  壓成 zip，**全程 `BytesIO` 不落地**（`app/service/api_log_service.py:63,71`）。
  另用 `werkzeug.utils.secure_filename`（README `:158-160` 說明這是 P3.5 才補上顯式宣告的，
  原本靠 Flask 傳遞相依撐著）。**無網路 I/O、無 subprocess。**
- 快取 / Redis：**無**。
- **runtime 相依偏重**：`pandas==2.2.2` + `openpyxl>=3.1.4`（`pyproject.toml:37-38`）
  ——README `:24` 說「實查 6 處 import」。一個「查 log」的套件拖 pandas 進來，
  是它與其他六支最大的體積差異。
- **能不能單獨起成 process：不能——無 harness**（`ls jedi-log/` 無 `harness/`）。
  ⚠️ **但更關鍵的限制是「寫入端在宿主 middleware 裡」**：真要服務化，
  `api_logs` 的**寫**發生在每一個 HTTP 請求的 before/after_request（`<be>/common/middleware/app_mw.py`），
  而**讀**在這兩條 route。拆成獨立 process 會讓寫入變成跨進程呼叫，
  每個請求多一次網路往返——這是本支服務化的實質障礙。

**Q6 通用性**

(a) **能直接用**——「記錄每筆 API 請求並可查可匯出」是所有產品都要的。

(b) 寫死 Guidant 產品知識：

| 位置 | 寫死了什麼 |
|---|---|
| `<mono>/jedi-log/jedi_api_log/app/service/api_log_service.py:58,76` | 匯出檔名前綴 `user_log_` ——Guidant 前端「操作日誌」頁的叫法，非通用詞 |
| 同上 `:66` | `df_clean.to_excel(writer, index=False, header=False)` ——**匯出無表頭**。這是配合特定前端/客戶格式的決定，新產品拿到會是一份沒有欄位名的 Excel |
| `plugin.py:113-114` | `DEFAULT_URL_PREFIX="/api/1.0"` / `DEFAULT_BLUEPRINT_NAME="api-log"` | 可覆寫 |
| `<mono>/jedi-log/README.md:161-163`（行為，非硬編） | 「列表預設排序是後端強制的：FE 未帶排序時後端塞 `created_at desc`」——因為 jedi-common 的 `BaseRepository` 預設 `created_at ASC` 會把今日資料排到最後幾頁。README 標「**這是刻意的補償，不要拿掉**」。是為特定 FE 行為做的補償 |

`os.getenv` 0 處（`plugin.py:58` 自述，實查屬實）。跨 jedi 套件 import 0 處。

**Q7 插件完整度**

| 項目 | 有/無 | 證據 |
|---|---|---|
| ① api 層隨包 | **有** | `jedi_api_log/api/`，2 條 route ＋ `FROZEN_URLS` |
| ② `register()` | **有** | `register(app, adapters=None, config=None, schema_extensions=None, mount_api=True)`（`plugin.py:254-259`）；`create_blueprint()`（`:225`）；`_assert_api_wiring()`（`:204`） |
| ③ migrations 隨包 | **無** | 0 支。`api_logs` 建表與分區在 `<be>/scripts/sql/2026-06-03-log-tables-partitioning.sql` |
| ④ DI container 預設 | **無** | 宿主 wire |
| ⑤ 獨立 harness | **無** | 無 `harness/` |
| ⑥ 接入 README | **有** | 163 行 |

**做到 3/6**（與 system-menu 同為「有 route 沒 harness」）。

**Q8 糾纏對象**

- **與 jedi-common：曾經是同一張表的兩份 model，現在是同一條請求裡寫的兩張表。**
  歷史面已在 Q1 詳述（`jedi_system_log` 因與 jedi-common 撞 `system_logs` 表而整包刪除）。
  現況面的四項證據：
  ① **同一次 HTTP 請求同時寫兩張表**——`api_logs` 由 `<be>/common/middleware/app_mw.py:41,52`
  的 before/after_request 寫；`system_logs` 由同一請求內任何 logger 呼叫經 `DBLogHandler` 寫。
  ② **兩表在同一支 migration 裡被同樣處理**——
  `<be>/scripts/sql/2026-06-03-log-tables-partitioning.sql:2` 檔頭「api_logs / system_logs 改
  RANGE(act_time) 月分區 + BRIN + retention 維護函式」，retention 設在同一張 VALUES 表
  （`:136` `('api_logs', 90), ('system_logs', 180)`）。
  ③ **基線清理腳本把兩者並列**——`<be>/scripts/sql/2026-08-17-fr065-t20b-baseline-clear-operation-traces.sql:11,45,74,99`
  同一支 TRUNCATE 兩張表。
  ④ **出貨基線產生器把兩者的月分區用同一條 regex 過濾**——
  `<be>/scripts/init/gen_schema_sql.sh:64` `MONTH_PARTITION_RE = re.compile(r"^(api_logs|system_logs)_\d{4}_\d{2}(_|$)")`。
  **這四條都是「一邊沒有另一邊就沒意義」的證據——維運上它們已經是一件事。**
  但**資料層是分開的**：兩表無 FK、無共用欄位語意（`api_logs` 有 url/params/response，
  `system_logs` 有 path/func_name/line_no），兩份 model 各自獨立。
- **與 jedi-log-forwarding：都叫「log」但不是一件事，而且分工是明確的。**
  log-forwarding 轉發的是 **logging 鏈上的 record**（也就是最終會進 `system_logs` 的那些），
  **不碰 `api_logs`**——實查 `jedi_log_forwarding/` 零處 import `jedi_api_log`、
  零處提及 `api_logs`。兩者的接觸點只有一個：兩支都是「log 相關」而在 `<be>/scripts/sql/` 的
  維運腳本裡被一起想到。**看起來像但不是一件事。**
- **與 jedi-iam：只有守門的執行期依賴。** 兩條 route 的 `platform_admin_required`
  由宿主接成 `jedi_iam.authz.require_platform_admin_route`。能力點 `log.read` 確實存在於
  `capabilities` 表（DEV live 實查），但 README `:88-92` 說明本模組**刻意不用能力點**、
  只用 platform-admin 這一軸——所以那筆 `log.read` 資料目前對本套件而言是無用的。

---

## jedi-log-forwarding

**Q1 它是什麼**

把產品自己的 log **同步送一份**到客戶的日誌伺服器（rsyslog / Graylog / ELK）——
負責「怎麼送、送去哪、送什麼、送不出去怎麼辦」，附帶一個設定頁的 CRUD API。

README 與程式碼**一致，是七支裡最詳盡的一份（281 行）**。`<mono>/jedi-log-forwarding/README.md:3-4`
說「裝上、註冊一行，產品就長出『系統 log 轉發』這整套能力；拔掉那一行，功能消失、產品照常跑」
→ `plugin.py:37` 逐字印證：「宿主註解掉註冊行 → 服務正常啟動、`/api/1.0/log-forwarding` 回 404、
其餘功能不受影響」。README 的 quickstart（`:29-77`）不只驗 route 掛得起來，還用 `--sink`
起迷你接收器把「設定頁存檔 → log 真的送到對面」整條打通——**這是七支裡唯一做到端到端 harness 的**。

**Q2 資料**

自有表 **1 張**，且是**唯一一支放在自訂 schema 的**：

| 表 | 存什麼 | 關鍵欄位 | 證據 |
|---|---|---|---|
| `config.log_forwarding_settings` | 轉發設定（一套系統一列） | `enabled` / `protocol`(syslog\|gelf) / `transport`(udp\|tcp) / `host`+`port` / `forward_app_log`+`forward_audit_events` / `tenant_id`(可空) | `<mono>/jedi-log-forwarding/jedi_log_forwarding/infra/model/log_forwarding_setting.py:29-30` |

schema 名由 `SETTINGS_SCHEMA = os.getenv("LOG_FORWARDING_SETTINGS_SCHEMA", "config")`（`:14`）決定，
**必須在 import 本模組之前設**（SQLAlchemy 在 class 定義當下就把 `__table_args__` 定死）。
DEV live：**1 列**（`config.log_forwarding_settings`）。

🔴 **刻意不繼承 `TenantScopedMixinModel`**（`:24-27` 有明確理由）：這是平台層設定表，
`tenant_id` 是「預留的覆寫維度」而非租戶隔離欄位——**它可以是 NULL，而 mixin 的欄位是
NOT NULL + FK + RLS，硬套會讓全域列根本寫不進去**。
🔴 **刻意不掛 RLS**（`migrations/001-*.sql:23-30`）：轉發 handler 的掛載發生在
「沒有 request 脈絡」的背景執行緒（QueueListener／watcher），RLS 需要的
`app.allowed_tenant_paths` 當下不存在，掛了只會讓背景讀取恆空。

對別人的表的參照：

- **(a) 真 FK：0 條。** `tenant_id` 是裸 `BIGINT`（`migrations/001:35`），**刻意不建 FK**。
- **(b) ORM relationship 跨包：0 條。**
- **(c) 軟參照：2 條** ——`tenant_id`（`log_forwarding_setting.py:34`，「NULL＝全域設定；預留租戶層覆寫」，
  軟指向 `tenants.id`）；`created_user` / `updated_user`（`:43-44`，varchar 255，存 login_name，
  軟指向 `users.login_name`，由宿主的 `IUserNameResolver` 換成暱稱）。
- **(d) raw SQL：有 2 句，但只打自己的表。**
  `common/settings_reader.py:87,94` `SELECT {_COLUMNS} FROM {table} WHERE tenant_id = :tid / IS NULL LIMIT 1`，
  `table` 預設是自己的 `config.log_forwarding_settings`。
  🔴 這支用的是**繞 RLS 的獨立唯讀 session**（`:10-11` 檔頭明說），理由是 handler 鏈的組裝
  發生在啟動期（DI 未 wire 完）與 watcher 背景執行緒（無 user_context、無 session_scope），
  走一般 repo 路徑會撞 `@transaction` 需要有人先開 session_scope。
  **「API 端的 CRUD 一律走正規 DDD 路徑（`infra/`），這支只服務背景掛載」——兩條讀取路徑並存是刻意的。**

表名產品詞彙：**無**。`log_forwarding_settings` 完全通用。

**Q3 Port**

需要宿主提供的（`<mono>/jedi-log-forwarding/jedi_log_forwarding/plugin.py:83-126`）——
**是七支裡唯一有「必填位置參數」的**（前三個沒有預設值，dataclass 層級就強制）：

| port | 簽名 | 預設 | 用途 |
|---|---|---|---|
| `read_required` | `Callable[[Callable], Callable]` | ❌ **必填** | GET 守門 |
| `update_required` | `Callable[[Callable], Callable]` | ❌ **必填** | PUT/POST 守門（**與讀分開**） |
| `current_user` | `Callable[[], str]` | ❌ **必填** | 寫審計欄位 |
| `user_name_resolver` | `IUserNameResolver`（見下） | ✅ None | 補 `*_user_name` 暱稱 |
| `audit` | `AuditHook` | ✅ 套件自帶同格式版 | 稽核事件寫哪 |
| `audit_logger_name` | `str` | ✅ `"jedi_log_forwarding"` | 🔴 見 Q6 |
| `response_builder` | `ResponseBuilder` | ✅ jedi-common `return_response` | 回應信封 |

config 另有 `ForwardingConfig`（`common/settings.py`）：`hostname` callable / `app_name` /
`watch_interval_seconds`（預設 30）/ `ops_logger_name`。

🔴 **決策者指定要查的「`IUserNameResolver` 與 jedi-common 的是否同形狀」——實查結論：簽名逐字相同，
但是兩個獨立的 ABC，宿主為此寫了兩份實作。**
- 本支的：`<mono>/jedi-log-forwarding/jedi_log_forwarding/domain/repository/user_name_resolver.py:18-22`
  `class IUserNameResolver(ABC)` / `def resolve(self, login_names: Iterable[str]) -> Dict[str, str]`
- jedi-common 的：`<mono>/jedi-common/jedi_common/identity/resolvers.py:63-80`
  `class IUserNameResolver(ABC)` / `def resolve(self, login_names: Iterable[str]) -> Dict[str, str]`
- **簽名、參數名、回傳型別、「查不到就不放進 dict」的契約，全部一致。**
  jedi-common 那份的 docstring `:15-16` 直接寫「參考實作：**jedi-log-forwarding 的 `IUserNameResolver`（P3 樣板）**
  ——本檔即它的一般化與擴編」。也就是說 jedi-common 那份是**後來從這支抄過去再擴編**的
  （擴成三件套：user_name / tenant / org_unit ＋ 聚合包 `IdentityContext`）。
- **但這支沒有改用 jedi-common 的版本**——`log_forwarding_app_service.py:18` 仍
  `from jedi_log_forwarding.domain.repository.user_name_resolver import IUserNameResolver`。
- **代價在宿主端：兩份幾乎一樣的 adapter，class 名還一模一樣。**
  ① `<be>/infra/log_forwarding/user_name_resolver.py:19` `class AuthUserNameResolver(IUserNameResolver)`
  ——繼承**本套件的** ABC（`:14` import）。
  ② `<be>/core/upload_file_wiring.py:53` `class AuthUserNameResolver`（**同名不同檔**）
  ——走 jedi-common 的統一 port（`:116` `from jedi_common.identity import IdentityContext`）。
  該檔 `:62-63` 的 docstring 自己承認：「形狀與 `infra/log_forwarding/user_name_resolver.py` **相同**（P3 樣板），
  差別只在這裡走 jedi-common 的統一 port 定義（D8 收斂）而非套件自訂的介面」。
  ③ 兩份實作邏輯幾乎一樣（都是拿 `jedi_iam.UserQueryEntity(_in_login_name=names)` 批次查、
  回 `{login_name: nickname}`），**唯一差別是 upload_file 那份多了 `@transaction`**
  （`upload_file_wiring.py:83`，檔頭 `:73-79` 記載這是 CM-1468 實測踩到的坑：
  補名發生在 route 組裝點、那裡沒有 session scope，少了它會被 `IdentityContext` 的降級吞成 warning、
  API 回 200 但暱稱恆為 null）。
  **→ 這是「D8 收斂只做了一半」的實據：標準件已在 jedi-common，但本套件還在用自己那份。**

- 提供給別人：`plugin.register` / `LogForwardingAdapters` / `LogForwardingConfig` /
  `create_blueprint` / `start_forwarding_chain` / `restart_after_fork` / `iter_migrations` /
  `common.settings.ForwardingConfig` / `domain.repository.user_name_resolver.IUserNameResolver`。
- **宣告了但零使用的 port：無**（七張全在用）。

**Q4 消費者**

- **(a) 主專案：5 處，4 個不同的接入點——是七支裡接線面最分散的**：
  ① `<be>/core/app_factory.py:155,159-165` `start_forwarding_chain(...)`（**啟動期掛轉發鏈**，
  包 try/except，`:164` 註解「log 轉發是旁路，掛不起來絕不可讓服務起不來」）；
  ② `<be>/main.py:230,233` `restart_after_fork()`（**gunicorn post_fork**，
  `:227-229` 註解說明 listener 與 watcher 都是執行緒、fork 不會帶過來，不重建的話該 worker 的 log
  會被丟進沒人消費的佇列且設定改了永遠不生效）；
  ③ `<be>/api/log_forwarding/__init__.py:51-86` `create_blueprint(...)`（**route 掛載**，
  走 `REGISTERED_APPS` 而非 `app_factory` 註冊行——**七支裡唯一一支這樣接的**）；
  ④ `<be>/infra/log_forwarding/forwarding_config.py:11` ＋ `user_name_resolver.py:14`（adapter）。
  對應 `<be>/config/app_modules.py:82-83` 的 `"log_forwarding"` **仍在清單內**（未註解掉）。
- **(b) 其他 jedi-* 套件：0 處。**
- 它自己依賴：只有 jedi-common（`pyproject.toml:21`，floor `>=0.0.30`，8 處 import）。

**Q5 執行期形狀**

- route：**2 條 path / 3 個動作**，blueprint prefix `/api/1.0`（`api/log_forwarding_route.py:96-97`）：
  `GET|PUT /log-forwarding`、`POST /log-forwarding/test`。
  🔴 守門是 **per-method** 而非整支 Resource 一種（`:91-97` 的第三欄 `{"get": "read", "put": "update"}`）
  ——讀寫分權。掛載走 `_guard()`（`plugin.py:247,242`）。
- **背景執行緒：有，而且是七支裡唯一的** ——
  ① **QueueListener 執行緒**（`common/forwarder.py:243`）：真正的 socket I/O 全在這裡，
  `QueueHandler.emit()` 只把 record 丟進 `queue.Queue`（微秒級記憶體操作）。
  ② **daemon watcher 執行緒**（`:143,408-409` `threading.Thread(name="log-forwarding-watcher", daemon=True)`）：
  每 30 秒重讀設定、必要時重掛 handler；`:286` 另檢查「QueueHandler 是否仍掛在當初那些 logger 上」
  （因為 `dictConfig` 會整批換掉 handlers 清單，`:342` 註解）。
- **對外 I/O：socket（UDP/TCP）＋ DB** ——
  syslog 走 stdlib `SysLogHandler`（`forwarder.py:178-181`，`socket.SOCK_STREAM|SOCK_DGRAM`），
  `:190-191` 特別關掉 stdlib 的 NUL 結尾（RFC 5424 沒有這個）；
  GELF 走自寫 handler（`common/gelf_handler.py:27,136-147`，`_send_udp` / `_send_tcp`，TCP timeout 2.0s）。
  DB：`settings_reader.py` 的繞 RLS 唯讀 session。
- 快取 / Redis：**無**。設定靠 watcher 輪詢（30s），不用快取層。
- **失敗語意：一律靜默吞** ——`gelf_handler.py:71,163-166`「`emit()` 內所有 socket 例外一律吞掉並降級
  ——本地 log 已經寫」；`_DroppingQueueHandler`（`forwarder.py:72-75`）在佇列滿時丟棄新訊息而非阻塞；
  `settings_reader.py:66-67` 「**永不拋錯**：DB 未就緒、表不存在、連線失敗都回 DISABLED」。
- **能不能單獨起成 process：能，且已實走驗證。** `harness/dev_app.py` 有 `--migrate` / `--sink` / `--emit`
  三個模式 ＋ `docker-compose.yml`（port 55433，避開 5432/25432），README `:29-77` 的 quickstart
  把整條鏈打通。**但服務化的實質障礙與 jedi-log 相同且更重**：它的價值在於「掛在宿主的 logging 鏈上」
  ——`QueueHandler` 必須在宿主進程內，拆出去就不叫 log 轉發了。
  **它天生是「每個進程內的一個零件」，不是「一個可獨立部署的服務」。**

**Q6 通用性**

(a) **能直接用**——README `:6-8` 的立論：「企業客戶幾乎都會問『你們的 log 能不能進我們的日誌中心／SIEM？』。
這件事每個落地版產品都會遇到，而且做法完全一樣」。

(b) 寫死 Guidant 產品知識：**程式碼幾乎零命中**，但有一個**設計層面的耦合**：

| 位置 | 情況 |
|---|---|
| `<mono>/.../infra/model/log_forwarding_setting.py:14` | `SETTINGS_SCHEMA` 預設 `"config"` ——`config` 是 Guidant DB 的 schema 名，但**可用環境變數覆寫**（`LOG_FORWARDING_SETTINGS_SCHEMA`） |
| `migrations/002-log-forwarding-grants.sql:15-19` | 硬編角色名 `cm_app`（Guidant 的應用帳號）。但 `:6-9` 已寫明「此處假設 `cm_app`，可依宿主環境調整；沒有這個角色的 consumer 可以只套 001、跳過本檔」，且 SQL 用 `IF EXISTS(...) ELSE RAISE NOTICE` 包住不會炸 |
| `plugin.py:125` / `common/settings.py` | `audit_logger_name` 預設 `"jedi_log_forwarding"`、`ops_logger_name` 預設同名 |
| 🔴 **`README.md:112-120` ＋ `<be>/api/log_forwarding/__init__.py:23-30`** | **這是真正的耦合**：套件的 logger 名是 `jedi_log_forwarding.*`，而 **jedi-common 的 `dictConfig` 只認得 `api`/`app`/`infra`/`domain`/`common`/`middleware`/`error_handler` 這 7 個名字**（`<mono>/jedi-common/jedi_common/logger/config_dev.py:53-95`），且 `disable_existing_loggers` 預設 True。宿主不傳這兩個參數的後果（`<be>/api/log_forwarding/__init__.py:29-30` 逐條記載）：`audit_logger_name` 不傳 → 稽核事件掉出 `system_logs`、也不會被轉發出去；`ops_logger_name` 不傳 → 轉發鏈自己的維運訊息（已啟用／掛載失敗／輪詢失敗）**整個消失**。主專案的解法是傳 `"app.log_forwarding"` 與 `"common.log_forwarding"`（`<be>/infra/log_forwarding/forwarding_config.py:28`、`<be>/api/log_forwarding/__init__.py:79`）——**掛在 jedi-common 認得的名字底下** |

也就是說：**這支套件要能「不靜默失效」，前提是宿主用 jedi-common 的那套 logger 命名。**
一個不用 jedi-common 的產品裝上它，會得到一個「看起來裝好了、但所有維運訊息與稽核事件都不見」的狀態。
`IUserNameResolver` 未收斂到 jedi-common 標準件（Q3）是第二個通用性缺口。

**Q7 插件完整度**

| 項目 | 有/無 | 證據 |
|---|---|---|
| ① api 層隨包 | **有** | `jedi_log_forwarding/api/`，2 條 path，per-method 守門 |
| ② `register()` | **有** | `register(app, adapters, config=None, schema_extensions=None, mount_api=True, start_forwarding=True)`（`plugin.py:294-300`）——**七支裡唯一多一個 `start_forwarding` 旗標的**；另有 `create_blueprint()`（`:211`）、`build_services()`（`:178`）、`start_forwarding_chain()`（`:264`）、`restart_after_fork()`（`:282`） |
| ③ migrations 隨包 | **有，2 支** | `migrations/001-log-forwarding-settings.sql`（建表＋預設全域列，冪等）、`002-log-forwarding-grants.sql`（GRANT，冪等）。**且 `pyproject.toml:43-45` 有 `include` 段把 `.sql` 打進 wheel**——註解 `:41-42` 說明「沒有這段，wheel 裡只會有 .py，`iter_migrations()` 在安裝後會回空清單而毫無錯誤訊息」。另有 `iter_migrations() -> List[Tuple[str, str]]`（`plugin.py:326`）讓宿主自己決定何時套 |
| ④ DI container 預設 | **無** | 宿主 wire |
| ⑤ 獨立 harness | **有，且最完整** | `harness/dev_app.py`（`--migrate` / `--sink` / `--emit`）＋ `docker-compose.yml`（55433） |
| ⑥ 接入 README | **有** | 281 行（七支最長），含端到端 quickstart |

**做到 5/5——七支裡唯一一支五件套全齊、且是唯一有 migrations 的。**

**Q8 糾纏對象**

- **與 jedi-common：綁在同一條 logging 鏈上，且有一條啟動順序的紅線。**
  ① **啟動順序紅線**：`<be>/core/app_factory.py:67-70` 明文「🔴 位置紅線：
  `configure_logging()` 必須在 `start_forwarding_chain()` 之前——`dictConfig` 會**整批替換**
  logger 的 handlers 清單，晚於轉發鏈就會把它掛上的 QueueHandler 丟掉」。
  jedi-common 那側也記了同一條（`<mono>/jedi-common/jedi_common/logger/config_logger.py:30-33`）。
  **兩份文件互相指名對方——這是「一邊的動作直接毀掉另一邊」的關係。**
  ② **logger 名依賴**（Q6 詳述）：套件的維運訊息與稽核事件能不能活，取決於 jedi-common 的
  `dictConfig` 認不認得那個 logger 名。
  ③ **port 重複**（Q3 詳述）：`IUserNameResolver` 同形狀兩份，jedi-common 那份的 docstring
  直接說是抄本支的。
  ④ **轉發的內容就是 jedi-common 寫進 `system_logs` 的那些 record**——同一批 record
  同時走 `DBLogHandler`（進 DB）與 `QueueHandler`（送出去），是**同一份資料的兩個落點**。
  ⑤ 本支的稽核事件格式與 jedi-common 的 `audit()` **是兩份同格式的實作**：
  `<mono>/jedi-log-forwarding/jedi_log_forwarding/common/audit.py:20` 產出
  `[AUDIT:<event_name>] k=v ...`，與 `<mono>/jedi-common/jedi_common/utils/audit_log.py:35`
  的 `f"[AUDIT:{event_name}] {parts}"` **格式逐字相同**。本支的是「預設實作，宿主可用
  `adapters.audit` 換掉」，主專案就換成了 jedi-common 那支（`<be>/api/log_forwarding/__init__.py:57,77`
  `from jedi_common.utils.audit_log import audit` → `audit=audit`）。
  **也就是說套件內那份 `common/audit.py` 在主專案是死碼。**
- **與 jedi-log：看起來像但不是一件事**（詳見 jedi-log Q8——本支不碰 `api_logs`，實查零 import、零提及）。
- **與 jedi-notification：看起來像但不是一件事**（詳見 jedi-notification Q8——觸發者、資料流向、失敗語意三者皆相反）。
- **與 jedi-iam：只有守門的執行期依賴。** 兩個能力點 `log-forwarding.read` / `log-forwarding.update`
  存在於 `capabilities` 表（DEV live 實查），由 `<be>/api/log_forwarding/__init__.py:62-71`
  接成 `jwt_required() + require_capability(...)`。另外 `IUserNameResolver` 的宿主實作
  （`<be>/infra/log_forwarding/user_name_resolver.py:35`）用的是 `jedi_iam.UserQueryEntity`
  ——**但那是宿主 adapter 的事，套件本身零 import jedi_iam**。

---

## 跨套件觀察

### 表 1：這七支之間的 import 依賴（方向：列 → 欄，「A 依賴 B」）

| A ＼ B | common | iam | sys-config | sys-menu | notification | log | log-fwd |
|---|---|---|---|---|---|---|---|
| **jedi-common** | — | 0 | 0 | 0 | 0 | 0 | 0 |
| **jedi-iam** | **183** | — | 0 | 0 | **0**（刻意斷） | 0 | 0 |
| **jedi-system-config** | 9 | 0 | — | 0 | 0 | 0 | 0 |
| **jedi-system-menu** | 15 | 0 | 0 | — | 0 | 0 | 0 |
| **jedi-notification** | 3 | 0 | 0 | 0 | — | 0 | 0 |
| **jedi-log** | 11 | 0 | 0 | 0 | 0 | — | 0 |
| **jedi-log-forwarding** | 8 | 0 | 0 | 0 | 0 | 0 | — |

**七支之間只有一條邊：全部指向 jedi-common。** 這七支彼此**零 import**（實跑
`grep -rn "from jedi_xxx|import jedi_xxx"` 排除 tests/harness/自身，全部 0 命中）。
唯一的例外是 jedi-iam 對 jedi-notification 那條**已被刻意剪斷**的邊
（`<mono>/jedi-iam/pyproject.toml:51-53`，改走 `INotifier` port）。

⚠️ **但有一條反向的隱性依賴，方向與上表相反**：jedi-common 的
`tenant_mixin_model.py:14,22` 硬編 `ForeignKey("tenants.id")` / `ForeignKey("org_units.id")`
——**那是 jedi-iam 的表**。jedi-common 不 import jedi-iam，但用了那個 mixin 的表在
mapper 設定期需要 jedi-iam 的表在 metadata 裡，否則炸 `NoReferencedTableError`。
本批中受此約束的是 **jedi-system-config**（`system_config.py:12` 繼承 mixin）。

### 表 2：資料層關係（FK / 表 / RLS）

| 套件 | 自有表 | live 列數(DEV) | 繼承 TenantScopedMixin | DB 真 FK | RLS policy | 分區 |
|---|---|---|---|---|---|---|
| jedi-common | `system_logs` | 663,249 | ❌ | 0 | ❌ | RANGE(act_time) 5 分區 |
| jedi-iam | 16 張（users/tenants/org_units/roles/…） | users 40 / tenants 6 / capabilities 134 | ❌（自己就是被指的那端） | **11 條（全在自己 16 張表之間）** | 部分 | ❌ |
| jedi-system-config | `system_configs` | 20 | ✅ | **0（ORM 宣告 2 條但 DB 沒有）** | ✅ 4 條 | ❌ |
| jedi-system-menu | `system_menus` | 74 | ❌ | 0 | ❌ | ❌ |
| jedi-notification | **無表** | — | — | — | — | — |
| jedi-log | `api_logs` | 116,319 | ❌ | 0 | ❌ | RANGE(act_time) 5 分區 |
| jedi-log-forwarding | `config.log_forwarding_settings` | 1 | ❌（刻意） | 0 | ❌（刻意） | ❌ |

**關鍵事實：這七支的表之間，一條跨套件 FK 都沒有。**
DEV live 實查（`pg_constraint WHERE contype='f'`）：
- `system_logs` / `api_logs` / `system_menus` / `log_forwarding_settings` → **零 FK，進出都沒有**。
- `system_configs` → ORM 宣告了 `tenant_id → tenants.id`（NOT NULL）與 `org_unit_id → org_units.id`，
  **但 DB 端一條都沒建，且 `tenant_id` 在 DB 是 nullable**（與 ORM 相反）。
  對照組 `bulletins` 同樣掛 mixin 卻有 `bulletins_tenant_fk` / `bulletins_org_fk` 兩條真 FK
  ——**所以這是 system_configs 這張表的個別落差，不是 mixin 的通則**。
- jedi-iam 的 11 條 FK **全部在自己 16 張表之間**（users→tenants/org_units、
  org_units→tenants/org_units、roles→tenants、user_roles→users/roles/tenants/org_units、
  tenants→tenants、user_tenants→tenants）。
- **jedi-iam 是全產品的 FK 匯聚點**：入向 FK 統計 `org_units` 23 條、`tenants` 15 條、
  `users` 7 條、`roles` 4 條、`ui_routes` 1 條，來源橫跨 bulletin / compliance / config /
  devices / feedback / oscal / survey——**但沒有一條來自本批其他六支**。

### 表 3：port 供需

| port 名 | 定義方 | 消費方 | 宿主實作 |
|---|---|---|---|
| `IUserNameResolver` / `ITenantResolver` / `IOrgUnitResolver` ＋ `IdentityContext` | **jedi-common**（`identity/`） | jedi-file-upload（**本批外**，1 支） | `<be>/core/upload_file_wiring.py:53` |
| `IUserNameResolver`（**同形狀第二份**） | **jedi-log-forwarding**（`domain/repository/`） | 自己 | `<be>/infra/log_forwarding/user_name_resolver.py:19`（**同名 class，另一份**） |
| `INotifier` | jedi-iam（`plugin.py:60`） | 自己（10 處） | `<be>/common/iam_ports.py:46` → 背後是 **jedi-notification** |
| `ISettingsReader` | jedi-iam（`:71`）**＋** jedi-system-config（`:80`，零使用） | iam 自己 8 處 / config 0 處 | `<be>/common/iam_ports.py:31`（只給 iam） |
| `INotifier`（第二份宣告） | jedi-system-config（`:90`） | **零使用** | 無 |
| `IOtpMessageBuilder` / `IAccountMessageBuilder` | jedi-iam | 自己（11 / 6 處） | `<be>/common/iam_ports.py:65` / — |
| `ISubTenantQuota` / `ILicensedModules` / `ITenantStorageSeeder` | jedi-iam（`ports/tenant_provisioning.py`） | 自己（`tenant_provisioning_service.py:98-133`） | `<be>/core/iam_wiring.py` |
| `auth_required` / `capability_required` / `platform_admin_required` | sys-menu / log / notification / log-fwd **各自宣告一份** | 各自 route | 全部接到 **`jedi_iam.authz`** |
| `response_builder` | sys-menu / log / notification / log-fwd 各一份 | 各自 route | 全部接到 **`jedi_common.utils.response_util.return_response`** |

**兩個明確的重複**：
① `IUserNameResolver` 同簽名兩份（jedi-common 標準件 vs log-forwarding 自訂），
宿主為此寫了兩個同名 class 的 adapter（`<be>/core/upload_file_wiring.py:53` 與
`<be>/infra/log_forwarding/user_name_resolver.py:19`），後者 docstring `:62-63` 自己承認形狀相同。
② `ISettingsReader` / `INotifier` 在 jedi-iam 與 jedi-system-config **各宣告一份**
（`<mono>/jedi-iam/jedi_iam/plugin.py:71,60` vs `<mono>/jedi-system-config/jedi_system_config/plugin.py:80,90`），
**且 system-config 那兩份零使用**（自己在 `:118-119` 註明）。

**四支有 route 的套件（sys-menu / log / notification / log-fwd）宣告的守門 port，
宿主端全部接到同一個地方：`jedi_iam.authz`。** 也就是說這四支雖然靜態上不依賴 jedi-iam，
**執行期沒有 jedi-iam 就沒有守門可注入**（且四支都刻意無預設值，缺就拒絕掛載）。

### 表 4：D6 插件五件套完整度

| 套件 | ①api層 | ②register() | ③migrations | ④DI container | ⑤harness | ⑥接入README | 分數 |
|---|---|---|---|---|---|---|---|
| jedi-log-forwarding | ✅ | ✅ | ✅ 2 支 | ❌ | ✅ 端到端 | ✅ 281行 | **5/6** |
| jedi-notification | ✅ 1條 | ✅ | —（無表） | ❌ | ✅ 無需DB | ✅ 276行 | **4/5** |
| jedi-iam | ✅ 39條 | ✅ | ❌ | ❌ | ✅ | ✅ 244行 | **4/6** |
| jedi-system-menu | ✅ 5條 | ✅ | ❌ | ❌ | ❌ | ✅ 169行 | **3/6** |
| jedi-log | ✅ 2條 | ✅ | ❌ | ❌ | ❌ | ✅ 163行 | **3/6** |
| jedi-system-config | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ 173行 | **3/6** |
| jedi-common | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ 過時 | **0/6** |

**沒有任何一支自帶 DI container**（七支皆由宿主 wire）。
**只有 jedi-log-forwarding 有 migrations**——其餘六支的建表全在主專案 `<be>/scripts/sql/`。

### 表 5：執行期形狀（能否服務化的判斷材料）

| 套件 | route | 背景 thread | 對外 I/O | Redis | 狀態 |
|---|---|---|---|---|---|
| jedi-common | 0 | ❌（但 import 期起 OTel exporter → localhost:4317） | 檔案(log/app.log) + OTLP gRPC | 空殼 | 純 library，**不可服務化** |
| jedi-iam | 39 | ❌ | LDAP / Google OAuth / Cloudflare HTTP / 檔案 | ✅ 自己一份 | **harness 已驗可獨立起** |
| jedi-system-config | 0 | ❌ | 無 | ❌ | harness 能起但**無端點可打** |
| jedi-system-menu | 5 | ❌ | 無 | ❌ | 無 harness |
| jedi-notification | 1 | ❌ | SMTP / Discord HTTP / Telegram HTTP | ❌ | **無表無狀態，最易服務化** |
| jedi-log | 2 | ❌ | 記憶體內 xlsx/zip | ❌ | 無 harness；**寫入端在宿主 middleware，拆出去每請求多一次網路往返** |
| jedi-log-forwarding | 2 | ✅ **2 條**（QueueListener + daemon watcher） | UDP/TCP socket + DB | ❌ | harness 端到端已驗；**但 QueueHandler 必須在宿主進程內，天生是零件不是服務** |

### 🔴 決策者指定題：這七支中，哪幾支是「BE 啟動時就必須載入，否則起不來或登不進去」？

判準分三級。證據為 `<be>/core/app_factory.py` 與 `<be>/main.py` 的實際載入順序、
以及 `<be>/config/app_modules.py` 的 fail-fast 註記。

**A 級——拔掉就起不來（import 期硬相依，無 try/except）：2 支**

| 套件 | 證據 | 拔掉的症狀 |
|---|---|---|
| **jedi-common** | `<be>/core/app_factory.py:17` `from jedi_common.handler.handler import register_error_handlers`（**模組層 import，第 17 行**）、`:28` `from jedi_common.session.database.db import init_db`、`:71-73` `configure_logging()`、`:131` `init_database(db_uri)`、`:230` `import jedi_common.session.database.db_mw`、`:343` `session_scope` | **ImportError，服務根本 import 不進來。** 沒有它就沒有 DB engine、沒有 session、沒有 `@transaction`、沒有 error handler、沒有 logging |
| **jedi-iam** | `<be>/core/app_factory.py:23` → `common/middleware/jwt_mw.py:19-20` `from jedi_iam.middleware.jwt_mw import init_jwt_middleware`（**模組層**）；`:95` `init_jwt_middleware(jwt)`；`:298-311` `register_identity(..., mount_api=True)`；`:438,440` `configure_socket_identity(...)`；`:581` `from jedi_iam.common.exception.mfa_exception import OtpResendTooFrequentError`；`<be>/common/authz/{platform,capability,admin,signed_token,decorators}.py` 全是 re-export shim | **ImportError（jwt_mw 在第 23 行就 import 它）。** 即使勉強過了 import，`register_identity(mount_api=True)` 是「身分 API 存在與否的總開關」（`:290-291` 註解）——拔掉則 39 條身分 API 全消失，**含 `/login`，等於登不進去**；且全站授權守門（`common.authz` 的軸①②④⑤）全部斷 |

**B 級——起得來但「登得進去卻不正常」／功能靜默失效：3 支**

| 套件 | 證據 | 拔掉的症狀 |
|---|---|---|
| **jedi-system-config** | `<be>/core/app_factory.py:144-146` `from infra.system_config.runtime_config import get_runtime_config` → `app.config.update(get_runtime_config())`，**在 `register_identity()` 之前**；註解 `:141-142`「必須在 init_database 之後，且在發 token 之前——JWT 效期由 flask_jwt_extended 讀 app.config」；`<be>/app/auth/service/login_service.py:19,28` 登入 service 建構子直接收 `SystemConfigDomainService`；`<be>/config/app_modules.py:19` `"system_config"` 仍在 fail-fast 清單內 | **登入路徑會炸**（`login_service` 的 DI 相依解不開），且 JWT 效期／登入鎖定次數／密碼政策全部退回編譯內建預設。註解 `:143` 明說「DB 不可用時回內建預設，不影響啟動」——**但那是「DB 讀不到」的降級，不是「套件不在」的降級**；套件不在是 ImportError |
| **jedi-notification** | `<be>/core/app_factory.py:373,375` `register_notification(...)`（模組層 import，無 try/except）；`:301-311` `register_identity` 收 `container.notification_container.notification_service()` 作為 `INotifier` 來源 | ImportError（`:373` 沒包 try）。即使繞過，OTP 信與帳號通知信寄不出去——**MFA 開啟時等於登不進去**（`<be>/common/iam_ports.py:91-92` 的降級是「產碼但不寄信」） |
| **jedi-log** | `<be>/core/app_factory.py:259` `init_app_interceptor(app)` → `<be>/common/middleware/app_mw.py:5-8` **模組層** import 4 個 jedi_api_log 符號；`:419,421` `register_api_log(...)` | ImportError（`app_mw.py` 是模組層 import，且 `app_factory.py:21` 在檔頭就 import 它）。**這支是 B 級裡最硬的**——它的寫入端焊在每個請求的 before/after_request 上 |

**C 級——起得來、也登得進去，只是少功能：2 支**

| 套件 | 證據 | 拔掉的症狀 |
|---|---|---|
| **jedi-system-menu** | `<be>/core/app_factory.py:388,390` `register_system_menu(..., mount_api=True)`（模組層 import，**技術上仍會 ImportError**，但這是接線寫法問題不是功能相依）。`:381-384` 註解自述：「這一行就是『選單 API 存在與否』的總開關……**選單資料本身不受影響**（表還在、別的模組若直接用 SystemMenuService 也照常），消失的只有 HTTP 端點」 | 5 條選單 API 404。前端下拉會空，但**登入與核心流程不受影響** |
| **jedi-log-forwarding** | `<be>/core/app_factory.py:155-165` **是七支裡唯一被 try/except 包住的**——`:163-165` `except Exception: logger.warning("log 轉發初始化失敗，本機 log 不受影響")`，`:164` 註解「log 轉發是旁路，掛不起來絕不可讓服務起不來」。route 走 `REGISTERED_APPS`（`<be>/config/app_modules.py:82-83`）。`<be>/main.py:230-235` 的 `restart_after_fork()` 也包 try | **唯一一支「設計上就允許失敗」的。** 拔掉：轉發停止、2 條設定 API 404，其餘全部照常 |

**結論摘要（回答決策者的「系統開起來一定要有的基本功能」）**：

- **絕對必要（起不來 / 登不進去）：jedi-common ＋ jedi-iam。** 這兩支是名副其實的 system-infra 核心
  ——一支是地基（session/交易/例外/logging），一支是身分與授權（沒有它連 `/login` 都不存在）。
- **實質必要（起得來但不可用）：jedi-system-config ＋ jedi-notification ＋ jedi-log。**
  三支都是模組層硬 import、無 try/except，且各自卡在登入鏈上的一環
  （config 供 JWT 效期與密碼政策、notification 供 OTP 信、log 焊在每請求的 middleware）。
- **可選（少功能但系統健全）：jedi-system-menu ＋ jedi-log-forwarding。**
  其中只有 **jedi-log-forwarding 在程式碼層面被明確設計成「可以失敗」**（唯一有 try/except 的）。

⚠️ **一個必須指出的觀察**：上述「A/B/C 級」的技術判定，有一部分是**接線寫法**造成的而非真實功能相依。
七支中有六支在 `app_factory.py` 是**裸 import 無 try/except**，所以任何一支不在，
服務都會 ImportError——包含 C 級的 jedi-system-menu。**真正按「功能是否必要」分級的話，
只有 jedi-common / jedi-iam / jedi-system-config 三支是「沒有它就登不進去」，
其餘四支拔掉後系統仍可登入運作**（notification 要看 MFA 是否強制開啟、
log 只是不再記錄存取軌跡、menu 只是下拉空、log-forwarding 本來就是旁路）。

### 補充觀察：七支的「產品洩漏」分佈（Q6 彙總）

| 套件 | 程式碼層洩漏 | 嚴重度 |
|---|---|---|
| jedi-system-config | **`"SMTP"` / `"THIRD_PARTY_LOGIN"` / `value["secret"]` / `changePwd` 共 14 處硬編，散在 app 與 domain 兩層** ＋ 一個叫 `update_smtp_config()` 的公開 method | **最高**——通用設定表認得特定產品的設定內容 |
| jedi-common | `gen_comment.py:8` OPENAI_API_KEY ＋ runtime 硬掛 `openai>=2.8.0`（每個 consumer 被迫裝）；`custom_formatter.py:27,35` OTLP endpoint 硬編 localhost:4317 且 import 期就啟動；`db.py:102` 「最上層租戶＝平台管理者」的租戶模型判定；`config_dev.py:53-95` 8 個 logger 名硬編 | **高**——地基層的洩漏影響全生態 |
| jedi-log | 匯出檔名 `user_log_` ＋ **匯出無表頭**（`to_excel(header=False)`）＋ 「後端強制 `created_at desc`」的 FE 補償 | 中 |
| jedi-log-forwarding | logger 名必須掛在 jedi-common 認得的名字底下才不靜默失效；`cm_app` 角色名（已做 IF EXISTS 防護）；schema 預設 `config`（可覆寫） | 中（**是與 jedi-common 的耦合，不是與產品的**） |
| jedi-iam | `authz/error_code.py` 的 `GRC_*` 前綴（凍結於 FE i18n 相容） | 低 |
| jedi-system-menu | **程式碼零洩漏**；洩漏全在資料層（`AUDIT_METHOD` / `ssp_party_role` / `COMPLIANCE_FRAMEWORK_PUBLISH_STATUS` 等 group 名） | **最低** |
| jedi-notification | **程式碼零洩漏**（grep 命中全是解釋「什麼不在疆界內」的註解） | **最低** |

### 未查證事項（誠實列出）

1. **STG / POC 的 schema 是否與 DEV 一致**——本次只查 DEV（`guidant_ai_dev`）。
   `system_configs` 的「ORM 宣告 FK 但 DB 沒有」這個落差，未確認在 STG/POC 是否相同。
2. **`api_logs.duration` 的 Float vs numeric 型別落差**是否造成過實際問題——只確認宣告不符，未查歷史 issue。
3. **jedi-common 的 OTel exporter（localhost:4317）在生產環境的實際行為**——
   未確認 exporter 連不上時是否有重試風暴或記憶體累積（只確認它是 import 期無條件啟動）。
4. **jedi-notification README 說的「12 個消費點」**——只驗證了主專案 14 處直接 import
   與 app_factory 註解列出的 6 個模組名，未逐一追到 12 個確切呼叫點。
5. **各套件的測試實際能否跑起來**——只讀 pyproject 與檔案清單，未實跑任何一支的 pytest。
6. **`login_logs` 表在 DEV 的實際列數**——只確認四個檔案的 docstring 標記它是死鏈，未查表內是否有殘留資料。
