# FR-080 批次 B 套件盤點（8 支）

盤點日期：2026-09-09。證據路徑相對根：
- 套件 monorepo `~/Projects/Jedicogy/module/jedi-python-package/`（以下簡稱 **M**）
- 主專案 `~/Projects/Billows/Audit-Manager/compliance-manager-be/`（以下簡稱 **H**）
- DB 查證：DEV `192.168.50.188:25432 / guidant_ai_dev`（唯讀 SELECT）

---

## jedi-file-upload

**Q1 它是什麼**

把「一個檔案存到某個後端、記一列 metadata、之後能下載／轉 PDF 預覽／刪除」這件事包起來的插件。裝上 `register()` 就長出六支 HTTP 檔案端點；`mount_api=False` 則退化成純 library（jedi-issue 就這樣用）。

README 與程式碼**一致**，且是我讀過最誠實的一份：
- README 說「六支端點、URL 凍結」→ `M jedi-file-upload/jedi_file_upload/api/__init__.py:104-110` 的 `mount_routes()` 確實掛六條，與 README 表格逐條吻合。
- README 說「套件不解析儲存設定，宿主交一個已知道怎麼挑後端的 service」→ `plugin.py:97-98` 的 `FileUploadServices` 兩個欄位都是 `Optional[Callable[[], Any]]` provider，套件端零解析。
- README 說「守門三件沒給就拒絕掛載」→ `plugin.py:282-300` `_assert_api_wiring()` 實作確認，`missing` 非空即 `RuntimeError`。

唯一與「純通用」有落差的地方（README 自己也寫了）：`common/utils/libreoffice_util.py` 把 LibreOffice 轉檔當成套件知識內建，`plugin.py:65-67` 硬編 `DEFAULT_PDF_CONVERTIBLE_EXTS` 十種副檔名。這是「辦公文件轉 PDF」耦合進檔案上傳套件，不是 Guidant 產品知識，但確實是「多做了一件事」。

**Q2 資料**

自己擁有的表：**只有一張**
| 表 | 存什麼 | 關鍵欄位 |
|---|---|---|
| `public.upload_files`（`M .../infra/models/upload_file.py:16`） | 一列＝一個已落地的檔案實體 | `uid`（對外識別）、`storage_type`（local/minio/seaweedfs/宿主自有）、`path`（目錄或 bucket）、`save_file_name`、`sha256`（防竄改錨點）、`storage_scope`（system/customer） |

**本表無 `tenant_id`**（`M .../migrations/001-upload-file-tables.sql:24-27` 明文說明，ORM 繼承 `BaseModel` 而非 `TenantScopedMixinModel`），故刻意不掛 RLS。

對別人的表的參照：**零**。套件端無任何 FK、無 relationship、無 raw SQL 打別人的表。

反向：**別人對它建了三條真 FK**（DEV DB 查證）：
- `compliance.job_evidences.file_id → upload_files(id)`（H `infra/flow_engine/models/job_evidence.py:41`）
- `oscal.ssp_reference_documents.file_id → upload_files(id)`（H `scripts/sql/2026-06-15-fr038-b3-ssp-reference-documents.sql:15`）
- `issue_upload_file_mapping.file_uid → upload_files(uid) ON DELETE CASCADE`（jedi-issue 的表，DB `pg_constraint` 查證）

產品詞彙洩漏：**無**。表名 / 欄位名全是通用檔案語彙。`StorageTypeCode`（`M .../common/code/storage_type_code.py`）只有 local/minio/seaweedfs/s3/azure/gcs，`remote_agent` 刻意留在宿主（H `common/code/file_storage_type.py:9`）。

**Q3 Port**

需要宿主提供（`M .../plugin.py:124-154` `FileUploadAdapters`）：
| Port | 簽名 | 必填 | 用在哪 |
|---|---|---|---|
| `services.file_upload_service` | `() -> FileUploadService 或子類` | mount_api 時是 | route 的上傳/刪除/轉檔 |
| `services.upload_file_service` | `() -> UploadFileService` | 同上 | metadata 查詢 |
| `auth_required` | `(fn) -> fn` | 是 | 全部端點 |
| `file_access_guard` | `(scope, bind_uid_arg) -> decorator` | 是 | download / pdf-preview |
| `signed_token_issuer` | `(scope, resource_uid) -> str` | 是 | `/file/access-token/<uid>` |
| `identity` | `IdentityContext`（jedi-common D8 標準件） | 否 | 審計欄位補 nickname（`app/service/identity_enricher.py:28`） |
| `settings` | `ISettingsReader.get(key, default)` | 否 | 只問一個 key：`LIBREOFFICE_CMD` |
| `response_builder` | `(ok, data) -> dict` | 否 | 回應信封 |

提供給別人的入口：`FileUploadService` / `UploadFileService` / `UploadFileDomainService` / `UploadFileRepoImpl` / `UploadFileEntity` / `UploadFileMapper` / `UploadProviderFactory` / `IUploadFileProvider`（`ports/upload_file_provider/upload_file_provider.py:13`）/ 四支 config DTO。README:180-188 明文說 service 那一面是「兩個 consumer 共用的契約」。

樣板殘留 port：**無**。八張 port 全部有實際使用點（`settings` 只服務一個 key，但確實在用）。

**Q4 消費者**

**(a) 主專案**：22 個檔案 import（`grep -rln jedi_file_upload --include=*.py`），分佈：
- `H core/upload_file_wiring.py` + `core/app_factory.py:326-333` — 插件註冊（唯一組裝點）
- `H app/upload_file/service/managed_file_upload_service.py` — **繼承套件的 `FileUploadService`**，加上讀 `system_config` STORAGE_CONFIG 的租戶維度（8 處 import）
- `H infra/upload_file/remote_agent_adapter.py` — 實作套件的 `IUploadFileProvider`，第四種後端
- `H app/flow_engine/service/job_evidence_service.py`、`app/oscal/service/framework_parse_job_service.py`、`app/oscal/service/ssp_document_pool_service.py`、`app/cloud_integration/.../import_drive_file_handler.py` — 業務端用 service
- `H infra/remote_agent/recon_query_adapter.py:12`、`H infra/flow_engine/mapper/job_evidence_mapper.py` — 直接查 `UploadFile` ORM model
- `H di_containers/upload_file/upload_file_containers.py` — DI wiring

**(b) 其他 jedi 套件**（依賴方向：這些套件 → file-upload）：
- **jedi-issue**：9 處 import（`M jedi-issue/.../local_issue_attachment.py:4-8`、`local_issue_adapter.py:3-5`、`ports/dto/upload_file_dto.py:3`），且 `M jedi-issue/pyproject.toml:45` 顯式宣告 `jedi-file-upload>=0.0.12`
- **jedi-detection**：1 處（`M jedi-detection/.../detection_report_file_query.py:14` 直接 import `UploadFile` ORM model 做 id→uid 批次解析）

**它對 jedi-iam 的依賴：import 零處、pyproject 零宣告。** 全包 grep `jedi_iam|jedi_auth` 只命中 pyproject 註解與 docstring 文字（`M jedi-file-upload/pyproject.toml:30,37,42`、`plugin.py:1`、`common/settings.py:15`）。身分名冊走 `jedi_common.identity.IdentityContext` 這條標準 port，實作在宿主 `H core/upload_file_wiring.py:53-106`（那支才 import `jedi_iam`）。

**Q5 執行期形狀**

- **Route 6 條**，blueprint 名 `upload_file`，prefix `/api/1.0`（`plugin.py:55-58`）：`POST /file/upload`、`DELETE /file/upload/<uid>`、`POST /file/uploads`、`GET /file/access-token/<uid>`、`GET /file/download/<uid>`、`GET /file/pdf-preview/<uid>`
- 背景排程 / worker / 長駐 thread：**無**
- 對外 I/O：**檔案系統**（local adapter）、**S3 協議 HTTP**（minio SDK，`infra/adapter/minio/minio_adapter.py`）、**subprocess**（LibreOffice 轉 PDF，`local_file_adapter.py:135`、`minio_adapter.py:263`、`app/service/upload_file_service.py:2`）
- 快取 / Redis：**無**（pyproject 已在 P3.2 清掉虛掛的 redis）
- 單獨起 process：**可以**——自帶 api 層、自帶 harness（`M jedi-file-upload/harness/dev_app.py` 244 行 + docker-compose）

**Q6 通用性**

**(a) 能直接用。** 一個工單系統裝它，只要提供守門三件 + 兩支 service provider，六支端點就能用。

**(b) 寫死的產品知識：極少，且都不是 Guidant 專屬**
- `plugin.py:61` `DEFAULT_FILE_ACCESS_SCOPE = "file_access"` — 註解明說「Guidant AI 抽出前用的就是這個字串」，但已 config 化可覆寫
- `plugin.py:58` `DEFAULT_BLUEPRINT_NAME = "upload_file"` — 同上，可覆寫
- `migrations/002-upload-file-grants.sql` GRANT 對象假設 `cm_app`（README:151 已標「不吃這套生態的 consumer 可只套 001」）
- `plugin.py:65-67` LibreOffice 十種副檔名硬編 — 這是「Office 轉檔」領域知識塞進檔案套件，不是 Guidant 知識，但屬多餘職責

**Q7 插件完整度**

| 項目 | 有/無 |
|---|---|
| ① api 層隨包 | ✅ `jedi_file_upload/api/`（`__init__.py` + `routes/upload_file_route.py` 262 行 + serializers） |
| ② 註冊入口 | ✅ `register(app, adapters=None, config=None, schema_extensions=None, mount_api=True) -> PluginHandle`（`plugin.py:303`）＋ `create_blueprint(adapters, config, schema_extensions)`（:253） |
| ③ migrations 隨包 | ✅ **2 支**（001 建表、002 GRANT），`iter_migrations()` (`plugin.py:241`)，pyproject:58-60 有 include |
| ④ DI container 預設 | ❌ 無（刻意：provider 名冊交宿主，`plugin.py:31-36`） |
| ⑤ 獨立 harness | ✅ `harness/dev_app.py` 244 行 + `harness/docker-compose.yml`（port 5498） |
| ⑥ 接入 README | ✅ 188 行，含 quickstart / port 表 / 宿主前提表 / 常見坑 |

**Q8 糾纏對象**

**最糾纏：jedi-issue**（單向，issue → file-upload）
- `issue_upload_file_mapping.file_uid → upload_files(uid) ON DELETE CASCADE` — **DB 層真 FK**（DEV `pg_constraint` 查證）。這是本批唯一「兩支套件的表之間有真 FK」的案例。
- `M jedi-issue/.../local_issue_attachment.py:60-65`：同一交易內先 `file_upload_service.upload_file()` 再 `add_issue_file_mapping()`，**寫兩邊**。
- 但方向是**明確的上下游**（issue 沒有 file-upload 就沒有附件；file-upload 沒有 issue 完全活得下去）。file-upload 對 issue 零認知。

**次糾纏：jedi-remote-agent**（雙向繞了一圈，但都經 port / 宿主）
- remote-agent 的 `IRemoteAgentReconQuery` 對帳需要「這台 agent 上有哪些檔」，實作在宿主 `H infra/remote_agent/recon_query_adapter.py:12` 直接 query `UploadFile` 篩 `storage_type='remote_agent'`。
- 反向：`upload_files.storage_type` 的第四種值 `remote_agent` 由宿主 `H core/upload_file_wiring.py:50` 經 `extra_object_storage_types` 注入。
- **兩邊都不互相 import**，耦合點全在宿主的 adapter。

**看起來像但不是一件事**：
- **jedi-detection**：只 import `UploadFile` model 做一支唯讀 id→uid 批次查（`M jedi-detection/.../detection_report_file_query.py:14`），是 consumer 不是同一件事。
- **jedi-integrity**：兩者都算 sha256、都講「防竄改」，但 integrity 算的是**程式碼產物**的 hash（`integrity_tamper_events` 表與 `upload_files` 零關係），file-upload 的 `sha256` 欄是**使用者上傳檔**的錨點。同一個演算法、完全不同的資料。

---

## jedi-issue

**Q1 它是什麼**

「一張工單 + 誰負責 + 貼哪些標籤 + 掛哪些附件」的儲存與查詢，並帶一套「issue 來源可以是本地 DB / GitLab / GitHub」的 adapter 架構。

README 與程式碼**一致，且 README 自己就是最誠實的那份**（`M jedi-issue/README.md:9-27`）：README 開頭紅字說「約 58% 是待裁決的死碼」，只有成員管理是活的。程式碼對照確認：
- `M jedi-issue/jedi_issue/api/__init__.py:110` `FROZEN_URLS = frozenset({"/api/1.0/issue/get_members"})` — 整支套件只有一條 route。
- GitLab/GitHub adapter 確實存在且完整（`infra/issue/adapter/gitlab/gitlab_issue_adapter.py` 197 行、`github/github_issue_adapter.py` 163 行）。

**但「58% 死碼」這個說法對主專案而言不成立**（這正是 FR-080 開卡時說的那句「檔頭那句是錯的」）：`H app/feedback/service/issue_service.py` **有 24 處**呼叫 `JediIssueService`，且 `:116` 用 `IssueProviderCode.GITLAB`、`:141` 用 `IssueProviderCode.GITHUB`——主專案的意見回饋模組是把 issue 同步推到 GitLab/GitHub 的。「產品沒在用」的判斷與主專案程式碼衝突。

**Q2 資料**

自己擁有的表：**六張**
| 表 | 存什麼 | 關鍵欄位 |
|---|---|---|
| `issues`（`infra/issue/models/issue.py:10`） | 一張工單 | `uid`、`project`（字串非 FK）、`title`、`description`、`state` |
| `members`（`infra/member/models/member.py:8`） | 可被指派的成員名冊 | `uid`、`username`、`email`、`role`、`type`、`enabled` |
| `labels`（`infra/label/models/label.py:9`） | 標籤 | `uid`、`name`、`color`、`scope`、`is_deleted` |
| `issue_assignee_mapping`（:8） | issue↔成員 | `issue_uid` + `member_uid` 複合 PK |
| `issue_label_mapping`（:8） | issue↔標籤 | `issue_uid` + `label_uid` 複合 PK |
| `issue_upload_file_mapping`（`infra/issue_upload_files/models/issue_upload_file.py:11`） | issue↔檔案 | `issue_uid` + `file_uid` 複合 PK |

**六張表全無 `tenant_id`**（皆繼承裸 `Base` 而非 `TenantScopedMixinModel`）。

對別人的表的參照：
- **(a) 真 FK**：`issue_upload_file_mapping.file_uid → upload_files(uid) ON DELETE CASCADE` — **跨套件真 FK**。ORM 上 `issue_upload_file.py:16` 只宣告 `String(50)` 沒寫 `ForeignKey`，但 **DB 上實際有這條 constraint**（DEV `pg_constraint` 查得 `issue_upload_file_mapping_file_uid_fkey`）。ORM 與 DB 不一致。
- **(b) ORM relationship 跨包**：套件端無。**宿主端有**：`H infra/feedback/model/feedback_issue.py:30-37` 的 `FeedbackIssue.issue` relationship 直接指向套件的 `Issues` model（`viewonly=True`，`primaryjoin` 用 `foreign(Issues.uid)`），且 `:74-77` 的 `title` hybrid_property 對 `Issues.title` 發 scalar_subquery。
- **(c) 軟參照**：`issues.project` 只存字串（`issue.py:18`），宿主傳 `self.project_name`。
- **(d) raw SQL 打別人的表**：無。

**產品詞彙洩漏：無**（issue/label/member 都是通用軟體工程語彙）。

**Q3 Port**

需要宿主提供（`M jedi-issue/jedi_issue/plugin.py:133-148` `IssueAdapters`）：
| Port | 簽名 | 必填 | 用在哪 |
|---|---|---|---|
| `services.member_service` | `() -> MemberService` | mount_api 時是 | 唯一那條 route |
| `auth_required` | `(fn) -> fn` | 是（`_assert_api_wiring` :216-234） | 同上 |
| `response_builder` | `(ok, data) -> dict` | 否 | **零使用**（`plugin.py:40-42` 明說 route 直接回 service 結果不包信封） |

套件內部的 adapter 註冊表（不是對宿主的 port，是內部依賴反轉）：`ports/issue_provider/issue_factory.py:83` `TYPE: Dict[str, Type[IIssueProvider]] = {}` + `register_adapter()`，由 `plugin.py:83-84` import 觸發填入。這條線很脆：`get_issue_adapter()` 若註冊表空會 RuntimeError（`issue_factory.py:100-107`），而註冊的唯一觸發點是 import `jedi_issue.plugin`。

提供給別人：`MemberService`、`IssueService`（`app/issue/service/issue_service.py:10`）、`IssueAttachmentService`、`LabelDomainService`、`Issues` model、`IssueDTO` / `CreateIssueDTO` / `UpdateIssueDTO` / `IssueResponseDTO`。

樣板殘留：`response_builder` 有欄位但零使用（plugin.py 自己註記）。`SchemaExtensions`（:152-163）插槽開齊零使用。

**Q4 消費者**

**(a) 主專案**：
- `H app/feedback/service/issue_service.py` — **24 處**呼叫，是最大 consumer。用 `JediIssueService`（LOCAL/GITLAB/GITHUB 三 provider）、`IssueAttachmentService`、`LabelDomainService`
- `H infra/feedback/model/feedback_issue.py:7` — import `Issues` 建 relationship
- `H app/feedback/dto/feedback_issue_dto.py:5` — import `IssueDTO`
- `H infra/issue/issue_plugin_wiring.py` + `core/app_factory.py:402-404` — 插件註冊
- 對外端點是主專案的 `H api/feedback/__init__.py:21-24` 四條 `/feedback*`，不是套件那條

**(b) 其他 jedi 套件**：**零**。沒有任何 jedi-* 套件 import jedi-issue。

反向依賴（issue → 別人）：jedi-file-upload（9 處，pyproject 顯式宣告）、jedi-common（29 處，P3.6 才補上宣告）。守衛測試 `M jedi-issue/tests/unittest/test_plugin_contract.py:32` 把允許清單焊死成 `("jedi_common", "jedi_file_upload", "jedi_issue")`。

**Q5 執行期形狀**

- **Route 1 條**：`GET /api/1.0/issue/get_members`，blueprint 名 `issue`（`plugin.py:97`）
- 背景排程 / worker / thread：**無**
- 對外 I/O：**HTTP 到 GitLab / GitHub**（`infra/gitlab.py`、`infra/github.py`，經 python-gitlab / PyGithub SDK）；經 jedi-file-upload 間接碰檔案系統／S3
- `os.getenv` **4 處，全在 GitLab/GitHub 檔內**（`infra/github.py` 讀 `GITHUB_PRIVATE_TOKEN`；`infra/gitlab.py` 讀 `GITLAB_URL` / `GITLAB_PRIVATE_TOKEN` / `GITLAB_API_VERSION`）——這是全批八支唯一還在直讀 env 的套件
- 快取 / Redis：**無**（P3.5 已清掉虛掛的 redis 宣告）
- 單獨起 process：**理論可以但意義不大**——只有一條 route，且**沒有 harness**（README:121 明說「本套件沒有 harness」）

**Q6 通用性**

**(a) 能直接用**——工單系統裝它反而是最貼題的 consumer。

**(b) 寫死的產品知識**：
- `plugin.py:97` `DEFAULT_BLUEPRINT_NAME = "issue"` — 可覆寫
- 四個 GitLab/GitHub env 變數名寫死在 `infra/gitlab.py` / `infra/github.py`（未 port 化，plugin.py:56-59 明文記錄「動了等於替產品決定要保留第三方整合」）
- `python-gitlab==5.6.0` / `PyGithub==2.6.1` 在 runtime 相依（pyproject:43-44），任何 consumer 都被迫背這兩個
- **零 Guidant 產品詞彙**（沒有稽核 / CMMC / SSP / round / detection）

**Q7 插件完整度**

| 項目 | 有/無 |
|---|---|
| ① api 層隨包 | ✅ 但只有一條 route（`jedi_issue/api/`） |
| ② 註冊入口 | ✅ `register(app, adapters=None, config=None, schema_extensions=None, mount_api=True)`（`plugin.py:266`）＋ `create_blueprint`（:237） |
| ③ migrations 隨包 | ❌ **無 `migrations/` 目錄**（六張表全靠宿主自己建） |
| ④ DI container 預設 | ❌ 無 |
| ⑤ 獨立 harness | ❌ **無**（README:121 明說沒有；驗收改用「在真宿主上拔掉註冊行」） |
| ⑥ 接入 README | ✅ 144 行 |

**Q8 糾纏對象**

**最糾纏：jedi-file-upload**（單向下游，證據見上：真 FK + 同交易寫兩邊 + pyproject 顯式相依 + 9 處 import）。issue 沒有 file-upload 就沒有附件功能；反過來完全成立。

**次糾纏：主專案的 feedback 模組**（不是套件，但耦合度比任何套件對都高）：`H infra/feedback/model/feedback_issue.py` 的 `feedback_issues` 表以 `issue_uid` 軟參照 `issues.uid`，且用 ORM relationship + hybrid_property 跨包 join。等於「issue 的一半用例住在宿主」。

**看起來像但不是一件事**：
- **jedi-bulletin**：兩者都是「一段文字 + 狀態 + 時間」的 CRUD，形狀很像，但 issue 有 assignee/label/attachment 三個關聯維度與外部同步，bulletin 是單表無關聯。兩邊零 import、零 FK。
- **jedi-issue 的 `members` 表 vs jedi-iam 的 `users` 表**：`members`（`infra/member/models/member.py`）是套件自己的名冊，與 `public.users` **零 FK、零 join**，是兩份平行的人員資料。`issue_assignee_mapping.member_uid → members(uid)`（DB 查證），不是指向 users。

---

## jedi-bulletin

**Q1 它是什麼**

系統公告的 CRUD 與分頁查詢 library（單表、軟刪除）。

README 與程式碼**一致，且 README 的驗收邊界誠實聲明是準的**（`M jedi-bulletin/README.md:11-24`）：明說「D6 五件套只做到三項」「沒有 api 層」「拔掉測試做不到」。程式碼確認：`M jedi-bulletin/jedi_bulletin/plugin.py:171-193` 的 `create_blueprint()` 建出來的 blueprint **不掛任何 resource**，docstring:182-184 明文「這是刻意的，且有測試焊死——不是還沒寫完」。

README:39-45 的「系統裡有兩層公告 service」也屬實：宿主 `H app/bulletin/service/bulletin_service.py` 是產品用例層，套件的 `BulletinService` 只做 CRUD。

**Q2 資料**

自己擁有的表：**一張**
| 表 | 存什麼 | 關鍵欄位 |
|---|---|---|
| `bulletins`（`M .../infra/models/bulletin.py:16`） | 一則系統公告 | `uid`、`title`、`content`、`category`（default `"others"`）、`release_time` / `expire_time`、`enable`（1/0）、`is_delete`（1/0 軟刪除） |

繼承 `BaseModel, TenantScopedMixinModel`（:14）→ 多租戶模式下有 `tenant_id` / `org_unit_id`。

對別人的表的參照：**套件端零**。跨包 import 0 處（README:98 宣稱，我逐檔 grep 確認：全包只 import jedi_common + sqlalchemy + 自己）。

反向，**宿主對它做了三件事**（`H infra/bulletin/models/bulletin.py`）：
- **(a) 真 FK**：`bulletin_org_units.bulletin_id → bulletins(id) ON DELETE CASCADE`（:16-20，DEV DB 查證 `bulletin_org_units_bulletin_id_fkey`）；同表另一欄 `org_unit_id → org_units(id)`（jedi-iam 的表）
- **(b) ORM relationship 跨包**：`ExtendedBulletin(BaseBulletin, TenantScopedMixinModel)`（:29）用 `__table_args__ = {"extend_existing": True}` **就地擴充套件的 model**，加上 `org_units` many-to-many（:41-45）與 `created_user_info` / `updated_user_info` 對 `jedi_iam.User` 的 relationship（:47-57、:75-85）+ 兩個 hybrid_property 發 scalar_subquery 查 `User.nickname`
- **(d) raw SQL**：無

這個 `extend_existing` 擴充模式是本批唯一一例——宿主不是「用」套件的 model，是**改寫**它。

**產品詞彙洩漏：無。**

**Q3 Port**

需要宿主提供（`M .../plugin.py:115-127` `BulletinAdapters`）：
| Port | 簽名 | 現況 |
|---|---|---|
| `settings` | `ISettingsReader.get(key, default)`（:79-85） | **零使用** |
| `notifier` | `INotifier.send(*, to, subject, body)`（:89-97） | **零使用** |

**兩張 port 都是樣板殘留**——README:93-102 自己就這樣寫（「插槽開齊是為了日後真需要時簽名不用改，不是現在有東西被 port 化了」），且有測試焊死。

提供給別人：`BulletinService`、`BulletinDomainService`、`BulletinRepoImpl`、`BulletinEntity` / `BulletinQueryEntity`、`BulletinDTO`、`Bulletin` model。

另一處樣板殘留：`M .../common/enum/code.py` 整支 37 行是**從別支套件照抄的死碼**——`UserStatus` / `LogTypeCode` / `RoleStatusCode` / `ChangePasswordTypeCode` / `ChangePasswordStatusCode` 全部與公告無關，且全包 grep 零使用（我查過，只有定義處那一行命中）。

**Q4 消費者**

**(a) 主專案**：**只有 3 處**（全是「繼承／別名」而非呼叫）
- `H infra/bulletin/models/bulletin.py:4` — `from jedi_bulletin.infra.models.bulletin import Bulletin as BaseBulletin`
- `H app/bulletin/dto/bulletin.py:4` — `BulletinDTO as BaseBulletinDTO`
- `H domain/bulletin/entities/bulletin_entity.py:1` — `BulletinEntity as BaseBulletinEntity`

也就是說：**主專案沒有用套件的 service / domain service / repo**，只繼承了 model / DTO / entity 三個資料形狀，業務全部自己寫（`H app/bulletin/service/bulletin_service.py` 181 行 + `H domain/bulletin/` 整層 + `H infra/bulletin/repository/`）。README:44 說「被上者間接使用」——**不成立**，宿主的 `BulletinDomainService` 是自己的（`H domain/bulletin/service/bulletin_domain_service.py`），不是套件的。

**(b) 其他 jedi 套件**：**零**。

**(c) 註冊狀態**：`grep jedi_bulletin.plugin` 在主專案 **零命中**——`register()` 從沒被呼叫過。套件的插件機制在本產品上是完全未使用的。

**Q5 執行期形狀**

- **Route 0 條**（blueprint 是空的，`plugin.py:182-184`）。對外端點是宿主的 `H api/bulletin/__init__.py:20-24` 三條
- 背景排程 / worker / thread：**無**
- 對外 I/O：**只有 DB**。零 subprocess、零 HTTP、零檔案系統、零 socket
- 快取 / Redis：**無**
- 單獨起 process：**沒有意義**——零 route。harness 起來只有 `/healthz`

**Q6 通用性**

**(a) 能直接用**——它就是一張表的 CRUD，任何產品都能裝。

**(b) 寫死的產品知識**：
- `bulletins.category` default `"others"`（`infra/models/bulletin.py:41-43`）——類別集合本身沒定義，是自由字串，不算寫死
- `plugin.py:74` `DEFAULT_BLUEPRINT_NAME = "jedi_bulletin"`（避開主專案同名 blueprint，可覆寫）
- **零 Guidant 產品詞彙**
- 唯一的「不通用」是 README:113-116 那兩道宿主前提：必須有 `tenants` / `org_units` 在 SQLAlchemy metadata 裡（`TenantScopedMixinModel` 的 FK 在 mapper 設定期就要解析），這把套件綁死在「jedi 多租戶生態」上

**對 身分名冊／通知 的依賴形式（題目特別問的）**：
- **身分名冊**：**套件端零依賴**。公告的 `created_user` / `updated_user` 只存 login_name 字串（`BaseModel` 給的），補 nickname 這件事完全在宿主（`H infra/bulletin/models/bulletin.py:47-100` 用 ORM relationship + hybrid_property 直接 join `jedi_iam.User`）——**注意這違反了主專案自己的「審計欄位在 app service 層批次查、不在 infra 層 JOIN」規範**。
- **通知**：`INotifier` port 宣告了但**零使用**（`plugin.py:89-97、126-127`）。宿主的 `H app/bulletin/service/bulletin_service.py` grep `notif|mail|send` **零命中**——公告發布目前根本不寄通知。「公告 → 通知」這條線在整個系統裡不存在。
- **公告發給哪些部門**：在宿主 `H domain/bulletin/service/bulletin_org_unit_domain_service.py`，README:153-154 明文說不在套件。

**Q7 插件完整度**

| 項目 | 有/無 |
|---|---|
| ① api 層隨包 | ❌ **無**（blueprint 空的） |
| ② 註冊入口 | ✅ 簽名齊全 `register(app, adapters=None, config=None, schema_extensions=None, mount_api=True)`（`plugin.py:196`）——但註冊了什麼都不會發生 |
| ③ migrations 隨包 | ❌ **無 `migrations/` 目錄** |
| ④ DI container 預設 | ❌ 無 |
| ⑤ 獨立 harness | ✅ `harness/dev_app.py` 200 行 + docker-compose（port 5491），會實跑建表→CRUD→軟刪除逐步斷言 |
| ⑥ 接入 README | ✅ 190 行 |

**Q8 糾纏對象**

**本批八支裡最不糾纏的一支。** 它跟任何一支 jedi 套件都沒有 FK、沒有 import、沒有共同交易。

唯一的資料層牽連是**與 jedi-iam**：`bulletin_org_units` 這張橋表一頭接 `bulletins`、一頭接 `org_units`（jedi-iam 的表），且**這張表定義在宿主而非任一套件**（`H infra/bulletin/models/bulletin.py:13-26`）。所以「公告發給哪些部門」這個關聯的擁有者是宿主，不是兩支套件任一方。

**看起來像但不是一件事**：
- **jedi-notification**（不在本批，但相關）：bulletin 宣告了 `INotifier` port 讓人以為兩者該接起來，實際上**零接線**，且宿主也不寄公告通知。
- **jedi-issue**：形狀相似（文字 + 狀態 + 時間 + 軟刪除）但零關聯，見 issue 的 Q8。

---

## jedi-device

**Q1 它是什麼**

一台實體／虛擬機器的基本資料（名稱、IP、OS、製造商、用途）的 CRUD 與 picker 選單查詢。README:3 標「D11 已定：本套件歸**資產盤點**語境」。

README 與程式碼**大致一致**，但**有一處明確錯誤**：
- README:145 常見坑 4 寫「**刪除是軟刪除**（`is_delete=1`），資料列仍在」——**程式碼裡 `devices` 表根本沒有 `is_delete` 欄位**（`M jedi-device/jedi_device/infra/models/device.py` 全檔無此欄；全包 grep `is_delete` **零命中**；DEV DB `devices` 欄位清單也沒有）。`DeviceRepoImpl` 走的是 `BaseRepositoryImpl` 的實刪。這條是從 jedi-bulletin README 照抄漏改的。
- README:11-19 的「驗收邊界誠實聲明」（沒有 api 層、拔掉測試做不到）**屬實**：`plugin.py:174-197` 的 blueprint 不掛任何 resource。

**Q2 資料（決策者要的表欄位全列）**

自己擁有的表：**一張**

| 欄位 | 型別 | Null | 說明 |
|---|---|---|---|
| `id` | integer | NO | PK（BaseModel） |
| `uid` | varchar(50) | NO | UUID，unique |
| `name` | varchar(255) | **NO** | 設備名稱 |
| `description` | text | YES | 描述 |
| `os` | varchar(255) | YES | 作業系統 |
| `os_version` | varchar(100) | YES | OS 版本 |
| `hostname` | varchar(255) | YES | 主機名 |
| `ip` | varchar(50) | **NO** | IP 位址 |
| `device_type` | varchar(255) | YES | 設備類型（自由字串，**無 enum**） |
| `manufacture` | varchar(255) | YES | 製造商 |
| `manufacture_at` | date | YES | 製造日期 |
| `status` | integer | NO | **1正常 / 0異常**（整數，非 enum） |
| `purpose` | text | YES | 用途 |
| `created_at` / `updated_at` | timestamp | NO | BaseModel |
| `created_user` / `updated_user` | varchar(50) | YES | login_name（BaseModel） |
| `tenant_id` | integer | **YES** | TenantScopedMixinModel |
| `org_unit_id` | integer | YES | 同上 |

（表定義 `M .../infra/models/device.py:11-85`；欄位型別／nullable 以 DEV DB `information_schema.columns` 實查為準。**schema 是 `public`**，不是 compliance。）

⚠️ **`devices.tenant_id` 是 nullable，`information_systems.tenant_id` 是 NOT NULL**——這是兩支合併時的第一個 schema 落差。

對別人的表的參照：**套件端零**（跨包 import 0 處，README:92 宣稱，逐檔 grep 確認）。

反向，**三張表對 `devices` 建了真 FK**（DEV DB `pg_constraint` 查證）：
- `compliance.job_execution_device_mapping.device_id → devices(id)`（H `infra/associations/model/job_execution_device_mapping.py:78`，`ondelete="CASCADE"`）
- `compliance.job_execution_devices.device_id → devices(id)`
- `compliance.project_job_execution_device_mapping.device_id → devices(id)`

還有一支**跨套件 ORM relationship**：`M jedi-survey/jedi_survey/infra/models/task_survey.py:1,136-145` — `TaskSurvey.device_id` + `device` relationship + `device_uid` / `device_name` association_proxy。**jedi-survey 直接 import 並 join jedi-device 的 model。**

**產品詞彙洩漏：無**（devices 是通用 IT 資產語彙）。

**Q3 Port**

需要宿主提供（`M .../plugin.py:118-130` `DeviceAdapters`）：`settings`（ISettingsReader）與 `notifier`（INotifier）——**兩張都零使用**，README:81-84 與 plugin.py:54-55 都明文承認是樣板殘留。

提供給別人：`DeviceService`（app 層，9 支方法）、`DeviceDomainService`（12 支）、`DeviceRepoImpl`、`DeviceEntity` / `DeviceQueryEntity` / `DeviceMenuEntity`、`DeviceDTO` / `DeviceMenuDTO`、`Device` model、`DeviceMapper`、`ErrorCode`。

**Q4 消費者**

**(a) 主專案**：11 個檔案
- `H app/device/service/device_app_service.py:10-12` — wrapper app service（98 行），加審計 nickname enrich 與刪除前引用計數
- `H common/util/system_asset_snapshot.py:170` — **與 information-system 並列使用的共用檔**（見 Q8）
- `H app/module_frame/service/ssp_import_template_app_service.py:62-63,161,197,757,855,973,1409` — Excel 匯入匯出的設備 picker
- `H app/associations/service/job_execution_device_mapping_service.py:5-6`、`H app/associations/dto/job_execution_device_mapping_dto.py:4`
- `H infra/flow_control/repository/{job_export_query,flow_control_job_repo_impl,job_import_lookup_query}.py` — 直接 import `Device` model 做 join
- `H infra/associations/{model,repository,mapper}/job_execution_device_mapping*.py`
- `H di_containers/device/device_containers.py:4-6`
- 對外端點是宿主的 `H api/device/__init__.py:22-27` **5 條**（`/devices`、`/devices/menu`、`/device`、`/device/<uid>`、`/device/<uid>/references`）

**(b) 其他 jedi 套件**：**jedi-survey 1 處**（`task_survey.py:1`，ORM relationship，見 Q2）。

**(c) 註冊狀態**：`grep jedi_device.plugin` 主專案 **零命中**——`register()` 從沒被呼叫。

**Q5 執行期形狀**

- **Route 0 條**（blueprint 空的）
- 背景排程 / worker / thread：**無**
- 對外 I/O：**只有 DB**
- 快取 / Redis：**無**
- 單獨起 process：**沒有意義**（零 route）

**Q6 通用性**

**(a) 能直接用**。
**(b) 寫死的產品知識**：
- `status` 用 magic number `1正常/0異常`（`device.py:71-77`），沒有 enum 類別
- `plugin.py:78` `DEFAULT_BLUEPRINT_NAME = "jedi_device"`
- **零 Guidant 產品詞彙**
- 同 bulletin：綁死 `tenants` / `org_units` 在 metadata 裡這道前提

**Q7 插件完整度**

| 項目 | 有/無 |
|---|---|
| ① api 層隨包 | ❌ 無 |
| ② 註冊入口 | ✅ 簽名齊全（`plugin.py:199`）＋ `create_blueprint`（:174） |
| ③ migrations 隨包 | ❌ **無** |
| ④ DI container 預設 | ❌ 無 |
| ⑤ 獨立 harness | ✅ 192 行 + docker-compose（port 5493） |
| ⑥ 接入 README | ✅ 175 行（但常見坑 4 是錯的，見 Q1） |

**Q8 糾纏對象**

**最糾纏：jedi-information-system**——決策者已裁定合併，以下是支撐證據（也是反面證據）：

**支持「同一件事」的事實：**
1. **宿主已經用一張表把兩者統一了**：`compliance.module_frame_inventory_item_defaults` 與 `oscal.ssp_inventory_items` 兩張表都有 `asset_type` / `ref_type` / `ref_id` 三欄（`H scripts/sql/2026-06-04-fr032-inventory-asset-type.sql:19-38`），`asset_type` 的值域就是 **`'hardware'`（＝device）與 `'information_system'`**，`ref_type` 是 `'device'` / `'information_system'`。**資料模型上兩者已經是同一張表的兩個 discriminator 值。**
2. **同一支檔案同時操作兩者**：`H common/util/system_asset_snapshot.py` 整支 197 行就是「把 device master 或 information_system master snapshot 成 inventory props」——`snapshot_information_system()`（:39）與 `snapshot_device()`（:177）是姊妹函式，`_match_information_system_by_name()`（:106）與 `_match_device_by_name()`（:167）是姊妹函式，`resolve_system_asset_fields()`（:54）與 `resolve_device_inventory_fields()`（:137）是姊妹函式。**檔頭常數 `ASSET_TYPE_HARDWARE` / `ASSET_TYPE_INFORMATION_SYSTEM` / `REF_TYPE_DEVICE` 三個並列在 :17-19。**
3. **同一支 app service 同時注入兩個 domain service**：`H app/module_frame/service/ssp_import_template_app_service.py:161-162` 建構子並列收 `device_domain_service` 與 `information_system_domain_service`；:197-198 存成 `self._device_domain_service` / `self._is_domain_service`。Excel 匯入時兩張 sheet 各走一條，落到同一張 inventory 表。
4. **兩支套件的 pyproject 相依完全相同**：`Flask>=3.0,<4` + `SQLAlchemy==2.0.37` + `jedi-common>=0.0.23`，一字不差。
5. **兩支的 plugin.py 結構完全相同**（同批 P3.5 補殼，`ISettingsReader` / `INotifier` 兩張零使用 port、空 blueprint、同樣的 `EXTENSION_KEY` 命名法）。
6. **兩支的 README 有相同的照抄錯誤**（都寫「刪除是軟刪除 is_delete=1」，兩支都沒這欄位——info-system README:145、device README:145）。

**反對「同一件事」的事實（合併時會撞到的）：**
1. **schema 不同**：`devices` 在 `public`，`information_systems` 在 `compliance`（`M .../information_system.py:21-22` `"schema": "compliance"`）。合併後兩張表要不要搬 schema 是 breaking change（`devices` 有三條 FK 指著）。
2. **`tenant_id` nullable 不同**：devices YES / information_systems NO。
3. **enum 使用完全不同**：information-system 有 **4 個 PostgreSQL ENUM 型別**（`security_sensitivity_level_enum` / `security_objective_level_enum` / `system_status_enum` / `deployment_model_enum`，`M .../common/enum/information_system_enum.py`，值域全是 **OSCAL / FIPS-199 詞彙**：`fips-199-low`、`operational`、`under-major-modification`、`on-premise`…）；device **一個 enum 都沒有**（`status` 是裸 integer、`device_type` 是自由字串）。
4. **information-system 有 OSCAL 序列化**：`M .../domain/entity/information_system_entity.py:57-114` 的 `to_oscal_dict()` / `from_oscal_dict()` 直接吐 OSCAL SSP system-characteristics 結構（`system-id` / `security-sensitivity-level` / `authorization-boundary`…）。**這是產品詞彙洩漏**（見該支 Q2），device 完全沒有。
5. **owner 欄位形狀不同**：information-system 有 `system_owner`（int，users.id 軟參照）+ `created_by`（int）+ 一張 `IUserLookup` port；**device 完全沒有 owner 概念**，只有 BaseModel 的 `created_user`（login_name 字串）。

**共同欄位盤點（合併後資料模型的交集）**：`id` / `uid` / `name` / `description` / `created_at` / `updated_at` / `created_user` / `updated_user` / `tenant_id` / `org_unit_id`。也就是說——**兩張表的交集就是 BaseModel + TenantScopedMixinModel 給的那些，加上 `name` 與 `description` 兩個業務欄位。** 其餘 11 個 device 欄位與 12 個 information-system 欄位**零重疊**。

**誰在 JOIN 它們**：
- `devices`：`compliance.job_execution_device_mapping` / `job_execution_devices` / `project_job_execution_device_mapping` 三張表以真 FK join；jedi-survey 的 `task_survey` 以 ORM relationship join。
- `information_systems`：**DB 上零 FK 指向它**（DEV 查證，`pg_constraint` 無任何 confrelid = `compliance.information_systems`）。曾有 `compliance.project_information_systems` 橋表但已於 2026-05-23 DROP（`H scripts/sql/2026-05-23-c3-pr2-drop-project-mapping-tables.sql:24`）。現在只靠 inventory 表的 `ref_id`（存 uid 字串）軟參照。
- **兩者共同的下游是 inventory 表**（`oscal.ssp_inventory_items` / `compliance.module_frame_inventory_item_defaults`），而那張表對兩者都是**軟參照**（`ref_id` 存 uid 字串，無 FK）。

**看起來像但其實不是**：
- **jedi-remote-agent**：`remote_agents` 也是「一台機器」，但存的是 agent endpoint（base_url / capabilities / 心跳），與 `devices` **零 FK、零 join、零 import**。`agent_tasks` 沒有 device_id 欄位。兩者在系統中從未被關聯過。

---

## jedi-information-system

**Q1 它是什麼**

「被列入稽核範圍的資訊系統」的基本資料 CRUD——名稱、縮寫、FIPS-199 三軸安全等級、系統狀態、部署模型、授權邊界、系統負責人。README:3 標「資產盤點的主體之一」，且是 FR-032 G 抽套件的先例。

README 與程式碼**大致一致**，兩處錯誤：
- README:145 同樣寫「刪除是軟刪除（`is_delete=1`）」——**表裡沒有 `is_delete`**（全包 grep 零命中）。實際的停用機制是 `is_active` boolean，domain service 那支叫 `deactivate()`（`M .../domain/service/information_system_domain_service.py:84`）。與 device README 是同一個照抄漏改。
- README:89 說「跨 jedi 套件 import：0 處」屬實。

README:83-85 說「本套件已有一張自己的 port `IUserLookup`，D16 精神在這支已經實現了」——**屬實**（`M .../domain/repository/user_lookup.py:13`），是本批八支裡唯一在 FR-069 之前就自己做過 dependency inversion 的。

**Q2 資料（決策者要的表欄位全列）**

自己擁有的表：**一張**

| 欄位 | 型別 | Null | 說明 |
|---|---|---|---|
| `id` | integer | NO | PK |
| `uid` | varchar(50) | NO | UUID，unique，**對應 OSCAL system-id** |
| `name` | varchar(255) | **NO** | 系統全名，對應 OSCAL system-name |
| `abbreviation` | varchar(100) | YES | 縮寫，對應 OSCAL system-name-short |
| `description` | text | YES | 描述 |
| `security_sensitivity_level` | **ENUM** `compliance.security_sensitivity_level_enum` | YES | low / moderate / high |
| `security_objective_confidentiality` | **ENUM** `security_objective_level_enum` | YES | fips-199-low/moderate/high |
| `security_objective_integrity` | **ENUM** 同上 | YES | 同上 |
| `security_objective_availability` | **ENUM** 同上 | YES | 同上 |
| `system_status` | **ENUM** `system_status_enum` | YES | operational / under-development / under-major-modification / disposition / other |
| `deployment_model` | **ENUM** `deployment_model_enum` | YES | on-premise / cloud / hybrid / co-location |
| `authorization_boundary` | text | YES | 對應 OSCAL authorization-boundary.description |
| `system_owner` | integer | YES | **軟參照 `public.users.id`**（無 FK） |
| `is_active` | boolean | **NO** | 停用後不出現在專案選單 |
| `created_by` | integer | YES | **軟參照 `public.users.id`**（無 FK） |
| `created_at` / `updated_at` | timestamp | NO | BaseModel |
| `created_user` / `updated_user` | varchar(50) | YES | login_name |
| `tenant_id` | integer | **NO** | TenantScopedMixinModel |
| `org_unit_id` | integer | YES | 同上 |

（`M .../infra/models/information_system.py:17-137`；型別/nullable 以 DEV DB 實查為準。**schema 是 `compliance`**。）

對別人的表的參照：
- **(a) 真 FK**：**零**
- **(b) ORM relationship 跨包**：**零**——`information_system.py:131-135` 有註解明文記錄「FR-032 G 移除對 `jedi_auth.User` 的 infra 層 ORM join」，改走 `IUserLookup` 批次 enrich
- **(c) 軟參照**：`system_owner → public.users.id`、`created_by → public.users.id`（欄位 comment 自陳）
- **(d) raw SQL**：無

反向：**DB 上零 FK 指向 information_systems**（DEV `pg_constraint` 查證）。

**產品詞彙洩漏：有，而且是本批最重的一支。**
- 表 comment 直接寫「儲存**可被列入稽核範圍**的系統基本資料」（`information_system.py:23`）
- **四個 enum 全是 OSCAL / FIPS-199 合規標準詞彙**（`common/enum/information_system_enum.py:4-28`）：`fips-199-low` / `operational` / `under-major-modification` / `disposition` — 這些是 NIST SP 800-18 / FedRAMP 的系統生命週期詞，不是通用 IT 資產詞
- **entity 帶 OSCAL 序列化**：`domain/entity/information_system_entity.py:57-79` `to_oscal_dict()` 吐 `{"system-id": {"identifier-type": "https://ietf.org/rfc/rfc4122", ...}, "security-objective-confidentiality": ..., "authorization-boundary": {...}}`；:81-114 `from_oscal_dict()` 反向解析。**這是把 OSCAL SSP 的資料格式焊進「通用資產套件」**。
- 欄位 comment 六處寫「對應 OSCAL xxx」

**Q3 Port**

需要宿主提供：
| Port | 簽名 | 現況 |
|---|---|---|
| `IUserLookup.get_id_by_uid(uid) -> Optional[int]`（`domain/repository/user_lookup.py:17`） | user uid → users.id | **真的在用**——`InformationSystemService._resolve_system_owner()`（`app/service/information_system_service.py:31`）寫入時轉換 |
| `IUserLookup.get_display_by_ids(ids) -> dict[int, tuple[uid, nickname]]`（:23） | 批次 id → 顯示名 | **真的在用**——`InformationSystemDomainService._enrich_owner()`（`domain/service/...:25`）讀取時 enrich |
| `settings`（`plugin.py:84`） | ISettingsReader | **零使用**（樣板） |
| `notifier`（:94） | INotifier | **零使用**（樣板） |

宿主實作在 `H infra/information_system/jedi_auth_user_lookup.py:13`（`JediAuthUserLookup implements IUserLookup`）。

提供給別人：`InformationSystemService`（app，10 支方法含 `upsert_by_name`）、`InformationSystemDomainService`（8 支）、`InformationSystemRepoImpl`、`InformationSystemEntity` / `InformationSystemQueryEntity` / `InformationSystemMenuEntity`、DTO、`InformationSystem` model、四個 enum、`IUserLookup` ABC。

**Q4 消費者**

**(a) 主專案**：8 個檔案（不含測試/docs）
- `H api/information_system/routes/information_system_route.py:22` — **route 層直接 import 套件的 app service**（是本批唯一一支 route 直接吃套件 service 的）
- `H infra/information_system/jedi_auth_user_lookup.py:13` — IUserLookup 實作
- `H common/util/system_asset_snapshot.py:111` — 與 device 並列（見 device Q8）
- `H app/module_frame/service/ssp_import_template_app_service.py:64-67,162,198` — Excel 匯入
- `H app/oscal/service/ssp_resources_context_service.py:153` — SSP 匯出時取系統資料
- `H app/module_frame/excel_template/sheet_definitions.py:253` — 註解說「直接用 OSCAL 值」
- `H di_containers/information_system/information_system_container.py:5-7`
- 對外端點是宿主的 `H api/information_system/__init__.py:23-26` **4 條**（menu / list / create / detail），其中 `list` 那條吃的是宿主的 `InformationSystemAppService`（做 nickname enrich），其餘三條吃套件的 service（README:26-33 說明）

**(b) 其他 jedi 套件**：**零**。

**(c) 註冊狀態**：`register()` 從沒被呼叫（grep `jedi_information_system.plugin` 主專案零命中）。

**Q5 執行期形狀**

- **Route 0 條**（blueprint 空的，`plugin.py:175-198`）
- 背景排程 / worker / thread：**無**
- 對外 I/O：**只有 DB**
- 快取 / Redis：**無**
- 單獨起 process：**沒有意義**（零 route）

**Q6 通用性**

**(a) 不能直接用。** 一個工單系統裝它，會得到一張帶四個 FIPS-199 / OSCAL enum 的表——那些欄位對非合規產品毫無意義，且 `to_oscal_dict()` 這支 API 完全用不上。要用只能忽略掉一半的欄位。

**(b) 寫死的產品知識（本批最多）**：
| 位置 | 內容 |
|---|---|
| `common/enum/information_system_enum.py:10-13` | `SecurityObjectiveLevel` = fips-199-low / moderate / high（FIPS 199 標準） |
| :23-28 | `SystemStatus` = operational / under-development / under-major-modification / disposition / other（OSCAL system-status） |
| :16-20 | `DeploymentModel` = on-premise / cloud / hybrid / co-location（OSCAL deployment-model） |
| `domain/entity/information_system_entity.py:57-114` | 整組 OSCAL SSP system-characteristics 序列化／反序列化 |
| `infra/models/information_system.py:23` | 表 comment「可被列入**稽核**範圍」 |
| `infra/models/information_system.py:21` | schema 硬寫 `"compliance"`（README:106 列為宿主前提：庫必須先有這個 schema） |

**Q7 插件完整度**

| 項目 | 有/無 |
|---|---|
| ① api 層隨包 | ❌ 無 |
| ② 註冊入口 | ✅ 簽名齊全（`plugin.py:200`）＋ `create_blueprint`（:175） |
| ③ migrations 隨包 | ❌ **無** |
| ④ DI container 預設 | ❌ 無 |
| ⑤ 獨立 harness | ✅ 198 行 + docker-compose（port 5494） |
| ⑥ 接入 README | ✅ 173 行（常見坑 5 是錯的，見 Q1） |

**Q8 糾纏對象**

**最糾纏：jedi-device**——完整證據見 device 的 Q8（不重複），要點：
- 宿主已用單一 inventory 表 + `asset_type` discriminator 統一兩者
- `H common/util/system_asset_snapshot.py` 三對姊妹函式並列處理兩者
- `H app/module_frame/service/ssp_import_template_app_service.py:161-162` 同一建構子並列注入兩個 domain service
- **但欄位交集只有 `name` / `description`**，其餘全部不重疊；schema 不同、tenant_id nullable 不同、enum 有無不同、owner 模型不同

**與 jedi-oscal-v2 的關係（本批外，但值得記錄）**：`InformationSystemEntity.to_oscal_dict()` 產出的是 OSCAL SSP system-characteristics 區塊，而 SSP 的擁有者是 jedi-oscal-v2。宿主 `H app/oscal/service/ssp_resources_context_service.py:153` 在 SSP 匯出鏈路上 import 本套件。**「資訊系統是 SSP 的一部分」這個關係目前靠宿主膠水維繫**——但要注意這條線指向的是 oscal 而不是 device，與「合併成資產套件」的方向不同。

**看起來像但其實不是**：
- **jedi-remote-agent**：都有「系統/機器 + 狀態」，但零關聯（見 device Q8 同一段）。

---

## jedi-remote-agent

**Q1 它是什麼**

「裝在客戶機房裡的代理程式」的**註冊、心跳、任務派送、身分驗證（mTLS + 內部 CA + 短效 JWT）** 四件事。README:6-8 白話講得很準：「那台機器怎麼被系統認得、怎麼證明還活著、伺服器怎麼把工作交給它、雙方怎麼互相驗證身分；**工作內容本身**留給產品自己決定」。

README 與程式碼**一致**，且是本批最完整的一份（282 行，含端點表、port 表、常見坑對照表）。程式碼確認：
- README:265 說「每支端點的認證意圖在 `ROUTE_TABLE` 第三欄顯式宣告」→ `M jedi-remote-agent/jedi_remote_agent/api/remote_agent_route.py:216-233` 確實是 `(path, Resource, needs_admin_bool)` 三元組，agent 側四支明確標 `False`
- README:180-182 三張 port「未注入時優雅降級」→ `plugin.py:122-124` 三個欄位皆 `= None` 預設

**Q2 資料**

自己擁有的表：**三張**
| 表 | 存什麼 | 關鍵欄位 |
|---|---|---|
| `compliance.remote_agents`（`infra/remote_agent/model/remote_agent.py:16`） | 一台已註冊的 agent | `uid`、`base_url`、`agent_type`（default `file_storage`）、`device_fingerprint`（SHA-256）、`status`（active/revoked）、`last_seen_at`、`capabilities`（JSONB）、`hardware_info`（JSONB） |
| `compliance.remote_agent_enroll_tokens`（`.../remote_agent_enroll_token.py:14`） | per-tenant 一次性註冊 token | `token_hash`（sha256，不存明文）、`label`、`enabled` |
| `compliance.agent_tasks`（`infra/agent_task/model/agent_task.py:16`） | 派工單 + 狀態機 | `agent_id`、`detection_tool_id`、`job_execution_uid`、`params`（JSONB）、`status`、`result_ref`（JSONB）、`scheduled_at` |

三張全繼承 `TenantScopedMixinModel`。

對別人的表的參照：**全是 (c) 軟參照，零真 FK**（`M .../migrations/001-remote-agent-tables.sql:94-96` 三條 COMMENT 自陳）：
- `agent_tasks.agent_id → compliance.remote_agents.id`（BIGINT，**連自己套件內的表都沒建 FK**）
- `agent_tasks.job_execution_uid → 宿主的執行紀錄`（VARCHAR(50)，指向 `compliance.job_executions`）
- `agent_tasks.detection_tool_id → config.detection_tools.id`（**指向 jedi-detection 的表**）

DEV DB 查證：`compliance.agent_tasks` 上**零 FK constraint**，確認軟參照。

**產品詞彙洩漏：有，兩處**
1. **`agent_tasks.detection_tool_id` 是 `NOT NULL`**（`agent_task.py:31-33` `nullable=False`；migration:79 `BIGINT NOT NULL`；DEV DB `is_nullable=NO` 確認）。**這是最硬的一處洩漏**——套件宣稱「工作內容留給產品決定」，但每張派工單都**強制**要有一個「檢測工具 id」。一個不做檢測的產品（例如備份 agent、監控 agent）**建不出一張合法的派工單**。DEV 實查 79 筆 agent_tasks，`detection_tool_id` 79 筆全有值。
2. `remote_agents.capabilities` 欄位 comment 寫死「派掃描任務只給含 **detection_scan** 者」（`remote_agent.py:44-47`；migration:47 同樣的 COMMENT）。
3. 錯誤碼前綴 `FILE_AGENT_*`（`common/code.py:26-38`）——「file agent」是 FR-039 的歷史命名，現在 agent 已多能力，但字串凍結為對外契約不能改（檔頭:3-10 說明）。
4. 事件碼硬編整數 `6082` / `6083`（`common/code.py:57-58`），與主專案 `common/enum/event_code.py` 同值。

**capabilities 的實際值域（題目特別問的）**：
- **套件內沒有 enum 類別**——`capabilities` 是自由 JSONB list，套件只做 `capability in (a.capabilities or [])` 的成員判定（`domain/remote_agent/service/remote_agent_domain_service.py:50,111`）
- **DB default 是 `'["file_storage"]'`**（migration:37）
- **DEV DB 實查：`SELECT DISTINCT capabilities FROM compliance.remote_agents` → 只有一種值 `["file_storage", "detection_scan"]`**
- 也就是說：**detection 以外只有 `file_storage` 一個**，且兩者實務上總是成對出現。所謂「多能力」目前只是二選一的旗標
- 三態語意（README:277）：心跳沒帶 / 帶空清單 / 帶非 list 都回 `None` 不動 DB 現值，只有非空 list 才寫入（`app/service/agent_enrollment_service.py:50-70`）

**Q3 Port**

需要宿主提供（`M .../plugin.py:118-127` `RemoteAgentAdapters`）：
| Port | 簽名 | 必填 | 未給時 |
|---|---|---|---|
| `admin_required` | `(fn) -> fn` | **是**（無預設值，dataclass 前兩欄無 default） | — |
| `current_user` | `() -> str` | **是** | — |
| `settings_provider` | `() -> AgentAuthSettings` | 否 | 退回 config 內的靜態值 |
| `payload_provider` | `IAgentTaskPayloadProvider.build_pending_payloads(tasks, tenant_id) -> list[dict]` | 否 | 待辦只回 `{"uid": ...}` 空信封 |
| `lifecycle_listener` | `IAgentTaskLifecycleListener`：`on_task_claimed(task)` / `on_scan_succeeded(task, summary)` / `on_scan_failed(task, error_message)` | 否 | 三個掛鉤不呼叫 |
| `recon_query` | `IRemoteAgentReconQuery.list_files_for_base_url(base_url) -> List[dict]`（`domain/remote_agent/repository/recon_query.py:22`） | 否 | reconcile 回空盤點 |
| `audit` | `AuditHook` | 否 | 套件自帶同格式版本 |
| `response_builder` | `(ok, data) -> dict` | 否 | `return_response` |

⚠️ **`IAgentTaskLifecycleListener` 的方法名 `on_scan_succeeded` / `on_scan_failed` 帶 "scan" 這個檢測詞彙**——這是 port 命名層的產品知識洩漏（一個備份 agent 完成任務不叫 "scan succeeded"）。

提供給別人：`RemoteAgentService` / `AgentEnrollmentService` / `AgentEnrollTokenService` / `AgentTaskService`（app 層 4 支）、三個 domain service、`AgentAuthSettings`、`jwt_util`、`build_cloud_mtls_context`、`is_online`、`RemoteAgentErrorCode` / `AgentTaskErrorCode`、三個 ORM model。

樣板殘留：`SchemaExtensions`（`plugin.py:132-150`）插槽開齊零使用。

**Q4 消費者**

**(a) 主專案 `app/remote_agent` 做什麼（題目特別問的）**：整個目錄只有 **3 支 adapter，212 行**，全部是「把套件的 port 接到 detection 的編排」：
- `H app/remote_agent/adapter/detection_task_payload_provider.py`（125 行）— 實作 `IAgentTaskPayloadProvider`。每張單補上 ① `detection_tool_code`（查 `detection_tools` 目錄）② 解密後的 params（走 `jedi_detection.common.detection_secret_params`）③ 該租戶該工具的憑證（查 `tenant_detection_tool_configs` 解密）④ source file 限額。**檔頭:3-8 明說「套件管信封、這裡管內容」**
- `H app/remote_agent/adapter/detection_lifecycle_listener.py`（32 行）— 實作 `IAgentTaskLifecycleListener`，三支方法各一行轉呼叫 `DetectionOrchestrationService`
- `H app/remote_agent/adapter/lazy.py`（55 行）— 三個 port 的延遲解析代理（`create_module()` 跑在 blueprint 載入迴圈裡，此時 DI 拿不到實例）

主專案其他接觸點：
- `H api/remote_agent/__init__.py`（99 行）— 組 adapters 呼叫 `create_blueprint()`；**另外單掛一條留在主專案的 route** `GET /agents/files/<uid>`（:96，回 binary 串流不是 JSON 信封，授權走 detection resolver）
- `H infra/remote_agent/recon_query_adapter.py:12` — 實作 `IRemoteAgentReconQuery`，query `UploadFile` 篩 `storage_type='remote_agent'`
- `H infra/upload_file/remote_agent_adapter.py:25,27` — **反向**：實作 file-upload 的 `IUploadFileProvider`，借用 remote-agent 的 `jwt_util` 與 `build_cloud_mtls_context` 打 agent 取檔
- `H app/flow_control/service/job_service.py:19`、`H app/oscal/service/ssp_control_implementation_service.py:29` — 只借 `is_online()` 判在線
- `H common/util/agent_auth_settings.py:15`、`H di_containers/remote_agent/`、`H di_containers/agent_task/`

**(b) 其他 jedi 套件**：**jedi-detection 3 處**（`M jedi-detection/.../agent_probe_client.py:18,20`、`agent_cancel_client.py:16,18`、`app/service/detection_result_handler.py:36,38`）——全都是借用 `common.agent_auth.jwt_util` 與 `tls.build_cloud_mtls_context` 這兩支認證原語去打 agent。

**Q5 執行期形狀**

- **Route 15 條**（`api/remote_agent_route.py:216-233` ROUTE_TABLE），blueprint 名 `remote-agent`，prefix `/api/1.0`。11 條管理端（套 `admin_required`）+ **4 條 agent 側**（`/agents/register`、`/agents/heartbeat`、`/agents/tasks/<uid>/ack`、`/agents/tasks/<uid>/result`，**刻意不掛守門**，身分靠 mTLS + payload 自報）
- 背景排程 / worker / thread：**套件內無**（心跳是 agent 主動打進來的 pull 模型，不是雲端 push）
- 對外 I/O：**httpx HTTP/mTLS 打 agent**（`app/service/remote_agent_service.py:62-66,236,244` 健康檢查與對帳）、**檔案系統讀憑證**（CA cert/key、JWT 金鑰對、client cert，`common/agent_auth/settings.py`）、**cryptography 簽 CSR**（`common/agent_auth/ca.py`）
- 快取 / Redis：**無**
- 單獨起 process：**可以**——自帶 15 條 route + 自帶 migration + harness（`harness/dev_app.py` 208 行，有 `--migrate` / `--seed` 兩個模式）

**Q6 通用性**

**(a) 不能直接用。** 阻斷點是 `agent_tasks.detection_tool_id NOT NULL`：一個備份 agent 產品裝上它，**建不出一張合法派工單**，除非塞假值。

**(b) 寫死的產品知識**：
| 位置 | 內容 |
|---|---|
| `infra/agent_task/model/agent_task.py:31-33` + `migrations/001:79` | `detection_tool_id BIGINT NOT NULL`（**最硬的一處**） |
| `infra/remote_agent/model/remote_agent.py:46` + `migrations/001:47` | capabilities comment「派掃描任務只給含 detection_scan 者」 |
| `domain/agent_task/repository/lifecycle.py`（port 方法名） | `on_scan_succeeded` / `on_scan_failed` 帶檢測詞 |
| `common/code.py:26-38` | 全部錯誤碼前綴 `FILE_AGENT_*`（歷史命名，凍結為 FE i18n key） |
| `common/code.py:57-58` | 事件碼硬編 `6082` / `6083` |
| `infra/remote_agent/model/remote_agent.py:31` | `agent_type` default `"file_storage"` |
| 三張表 schema 硬寫 `compliance` | migration:23,53,72 |

**Q7 插件完整度**

| 項目 | 有/無 |
|---|---|
| ① api 層隨包 | ✅ 15 條 route + serializers（86 行） |
| ② 註冊入口 | ✅ **兩個**：`register(app, adapters, config=None, schema_extensions=None, mount_api=True)`（`plugin.py:318`）與 `create_blueprint(adapters, config, schema_extensions, handle)`（:270）。主專案走後者（`main.py` 的載入迴圈是 `app.register_blueprint(mod.create_module())`）。另有 `build_services(adapters, config)`（:203）供 `mount_api=False` |
| ③ migrations 隨包 | ✅ **2 支**（001 建三表+索引+COMMENT、002 RLS+GRANT），FR-069 的**首例**（README:194），pyproject:48-50 有 include |
| ④ DI container 預設 | ⚠️ 半有 — `build_services()` 自己組完整的 repo→domain→app 三層（`plugin.py:203-268`），不需要外部 DI |
| ⑤ 獨立 harness | ✅ 208 行 + docker-compose（port 55432），有完整的 register→heartbeat curl 劇本 |
| ⑥ 接入 README | ✅ 282 行，本批最完整 |

**Q8 糾纏對象**

**最糾纏：jedi-detection**（雙向，且是本批最深的耦合）
- **資料層**：`agent_tasks.detection_tool_id NOT NULL → config.detection_tools.id`（jedi-detection 的表）。**這是「一邊沒有另一邊就沒意義」的教科書案例**——沒有 detection_tools 表，agent_tasks 一列都寫不進去（雖然是軟參照不是真 FK，但 NOT NULL 讓它等效於必要相依）。
- **程式碼層雙向**：detection → remote-agent 3 處 import 認證原語；remote-agent → detection **零 import**（走 port），但兩張 port（payload_provider / lifecycle_listener）的實作**100% 是 detection 邏輯**（`H app/remote_agent/adapter/detection_task_payload_provider.py` import 了 `jedi_detection.common.detection_source_file` 與 `detection_secret_params`）。
- **同一交易寫兩邊**：`on_scan_succeeded` 在派工單狀態落地後同步呼叫 detection 的轉證據編排（`H app/remote_agent/adapter/detection_lifecycle_listener.py:24-27`）。

**次糾纏：jedi-file-upload**（間接，經宿主）
- `IRemoteAgentReconQuery` 的唯一實作 query `upload_files`（`H infra/remote_agent/recon_query_adapter.py:12,26`）
- 反向：`upload_files.storage_type = 'remote_agent'` 是第四種後端，adapter 在 `H infra/upload_file/remote_agent_adapter.py`，**該檔 import remote-agent 的 jwt_util 與 mTLS builder**
- 兩支套件互相不 import，全部經宿主的兩支 adapter

**看起來像但其實不是**：
- **jedi-device**：都是「一台機器」。但 `remote_agents` 與 `devices` **零 FK、零 join、零 import**，`agent_tasks` 也沒有 device 欄位。系統中從沒把「這台 agent 裝在哪台 device 上」關聯起來。
- **jedi-integrity**：都算 SHA-256 指紋、都有「machine fingerprint」概念。但 remote-agent 的 `device_fingerprint` 是**客戶機器的識別碼**（防 VM 複製撞號），integrity 的 `machine_fingerprint` 是**本機自己**的（unlock token 綁定）。兩者零共用程式碼（remote-agent 的指紋是 agent 送上來的，integrity 的是自己算的）。

---

## jedi-integrity

**Q1 它是什麼**

落地版產品的**防篡改偵測**：開機時逐檔比對簽章 manifest，不符就拒啟＋起一個只回 503 的鎖定殼＋留 FS/DB 雙落點證據；運行中每 4h 隨機抽查；只有原廠簽的一次性 unlock token 能解。

README 與程式碼**一致**，且是本批唯一「README 主動聲明自己不是 REST 插件」的（README:193-201）。程式碼確認：
- README:26-34 的能力表對得上模組：`startup_gate.py`（242 行）/ `lockdown.py`（258）/ `runtime_check.py`（358）/ `tamper_marker.py`（159）/ `unlock.py`（363）/ `forensic.py`（136）/ `lc_report.py`（91）
- README:212-214 說「stdlib-only 紅線」→ `pyproject.toml:11-21` 註解明說執法核心零 runtime 相依，唯一宣告的 `jedi-common>=0.0.30` 只給 `app/` 與 `infra/` 兩層
- README:36-38 的「邊界誠實聲明」（這是拉高成本不是不可破解）— 這種自我設限在套件 README 裡很少見

**Q2 資料**

自己擁有的表：**一張**
| 表 | 存什麼 | 關鍵欄位 |
|---|---|---|
| `public.integrity_tamper_events`（`migrations/001-integrity-tamper-events-tables.sql:25`；表名寫死在 `infra/tamper_event_repo.py:28` `TABLE_NAME`） | 一次被偵測到的竄改事件 | `event_uid`（UNIQUE，冪等基礎 + unlock token 綁定對象）、`detected_at`、`detected_by`（CHECK IN boot/scheduler/hook）、`machine_fingerprint`、`detail`（JSONB）、`unlocked_at`（NULL=仍鎖定）、`unlock_nonce`（防重放） |

**刻意不掛 RLS**（README:319-323）：竄改是機器級現象與租戶無關，且閘門在無使用者脈絡下執行。有測試焊死「不得出現 RLS」。**表無 `tenant_id`**。

對別人的表的參照：**零**。無 FK、無 relationship。

`infra/tamper_event_repo.py` 用 **raw SQL 打自己的表**（`:60` INSERT、`:88` UPDATE、`:98` SELECT，f-string 拼 `TABLE_NAME`）——不是打別人的表。

**產品詞彙洩漏：零。** 表名 / 欄位名 / 錯誤訊息全部是通用防竄改語彙。

**Q3 Port**

需要宿主提供（`M jedi-integrity/jedi_integrity/ports.py`）：
| Port | 簽名 | 必填 |
|---|---|---|
| `ISignatureVerifier.verify(envelope: dict, expected_type: str) -> dict`（:57） | 離線驗章，manifest 與 unlock token 共用 | **是**（無預設） |
| `IMachineFingerprint.compute() -> str`（:80） | 跨重開機穩定的機器識別 | **是** |
| `IResourceLocator.resource_root() -> Path` / `is_packaged() -> bool`（:96,:104） | 產物根目錄 + 打包判定 | **是** |
| `TamperEventWriter`（型別別名，:117） | `(event_uid, detected_at, detected_by, machine_fingerprint, detail) -> bool` | 否，未給 fail-open |
| `TriggerContextCollector`（:124） | `(detected_by) -> dict` 宿主側旁證 | 否 |
| `ProcessTerminator`（:128） | `() -> None` | 否（README:216 明說「不建議覆寫」） |

`ports.py:15-19` 有一張很清楚的對照表記錄「抽出前這三件事是直接 import 主專案哪三個模組」。

`SignatureVerificationError`（:39）是套件定義、要求宿主 adapter 把自己的例外轉成它——README:87-88 紅字警告「漏轉會在啟動路徑上炸成未預期例外」。

樣板殘留：`schema_extensions` 插槽（`plugin.py`）有開但 README:190 說「宿主往 tamper 事件多塞欄位」，屬選填未使用。

提供給別人：`run_startup_gate(context)`、`build_context(adapters, config)`、`register(app, adapters, context, db_event_writer)`、`handle.checker`（`run_spot_check()` / `hotpath_verify(op)` / `next_spot_check_delay_seconds()`）、`register_gunicorn_master(pid)`、`TamperEventRepo`、`TamperSyncService`、`iter_migrations()`、`IntegrityConfig` / `IntegrityContext`。

**Q4 消費者**

**(a) 主專案**：6 個檔案
- `H main.py:106,112` — **`run_startup_gate(INTEGRITY_CONTEXT)` 在 `create_app()` 之前**（:110 建 context，:112 跑閘門，:121 才 `from core.app_factory import create_app`）
- `H main.py:223` — gunicorn `post_fork` 掛 `register_gunicorn_master(server.pid)`
- `H core/app_factory.py:342-352` — 第二段接線：`_write_tamper_event_to_db` writer + `register_integrity(...)`
- `H core/scheduler.py:72,301-304` — 抽查排程（`init_integrity_scheduler`）+ FS→DB 補同步（`TamperSyncService`）
- `H common/integrity/adapters.py` — 三支 port adapter（`LicenseEngineVerifier` 包 `jedi_license_runtime.common.engine`、`MachineIdFingerprint` 包 `jedi_license_runtime.common.machine_fingerprint`、`ProjectResourceLocator`）
- `H common/integrity/__init__.py` — 對照表 + `build_integrity_context()`

**(b) 其他 jedi 套件**：**零**（無任何套件 import jedi_integrity）。

**啟動時機（題目特別問的）**：**兩段式，第一段在 app 存在之前。**
1. **第一段（閘門）**：`H main.py:112`，跑在 `validate_required_env()` 之後、`create_app()` 之前。此時 DB / DI / Flask app **全部不存在**——這正是套件 stdlib-only 紅線的原因。竄改時此處**不返回**（起 lockdown 殼或 SystemExit）。
2. **第二段（DB 落點 + checker）**：`H core/app_factory.py:350-352`，必須在 `container.wire()` 之後（writer 被呼叫時要開 DB session）。
3. **第三段（排程）**：`H core/scheduler.py` 掛抽查 job，用 **date trigger 自排下一輪**（interval 的 jitter 中心點仍可預測，README:151-163）。api / socketio **兩模式都跑**——是唯一兩模式都掛的 job（`main.py:166`）。
4. **業務埋點**：`handle.checker.hotpath_verify("upload-license")` 由宿主在低頻高價值操作入口呼叫。

**不是中介層**——沒有 `before_request` / `after_request` hook。

**Q5 執行期形狀**

- **Route 0 條**（README:193-201 明說沒有 api 層，`mount_api` 參數只為簽名一致而保留）
- **背景 thread：有** — `runtime_check.py:49,73` 用 `threading.Lock` 保護熱路徑；抽查 job 的排程由**宿主**的 APScheduler 掛（套件只提供 `run_spot_check()` 與 `next_spot_check_delay_seconds()`）
- **獨立 HTTP server：有** — `lockdown.py:51,234-235` 用 stdlib `ThreadingHTTPServer` 接管原 port 回 503（服務起不來時才由它接管）
- 對外 I/O：**檔案系統**（逐檔讀算 sha256、寫 FS tamper 標記、讀 unlock token）、**stdlib urllib HTTP**（`lc_report.py:30-31,69,78` best-effort 回報 License Center，單次嘗試不重試）、**process 訊號**（`termination.py` 對 gunicorn master 送 SIGQUIT / `os._exit(1)`）
- 快取 / Redis：**無**
- **單獨起 process：不適用**——它的形狀就不是 service，是「在別人的 process 起來之前先跑一段」。harness（410 行）跑的是四個劇本（pass / tamper / unlock / lockdown），不是起服務

**Q6 通用性**

**(a) 能直接用。** 這是本批八支裡通用性最好的一支——任何「編譯成 binary 裝在客戶機器上」的產品都能裝，提供三支十行等級的 adapter 即可。

**(b) 寫死的產品知識：幾乎沒有**
- `migrations/001` 的 `GRANT` 對象假設 `cm_app`，但**包在 `pg_roles` 存在判斷內**（README:302-303），單帳號 consumer 不會整支失敗
- `TABLE_NAME = "public.integrity_tamper_events"` 硬寫（`infra/tamper_event_repo.py:28`，README:322 說明「維運查詢與簽發端都認這個名字」）
- manifest 三層 `core` / `resources` / `thirdparty`（README:245-252）是套件約定的分層語意，consumer 的 build 產線要照這個結構產 manifest — 算契約不算產品知識
- **零 Guidant 詞彙、零 `os.getenv`**（有測試守著，README:212-217）

**Q7 插件完整度**

| 項目 | 有/無 |
|---|---|
| ① api 層隨包 | ❌ **無，且是刻意的**（README:193-201 給了理由：對外表面是啟動閘門與鎖定殼，不是 REST 端點；解鎖走檔案系統正因為「要解鎖的機器服務起不來」） |
| ② 註冊入口 | ✅ `register(app, adapters, config=None, schema_extensions=None, mount_api=True)`（`plugin.py`）＋ `build_context(adapters, config)`（閘門用，不需要 app） |
| ③ migrations 隨包 | ✅ **1 支**（001 建表；README:296-298 說明「沒有 002-rls 是刻意的，本表不掛 RLS」），pyproject:42-44 有 include |
| ④ DI container 預設 | ❌ 無（不需要——它不吃 DI） |
| ⑤ 獨立 harness | ✅ 410 行，**不需要 DB / Redis / 主產品**，會在 tmp 造假 dist + 真 Ed25519 簽章 manifest 跑四個劇本 |
| ⑥ 接入 README | ✅ 429 行，本批最長。含「宿主要做的四件事」逐步範例、與簽章產線的檔案座標約定、離線解鎖五步流程、七條踩過的坑（含 CM-1241 容器內 killpg 打不到 PID 1 這種只有實測才知道的事） |

**Q8 糾纏對象**

**最糾纏：jedi-license-runtime**（單向借用，且方向反直覺）
- 主專案的 `ISignatureVerifier` 實作**包的是 license-runtime 的驗章引擎**：`H common/integrity/adapters.py:33` `from jedi_license_runtime.common.engine import ...`；`:39` `from jedi_license_runtime.common.machine_fingerprint import compute_machine_fingerprint`
- 也就是說：**integrity 的兩支必填 port，宿主是拿 license-runtime 的東西去實作的**。兩套 Ed25519 驗章共用同一個引擎、同一個公鑰列表機制、同一個機器指紋算法。
- **反向**：license-runtime 有一張 `integrity_hook` port（`M jedi-license-runtime/.../plugin.py:167`），宿主接的是 integrity 的 `hotpath_verify`（README:98）。**兩支套件互相在對方身上開了 port**，但都不直接 import 對方——全部經宿主的 `H common/integrity/adapters.py`。
- `H common/integrity/adapters.py:64-70` 那段 Nuitka 產物路徑清單同時列 `jedi_integrity/` 與 `jedi_license_runtime/`，兩者在 build 產線上也是綁在一起的。
- **但兩者的資料完全不共用**：`integrity_tamper_events`（無 tenant，機器級）與 `config.tenant_licenses`（有 tenant，租戶級）零 FK、零 join。

**次糾纏：License Center（外部系統，非套件）**：`lc_report.py` best-effort POST tamper 事件到 LC；license-runtime 也打 LC 請照。兩者共用 `LICENSE_ACTIVATION_SERVER_URL` / `LICENSE_CENTER_API_TOKEN` 兩個設定值（`lc_report.py:24-27` 明說「不自創新名」）。

**看起來像但其實不是**：
- **jedi-file-upload**：都算 sha256。但 integrity 算的是**程式碼產物**的 hash（比對 manifest），file-upload 的 `sha256` 欄是**使用者上傳檔**的錨點（給 remote-agent 對帳用）。零共用程式碼、零資料交集。
- **jedi-remote-agent**：都有 machine fingerprint。但一個是「本機自己的」、一個是「agent 回報的」，零共用（見 remote-agent Q8）。

---

## jedi-license-runtime

**Q1 它是什麼**

授權體系的**消費端**：讀客戶的授權照、Ed25519 驗簽、綁機器指紋、推進到期狀態機（valid→notice→grace→readonly→locked）、到期前後寄通知、提供 root 後台的租戶授權管理端點。簽發那一端在獨立的 License Center 系統。

README 與程式碼**一致**，且 README:11-24 那張「做 / 不做」表是全批最清楚的疆界宣告：「套件只管**驗出狀態**，執法留宿主」。程式碼確認：套件內確實沒有任何「擋哪些端點」的邏輯；宿主的兩個執法點在 `H common/middleware/license_readonly_mw.py`（唯讀 gate）與 `H common/authz/license.py`（軸⑥模組守門）。

README:26-28 的紅字「宿主的唯讀 gate 必須豁免整段 `/license/` 前綴，攔了就是永久死鎖」→ `H common/middleware/license_readonly_mw.py:55,69` `_EXEMPT_PREFIXES` 內確實有 `"/license/"`。

**Q2 資料**

自己擁有的表：**四張**（README:146 說三張，實際四張——`tenant_license_suspensions` 是 CM-1579 後加的，README 未更新）
| 表 | 存什麼 | 關鍵欄位 |
|---|---|---|
| `config.tenant_licenses`（`infra/model/tenant_license.py:18`） | 一列＝一張落地的照（replace 制，換發新增列不覆寫） | `license_raw`（照原文 JSON，逐次驗章依據）、`license_id`、`expires_at`、`expiry_policy`（JSONB）、`modules`（JSONB）、`limits`（JSONB）、`deployment_mode`（saas/host）、`machine_fingerprint`、`kid`、`status`、`is_current` |
| `config.tenant_license_events`（raw SQL 操作，`infra/license_event_repo.py:33,40`） | 生命週期事件時間線 | — |
| `config.tenant_license_suspensions`（`infra/model/tenant_license_suspension.py:32`） | 一列＝一個租戶目前人為停權中 | `tenant_id`（UNIQUE）、`suspended_at`、`suspend_reason`、`actor` |
| `public.license_clock_watermark`（raw SQL，`infra/clock_watermark_repo.py:22,30`） | 單列（id=1）的時鐘回撥防護浮水印 | `max_seen_issued_at` |

**對別人的表的參照：這是本批最嚴重的一支。**

**(d) raw SQL 直打別人的表／view — 兩處：**

1. **`infra/license_tenant_resolver.py`** 直打 `public.tenants`（jedi-iam 的表）**四次**：
   - `:66` `SELECT path FROM public.tenants WHERE id = :tid`
   - `:97` `SELECT name FROM public.tenants WHERE id = :tid`
   - `:128` `SELECT path FROM public.tenants WHERE id = :tid`
   - `:133` `SELECT count(*) FROM public.tenants WHERE path LIKE :prefix AND id <> :tid`
   而且**全部走「獨立 session + `SET LOCAL app.is_super_admin = 't'` 繞 RLS」**（`:148`）。檔頭:22-29 說明理由（子租戶對祖先租戶的 SELECT 被 RLS 前綴比對擋住）。
   ⚠️ **這條線把「租戶樹的 path 是 `/1/102/152/` 這種格式」這個 jedi-iam 的內部資料結構寫死進 license 套件**（`:74-86` 逐段 split 解析）。

2. **`infra/tenant_admin_notify_query.py:19-37`** 一支 raw SQL **同時 JOIN 四張 jedi-iam 的表 + 一個 view**：
   ```
   FROM public.users u
     ... public.user_roles ur JOIN public.roles r ...
     ... public.v_user_capabilities vc JOIN public.capabilities c ...
   ```
   **`v_user_capabilities` 就是題目問的「raw SQL 打 iam 的 view」——確認有，在這裡。** 且條件裡硬編了 `c.name = 'tenant.update'` 這個能力點名稱、`r.is_admin = 1`、`u.status >= -1`（檔頭:14 自陳「比照 jedi-auth `UserRepoImpl._gen_filters` 的既有慣例」）。

**(c) 軟參照**：`tenant_licenses.tenant_id` / `tenant_license_suspensions.tenant_id` 都是「頂層持照租戶 id」，指向 `public.tenants.id`，**無 FK**。

**(a) 真 FK**：零。**(b) ORM relationship 跨包**：零。

**產品詞彙洩漏：無 Guidant 專屬詞。** 但有**生態綁定**：`config` schema、`public.tenants` 的 path 格式、`v_user_capabilities` view 名、`tenant.update` capability 名——這四樣是「jedi 生態知識」，換一個不用 jedi-iam 的產品全部失效。

**Q3 Port**

需要宿主提供（`M .../plugin.py:160-169` `LicenseAdapters`）：
| Port | 簽名 | 必填 | 未給時 |
|---|---|---|---|
| `admin_required` | `(fn) -> fn` | **是**（無 default） | — |
| `login_required` | `(fn) -> fn` | **是** | —（README:112 註「必須比 admin 寬，否則自救路徑死鎖」） |
| `notifier` | `INotifier.notify(recipients: List[str], subject: str, body: str)`（`domain/repository/ports.py:26`） | 否 | 不寄信但狀態機照常推進（記 warning） |
| `tenant_directory` | `ITenantDirectory.list_tenants()` / `get_tenant_by_uid(uid)`（:56） | 否 | root 後台端點回「查無租戶」 |
| `is_platform_admin` | `() -> bool` | 否 | 恆 False |
| `licensed_job_types` | `() -> list[str]` | 否 | 回空清單 |
| `integrity_hook` | `(op: str) -> None` | 否 | no-op |
| `response_builder` | `(bool, Any) -> dict` | 否 | 套件自帶信封 |

`ports.py:1-18` 的檔頭寫得很好：「抽取時把照怎麼驗、狀態怎麼推全部搬進套件，留在宿主的只剩三類產品知識」。

⚠️ **但 `ports.py` 的宣告與實際不符**：它說「其餘（驗章、指紋、狀態機、事件稽核、DB 讀寫）全部在套件內，宿主不必提供」——**卻沒提到套件自己繞過 port 直接 raw SQL 打 `public.tenants` 與 `v_user_capabilities`**。`ITenantDirectory` 這張 port 存在的意義（「租戶模型是宿主的」）被 `license_tenant_resolver` 那四條 SQL 直接繞掉了。**同一件事（讀租戶）有兩條路：一條走 port、一條走 raw SQL 繞 RLS。**

提供給別人：`LicenseVerificationService` / `LicenseStatusService` / `TenantLicenseAdminService` / `LicenseExpiryNotificationService`、三個 domain service、`LicenseErrorCode`、`common.engine`（驗章）、`common.machine_fingerprint`、`LicenseTenantResolver`、`TenantLicenseEntity`、`iter_migrations()`。

**Q4 消費者**

**(a) 主專案**：
- `H api/license/__init__.py:44` — `create_blueprint(adapters, config)` 組裝（route 全在套件內）
- `H core/scheduler.py:244,269` — 每日 02:00 UTC 跑 `run_daily_tick()`（從 `app.extensions[HANDLE_KEY]` 取 handle，**不經 DI container**）
- `H common/middleware/license_readonly_mw.py:47` — **執法點①唯讀 gate**（Flask 中介層）
- `H common/authz/license.py:60` — **執法點②軸⑥模組守門**
- `H common/integrity/adapters.py:33,39` — **借驗章引擎與指紋算法給 jedi-integrity 用**（見 integrity Q8）
- `H app/auth/service/role_app_service.py:17`、`H app/setup/service/setup_wizard_service.py:61` — 借 error code / 指紋
- `H di_containers/license/license_containers.py:20-28` — DI wiring

**(b) 其他 jedi 套件**：**零**。

**Q5 執行期形狀**

- **Route 14 條**（`api/routes.py:41-56` ROUTE_TABLE），blueprint 名 `license`，prefix `/api/1.0`。9 條 `admin` + **5 條 `login`**（`/license/status`、`/license/activation/machine-code`、`/license/activation/upload`、`/license/activation/online`——README:221-223 紅字「這幾支不可以升級成 admin，開通當下該租戶通常還沒有照」）
- **背景排程：不自帶，但要求宿主每日跑一次** `run_daily_tick(curr_user="system")`（README:132-141）。**必須在 super-admin RLS bypass 的 session_scope 內**，冪等靠 conditional UPDATE（CAS）不需要分散式鎖
- 對外 I/O：**httpx 打 License Center**（`app/service/tenant_license_admin_service.py:244,283` 請照/展延/查方案；`app/service/license_verification_service.py:355` 線上開通）、**檔案系統讀 `/etc/machine-id`**（指紋）、**寄信經 `INotifier` port**（套件不直接碰 SMTP）
- 快取 / Redis：**無**
- 單獨起 process：**可以**——14 條 route + 3 支 migration + harness（355 行，含 `--migrate` / `--issue` 兩模式）

**啟動時機（題目特別問的）**：**沒有啟動閘門**。與 integrity 不同——license 是「app 起來之後掛 blueprint + 中介層 + 每日排程」，**不會阻止服務啟動**。三個生效點：① blueprint 掛載（`create_app()` 內）② 每個 request 過中介層 `license_readonly_mw`（宿主的，不是套件的）③ 每日 02:00 排程 tick。

**Q6 通用性**

**(a) 不能直接用。** 三個阻斷點：
1. **raw SQL 綁死 jedi-iam 的 schema**：`public.tenants`（含 `path` 格式）、`public.users` / `user_roles` / `roles` / `capabilities` / `v_user_capabilities`
2. **綁死 `SET LOCAL app.is_super_admin` 這個 RLS 慣例**（`license_tenant_resolver.py:148`）
3. **綁死 `config` schema**

**(b) 寫死的產品知識／生態知識**：
| 位置 | 內容 |
|---|---|
| `infra/license_tenant_resolver.py:66,97,128,133` | 四條 raw SQL 打 `public.tenants` |
| `infra/license_tenant_resolver.py:74-86` | 租戶 path `/1/102/152/` 格式的逐段解析 |
| `infra/license_tenant_resolver.py:148` | `SET LOCAL app.is_super_admin = 't'` |
| `infra/tenant_admin_notify_query.py:19-37` | JOIN 四表 + `v_user_capabilities` view |
| `infra/tenant_admin_notify_query.py:33` | 硬編 capability 名 `'tenant.update'` |
| `infra/clock_watermark_repo.py:22` | 硬編 `WHERE id = 1`（README:162-164 紅字說這列不可省，沒有它整道時鐘回撥防護靜默失效） |
| `common/public_keys.py` | **編譯進程式的公鑰列表**（README:170 「不放設定檔、不放 DB——可設定就等於可被替換」）。換環境要改這個檔並重新發版套件 |
| `common/constant.py` `DEFAULT_ROOT_TENANT_ID` | 預設 1（但已 config 化可覆寫，`plugin.py:101`） |
| 四張表 schema 硬寫 `config` / `public` | migrations 001/002/003 |

**Q7 插件完整度**

| 項目 | 有/無 |
|---|---|
| ① api 層隨包 | ✅ 14 條 route（`api/license_route.py` 140 行 + `license_admin_route.py` 143 行 + serializers 109 行） |
| ② 註冊入口 | ✅ `register(app, adapters, config=None, schema_extensions=None, mount_api=True)`（`plugin.py`）＋ `create_blueprint(adapters, config)`（主專案走這條） |
| ③ migrations 隨包 | ✅ **3 支**（001 建表+索引+浮水印 seed、002 RLS+GRANT、003 suspensions 表） |
| ④ DI container 預設 | ⚠️ 半有 — plugin.py 內自組 service 樹（:300-353），宿主 DI container 只補幾支 |
| ⑤ 獨立 harness | ✅ 355 行 + docker-compose（port 55433），有 `--migrate` / `--issue` 自簽照 |
| ⑥ 接入 README | ✅ 229 行，含 port 表 / 三道宿主前提 / 公鑰更新四步 / 安全敏感區警告 / 端點表 |

**Q8 糾纏對象**

**最糾纏：jedi-iam**（單向，且是繞過 port 的硬耦合）
- 兩支 infra 檔用 raw SQL 直打 iam 的五張表 + 一個 view（證據見 Q2）
- **這是「一邊沒有另一邊就沒意義」的實例**：拿掉 jedi-iam，`LicenseTenantResolver.resolve()` 每次都退回 fail-safe 分支（`:69-72` 查無 path 就回自己），`TenantAdminNotifyQuery` 直接 SQL 錯誤
- 諷刺的是套件**已經有一張 `ITenantDirectory` port 專門處理「租戶模型是宿主的」**，卻被同一個套件內的 raw SQL 繞過

**次糾纏：jedi-integrity**（雙向互開 port，見 integrity Q8，不重複）
- integrity 的兩支必填 port 用 license-runtime 的引擎實作
- license-runtime 的 `integrity_hook` port 接 integrity 的 `hotpath_verify`
- 兩者在 Nuitka build 產線清單上並列（`H common/integrity/adapters.py:64-70`）

**看起來像但其實不是**：
- **License Center（`H ../license_center/`）**：名字幾乎一樣、講同一套授權體系，但**刻意分離的獨立系統獨立 DB**（BE CLAUDE.md 跨 repo 表已載明：私鑰、客戶名單、訂單、抽佣不進主產品）。兩者只透過「簽好的 License 檔」與幾支 HTTP API 交互，**零 DB 共用**。這是「看起來該合併但絕不能合併」的典型。
- **jedi-notification**（不在本批）：license 有 `INotifier` port，但那是窄到只有一支 `notify()` 的投遞 port，不是通知領域本身。

---

## 跨套件觀察

### A. 本批八支之間的關係矩陣

方向讀法：**列 → 欄**（列依賴欄）。

| ↓依賴者 \ 被依賴→ | file-upload | issue | bulletin | device | info-sys | remote-agent | integrity | license-rt |
|---|---|---|---|---|---|---|---|---|
| **file-upload** | — | — | — | — | — | — | — | — |
| **issue** | **import 9 處＋pyproject 宣告＋DB 真 FK** | — | — | — | — | — | — | — |
| **bulletin** | — | — | — | — | — | — | — | — |
| **device** | — | — | — | — | — | — | — | — |
| **info-sys** | — | — | — | — | — | — | — | — |
| **remote-agent** | 經宿主 adapter（recon_query 讀 upload_files） | — | — | — | — | — | — | — |
| **integrity** | — | — | — | — | — | — | — | 經宿主 adapter（驗章引擎＋指紋） |
| **license-rt** | — | — | — | — | — | — | 經宿主（integrity_hook） | — |

**唯一的套件對套件直接 import：issue → file-upload**（9 處）。其餘全是「經宿主 adapter」或「無關係」。

### B. 本批八支對外（批次外套件）的關係

| 邊 | 形式 | 證據 |
|---|---|---|
| jedi-detection → **file-upload** | import ORM model 做唯讀查詢 | `M jedi-detection/.../detection_report_file_query.py:14` |
| jedi-detection → **remote-agent** | import 認證原語（jwt_util / mTLS builder）3 處 | `M jedi-detection/.../agent_probe_client.py:18,20` 等 |
| **remote-agent** → jedi-detection | **資料層軟參照且 NOT NULL**：`agent_tasks.detection_tool_id → config.detection_tools.id` | `M .../agent_task.py:31-33`、migration:79、DEV DB `is_nullable=NO` |
| jedi-survey → **device** | import ORM model + relationship + association_proxy | `M jedi-survey/.../task_survey.py:1,136-145` |
| **license-rt** → jedi-iam | **raw SQL 直打 5 表 + 1 view，繞 RLS** | `infra/license_tenant_resolver.py:66,97,128,133,148`、`infra/tenant_admin_notify_query.py:19-37` |
| **bulletin** → jedi-iam | **經宿主**：`bulletin_org_units.org_unit_id → org_units(id)` 真 FK（DEV DB） | `H infra/bulletin/models/bulletin.py:23` |
| **info-sys** → jedi-iam | 走 `IUserLookup` port（正解，零直接依賴） | `M .../domain/repository/user_lookup.py:13` |
| **file-upload** → jedi-iam | **零**（走 `jedi_common.identity` 標準 port） | 全包 grep 零命中 |

### C. DB 層 FK 事實（DEV `guidant_ai_dev` 實查 `pg_constraint`）

**指向本批套件的表的所有真 FK：**
```
bulletin_org_units.bulletin_id                        → bulletins            [宿主表 → bulletin 套件表]
compliance.job_execution_device_mapping.device_id     → devices              [宿主表 → device 套件表]
compliance.job_execution_devices.device_id            → devices              [同上]
compliance.project_job_execution_device_mapping.device_id → devices          [同上]
issue_assignee_mapping.issue_uid / member_uid         → issues / members     [issue 套件內部]
issue_label_mapping.issue_uid / label_uid             → issues / labels      [issue 套件內部]
issue_upload_file_mapping.issue_uid                   → issues               [issue 套件內部]
issue_upload_file_mapping.file_uid                    → upload_files         [★ 跨套件真 FK：issue → file-upload]
compliance.job_evidences.file_id                      → upload_files         [宿主表 → file-upload 套件表]
oscal.ssp_reference_documents.file_id                 → upload_files         [宿主表 → file-upload 套件表]
```

**零 FK 指向**：`compliance.information_systems`、`compliance.remote_agents`、`compliance.agent_tasks`、`config.tenant_licenses`、`public.integrity_tamper_events`。

### D. 插件完整度總表

| 套件 | api 層 | register | migrations | harness | README | 宿主有沒有真的呼叫 register() |
|---|---|---|---|---|---|---|
| file-upload | ✅ 6 route | ✅ | ✅ 2 支 | ✅ | ✅ 188 行 | ✅ `H core/app_factory.py:328` |
| issue | ✅ 1 route | ✅ | ❌ | ❌ | ✅ 144 行 | ✅ `H core/app_factory.py:404` |
| bulletin | ❌ 0 route | ✅ 空殼 | ❌ | ✅ | ✅ 190 行 | ❌ **零命中** |
| device | ❌ 0 route | ✅ 空殼 | ❌ | ✅ | ✅ 175 行 | ❌ **零命中** |
| info-sys | ❌ 0 route | ✅ 空殼 | ❌ | ✅ | ✅ 173 行 | ❌ **零命中** |
| remote-agent | ✅ 15 route | ✅ + create_blueprint | ✅ 2 支 | ✅ | ✅ 282 行 | ✅ `H api/remote_agent/__init__.py:97`（走 create_blueprint） |
| integrity | ❌（刻意） | ✅ + build_context | ✅ 1 支 | ✅ | ✅ 429 行 | ✅ `H main.py:112` + `H core/app_factory.py:352` |
| license-rt | ✅ 14 route | ✅ + create_blueprint | ✅ 3 支 | ✅ | ✅ 229 行 | ✅ `H api/license/__init__.py:44`（走 create_blueprint） |

**三支空殼插件（bulletin / device / info-sys）的共同特徵**：同一批 P3.5 補殼、同樣的兩張零使用 port（`ISettingsReader` / `INotifier`）、同樣的空 blueprint、同樣的 pyproject 三行相依、同樣的 README 照抄錯誤（都寫「軟刪除 is_delete=1」但兩支沒這欄位）、**register() 在主專案從未被呼叫過**。它們現在的實質身分是「三個被繼承/被 import 的資料形狀 library」。

### E. 產品知識洩漏排行（由重到輕）

1. **info-system**：四個 OSCAL/FIPS-199 enum + entity 帶 `to_oscal_dict()` / `from_oscal_dict()` + 表 comment 寫「稽核範圍」
2. **remote-agent**：`agent_tasks.detection_tool_id NOT NULL`（硬阻斷非檢測產品）+ capabilities comment 寫死 `detection_scan` + port 方法名 `on_scan_succeeded/failed`
3. **license-runtime**：raw SQL 綁死 jedi-iam schema（5 表 + 1 view）+ `SET LOCAL app.is_super_admin` RLS 慣例 + 硬編 `tenant.update` capability 名
4. **issue**：4 個 GitLab/GitHub env var 硬編 + 兩個第三方 SDK 進 runtime 相依
5. **device / bulletin**：只有 blueprint 預設名與 `cm_app` GRANT 假設，接近零洩漏
6. **file-upload**：接近零（`file_access` scope 名已 config 化；LibreOffice 是多餘職責不是產品洩漏）
7. **integrity**：**零**（stdlib-only + 零 os.getenv，有測試守著）

### F. 執行期形狀分類（能不能單獨起 process）

| 類別 | 套件 | 理由 |
|---|---|---|
| **真能獨立服務化** | remote-agent（15 route + 自帶 migration + agent 側 mTLS 端點）、license-runtime（14 route + 3 migration + 打 LC 的 HTTP）、file-upload（6 route + 2 migration + S3/FS I/O） | 有完整 api 層、有自己的表、有對外 I/O |
| **形狀上不適用** | integrity | 它是「在別人 process 起來之前先跑一段」+ 起 lockdown 殼，不是 service |
| **服務化沒有意義** | bulletin / device / info-sys（零 route）、issue（1 route，且真正的用例在宿主 feedback 模組） | 沒有對外表面，拆出去只是把 DB 呼叫變成 HTTP 呼叫 |

### G. 題目點名事項的直接回答

| 問題 | 答案 |
|---|---|
| file-upload 對 iam 的依賴是 import 還是 pyproject？ | **兩者皆無**。全包零 import、pyproject 零宣告。身分名冊走 `jedi_common.identity.IdentityContext` port，實作在宿主 `H core/upload_file_wiring.py:53-106`（那支才 import jedi_iam） |
| `storage_type` 有沒有被宿主擴成 REMOTE_AGENT？ | **有**。套件的 `StorageTypeCode` 不含它；宿主定義在 `H common/code/file_storage_type.py:9`，經 `FileUploadConfig.extra_object_storage_types=("remote_agent",)` 注入（`H core/upload_file_wiring.py:50`、`plugin.py:118`） |
| issue 用 file-upload 的哪些東西？ | `FileUploadService`、`UploadFileEntity`、`UploadFileDomainService`、`UploadProviderFactory`、`UploadFileRepoImpl` — 5 個類別、9 處 import，全在 `local_issue_attachment.py` / `local_issue_adapter.py` / `ports/dto/upload_file_dto.py` 三檔 |
| remote-agent capabilities 有哪些值？detection 以外還有誰？ | **沒有 enum 類別**，是自由 JSONB list。DB default `["file_storage"]`；**DEV 實查只有一種值 `["file_storage", "detection_scan"]`**。detection 以外只有 `file_storage` 一個 |
| `agent_tasks.detection_tool_id` 是否 NOT NULL？ | **是**。ORM `nullable=False`、migration `BIGINT NOT NULL`、DEV DB `is_nullable=NO`、79 筆資料全有值 |
| 宿主 `app/remote_agent` 做什麼？ | 只有 3 支 adapter 共 212 行：payload provider（補檢測工具 code/參數解密/憑證/限額）、lifecycle listener（3 行轉呼叫 detection 編排）、lazy 代理 |
| integrity 啟動時機？ | **兩段式，第一段在 `create_app()` 之前**（`H main.py:112`，DB/DI/app 皆不存在，故 stdlib-only）。第二段在 `app_factory.py:352` 接 DB 落點。排程由宿主掛，api/socketio 兩模式都跑 |
| license-runtime 啟動時機？ | **無啟動閘門**。blueprint 掛載 + 宿主中介層 + 每日 02:00 排程 tick |
| license-runtime 是否 raw SQL 打 iam 的 view？ | **是**。`infra/tenant_admin_notify_query.py:19-37` JOIN `public.v_user_capabilities` + `capabilities` + `users` + `user_roles` + `roles`，並硬編 `c.name = 'tenant.update'`。另有 `license_tenant_resolver.py` 四條 SQL 打 `public.tenants` 且繞 RLS |
| bulletin 對身分名冊／通知的依賴形式？ | **身分名冊**：套件端零依賴；補 nickname 在宿主 `H infra/bulletin/models/bulletin.py:47-100` 用 ORM relationship 直接 join `jedi_iam.User`（**違反主專案自己的「不在 infra 層 JOIN」規範**）。**通知**：`INotifier` port 宣告了但零使用，且宿主的公告 service grep `notif|mail|send` 零命中——「公告→通知」這條線在系統裡不存在 |
| device 與 info-system 合併後資料模型長怎樣？ | **欄位交集只有 `name` / `description` 加上 BaseModel/TenantMixin 給的那些**。device 11 個業務欄位（os/ip/hostname/device_type/manufacture/status/purpose…）與 info-sys 12 個（4 個 OSCAL enum/authorization_boundary/system_owner/is_active…）**零重疊**。schema 不同（public vs compliance）、tenant_id nullable 不同（YES vs NO）、enum 有無不同（0 個 vs 4 個）、owner 模型不同（無 vs int 軟參照+IUserLookup port） |
| 誰在 JOIN 它們？ | device：3 張 compliance 表以真 FK + jedi-survey 以 ORM relationship。info-system：**DB 上零 FK**（橋表 `project_information_systems` 已於 2026-05-23 DROP），只靠 inventory 表的 `ref_id` 軟參照 |
| 主專案哪裡同時用到兩支？ | **兩處，且都是姊妹函式並列形式**：① `H common/util/system_asset_snapshot.py` 整支 197 行（三對姊妹函式 + 檔頭三個並列常數）② `H app/module_frame/service/ssp_import_template_app_service.py:161-162` 同一建構子並列注入兩個 domain service。**更關鍵的是資料層已經合併了**：`oscal.ssp_inventory_items` 與 `compliance.module_frame_inventory_item_defaults` 兩張表以 `asset_type` ∈ {hardware, information_system} 統一承載兩者（`H scripts/sql/2026-06-04-fr032-inventory-asset-type.sql:19-38`） |

### H. 三件與既有文件衝突、值得首腦注意的事實

1. **jedi-issue README 的「58% 死碼」對主專案不成立**：README:15 說 GitLab/GitHub 整合「產品目前沒在用」，但 `H app/feedback/service/issue_service.py` 有 24 處呼叫，其中 `:116` `IssueProviderCode.GITLAB`、`:141` `IssueProviderCode.GITHUB` 明確走那兩條 path。FR-080 已為此開了掃描 arc（commit 2ffdbe7c），本盤點證實該質疑成立。

2. **device / info-system 兩支 README 都有同一個照抄錯誤**：兩支都在「常見坑」寫「刪除是軟刪除（`is_delete=1`），資料列仍在」，但**兩張表都沒有 `is_delete` 欄位**（全包 grep 零命中 + DEV DB 欄位清單確認）。device 走實刪、info-system 走 `is_active` boolean（`deactivate()`）。合併時若照 README 設計會設計錯。

3. **license-runtime 的 `ITenantDirectory` port 被自己套件內的 raw SQL 繞過**：`ports.py:52-56` 宣告「租戶模型是宿主的，走 port」，但 `infra/license_tenant_resolver.py` 四條 raw SQL 直接讀 `public.tenants` 且繞 RLS。同一件事兩條路，且 raw SQL 那條把 jedi-iam 的 path 格式寫死進 license 套件。

---

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

- **未跑任何套件的測試**（唯讀盤點紀律）。README 宣稱的「契約測試 N 條全綠」「守衛測試焊死」我只讀了測試檔名與少量測試碼，未實跑驗證。
- **STG / POC 的 DB schema 未查**（只查 DEV）。device/info-system 的 tenant_id nullable 落差在其他環境是否一致，未驗。
- **jedi-issue 的 GitLab/GitHub path 在 POC/STG 是否真的有流量**未查（我只證明了主專案程式碼會走那條，沒證明實際被觸發過）。系統日誌 / `feedback_issues.gitlab_issue_uid` 有無非空值未查。
- **`agent_tasks` 79 筆全有 detection_tool_id** 是 DEV 的樣本，未驗 POC。
- **bulletin 套件的 `common/enum/code.py` 死碼**我只用 grep 確認套件內零使用，未確認是否有外部 consumer import 它。
- **各套件 harness 未實跑**——「能不能單獨起 process」的結論是從 route 數 + migration + harness 檔存在性推的，不是實測。
- **jedi-issue 的 `issue_upload_file_mapping.file_uid` FK 在 ORM 沒宣告但 DB 有**這個不一致，我沒查是哪支 migration 建的（主專案 `scripts/sql/` grep 未命中，可能是更早期的手動 DDL 或 `create_all()` 時代遺留）。
