FR-093 · 套件 migration 進出貨線+能力點與選單自動補 row — 設計文件 · 2026-09-13(設計定案)
本文是 討論稿 D1–D8 拍板後的定案版:三個階段、每階段拆到 runner 拿到就能開工的粒度,末段的拆分表是開 Notion 卡的依據。只寫定案與理由,不寫討論沿革——沿革見討論稿與 git log。
狀態:設計定案(D1–D8 已拍板,2026-09-13)|前作:FR-089、FR-091(前置三卡 CM-1728~1730)、FR-092|討論稿:
discussion.html
| 日期 | 變更 | 依據 |
|---|---|---|
| 2026-09-13 | 設計定案:D1–D8 全數照討論稿建議拍板;三階段詳細設計+拆分表(3 子需求 10 棒) | 討論稿 v0+決策者拍板 |
| 2026-09-13 | 拆分表補 Notion 卡號(母卡 CM-1752,子卡 CM-1753~1762) | 開卡 runner |
FR-089/FR-091 之後,21 支 jedi-* 套件都有標準形狀,其中兩樣東西跟資料庫有關:migration(套件自帶 SQL,宣告「我需要哪些表」)與能力點(CAPABILITIES 常數,宣告「我的端點用哪些權限名稱守門」)。今天這兩份宣告都沒有任何一條路送進客戶的資料庫:
| 缺口 | 一句話 | 後果 |
|---|---|---|
| ① 套件 migration 從沒被執行 | 11 支套件 24 支 SQL 躺在 wheel 裡;BE image 不帶(build_release.sh:661-669 白名單無 */migrations)、init image 不讀(docker/init/Dockerfile:35 只 COPY scripts/sql/)、manifest 不列(migrate.sh:99-106 只算 manifest 內的檔) |
套件裡改表、修 enum 都默默消失。活例:CM-1678 把 compliance.system_status_enum 收斂成小寫,只寫在套件 002,三環境全沒套 |
| ② 新能力點沒進 DB | 權限判定拿 name 比對(jedi_iam/authz/capability.py:134-160),但 row 沒人寫、角色沒人綁;唯一自動化只在建租戶那一刻(tenant_provisioning_service.py:103-150),之後不回補 |
升級後租戶管理員點新頁面 403,且無錯誤訊息說是缺 row |
| ③ 新頁面沒選單/漏綁權限全員可見 | 選單來自 ui_routes,可見性來自 route_capabilities;沒綁任何 route_capabilities 的 route 視為可見(ui_route_repo_impl.py:77,fail-open) |
漏補一筆是靜默越權,不是報錯 |
同一批套件表另有一份「真正的 DDL」在主專案 scripts/sql/(41 支,已收進 02-schema.sql),兩份已漂移(detection 缺 7 FK、enum 大小寫)。本案把「套件宣告 → 出貨 bundle → 客戶升級 → 資料庫到位」這條鏈補齊,並定下長期規則 D3:套件疆界的表往後只在套件改。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
participant W as 套件 wheel(site-packages)
participant B as build_release.sh/build_init_image.sh
participant I as init image(migrate 模式)
participant D as 客戶 DB
B->>W: 掃 <pkg>/migrations/*.sql + CAPABILITIES 宣告
B->>B: 攤平到 /opt/guidant/sql/packages/<pkg>/ + 產套件子清單 + 宣告對照 _capabilities.sql
B->>B: stamp gate:wheel 檔案集合 = 攤平集合 = 99-stamp 清單,不一致 build fail
B->>I: COPY 整個 /opt/guidant/sql(主線+packages/)
Note over I,D: 客戶執行 install.sh --upgrade
I->>D: SELECT filename FROM schema_migrations
I->>I: 差集①:主 manifest active/*(既有行為)
I->>D: 套主線待套檔(psql --single-transaction)+ INSERT schema_migrations
I->>I: 差集②:packages/<pkg>/NNN-*.sql 依套件名字母序、套件內序號序
I->>D: 套套件待套檔(每支 SQL 冪等)+ INSERT 'packages/<pkg>/NNN-*.sql'
I->>D: upsert capabilities by name(宣告對照 → INSERT ... ON CONFLICT (name) DO UPDATE description/is_platform)
I->>D: 回補 role_capabilities:root Administrator 補齊全部;租戶管理員照基準角色補,NOT is_platform + licensed_modules 扣除
D-->>I: 完成,印出「主線 N 支/套件 M 支/能力點新增 K 筆/回補角色 R 個」
整案一句話:讓套件宣告的「需要哪些表」與「需要哪些權限、哪些選單」在客戶升級時自動到位——出貨線讀套件 migration、升級時補能力點與角色授權、每支新頁面必綁權限。
| 階段 | 做什麼 | 產出 | 完成怎麼判定(決策者檢查法) |
|---|---|---|---|
| FR-093.1 出貨線接套件 migration | build 期把 site-packages 的套件 SQL 攤平進出貨 SQL 目錄並自動產子清單;init image 帶著它;migrate.sh 第二輪差集套套件檔;stamp gate 守「wheel=攤平=stamp 清單」;既有庫一次性補登;CM-1678 enum 修正當首次驗收案 |
build_release.sh/build_init_image.sh/Dockerfile/migrate.sh/assert_db_current.sh/gen_stamp_sql.sh 改動、補登 SQL |
乾淨機新裝:SELECT filename FROM schema_migrations WHERE filename LIKE 'packages/%' 得 24 支;DEV 補登後重跑 migrate.sh 顯示待套 0;故意刪一支攤平檔 → build_init_image.sh fail;DEV \dT+ compliance.system_status_enum 看到小寫值 |
| FR-093.2 能力點自動到位 | 出貨 seed 四段改用名稱反查(不寫死 id);build 期把 21 支套件 80 筆+主專案 36 筆能力點攤成 upsert SQL;migrate 尾端 upsert capabilities、root Administrator 補齊全部、租戶管理員參照角色法回補(扣平台級與未授權模組) |
gen_seed_sql.sh 後處理、scripts/sql/packages/_capabilities.sql(build 產)、回補 SQL、migrate.sh 第三輪 |
用租戶管理員(非 root)登入點新套件頁面不 403;SELECT count(*) FROM role_capabilities WHERE role_id=2 = capabilities 總數;刪一筆 capabilities 再跑升級 row 回來且 id 不同不影響登入;非 root 租戶沒拿到任何 is_platform 能力點 |
| FR-093.3 選單守衛+套件 SQL 冪等核對 | 守衛「有 ui_routes 的套件頁面必有 route_capabilities」;每支有頁面的套件 migration 補 ui_routes+route_capabilities;24 支套件 SQL 逐支對 DEV 實況核對修到冪等;併 jedi 1.1.0 |
守衛測試、24 支套件 SQL 修正、1.1.0 發版 | 故意寫一支只補 ui_routes 不綁權限的 SQL 守衛變紅;24 支 SQL 對 DEV 連跑兩次零錯;1.1.0 裝上 DEV 後新頁面有選單且非管理員看不到 |
依賴一句:.1 是地基(.2/.3 的 SQL 要走 .1 的線出貨);.2 不動套件源碼,可與 .3 平行;.3 動 jedi-*,發版在 .1 驗通之後、等令。
| # | 定案 | 理由 | 被排除方案與原因 |
|---|---|---|---|
| D1 | 靜態併入:build 期把 site-packages 的 <pkg>/migrations/*.sql 攤平進 /opt/guidant/sql/packages/<pkg>/,自動產套件子清單,stamp gate 守「wheel 有的=攤平的=stamp 清單有的」三者一致;init image 照舊 alpine+psql,多 COPY 一個目錄 |
既有 copy_pkg_data_dirs()(build_release.sh:671-683)與 stamp gate(build_init_image.sh:70-83)形狀就是「build 期產物+build 期驗證」,加白名單、擴 gate 即接上;攤平後的 SQL 是出貨包裡看得見的檔案,客戶端出事工程師開包就能對 |
動態掃描(init image 加 Python 層、升級時現場跑 iter_migrations()):init image 從 alpine 變成帶 Python,升級路徑多一個執行環境就多一種現場才炸的失敗 |
| D2 | schema_migrations.filename 記 packages/<pkg>/NNN-xxx.sql——相對路徑即命名空間,不動表結構;排序=主線 manifest 先跑完,再依套件名字母序、套件內序號序;跨套件依賴不做拓撲宣告;配套硬規則:套件 SQL 一律冪等 |
表只有 filename text PK(02-schema.sql:16056-16061),11 支套件全從 001- 起算直接撞;migrate.sh:86 的 grep '\.sql$' 與哨兵過濾對相對路徑照樣過得去;套件之間若真的互相依賴表,那是疆界劃錯,回 D3 處理 |
加 source/version 欄:動出貨基線表結構,且 24 支既有客戶庫都要 ALTER;拓撲依賴解析器:多一層機制,而「主線已把宿主生態建好」的前提已足夠 |
| D3 | 往後套件疆界的表只在套件 migrations/ 改,主專案 scripts/sql/ 不再新增套件表 migration;既有 41 支歷史 migration 保留不動。代價明列:① 上線前既有庫(DEV/STG/POC/已出貨客戶)一次性補登 24 支檔名(converged 手法);② 補登前 24 支逐支核對冪等(階段 3);③ CM-1678 enum 修法用這條線第一次真的套出去,是驗收案 |
不定規則,「兩份會漂移」在接線後變成「兩份都會被套」;41 支歷史檔是已出貨基線的一部分,動它等於改基線 | FR-089 README 短期建議「主專案 scripts/sql 另放一支」:正是加深雙份 |
| D4 | 04-seed-core.sql 的 capabilities/role_capabilities/route_capabilities/ui_routes 四段改 INSERT ... SELECT 以 name 反查 id,在 gen_seed_sql.sh 加特案後處理(比照 labels 段),不改 pg_dump 參數 |
地雷組合:id 寫死+ON CONFLICT 無 target+capability_id 無 FK——D5 自動補 row 先佔走某 id,seed 那筆因 name unique 靜默跳過,role_capabilities (2, 135) 指向錯的能力點,資料庫不擋、沒錯誤、權限就是錯的;附帶效果:seed 不再依賴基線庫的 id 跳號 |
改 pg_dump 參數:--on-conflict-do-nothing 產不出 conflict target(gen_seed_sql.sh:43-46);給 capability_id 加 FK:擋得住孤兒但擋不住「指到錯的 id」 |
| D5 | 在 migrate 模式加一支「宣告對照」步驟:build 期把 21 支套件 CAPABILITIES(80 筆)+主專案 36 筆攤成 scripts/sql/packages/_capabilities.sql,migrate 時 INSERT ... ON CONFLICT (name) DO UPDATE SET description, is_platform;範圍只 upsert capabilities 本體 |
啟動期寫 DB 是多 worker 競態+RLS 身分問題(BE 用 cm_app 受 RLS,系統層寫入 silent fail);migrate 模式本來就是「升級時用 cmmgr 改 DB」的唯一合法時點;宣告對照是 build 期產物,與 D1 同一道工序、stamp gate 一起守 |
BE 啟動期寫 DB(競態+RLS);寫進 seed 檔(seed 只在新裝跑,升級不跑) |
| D6 | ① root Administrator(role 2)維持「持有全部」並自動補齊;② 租戶管理員用參照角色法(凡持有基準能力點 flow_template.create 的角色都補這批新能力點),不採 roles.is_admin=1;③ 回補段必 AND NOT is_platform;④ 比照 _add_tenant_with_defaults 三道扣除(不販售 resource_type/is_platform/licensed_modules 未授權模組);⑤ 只回補新增的能力點,不碰客戶已刪的 |
改 root 成繞過與設計哲學衝突(角色層 is_admin 不該有能力語意,authz/admin.py 檔頭)且 UI 看不見它有什麼;is_admin=1 會誤中客戶自訂的管理角色;不加 NOT is_platform 會被 trg_role_capabilities_platform_guard 對非 root 租戶整批 raise;沒買的模組升級後不該憑空出現權限 |
root 改繞過(見左);用 is_admin 條件(誤中自訂角色);回補全集(越過 licensed_modules) |
| D7 | 本案不擴套件契約(不加 UI_ROUTES 宣告)。「補 ui_routes+綁 route_capabilities」列為每支有頁面的套件 migration 必做項,以 2026-08-29-cm1423-security-policy-page.sql 為樣板(不寫 id、WHERE NOT EXISTS (name=)、pid 用 group-* 的 name 反查);加守衛「有 ui_routes 的套件頁面必有 route_capabilities」 |
ui_routes 有 pid/sort/icon/url,是 FE 版面知識,用 Python 常數表達會把選單樹的一半塞進套件;SQL 已有現成 name-based 寫法;fail-open 要靠守衛擋 |
契約加 UI_ROUTES:留給日後有第二個消費者時再議 |
| D8 | 階段 1、2 純主專案+build 腳本,不動套件源碼,不綁 1.1.0,在 21 支 1.0.x 上直接做;階段 3 的「24 支套件 SQL 冪等」+「每支套件補 ui_routes/route_capabilities」要動套件,併 1.1.0 發版(jedi-package-dev path dependency dev loop,發版等令)。順序:階段 1 先接線並用現有 24 支驗通,套件修正隨 1.1.0 進來時走同一條線第一次真的套 | 套件 migrations/ 已在 wheel、CAPABILITIES 已宣告,讀它們不需改它們 |
全案綁 1.1.0:階段 1、2 被套件發版節奏卡住 |
| 元件 | 現況 | 本案動作 |
|---|---|---|
scripts/sql/manifest.tsv |
四欄 TSV(filename/phase/envs/note),行序即套用順序;只列主專案檔 |
不改格式、不加套件列;套件檔另有子清單(D1/D2) |
scripts/init/migrate.sh |
讀單一 manifest 算差集(:99-106),grep '\.sql$' 過濾(:86),__init_baseline_* 守門(:92-95),逐支 --single-transaction 套並 INSERT schema_migrations(:138-155) |
加第二輪:主線套完後依套件子清單套 packages/<pkg>/;加第三輪:能力點 upsert+角色回補(D2/D5/D6) |
public.schema_migrations |
filename text PK / applied_at / note(02-schema.sql:16056-16061) |
不動表結構;filename 內含相對路徑當命名空間(D2) |
scripts/build/build_release.sh PKG_DATA_DIRS/copy_pkg_data_dirs() |
依白名單從 site-packages 複製套件資料目錄到產物(:661-683),assert_pkg_data_files 斷言代表檔(:686) |
新增一支獨立函式攤平 */migrations 到 sql/packages/<pkg>/ 並產子清單(D1) |
docker/init/Dockerfile |
COPY scripts/sql/ /opt/guidant/sql/(:35),無 Python |
多 COPY build 期產的 packages/ 目錄;不加 Python 層(D1) |
scripts/build/build_init_image.sh stamp gate |
99-stamp.sql 補登清單須與 manifest active/* 逐字一致(:70-83) |
擴到套件段:wheel 內檔案集合=攤平集合=stamp 清單(D1) |
scripts/init/gen_stamp_sql.sh |
把 manifest active/* 補登、標 converged into __init_baseline_v<版>__(:66-70) |
補登清單同步納入套件檔(D3) |
scripts/build/assert_db_current.sh |
前置斷言只比主 manifest(:174-199) |
同步比套件子清單 |
套件 migrations/*.sql(11 支套件 24 支) |
三位數序號檔名、無版號;detection 缺 7 FK;asset 002 的 RENAME VALUE 已用 pg_enum 判斷包冪等 |
階段 3 逐支核對;往後套件表只在這裡改(D3) |
主專案 scripts/sql/ 41 支套件表 migration |
已收進 02-schema.sql;歷史真相 |
保留不動;往後不再新增(D3) |
scripts/init/04-seed-core.sql 四段 |
pg_dump 產、id 寫死、ON CONFLICT DO NOTHING 無 target(L216/L476 起) |
改 INSERT...SELECT name 反查(D4) |
scripts/init/gen_seed_sql.sh |
--inserts --column-inserts --on-conflict-do-nothing;labels 段已是「不 dump、改寫邏輯模板」的特案(LABELS_SECTION,:257-299) |
加四段後處理,比照 labels 特案(D4) |
套件 CAPABILITIES(21 支 80 筆)+主專案 36 筆(CM-1748) |
只是宣告引用(core/plugins/*.py 傳給 Plugin.capabilities),無寫入路徑 |
build 期攤成 _capabilities.sql,migrate 模式 upsert by name(D5) |
_add_tenant_with_defaults(jedi-iam tenant_provisioning_service.py:103-150) |
建租戶時一次性快照,三道扣除(:121-133):TENANT_ADMIN_EXCLUDED_RESOURCE_TYPES(core/plugins/identity.py:89-95)/is_platform/licensed_resource_types() |
不改;回補 SQL 複用同三道扣除(D6) |
trg_role_capabilities_platform_guard |
is_platform=true 只能給 root 租戶的角色,否則 raise |
回補段必 NOT is_platform(D6) |
ui_route_repo_impl.py fail-open(:77) |
無 route_capabilities 的 route 全員可見 | 不改邏輯;加守衛擋「有頁無綁」(D7) |
2026-08-29-cm1423-security-policy-page.sql |
name-based 補 ui_routes+route_capabilities 的現成寫法 | 升格為套件 migration 樣板(D7) |
2026-08-01-fr059-2-detection-profile-capability.sql |
name-based capabilities+參照角色法的現成寫法 | 升格為回補樣板(D6) |
build_release.sh:攤平套件 migration(build 期產物,不入版控)PKG_DATA_DIRS 白名單(它是「複製到 BE 產物目錄」的機制,目標是 BE image;migration 的目標是 init image 的 /opt/guidant/sql/),新增獨立函式 flatten_pkg_migrations <目標目錄>:
sysconfig.get_paths()["purelib"],glob */migrations/*.sql(只掃 jedi_* 前綴目錄,避免撈到第三方套件)。<目標目錄>/packages/<pkg import 名>/<原檔名>(<pkg> 用 import 名如 jedi_asset,與 site-packages 目錄名一致,避免 dist name/import name 兩套)。<目標目錄>/packages/manifest.tsv(套件子清單),格式與主 manifest 同四欄,每列:packages/<pkg>/NNN-xxx.sql<TAB>active<TAB>*<TAB>auto-generated from <dist-name> <version>;行序=套件 import 名字母序、套件內檔名序(LC_ALL=C sort)。scripts/sql/packages/——.gitignore 排除,不入版控、build 期產生。理由:它是 wheel 的鏡像,入版控就變成第三份 DDL;但 build_init_image.sh(跑在 repo 根、COPY context 是 $REPO_ROOT)與 assert_db_current.sh 要讀得到它,所以攤平動作要在 build_init_image.sh 前置段也能單獨觸發(抽成 scripts/build/flatten_pkg_migrations.sh,兩支 build 腳本共用;build_all.sh 只跑一次)。^\d{3}-[a-z0-9-]+\.sql$;不含 INSERT INTO public.schema_migrations(套件 SQL 不自登記,由 migrate.sh 統一登記相對路徑檔名,否則主鍵會登成裸檔名而再撞)。不符即 build fail。同主 manifest 四欄;phase 固定 active、envs 固定 *(套件沒有「原廠內部環境限定」的概念)。migrate.sh 讀它時走同一段解析程式碼(抽成函式 read_manifest <檔> <陣列名>),差別只在 filename 帶 packages/ 前綴。
docker/init/DockerfileCOPY scripts/sql/ /opt/guidant/sql/ 已會把 scripts/sql/packages/ 一併帶入(子目錄);不需新增 COPY 行,但要在 Dockerfile 加一行斷言 RUN test -f /opt/guidant/sql/packages/manifest.tsv——沒先跑攤平就 build image 要當場 fail,不能出一顆「主線正常、套件段空白」的 init image。
migrate.sh 第二輪差集${SQL_DIR}/packages/manifest.tsv(缺檔 → die 1 "出貨包不完整"),算差集 PENDING_PKG,印「套件待套 M 支」,同樣 dry-run/APPLY 兩段。apply_one <相對路徑>),登記值就是 packages/<pkg>/NNN-xxx.sql。:86 的 grep -v '^__' | grep '\.sql$' 對 packages/jedi_asset/002-asset-rls-grants.sql 過得去;grep -qxF 全字比對相對路徑也正確。哨兵 __init_baseline_* 不受影響。schema_migrations 內 packages/% 筆數為 0 且 PENDING_PKG 等於子清單全量,印警告「套件段從未登記過,將全數重放;請確認本版主線已含補登 SQL」——不擋(補登 SQL 在主線先跑,跑完就不會是 0),只是讓人看得到。assert_db_current.shbuild_init_image.sh:現有 diff 改成兩張:主 manifest active/* ↔︎ stamp 內無 packages/ 前綴的列;套件子清單 ↔︎ stamp 內 packages/ 前綴的列。任一不一致 fail,訊息分開印。另加第三道:wheel 內實際檔案集合(重新 glob site-packages)↔︎ 攤平集合,抓「攤平產物 stale」。gen_stamp_sql.sh:補登段多讀套件子清單,同樣標 converged into __init_baseline_v<版>__。assert_db_current.sh:174-199:tmp_expect 併入套件子清單;訊息「落後 N 支」分主線/套件兩個數字。scripts/sql/2026-09-<日>-fr093-1-backfill-package-migrations.sql,走 sql-migration skill 全部規範(檔頭 -- Date:、語句日期註解、收尾 INSERT schema_migrations)。內容:24 列 INSERT INTO public.schema_migrations(filename, note) VALUES ('packages/<pkg>/NNN-xxx.sql', 'FR-093.1 backfill:表已由 02-schema.sql/主線 migration 建立,converged into __init_baseline_v1.19.0__') ON CONFLICT (filename) DO NOTHING。packages/jedi_asset/002-asset-rls-grants.sql 從補登清單排除,讓它成為第二輪唯一待套的檔,真的套進 DEV。99-stamp.sql 由 gen_stamp 產、已含 24 支(含 002),新裝機不會重放。002-asset-rls-grants.sql:120-146 已經是冪等寫法(DO $$ ... IF EXISTS (SELECT 1 FROM pg_enum ...) THEN ALTER TYPE ... RENAME VALUE),在已是小寫的庫上會跳過、不會 enum label already exists。階段 1 不必先改套件,直接用 1.0.1 驗。02-schema.sql:116-133 建出大寫型別 → 第二輪套 002 改成小寫,終態與升級路徑一致(\dT+ compliance.system_status_enum 兩條路都是小寫);(b) RENAME VALUE 後同一交易內不能使用新值——002 檔內 enum 段之後不得有用到新值的 INSERT/UPDATE,核對一次。\dT+ compliance.system_status_enum、\dT+ compliance.security_sensitivity_level_enum 全小寫;SELECT filename FROM schema_migrations WHERE filename LIKE 'packages/%' 得 24 支(23 補登+1 實套)。04-seed-core.sql 四段改 name 反查(gen_seed_sql.sh 後處理)比照 LABELS_SECTION 的做法:這四張表不再 pg_dump(從 dump 表清單移除),改由產生器內建模板從基線庫 SELECT 出資料後產成邏輯 SQL:
| 表 | 產出形狀 |
|---|---|
capabilities |
INSERT INTO capabilities (name, resource_type, action, description, is_platform) VALUES (...) ON CONFLICT (name) DO NOTHING——不帶 id |
ui_routes |
先插 pid=0 的 group-* 節點(WHERE NOT EXISTS (name=)),再插子節點:INSERT ... SELECT gen_random_uuid(), 0, (SELECT id FROM ui_routes WHERE name='group-xxx'), 'name', ... WHERE NOT EXISTS (SELECT 1 FROM ui_routes WHERE name=...)——pid 用父節點 name 反查 |
role_capabilities |
INSERT ... SELECT r.id, c.id FROM roles r, capabilities c WHERE r.name='Administrator' AND r.tenant_id=1 AND c.name IN (...) ON CONFLICT (role_id, capability_id) DO NOTHING——seed 只有 root 租戶角色,用 (tenant_id=1, name) 定位 |
route_capabilities |
WITH r AS (SELECT id FROM ui_routes WHERE name=...) INSERT ... SELECT r.id, c.id, '<requirement>' FROM r, capabilities c WHERE c.name=... ON CONFLICT (route_id, capability_id) DO NOTHING |
setval 段對這四張表照舊(sequence 仍要推進)。gen_seed_sql.sh:333-381 那個 DO $$)加四項筆數斷言:capabilities 筆數=模板筆數、role 2 持有數=capabilities 總數、每筆 route_capabilities 的 route_id/capability_id 都反查得到。ui_routes 子節點的 id 不再寫死,因此 labels 段(D11.5 由 ui_routes 重建)不受影響(它本來就用 name)。scripts/sql/packages/_capabilities.sql(build 期產)flatten_pkg_migrations.sh,多一段)用 $VPY -c 載入 21 支套件的 <pkg>.plugin.CAPABILITIES+主專案宣告(CM-1748 落地後的位置,開卡前確認,見 §8),產:-- 由 flatten_pkg_migrations.sh 自 CAPABILITIES 宣告產生,勿手改
SET LOCAL app.is_super_admin = 't';
INSERT INTO public.capabilities (name, resource_type, action, description, is_platform) VALUES
('device.create', 'device', 'create', 'device create access', FALSE),
...
ON CONFLICT (name) DO UPDATE
SET description = EXCLUDED.description,
is_platform = EXCLUDED.is_platform;_ 開頭是刻意的:它不進套件子清單、不登 schema_migrations(每次升級都要跑,不是一次性 migration)。攤平時的重複 name 檢查:兩支套件宣告同名能力點 → build fail。is_platform 直接取 Capability.is_platform(jedi_common/plugin/capability.py:46-51,預設 False);dataclass 另有 default_roles(預設 Administrator),本案不消費它——授權走 D6 的參照角色法,不由套件決定角色名。scripts/init/migrate-capability-grants.sql(入版控、固定內容)每次升級在 _capabilities.sql 之後執行,兩段:
roles.tenant_id=1 AND name='Administrator' AND is_delete=0(不用 is_admin=1:同一理由,客戶在 root 租戶自訂的管理角色不該被當 Administrator)。INSERT INTO role_capabilities SELECT r.id, c.id FROM roles r CROSS JOIN capabilities c WHERE <定位> ON CONFLICT DO NOTHING——含 is_platform 能力點(root 租戶可持有)。flow_template.create(比照 fr059-2;它是每個租戶 System Manager 建立時必得的租戶級能力點)。凡持有它、且 roles.tenant_id <> 1 的角色,補「該角色尚未持有」的能力點,三道扣除在 SQL 內表達:INSERT INTO public.role_capabilities (role_id, capability_id)
SELECT r.role_id, c.id
FROM (SELECT DISTINCT rc.role_id FROM role_capabilities rc
JOIN capabilities b ON b.id = rc.capability_id
JOIN roles ro ON ro.id = rc.role_id
WHERE b.name = 'flow_template.create' AND ro.tenant_id <> 1 AND ro.is_delete = 0) r
CROSS JOIN public.capabilities c
WHERE NOT c.is_platform -- 扣除②
AND c.resource_type NOT IN ('cruise-project','dashboard','workflow','resource','report') -- 扣除①
AND c.resource_type = ANY (:licensed_resource_types) -- 扣除③
ON CONFLICT (role_id, capability_id) DO NOTHING;TENANT_ADMIN_EXCLUDED_RESOURCE_TYPES(core/plugins/identity.py:89-95)的五個值由攤平腳本從 Python 常數讀出後注入,不在 SQL 手抄(兩份會漂移)。config.tenant_licenses 的解析快取)算出來的集合,且 provisioning 時「無照=不扣」(ProvisioningLicensedModules,identity.py:275-303)。定案:階段 2 的回補 SQL 只做扣除①②,不做扣除③,改由 request-time 執法軸⑥ @require_license 擋——這與 provisioning 的 A 方案同一哲學(授予寬鬆、判定嚴格):多給的能力點在無照模組上打不動任何端點,照上傳後自動對齊。手冊註明「升級後角色可能持有未授權模組的能力點,實際可用性由授權決定」。若決策者要求 SQL 層也扣,開卡前先查 tenant_licenses 解析快取欄位能否直接 jsonb 取模組清單(§8)。migrate.sh 第三輪、所有 migration(主線+套件)套完之後——避免 D4 的 id 地雷(seed 尚未 name-based 的舊庫上,先 upsert 再跑 seed 會佔 id)。RAISE NOTICE 印「能力點新增 K 筆/root 補 X 筆/租戶角色回補 R 個角色 Y 筆」,migrate.sh 原樣轉印。migrate.sh 第三輪APPLY=1 時在第二輪之後:psql --single-transaction -f packages/_capabilities.sql → -f init/migrate-capability-grants.sql;dry-run 只印「將 upsert N 筆能力點宣告」(N=檔內 VALUES 列數)。兩支都不登記 schema_migrations。
test/test_package_ui_routes_have_route_capabilities.py~/Projects/Jedicogy/module/jedi-python-package/jedi-*/*/migrations/*.sql(本機源碼,CI 以 site-packages 為準:兩者擇一由環境變數決定)+主專案 scripts/sql/*.sql。INSERT INTO public.ui_routes 語句抽出 name= 字面值(正則抓 WHERE NOT EXISTS (SELECT 1 FROM public.ui_routes WHERE name = '<x>') 或 VALUES 內第 4 欄),同一檔內必須有 route_capabilities INSERT 引用同一 name(FROM public.ui_routes WHERE name = '<x>')。group-* 節點(pid=0)豁免。migrate-capability-grants.sql 末段):SELECT count(*) FROM ui_routes u WHERE u.pid<>0 AND NOT EXISTS (SELECT 1 FROM route_capabilities WHERE route_id=u.id) 大於基線白名單數即 RAISE WARNING(不 EXCEPTION:既有基線本來就有 fail-open 的頁,先列出不擋升級;白名單由開卡棒盤點 DEV 現況產出)。scripts/sql/2026-08-29-cm1423-security-policy-page.sql 的 ③④ 段;pid 改成 (SELECT id FROM public.ui_routes WHERE name = 'group-system-config') 反查,不寫 47。jedi_asset/003-asset-ui-routes.sql),不改既有 001/002。pg_dump 本機 DEV 後 restore 到臨時庫)連跑兩次,第一次可容忍「表已存在」類錯誤並記錄,第二次零錯才過;同時 pg_dump --schema-only 比對套件 SQL 建出的結構 vs DEV 實況(inventory/batch-D.md:836 記 detection 缺 7 條 FK 是已知差)。CREATE TABLE/INDEX/TYPE ... IF NOT EXISTS、DO $$ BEGIN ... EXCEPTION WHEN duplicate_object THEN NULL END $$、ALTER TABLE ... ADD COLUMN IF NOT EXISTS、FK 用 DO $$ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname=...)、資料用 ON CONFLICT/WHERE NOT EXISTS。migrations/ 內的修正 commit(jedi repo)+一份對照表(套件/檔/差異/修法)進 docs/features/FR-093-.../inventory/package-sql-idempotency.md。走 jedi-package-dev:主專案 pyproject 切 path dependency 開發、驗通後還原 pin;發版等令;1.1.0 wheel 裝上後重跑階段 1 攤平+stamp gate,第二輪差集應只剩新增的 003 檔。
每列=一張 Notion 子卡。「動哪些檔」是 runner 開工座標;「驗收條件」是決策者複測清單。
| 棒 | 做什麼 | 動哪些檔 | 驗收條件 | 依賴 |
|---|---|---|---|---|
| .1a 攤平腳本+子清單(CM-1753) | 新增 scripts/build/flatten_pkg_migrations.sh(掃 site-packages jedi_*/migrations/*.sql → scripts/sql/packages/<pkg>/+packages/manifest.tsv+靜態檢查);.gitignore 排除 scripts/sql/packages/;build_release.sh/build_init_image.sh/build_all.sh 接上 |
scripts/build/flatten_pkg_migrations.sh(新)、build_release.sh、build_init_image.sh、build_all.sh、.gitignore、scripts/build/README.md |
本機跑後 scripts/sql/packages/ 有 11 目錄 24 檔+manifest.tsv 24 列、字母序;故意放一支含 INSERT INTO public.schema_migrations 的 SQL 進某套件 migrations → 腳本 fail |
無 |
| .1b migrate.sh 第二輪+Dockerfile+三支斷言腳本(CM-1754) | migrate.sh 抽 read_manifest/apply_one,加第二輪差集與客戶庫警告;Dockerfile 加 test -f 斷言;build_init_image.sh stamp gate 三張 diff;gen_stamp_sql.sh 補登納套件;assert_db_current.sh 併子清單 |
scripts/init/migrate.sh、docker/init/Dockerfile、scripts/build/build_init_image.sh、scripts/init/gen_stamp_sql.sh、scripts/build/assert_db_current.sh |
對本機 DEV 快照庫 dry-run 印「主線待套 0/套件待套 24」;故意刪一支攤平檔 → build_init_image.sh fail 且訊息指出檔名;沒跑攤平直接 build init image → Dockerfile 斷言 fail |
.1a |
| .1c 既有庫補登 SQL+DEV 套用(CM-1755) | 寫 scripts/sql/2026-09-<日>-fr093-1-backfill-package-migrations.sql(23 支,排除 asset 002)、登 manifest;套 DEV;重生 99-stamp.sql(含 24 支) |
scripts/sql/2026-09-*-fr093-1-backfill-*.sql(新)、scripts/sql/manifest.tsv、scripts/init/99-stamp.sql(產物) |
DEV SELECT count(*) FROM schema_migrations WHERE filename LIKE 'packages/%'=23;migrate.sh dry-run 顯示套件待套 1(asset 002);stamp gate 通過 |
.1b |
| .1d CM-1678 驗收案:真套 asset 002+新裝路徑(CM-1756) | 對 DEV MIGRATE_APPLY=1 跑第二輪套 002;建 init image 在乾淨臨時庫 init → 驗新裝終態;核對 002 enum 段後無用到新值的語句 |
不改檔(驗收棒);產出驗收紀錄進 handoff/fr093-LOG.md |
DEV 與新裝庫 \dT+ compliance.system_status_enum、security_sensitivity_level_enum 全小寫;兩庫 packages/% 都是 24 筆;DEV 重跑 migrate.sh 待套 0 |
.1c |
| 棒 | 做什麼 | 動哪些檔 | 驗收條件 | 依賴 |
|---|---|---|---|---|
| .2a seed 四段 name-based(CM-1757) | gen_seed_sql.sh 從 dump 清單移除四張表,加邏輯模板產生;驗證段加四項斷言;重生 04-seed-core.sql |
scripts/init/gen_seed_sql.sh、scripts/init/04-seed-core.sql(產物)、scripts/init/README.md |
產出的 04 檔四段 grep 不到 INSERT INTO public.capabilities (id/VALUES (2, 這類寫死 id;乾淨庫 init 後 role 2 持有數=capabilities 總數、route_capabilities 孤兒 0;連 init 兩次第二次零錯 |
無(可與 .1 平行) |
| .2b 能力點宣告攤平(CM-1758) | flatten_pkg_migrations.sh 加段:載入 21 支 CAPABILITIES+主專案宣告 → scripts/sql/packages/_capabilities.sql(同名衝突 fail);讀 TENANT_ADMIN_EXCLUDED_RESOURCE_TYPES 注入回補 SQL 的扣除① |
scripts/build/flatten_pkg_migrations.sh、scripts/init/migrate-capability-grants.sql(新,模板) |
_capabilities.sql 116 筆(80+36;以 CM-1748 落地後實數為準);故意在某套件宣告一筆同名 → fail;扣除①五個值與 identity.py:89-95 一致 |
.1a、CM-1748 |
| .2c migrate 第三輪+回補+DEV 驗(CM-1759) | migrate.sh 第三輪接 _capabilities.sql→migrate-capability-grants.sql;DEV 套用;用租戶管理員實測 |
scripts/init/migrate.sh、scripts/init/migrate-capability-grants.sql |
租戶管理員登入點新套件頁面不 403;role_id=2 持有數=capabilities 總數;SELECT count(*) FROM role_capabilities rc JOIN capabilities c ON c.id=rc.capability_id JOIN roles r ON r.id=rc.role_id WHERE c.is_platform AND r.tenant_id<>1=0;扣除①的 resource_type 沒被回補到租戶角色;刪一筆 capabilities 重跑升級 row 回來、id 不同、登入權限不變;migrate 輸出印出 K/X/R/Y 四個數 |
.1b、.2a、.2b |
| 棒 | 做什麼 | 動哪些檔 | 驗收條件 | 依賴 |
|---|---|---|---|---|
| .3a 守衛測試+fail-open 白名單(CM-1760) | 寫 test_package_ui_routes_have_route_capabilities.py;盤點 DEV 現況 fail-open 的 ui_routes 產白名單;migrate-capability-grants.sql 末段加 WARNING 斷言 |
test/test_package_ui_routes_have_route_capabilities.py(新)、scripts/init/migrate-capability-grants.sql、docs/features/FR-093-.../inventory/fail-open-routes-baseline.md(新) |
突變:刪 cm1423 ④ 段跑測試紅、還原綠;DEV 跑 migrate 印出白名單外 fail-open 路由 0 筆 | .2c |
| .3b 24 支套件 SQL 冪等核對+修正(CM-1761) | DEV 快照臨時庫連跑兩次;schema diff;以 DEV 為準修 24 支(含 detection 7 FK);產對照表 | jedi repo 11 支套件 migrations/*.sql(path dependency)、docs/features/FR-093-.../inventory/package-sql-idempotency.md(新) |
24 支對臨時庫第二次零錯;pg_dump --schema-only 套件建出的結構 ⊆ DEV 實況(無多餘、無缺 FK);ddd-compliance-reviewer 掃 diff 無施工日誌型註解 |
.1d |
| **.3c 套件補 ui_routes/route_capabilities+發 1.1.0(CM-1762) | 盤點有頁面的套件;每支開 NNN-<pkg>-ui-routes.sql(cm1423 樣板、pid name 反查);守衛綠;path dependency 驗通 → 還原 pin → 發版等令** → 1.1.0 裝 DEV 重跑攤平+升級 |
jedi repo 各套件 migrations/+pyproject.toml 版號、主專案 pyproject.toml pin |
1.1.0 裝 DEV 後 migrate.sh 第二輪只多出新 ui-routes 檔;新頁面選單可見、非管理員看不到;stamp gate 因 wheel 變動 fail → 重生 stamp 後過(把這段寫進 scripts/build/README.md 與 version-bump skill) |
.3a、.3b |
共 10 棒(.1 四棒、.2 三棒、.3 三棒)。平行關係:.2a 可與 .1 全程平行;.2b 等 .1a;.3b 等 .1d;.3c 收尾。
全案收口時在一台乾淨機器依序做,任一步不過即退回對應階段:
install.sh 新裝 → SELECT filename FROM schema_migrations WHERE filename LIKE 'packages/%' 得 24 筆(1.1.0 後含新 ui-routes 檔);\dT+ compliance.system_status_enum 小寫。install.sh --upgrade 到本版 → 舊資料完整、packages/% 24 筆、enum 小寫且既有列值跟著變。route_capabilities 孤兒 0。schema_migrations 含 packages/ 前綴 24 筆(與 1 同查詢,升級路徑再驗一次),且重跑 migrate.sh 待套 0、能力點 upsert 0 新增、回補 0 筆(冪等)。pytest test/test_package_ui_routes_have_route_capabilities.py 綠;build_init_image.sh stamp gate 三張 diff 全空。| 項 | 要查什麼 | 歸哪一棒查 | 查到後怎麼用 |
|---|---|---|---|
10 支無 migrations/ 套件的表歸屬 |
ai-bot/ai-dashboard/common/compliance-audit/flow-engine/iam/issue/notification/oscal-v2/survey 是真的沒有表,還是表在主專案 41 支裡 | .3b(做 schema diff 時順手,同一套工具) | 若在主專案 41 支裡:D3 生效後該套件第一次改表要開 migrations/,寫進 docs/claude/jedi-packages.md 提醒;不影響本案任何棒 |
| 各套件基準能力點候選 | D6 定案基準能力點為 flow_template.create(單一基準、參照角色法);要驗 DEV 每個租戶的 System Manager 都持有它(tenant_provisioning 全集扣三道後必含,除非 workflow 被扣——已核 flow_template.create 的 resource_type 是 flow_template(04-seed-core.sql:307),不在扣除①的五個值內,新租戶會持有) |
.2b(寫回補 SQL 前) | DEV 實查每個非 root 租戶至少一個角色持有它;若有租戶零角色持有 → 該租戶不會被回補,.2b 卡要列出並由決策者裁是否補選第二基準(smtp-config.update) |
RENAME VALUE 冪等 |
已核 wheel 1.0.1 的 002 已用 pg_enum 判斷包冪等;剩下要查:002 檔內 enum 段之後有無用到新值的語句(PG 限制:同交易內不能用剛 RENAME 的值) |
.1d | 有 → 拆成兩支檔(003);無 → 直接驗收 |
| CM-1748 主專案 36 筆能力點的落地位置 | 宣告會放哪個模組/常數名(本文假設是一個可 import 的 tuple) | .2b | 攤平腳本的 import 路徑 |
| licensed_modules 在 SQL 層可否表達 | config.tenant_licenses 解析快取欄位能否直接取模組清單 |
.2b(只查不做;本案定案是不在 SQL 扣,靠軸⑥執法) | 決策者若改判要 SQL 層扣,才據此加扣除③ |
| FE 路由 ↔︎ 套件對照(哪些套件有頁面) | 21 支套件哪些有自己的 FE 頁面、掛哪個 group-* |
.3c | 決定要開幾支 ui-routes 檔 |
既有客戶升級失敗面:差集算成 24 支全待套
已出貨客戶庫的表都由 02-schema.sql 建好了,但 schema_migrations 沒有任何 packages/% 列。新機制上線那一版若沒先補登,migrate.sh 會把 24 支全算成待套並逐支重放——CREATE TABLE 撞表名、CREATE TYPE 撞型別名,--single-transaction 一炸整段回滾,升級卡死。
對策:① 補登 SQL 登在主線 manifest,排在套件差集之前執行(.1c);② 24 支全部冪等化是硬前置(.3b),補登只是保險不是替代;③ 出包前用「帶舊資料升級」驗(§7 第 2 步);④ migrate.sh 第二輪前的「從未登記過」警告讓人看得到。
自動擴權的產品面:新版一裝,租戶管理員自動多出權限
D6 的回補等於「升級=擴權」。對客戶是好事(新功能裝上就能用)也是風險(客戶可能刻意收掉某些權限,升級後又長回來)。
對策:① 只回補新增的能力點,不碰既有 role_capabilities(客戶刪掉的不會長回來——因為 ON CONFLICT DO NOTHING 只補不存在的列,而客戶刪掉的列在 upsert 後仍不存在……注意:這句只對「客戶刪掉某能力點的授權」成立;若客戶刪的是能力點 row 本身,upsert 會補回 row,回補會再授予——手冊要註明);② 扣除①②保證不給平台級與不販售的 resource_type;③ 未授權模組由軸⑥執法擋;④ migrate 輸出明列回補了哪些角色、哪幾筆;⑤ 「升級時不回補」的開關本案不做,客戶提出再議。
🔴 ALTER TYPE ... RENAME VALUE 在有資料的庫上的行為
它直接改型別定義、既有資料列跟著變(不需 rewrite 表),這是好消息;壞消息:① 目標值已存在時炸——wheel 1.0.1 的 002 已用 pg_enum 判斷包住,但往後任何新的 RENAME VALUE 都要照這寫法(列入 .3b 核對規則);② RENAME VALUE 不能在同一交易內接著使用新值——--single-transaction 下同一支 SQL 後段若 INSERT 新值會炸(.1d 核對);③ 出貨基線 02-schema.sql:116-133 仍是大小寫混雜——新裝走 02 建出舊型別、再由 002 改成小寫,新裝與升級兩條路要收斂到同一終態(.1d 親眼驗)。
stamp gate 變嚴後,套件發版與 BE 出包的耦合
擴大後的 gate 要求「wheel 裡的 migration 檔案集合=攤平集合=stamp 清單」。任何一支套件發新版帶新 SQL,BE 出包就要重跑 gen_stamp 才過 gate。這是刻意的(generate-then-commit 產物天生漂移,只驗烙印會出貨壞包),.3c 把它寫進 scripts/build/README.md 與 version-bump skill,否則第一次撞到的人會以為是 build 壞了。
能力點 upsert 的 DO UPDATE 會覆蓋客戶改過的 description
D5 的 upsert 每次升級都把 description/is_platform 蓋成宣告值。description 只是顯示文字,客戶不該改它(無 UI);is_platform 被蓋回宣告值是刻意的(它是守門語意,不該由資料庫側漂移)。但若某環境曾用 SQL 手動把某能力點 is_platform 改掉以下放(cm1423/CM-1283 那類「租戶級系統設定頁」的下放是靠宣告 FALSE,不是事後改),升級後會被蓋回——.2b 攤平前先 diff DEV capabilities 表與宣告,有差即列出讓決策者裁。