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

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

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

§1

核對方法

三個臨時庫,都在本機、跑完即刪:

庫 怎麼來的 用來回答
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 後重建」取得對等基準。

§2

冪等:兩輪零錯

輪次 對象 結果
第一輪 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 乾淨。

§3

差異逐項判定

① 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 張表都是標準四權。

§4

套件對既有庫的實際影響(最重要的一節)

「連跑兩次不炸」不等於「什麼都沒做」。拿兩份相同的 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 下次重產時會把套件措辭收進基線,屬預期行為。

§5

§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。

§6

出貨路徑一致性

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__* 皆無)

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

§7

清理

四個臨時庫(guidant_ai_dev_snap/guidant_ai_pkgonly/guidant_ai_before/guidant_ai_after) 驗畢即刪。DEV 本身全程唯讀,未執行任何寫入。188/STG/POC 未連線。