# 套件隨包 migration：冪等與基線一致性對照表

11 支 jedi-* 套件共 24 支 `migrations/*.sql`，逐支對本機 DEV（`localhost:5432/guidant_ai_dev`）
實況核對的結果。核對基準是 **DEV 現況**，不是 `scripts/init/02-schema.sql`。

**結論：24 支全部通過，未修改任何一支。** 差異共七類，逐類判定後都屬「套件正確」或
「DEV 端歷史殘留」，無一需要改套件 SQL。

## 核對方法

三個臨時庫，都在本機、跑完即刪：

| 庫 | 怎麼來的 | 用來回答 |
|---|---|---|
| `guidant_ai_dev_snap` | DEV `pg_dump --schema-only` restore | 對既有庫連跑兩次會不會炸（冪等） |
| 同上，DROP 36 張套件表後重跑 | 同上 + 只用套件 SQL 重建 | 套件建出來的結構與 DEV 差在哪 |
| `guidant_ai_before` / `guidant_ai_after` | 兩份相同的 DEV 複本，後者跑過 24 支 | 套件 SQL 對既有庫**實際改了什麼** |

36 張套件疆界表由 24 支 SQL 的 `CREATE TABLE` 目標反推。空庫直跑會失敗 6 支
（缺 `app_tenant_allowed_for_session()` 與 `compliance.projects`），那是宿主前置依賴、
不是套件缺陷，故改用上表的「DEV 複本 DROP 後重建」取得對等基準。

## 冪等：兩輪零錯

| 輪次 | 對象 | 結果 |
|---|---|---|
| 第一輪 | DEV 複本（表已存在） | 24/24 OK |
| 第二輪 | 同一庫再跑一次 | 24/24 OK |
| 重建輪 | DROP 36 張後只用套件 SQL 建 | 24/24 OK |

psql 的 `NOTICE: relation ... already exists, skipping` 不是錯誤，是 `IF NOT EXISTS`
正常跳過時的訊息；判定只看 `ERROR:`。

**突變測試**：把 `jedi-file-upload/001` 的 `CREATE TABLE IF NOT EXISTS public.upload_files`
改成裸 `CREATE TABLE`，第二輪立刻紅（`ERROR: relation "upload_files" already exists`），
證明這組斷言真的在驗冪等而非空跑。驗畢已還原，`git status` 乾淨。

## 差異逐項判定

### ① api_logs 是普通表 vs DEV 的 RANGE 分割表 — 刻意，不補

DEV 的 `public.api_logs` 按 `act_time` 切月分割（5 個分割區），主鍵 `(id, act_time)`；
套件建的是普通表，主鍵 `(id)`。

判定**套件正確**。`001-api-log-tables.sql` 檔頭已明文說明：分割是維運決定不是資料模型，
取決於 consumer 的量體與保留政策；ORM model 宣告的也是普通表，SQLAlchemy 對「底下是不是
分割表」無感。主專案量大所以分割，小型 consumer 不需要。對主專案全程 no-op。

### ② detection 7 條疆界內 FK — 已補齊，卡片的「已知差」是舊資訊

派工卡引用的 `batch-D.md:836` 記載 detection 隨包 migration「0 處 REFERENCES、缺 7 條 FK」。
實查**已經補上了**：`001-detection-tables.sql:449-473` 七條俱全，DEV 與套件建出來的庫
各查到同樣 7 條。補的是 `a35e09d fix(CM-1628)`，時間晚於 batch-D.md 的盤點。

本棒無事可做。此處記錄以免下一棒再照舊文件找一次。

### ③ 約束名不同（upload_files / remote_agents） — DEV 是歷史名，不追改

| 表 | DEV | 套件 |
|---|---|---|
| `public.upload_files` | `pk_upload_files` / `upload_files_uid_unique` | `upload_files_pkey` / `upload_files_uid_key` |
| `compliance.remote_agents` | `file_agents_pkey` / `file_agents_uid_key` | `remote_agents_pkey` / `remote_agents_uid_key` |

判定**兩邊都可接受，不改**。約束語意完全相同（同樣的 PRIMARY KEY / UNIQUE、同樣的欄位），
只有名字不同。`file_agents_*` 是 `remote_agents` 改名前的殘留，DEV 與 `02-schema.sql`
都還帶著它。

不改的理由：全 codebase grep 不到任何一處引用這些約束名（沒有 `ON CONFLICT ON CONSTRAINT`、
沒有 `DROP CONSTRAINT`），名字不構成契約；而套件在既有庫上 `IF NOT EXISTS` 判斷是看
**約束是否存在**，既有庫已有 PK 就整段跳過，不會多建一個。改名反而要寫 rename 邏輯、
在客戶庫上動既有約束，風險大於收益。

### ④ bulletins 的 uid UNIQUE — 套件會真的建，且是對的

DEV 的 `public.bulletins` **沒有** uid 唯一約束，套件 `002-bulletin-rls-grants.sql:74` 會加
`bulletins_uid_key UNIQUE (uid)`。這是本次唯一「套件在既有庫上真的動了結構」的一處
（見下節「套件對既有庫的實際影響」）。

判定**套件正確，保留**。uid 是 UUID 識別碼，本來就該唯一；DEV 現有 3 筆資料
uid 全不重複（`count(*)=3, count(distinct uid)=3, null=0`），加約束不會失敗。
DEV 缺這條才是漏的。

### ⑤ tenant_licenses / tenant_license_events 的 org_units FK 與部分唯一索引 — 套件刻意不帶

DEV 有、套件沒有：
- `tenant_licenses_org_unit_id_fkey` / `tenant_license_events_org_unit_id_fkey` → `public.org_units(id)`
- `idx_tenant_licenses_license_id_current`（`license_id WHERE is_current = true`）

判定**套件正確，不補**。

FK 指向 `public.org_units`，那是 **jedi-iam 的表**，不在 jedi-license-runtime 的疆界內。
套件不能假設 consumer 一定裝了 jedi-iam——`001-license-tables.sql` 檔頭明寫
`tenant_id`/`org_unit_id` 在單租戶宿主的 ORM 根本不會被帶上（`TenantScopedMixinModel`
只在 `ENABLE_MULTI_TENANT=true` 時才產生這兩欄）。硬加跨套件 FK 會讓只裝 license-runtime
的 consumer 直接裝不起來。這與 FR-080 D17 軟參照同一原則。

部分唯一索引來自 CM-1580（跨租戶重複用照的 TOCTOU 防護），是**主專案的業務規則**——
D6 replace 制下「同一 license_id 可有多列歷史、同時只能一列現行」是 Guidant AI 的產品決定，
不是 license 資料模型的普遍約束。它已在主專案 `scripts/sql/2026-09-06-cm1580-*.sql`
且標 `envs=*` 隨出貨走，客戶庫拿得到，不需要套件再帶一份。

### ⑥ CHECK 約束的 ARRAY 寫法 — 純渲染差異，零實質

四條 CHECK（`chk_lfs_protocol`、`chk_lfs_transport`、`ck_tenant_license_events_type`、
`ck_tenant_licenses_status`、`ck_integrity_tamper_events_detected_by`）在 DEV 渲染成
`ANY (ARRAY[('a')::text, ('b')::text])`，套件建出來渲染成 `ANY ((ARRAY['a','b'])::text[])`。

判定**完全等價，不動**。同一段 SQL 文字，PostgreSQL 因建立時的型別推導路徑不同而
反序列化成兩種寫法，約束行為一模一樣。

### ⑦ GRANT：DEV 給 ALL PRIVILEGES、套件給四權 — DEV 是歷史殘留，套件對

7 張表（`information_systems`、`task_assignees` 與 5 張 participants）在 DEV 上
`cm_app` 拿到 `DELETE,INSERT,REFERENCES,SELECT,TRIGGER,TRUNCATE,UPDATE`，套件只給
`SELECT, INSERT, UPDATE, DELETE`。

判定**套件正確，不追 DEV**。關鍵證據是出貨基線本身：`scripts/init/03-grants.sql`
（客戶裝機實際跑的那支）只給四權，且 `02-schema.sql` 是用
`pg_dump --no-privileges` 產的、完全不帶權限資訊。也就是**客戶庫從來就只有四權**，
套件與出貨基線一致，DEV 才是那個不一樣的。

DEV 上有 ALL 的共 19 張表，其中 4 張（`job_evidences`、`drive_sync_jobs` 等）
根本不屬任何套件——足證這是 DEV 早年手動 `GRANT ALL` 的殘留，不是規範。
其餘 154 張表都是標準四權。

## 套件對既有庫的實際影響（最重要的一節）

「連跑兩次不炸」不等於「什麼都沒做」。拿兩份相同的 DEV 複本、一份跑過 24 支，
`pg_dump` 對比，套件 SQL 在既有庫上**確實會改東西**：

1. **加一條 UNIQUE 約束**：`bulletins_uid_key`（見 ④，正確且必要）。
2. **改寫約 40 條 DB COMMENT**：套件版本的說明覆蓋掉 DEV 版本。

COMMENT 的改寫方向值得注意——套件版**去掉了 Guidant 內部追蹤編號**，改成通用描述：

| DEV（主專案版） | 套件版 |
|---|---|
| `FR-056.3 Agent 掃描派工單 + 狀態機` | `Agent 派工單 + 狀態機` |
| `FR-039 客戶端 agent registry` | `遠端代理 registry` |
| `soft-ref → 對應稽核任務，FR-056.4 才填` | `soft-ref → 宿主的執行紀錄` |

判定**這是對的方向，不擋**。套件要能給任何 consumer 用，DB COMMENT 裡帶
`FR-056.3` / `CM-952` 這種 Guidant 內部編號會隨 migration 落進別人的資料庫
（batch-D.md 第 6 點已點名此事）。套件版去編號化正是在修它。

且有幾條套件版的資訊**更新更準**，例如 `agent_tasks.status` 套件版列了
`cancelled`（DEV 版漏）、`information_systems.system_status` 套件版列全五個值
（DEV 版只列三個）。

另有 3 條 COMMENT 是**純新增**（DEV 的 `api_logs` 分割表上沒有表/欄位註解，套件補上）。

**這件事要讓首腦知道**：D3「往後套件表只在套件改」生效後，客戶升級跑到這 24 支時，
資料庫裡的表說明會從「主專案措辭」換成「套件措辭」。這不影響任何功能（COMMENT 純文件），
但 `02-schema.sql` 下次重產時會把套件措辭收進基線，屬預期行為。

## §8 附帶查證：10 支無 migrations 的套件，表在哪

design §8 問：`ai-bot`／`ai-dashboard`／`common`／`compliance-audit`／`flow-engine`／
`iam`／`issue`／`notification`／`oscal-v2`／`survey` 這 10 支沒有 `migrations/`，
是真的沒有表，還是表在主專案 41 支歷史 migration 裡？

**答案：三種情況都有。**

| 套件 | 有無 ORM 表宣告 | 表在哪 | D3 生效後第一次改表要開 `migrations/`？ |
|---|---|---|---|
| ai-bot | 無（0 個 model 檔） | 無表，狀態存 Redis KV | 不需要 |
| ai-dashboard | 無 | 無表，完全無狀態 | 不需要 |
| notification | 無 | 無表 | 不需要 |
| common | 有（`system_logs`） | 主專案 41 支內，DEV/02-schema 皆有 | **要** |
| compliance-audit | 有（9 張，`poams`／`project_audit_rounds` 等） | 同上 | **要** |
| flow-engine | 有（5 張，`workflow_executions` 等） | 同上 | **要** |
| iam | 有（19 個 model 檔，`capabilities`／`org_units` 等） | 同上 | **要** |
| issue | 有（`labels`／`issue_assignee_mapping` 等） | 同上 | **要** |
| oscal-v2 | 有（45 張，`ap_tasks` 等） | 同上 | **要** |
| survey | 有（15 個 model 檔，`survey_pages` 等） | 同上 | **要** |

逐表實查 DEV 與 `02-schema.sql` 都在（抽驗 `system_logs`／`poams`／`project_audit_rounds`／
`workflow_executions`／`capabilities`／`org_units`／`labels`／`survey_pages`／`ap_tasks` 九張，
兩邊皆命中）。

**待辦（等令，屬收尾類）**：把「7 支有表無 `migrations/` 的套件，D3 生效後第一次改表
要先開 `migrations/` 目錄」寫進 `docs/claude/jedi-packages.md`。

## 出貨路徑一致性

BE 的 `scripts/sql/packages/` 是 build 期由 `flatten_pkg_migrations.sh` 從
**site-packages** 攤平的，不是從源碼。實查三邊一致：

- 源碼 24 支 vs `scripts/sql/packages/` 24 支：逐檔 `diff` 全同
- 源碼 24 支 vs `.venv/lib/python3.11/site-packages/jedi_*/migrations/`：24/24 全同
- venv 無 editable 殘留（`jedi_*.pth` / `__editable__*` 皆無）

亦即本棒核對的源碼內容，與出貨會帶出去的內容是同一份。

## 清理

四個臨時庫（`guidant_ai_dev_snap`／`guidant_ai_pkgonly`／`guidant_ai_before`／`guidant_ai_after`）
驗畢即刪。DEV 本身全程唯讀，未執行任何寫入。188／STG／POC 未連線。
