FR-069 第二階段 — 全庫 ext 延伸表清點與收斂分類

狀態:盤點完成,待驗收|建立日期:2026-08-30|對應卡:CM-1447(FR-069.10) 母案:CM-1435 FR-069|上游依據:design.md §7 第二階段 性質:純盤點文件,不含任何程式或 migration 異動。 收斂實作見 FR-069.12(CM-1449)。


0. 三十秒版結論

  • 真正的 ext 形態側表:全庫 9 張(190 張表中)。掃描涵蓋主專案+26 支 jedi 套件+DEV live DB。
  • 分類結果:轉正式欄位 1 張、留置 6 張、待決策 2 張(附兩面理由,不硬分)。
  • JSONB 容器收回:0 張——這是本次盤點最重要的發現。沒有任何一張表符合「純展示型欄位」,因此 design.md §7 預設的「純展示型 → 收回主表+JSONB 容器」路徑,在現況下沒有適用對象。詳見 §5。
  • auth/user 優先批totp_secrets(唯一 1:1 側表)+一項附帶發現的死欄位 users.topt_secret。其餘 auth 側表經查證皆非 ext 形態(是時效性關聯表或事件流水),不列入收斂。
  • 附帶發現三則(不屬本卡範圍,另列 §6):assessment_plan_extensions 193 列全孤兒疑似 v1 遺留、users.topt_secret 死欄位、compliance.projects RLS 已停用但 policy 尚存。

1. 掃描方法(涵蓋性自證,可重跑)

「找 ext 表」不能只靠名字——命名為 *_extensions 的只有 2 張,但 ext 形態(一對一側掛、存附加欄位)的表未必這樣命名。因此採四個互相獨立的偵測面交叉,任一面命中即進候選池,再逐張人工判形態。

面①:DB 結構偵測——帶唯一性的 FK(1:1 的硬證據)

一張表若對主表的 FK 欄位同時具備 unique 約束或 unique index,該關係在 DB 層即被強制為 1:1。這是最可靠的一面。

WITH fk AS (
  SELECT con.oid, con.conname,
         ns.nspname||'.'||cl.relname AS child,
         fns.nspname||'.'||fcl.relname AS parent,
         con.conrelid, con.conkey
  FROM pg_constraint con
  JOIN pg_class cl ON cl.oid=con.conrelid
  JOIN pg_namespace ns ON ns.oid=cl.relnamespace
  JOIN pg_class fcl ON fcl.oid=con.confrelid
  JOIN pg_namespace fns ON fns.oid=fcl.relnamespace
  WHERE con.contype='f' AND ns.nspname NOT IN ('pg_catalog','information_schema')
),
uq AS (
  SELECT conrelid, conkey FROM pg_constraint WHERE contype IN ('u','p')
  UNION
  SELECT indrelid, indkey::int2[] FROM pg_index WHERE indisunique
)
SELECT DISTINCT fk.child, fk.parent,
  (SELECT string_agg(a.attname, ',' ORDER BY a.attnum)
     FROM pg_attribute a WHERE a.attrelid=fk.conrelid AND a.attnum = ANY(fk.conkey)) AS fkcols
FROM fk JOIN uq ON uq.conrelid=fk.conrelid
     AND uq.conkey @> fk.conkey AND uq.conkey <@ fk.conkey
ORDER BY 1;

結果 8 張(2026-08-30 DEV 實查):compliance.project_extensionsconfig.detection_profile_versionsoscal.ap_reviewed_controlsoscal.ssp_control_implementationsoscal.ssp_system_characteristicsoscal.ssp_system_implementationspublic.user_org_unitspublic.user_tenants

面②:放寬版——單欄 unique index 且欄名為 *_id(涵蓋未宣告 FK 者)

跨 schema 的側表常刻意不宣告 FK(如 assessment_plan_extensions),面①會漏掉,故補這一面。

SELECT ns.nspname||'.'||cl.relname AS tbl, a.attname
FROM pg_index i
JOIN pg_class cl ON cl.oid=i.indrelid
JOIN pg_namespace ns ON ns.oid=cl.relnamespace
JOIN pg_attribute a ON a.attrelid=i.indrelid AND a.attnum=i.indkey[0]
WHERE i.indisunique AND array_length(i.indkey::int2[],1)=1
  AND ns.nspname NOT IN ('pg_catalog','information_schema')
  AND a.attname LIKE '%\_id' AND a.attname NOT IN ('id')
ORDER BY 1;

結果 15 張——面①的 8 張,加上 compliance.assessment_plan_extensionscompliance.evidence_classification_runscompliance.job_evidencesconfig.job_execution_detection_toolsconfig.log_forwarding_settingspublic.drive_folder_mappingspublic.tenant_drive_integrations

面③:命名偵測(ORM 與 DB 雙查)

# 主專案 ORM
grep -rhn "__tablename__" --include="*.py" . | grep -viE "test" \
  | sed -E 's/.*__tablename__[ ]*=[ ]*//' | tr -d "\"'" | sort -u
# jedi 套件 ORM(26 支)
cd ~/Projects/Jedicogy/module/jedi-python-package && \
  grep -rn "__tablename__" --include="*.py" . | grep -viE "/test"
-- DB 側命名偵測
SELECT n.nspname||'.'||c.relname, c.reltuples::bigint
FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace
WHERE c.relkind='r'
  AND c.relname ~ '(ext|extension|extend|profile|meta|attr|extra|detail)'
  AND n.nspname NOT IN ('pg_catalog','information_schema') ORDER BY 1;

結果:命名含 ext 語意者僅 project_extensionsassessment_plan_extensions 兩張;*_trans(i18n 翻譯側表)6 張另計,見 §4 留置理由。

面④:極小表+ORM 1:1 宣告(撿漏)

欄位數 ≤ 6 且含 *_id(純掛載型特徵)掃出 35 張,逐張看形態;另掃 SQLAlchemy uselist=False 的 relationship 宣告(21 處),確認全部是 many-to-one 的查閱關聯(如 ProjectParticipant.user_info),非 1:1 側表,此面零新增候選。

SELECT t.table_schema||'.'||t.table_name,
 (SELECT count(*) FROM information_schema.columns c
   WHERE c.table_schema=t.table_schema AND c.table_name=t.table_name) ncols
FROM information_schema.tables t
WHERE t.table_type='BASE TABLE' AND t.table_schema NOT IN ('pg_catalog','information_schema')
AND (SELECT count(*) FROM information_schema.columns c
      WHERE c.table_schema=t.table_schema AND c.table_name=t.table_name) <= 6
ORDER BY 1;

掃描母數與環境

項目 數字
DEV live DB 實體表總數 190(compliance 46/config 13/oscal 58/public 58/survey 15)
主專案 ORM __tablename__ 宣告 66
jedi 套件 ORM model 檔 148 檔(26 支套件,oscal-v2 45/oscal 40 為大宗)
候選池(四面聯集去重) 22 張
判定為 ext 形態 9 張

環境:192.168.50.188:25432 / guidant_ai_dev,帳號 cmmgr全程唯讀(僅 SELECT\d)。

判準(形態,非名字)

一張表列入 ext 清單,須同時滿足:

  1. 一對一側掛:對主表恰好 0..1 列(DB 唯一性強制,或實際資料證實且語意如此);
  2. 存附加欄位:其存在目的是「替主表多記幾個值」,而非表達一段獨立的實體或關係。

明確排除(依卡片「不要為了交差硬塞」的要求):多對多關聯表、1:N 明細表、事件流水/歷史表、i18n 翻譯表(見 §4)。


2. 判定結果總表

排序依卡片要求:auth/user 優先批在前,其餘按遷移風險低→高。

# 所屬 列數 分類 一句話理由
A1 public.totp_secrets jedi-mfa 2 留置 憑證隔離——TOTP 種子屬機密,與 users 分表是安全設計非老做法
A2 public.user_tenants jedi-auth 39 留置 非 ext 形態(時效性關聯表,帶 starts_at/ends_at,語意支援多對多)
A3 public.user_org_units jedi-auth 35 留置 同上
B1 compliance.project_extensions 主專案 211 轉主表正式欄位 四欄全帶業務邏輯(軟刪除過濾/JOIN 導航/權限判定),無一純展示
B2 compliance.assessment_plan_extensions 主專案 193 待決策 193 列全為孤兒、程式 live-wired——是清死表還是修資料,超出本卡權責
C1 oscal.ssp_system_implementations jedi-oscal-v2 留置 OSCAL 規格節點,且被 4 張子表當父表引用
C2 oscal.ssp_control_implementations jedi-oscal-v2 留置 同上(被 ssp_implemented_requirements 引用)
C3 oscal.ap_reviewed_controls jedi-oscal-v2 留置 OSCAL AP 規格節點,收回會破壞 OSCAL 序列化對應
C4 oscal.ssp_system_characteristics jedi-oscal-v2 待決策 唯一「1:1 但無 unique 約束」者,且 35 欄超厚——形態判定兩面成立

分類統計:轉正式欄位 1、留置 6、待決策 2、JSONB 容器 0


3. 逐張詳析(含風險評估與讀寫端)

A1. public.totp_secrets — 留置(auth/user 優先批)

項目 內容
ORM jedi-mfa/jedi_mfa/infra/totp/models/totp_secret.py
欄位 id, user_id, secret, uri(4 欄)
1:1 證據 2 列 / 2 distinct user_id;語意上一使用者一組 TOTP 種子
列數(DEV) 2(相對 users 39 列,覆蓋率極低——僅 2 人啟用 TOTP)
FK 進出 無宣告 FKuser_id 裸欄位,連 index 都沒有)
寫入端 jedi-mfa/.../infra/totp/repository/totp_secret_repo_impl.py:25 add()只有 add,無 update/delete);呼叫點 domain/service/totp_secret_domain_service.py:33
讀取端 totp_secret_repo_impl.py:15 get_by_user_idtotp_secret_domain_service.py:19,25,40,50infra/totp/adapter/totp_adapter.py:14(驗 OTP 時讀)
主專案側 零直接讀寫——僅 api/auth/routes/otp_route.py:33,72,98,120(4 端點)注入 domain service,DI 在 di_containers/auth/auth_containers.py:156
RLS 否(relrowsecurity=f,0 policy)

分類理由(留置):TOTP 種子是認證機密,與 users 主表分離是刻意的安全邊界——即使收回主表,也不該和 nickname、job_title 躺在同一列被一般查詢帶出。這符合卡片「③確有獨立理由」。

但有一個真問題(附帶發現,見 §6.2):public.users 另有一個 topt_secret 欄位(拼字為 topttotp),DB 39 列全為 NULL、全 codebase 零引用——是同一能力的第二份殘骸。這是死欄位,非 ext 表問題。

風險評估:留置=零遷移風險。若日後仍要動,注意它沒有 FK,刪 user 不會連帶清 secret(既有孤兒風險,非本次引入)。


A2 / A3. public.user_tenantspublic.user_org_units — 留置(非 ext 形態

項目 user_tenants user_org_units
ORM jedi-auth/.../models/user_tenant.py jedi-auth/.../models/user_org_unit.py
欄位 user_id, tenant_id, starts_at, ends_at, is_default, created_at user_id, org_unit_id, starts_at, ends_at, is_primary, created_at
PK 複合 (user_id, tenant_id) 複合 (user_id, org_unit_id)
「1:1」的來源 部分唯一索引 uq_user_tenants_default UNIQUE (user_id) WHERE is_default uq_user_org_primary UNIQUE (user_id) WHERE is_primary
列數(DEV) 39(每人恰 1 筆) 35(每人恰 1 筆)
FK → users(id) CASCADE、→ tenants(id) CASCADE → users(id) CASCADE、→ org_units(id) CASCADE
RLS (已知安全債,CM-1445) (同)
寫入端 jedi-auth/.../user_tenant_repo_impl.py:21 add / :31 delete / :37 delete_by_user_id / :42 delete_by_tenant_id;呼叫點 app/service/user_service.py:160(建帳號)、:306,311(改帳號先刪後建) user_org_unit_repo_impl.py:21/31/37/42user_service.py:187,314,320
讀取端 repo :47,51;relationship models/user.py:59models/tenant.py:49(association_proxy) repo :47,51org_unit_repo_impl.py:102-103(刪組織單位前的 func.count 守門,唯一跨 repo 讀取點);relationship models/user.py:65org_unit.py:59
主專案側 零直接讀寫——DI di_containers/auth/auth_containers.py:146 + serializer api/auth/serializers/user.py:49app/setup/service/setup_wizard_service.py:249 呼叫 add_user() 連帶寫入 同左(DI :151、serializer :54)

為何被面①掃出又判定排除:面①命中的是那個帶 WHERE 的部分唯一索引——它約束的是「每人最多一個預設租戶」,不是「每人最多一個租戶」。複合 PK 明確表達多對多,starts_at/ends_at 表達成員資格的時效區間。這是時效性關聯表,不是「替 users 多記幾個值」的側表。

DEV 目前每人只有 1 筆,是資料現況(單租戶部署),不是結構約束——若據此收回主表,等於把多租戶能力寫死,屬破壞性變更。

分類理由(留置):不符 ext 形態判準第 2 條,本次不列入收斂。

⚠️ 交接註記:這兩張表與 CM-1445(RLS 覆蓋缺口)重疊。本卡結論是「結構上不動」,與 CM-1445「該補 RLS」不衝突、可並行——但 FR-069.12 若誤把它們當收斂對象,會與 CM-1445 撞車。


B1. compliance.project_extensions — 轉主表正式欄位(首要收斂對象

2026-08-30 進度(CM-1449 實作棒):五步搬遷已走到第 4 步——四欄已加進主表 compliance.projects(jedi-project 套件側,含兩支索引)、DEV 211 列 1:1 回填對帳通過、 雙寫期已上、14 處 raw SQL JOIN + ORM 讀取端全部切主表(grep 殘留 0)。舊表與舊寫入 路徑刻意保留作安全網,並已標 deprecated(DB COMMENT + model docstring)。 第 5 步 drop 屬另一棒,等決策者發令。守衛:test/test_module_boundaries.py 新增 兩支斷言(禁止新的 ext JOIN/主表須帶齊四欄)。以下內容為盤點當下的實況記錄,不追改。

項目 內容
ORM infra/flow_control/model/flow_control_project_extension.py
主表 compliance.projects(jedi-project 套件所有,13 欄)
欄位 project_id(uq FK)、module_frame_idliving_ssp_idowner_iddeleted_at + 5 審計欄
列數(DEV) 211 / 211——與 projects 完全 1:1,零缺列
欄位填充率 module_frame_id 211/211、owner_id 211/211、deleted_at 204/211、living_ssp_id 27/211
FK 進出 進:→ compliance.projects(id) ON DELETE CASCADE。出:無(living_ssp_id/owner_id/module_frame_id 皆為 soft-ref 無 FK
RLS 否(主表 projects 亦已停用 RLS——見 §6.3)

這張表為何存在:主表 projectsjedi-project 套件,GRC 專屬欄位無處可放,故側掛一張——這正是 FR-069 要消滅的模式的教科書案例

分類理由(轉正式欄位,非 JSONB):四欄逐一驗證,沒有任何一欄是「存了就是顯示」

欄位 程式引用數 用途 為何不能進 JSONB
deleted_at 14 處 軟刪除過濾,出現在幾乎每一條專案查詢的 WHERE 是查詢述詞,需索引(現有 ix_project_ext_deleted_at
living_ssp_id 12 處 FR-038 B2 專案 OSCAL 唯一錨點,是 → ssp → profile → catalog 導航鏈起點 是 JOIN 鍵,需索引(現有 idx_project_extensions_living_ssp_id
owner_id 2 處 專案負責人,用於儀表板權限判定flow_control_dashboard_repo_impl.py:108 參與授權決策
module_frame_id 0 處直接引用,但經 mapper 活用 flow_control_project_mapper.py:24,76 → entity → ssp_import_template_app_service.py:370-477 反查來源 MF 是關聯鍵,非展示值

module_frame_id 值得特別說明:直接 grep pe.module_frame_id 為 0,容易誤判為死欄位——但它經由 mapper 進入 entity 後被消費(SSP 匯入範本反查來源資源庫)。這是「grep 名字救不了」的典型,故本清單以行為而非名字為準。

讀寫端清單(主專案,jedi 套件零消費):

寫入端

  • infra/flow_control/repository/project_extension_repo_impl.py — 唯一 CRUD 入口(BaseRepositoryImpl 標準路徑)
  • infra/flow_control/repository/flow_control_project_repo_impl.py:708 — 軟刪除(設 deleted_at
  • infra/flow_control/repository/flow_control_project_repo_impl.py:68 — 注意:FlowControlProjectRepoImplmodel=ProjectExtension,即主專案的專案 repo 是掛在 ext 表上的

讀取端(ORM)

  • flow_control_project_repo_impl.py(:190-194 等多處 outerjoin + deleted_at 過濾)
  • flow_control_dashboard_repo_impl.py(:97-108 outerjoin + owner_id 權限)

讀取端(raw SQL JOIN compliance.project_extensions共 14 處 / 9 檔——精確計數指令見附錄)

  • infra/flow_control/repository/flow_control_project_repo_impl.py(3 處:137, 586, 658)
  • infra/flow_control/repository/flow_control_task_setup_repo_impl.py(3 處:267, 278, 292)
  • infra/flow_control/repository/flow_control_job_repo_impl.py(2 處:92, 675)
  • infra/flow_control/repository/flow_control_dashboard_repo_impl.py:186
  • infra/flow_control/repository/flow_control_review_repo_impl.py:59
  • infra/flow_control/repository/task_execution_query.py:246
  • infra/flow_control/repository/auditor_dashboard_query.py:42
  • infra/detection_tools/repository/detection_job_notify_query.py:43
  • infra/detection_tools/repository/detection_profile_usage_query.py:85

應用層讀取(經 domain service,非直接觸 model)

  • app/flow_control/service/project_service.py(:277, 425, 433, 614, 661)
  • app/flow_control/service/audit_round_app_service.py(:291, 389, 769)
  • app/project/service/project_current_ssp_service.py:42
  • app/project/service/project_start_app_service.py:368(建專案時建 ext 列)

遷移風險評估:中高

  • 風險來源不是資料,是 raw SQL 密度——211 列資料搬遷本身是小事(1:1 全覆蓋、無缺列、可一支 UPDATE...FROM 完成),但14 處 raw SQL JOIN 字串散在 9 個檔、跨 2 個模組(flow_control/detection_tools)必須逐一改寫,schema 名全為硬編字串。raw SQL 沒有型別檢查,改漏了不會報錯、只會靜默少過濾軟刪除(=已刪專案重新出現在列表)。這是全清單中 raw SQL 面最寬的一張。
  • 跨套件邊界projects 主表在 jedi-project,加欄位需動套件(依 CLAUDE.md 外部套件異動規範,要先提醒 user 決策、走 path dependency dev loop、feature 完成才發版)。
  • 「只加不破」搬遷路徑建議(供 FR-069.12 參考,非本卡決議):
    1. jedi-project 的 projects加四個 nullable 欄位(不刪 ext 表);
    2. 資料回填 UPDATE compliance.projects p SET ... FROM compliance.project_extensions e WHERE e.project_id=p.id
    3. 雙寫期——ext repo 寫入時同步寫主表(帶舊資料升級的安全網);
    4. 讀取端逐處改為讀主表欄位,每改一處驗一次軟刪除過濾仍生效
    5. 全部切換且觀察期過後,才 drop ext 表。
    • 步驟 1-2 滿足「只加不破」;帶舊資料升級的驗證點是升級後既有 211 專案的軟刪除狀態與 living SSP 導航都不變
  • 注意FlowControlProjectRepoImpl 本身 model=ProjectExtension,收斂時這個 repo 的模型基準要換,牽動範圍比單純改 JOIN 大。

B2. compliance.assessment_plan_extensions待決策

項目 內容
ORM infra/flow_control/model/assessment_plan_extension.py
宣稱主表 oscal.assessment_plans(jedi-oscal-v2 所有)
欄位 assessment_plan_id(uq)、workflow_execution_uidflow_template_snapshot_uid + 審計欄
列數(DEV) 193
孤兒率 193 / 193 = 100%——無任何一列的 assessment_plan_id 能在 oscal.assessment_plans 找到對應
ID 區間佐證 ext 側 assessment_plan_id 落在 49–277oscal.assessment_plans.id 落在 749–922——兩區間完全不相交
時間佐證 ext 最新 created_at = 2026-06-10;oscal.ap 最新 = 2026-07-23(ext 已 2.5 個月無新資料)
但是 193 列中 44 列的 workflow_execution_uid 能命中 compliance.workflow_executions.uid
FK 無宣告 FK(model docstring 寫「Cross-schema FK ... ON DELETE CASCADE」,但 DB 實際沒有建——文件與實況不符)
RLS

讀寫端infra/flow_control/repository/assessment_plan_extension_repo_impl.py(完整 CRUD:get_by_ap_idbatch_getupsert 等)——程式是 live-wired 的,不是註解掉的死碼。另有非程式引用:scripts/init/02-schema.sql(schema dump)、docs/system-design/database/scripts/*.py(文件產生器)——這些不算消費端。

為何標「待決策」而非直接分類——兩面都成立,且答案決定完全不同的動作:

A 面:這是 v1 遺留死表,應直接退役

  • 100% 孤兒 + ID 區間不相交 + 兩個半月無新資料,強烈指向「AP 資料在某次 v1→v2 遷移中重建,ext 表沒跟著搬」。
  • model docstring 自承是「Spec 2 階段」「暫用 hardcode default」的過渡設計。
  • 若為真,正確動作是退役整張表(含 repo/entity/mapper),而非收斂欄位——這比 ext 收斂更省事。

B 面:這是活功能的資料斷鏈,應修復

  • repo 是 live-wired 的完整 CRUD,44 列 workflow_execution_uid 仍能命中現存 workflow_executions——表示這些資料曾經是有意義的
  • 若某條路徑仍在寫入(DEV 資料靜止不代表 STG/POC 靜止),退役會炸掉該功能。
  • 若為真,正確動作是先查清誰在呼叫那個 repo、資料為何斷鏈,修好再談收斂。

判定所需、但超出本卡權責的資訊:①該 repo 的實際呼叫端是否仍在 live 路徑上(需追呼叫鏈,屬程式分析非表盤點);②STG/POC 的同表資料是否也全孤兒(唯讀查即可,但跨環境結論屬另案);③v1→v2 AP 遷移當時的決策紀錄。

建議開獨立 case 查證,不要塞進 FR-069.12 的收斂實作棒——它的答案可能是「刪表」而非「收斂」,兩者工序完全不同。在查清前,FR-069.12 應跳過此表

遷移風險評估:暫不評估(分類未定)。但註記一點:若走退役路線,風險反而——資料全是孤兒,刪除不影響任何現存 AP。


C 組共同前提:OSCAL 表有 v1 / v2 兩份 model 並存

在逐張討論前,一項對四張 OSCAL 表都成立的結構事實:

jedi-oscal(v1)與 jedi-oscal-v2 各有一份 __tablename__ 指向同一張實體表ap_reviewed_controls 例外,僅 v2 有)。主專案已 100% 走 v2——無任何 from jedi_oscal.infra...ssp_* 的 import,app/oscal/service/ssp_versioning_service.py:28-30 明文記載「FR-038 2A: jedi_oscal ORM model imports removed」,該 service 現為空存根。

含意:v1 那組 model 是可清的遺留,但不屬本卡範圍——它是套件退役問題(design.md §7 已列「jedi-oscal v1 vs v2 取代狀態」為疆界盤點案 FR-069.9 的退役查證項),不是 ext 表收斂問題。此處記錄供 FR-069.9 參考。

另兩項搬遷時容易漏的耦合(適用 C 組,來自讀寫端掃描):

  1. 主專案有 5 處繞過 DI 直接 new v2 RepoImpl(搬套件時 import path 會斷,且 DI container 掃不到):
    • app/flow_control/service/assessment_plan_app_service.py:83self._reviewed_repo = ApReviewedControlsRepoImpl()
    • app/flow_control/service/assessment_result_app_service.py:89 — 同上
    • app/oscal/service/ssp_control_implementation_service.py:315-320函式內 late import + ApReviewedControlsRepoImpl().get_by_ap()
    • app/project/service/project_start_app_service.py:193self._ssp_ctrl_impl_repo.add(...)(主專案唯一一處對 OSCAL 表的 ORM 寫入)
  2. ssp_control_implementations 是唯一主專案與套件雙邊都直接讀寫的 OSCAL 表——主專案 1 處 ORM 寫、4 處 raw SQL JOIN(flow_control_job_repo_impl.py:682project_start_app_service.py:256,257,276,277)、1 處 DELETE ... USINGresource_library_app_service.py:415-419,本表作條件非刪本表)。

以上皆為留置表的耦合現況記錄,不構成收斂要求。


C1–C3. OSCAL 規格節點三張 — 留置

oscal.ssp_system_implementationsoscal.ssp_control_implementationsoscal.ap_reviewed_controls(皆屬 jedi-oscal-v2)。

三張結構上確為 1:1 側表(各有 <parent>_id 的 UNIQUE CONSTRAINT + FK CASCADE),但不屬 ext 形態

  • 它們是 OSCAL 標準規格的結構節點,不是「替主表多記幾個值」。OSCAL 的 SSP 定義中,system-implementationcontrol-implementation 本就是獨立的具名區塊,DB 結構是在忠實映射規格。收回主表會破壞 OSCAL 匯入/匯出的序列化對應。
  • 其中兩張本身就是父表
    • ssp_system_implementations 被 4 張表引用(ssp_componentsssp_inventory_itemsssp_leveraged_authorizationsssp_system_users,皆 FK CASCADE)
    • ssp_control_implementationsssp_implemented_requirements 引用
    • 一張表若被別人當父表 FK 引用,它就不是可被吸收的側表——收回主表會使那些 FK 無所指向。
  • 三張各自已內建 JSONB 欄位(props/links/set_parameters/control_selections),OSCAL 的擴充機制本來就是 props/links——這正是 design.md 期望的「彈性容器」模式,只是它早已存在且是規格定義的。

分類理由(留置):符合卡片「③確有獨立理由」。理由記錄於此,供第三/四階段 OSCAL 疆界案(FR-069.9 候選 4)參考。

讀寫端摘要(三張皆以 jedi-oscal-v2 為擁有者,repo 皆只有一支 get_by_* 讀方法,寫入繼承 BaseRepositoryImpl):

套件側寫入 主專案側
ssp_system_implementations ssp_clone_service.py:93oscal_io_service.py:206 零直接讀寫(僅經 SspService facade)
ssp_control_implementations ssp_clone_service.py:98oscal_io_service.py:208ap_draft_service.py:79 1 處 ORM 寫 + 4 處 raw SQL JOIN + 1 處 DELETE...USING(見上)
ap_reviewed_controls assessment_plan_service.py:87,139,141ap_draft_service.py:143 5 處讀,全繞 DI(見上)

C4. oscal.ssp_system_characteristics待決策

項目 內容
ORM jedi-oscal-v2/.../infra/model/ssp/(同族)
主表 oscal.system_security_plans
欄位數 35(含 12 個 JSONB 欄位)
1:1 證據 面①命中(ssp_id 有唯一性) \d 顯示其索引段未見 unique constraint on ssp_id——與 C1/C2 明確有 ..._ssp_id_key 不同
寫入端 套件:ssp_clone_service.py:90oscal_io_service.py:205主專案 2 支 app service(經 SspService facade,未直接觸 repo):app/oscal/service/ssp_system_characteristic_app_service.py:53-66app/module_frame/service/module_frame_system_characteristic_service.py:66-86
讀取端 套件:ssp_service.py:84。主專案 5 處(皆 _ssp_service.get_system_characteristics()):project_service.py:320ssp_system_characteristic_app_service.py:47,57ssp_import_template_app_service.py:522module_frame_system_characteristic_service.py:60,69
跨 schema FK infra/associations/model/project_system_characteristic.py:44 — 主專案的表 ForeignKey("oscal.ssp_system_characteristics.id")是主專案唯一直接指向 OSCAL 表的 ORM 依賴
子表 FK v2 oscal_ssp_diagram.py:37oscal_ssp_information_type.py:37

為何標「待決策」

判為「留置」的理由:與 C1–C3 同族同源,都是 OSCAL SSP 規格節點(system-characteristics 是 OSCAL 標準區塊),且已內建 props/links 擴充機制。若按族群一致性,應留置。

判為「需進一步檢視」的理由:它與同族三張有一個結構差異——唯一性約束的表達方式不一致(C1/C2 有明確的 ssp_id_key UNIQUE CONSTRAINT,本張沒有)。這可能是①刻意(規格允許多筆?)②疏漏(漏建約束=資料可能出現重複的 system-characteristics)。若是②,那是一個資料完整性缺口,性質上比 ext 收斂更該優先處理。另外 35 欄的厚度在同族中也是異數。

判定所需資訊:OSCAL 規格中 SSP : system-characteristics 是否嚴格 1:1(若是,缺 unique 約束即為缺口);DEV 實際是否已有 SSP 帶多筆 characteristics。

額外注意:它是四張 OSCAL 表中主專案耦合最深的一張——2 支 app service 讀寫 + 1 個跨 schema FK 指向它。即使結論是留置,第四階段 OSCAL 疆界拆解(P13)動到它時,這個跨 schema FK 是必須先處理的邊界。

建議:形態分類上傾向留置(與同族一致),但唯一性約束的差異應另案查證——這不影響 FR-069.12 開工(無論如何都不收斂它)。


4. 明確排除的表(附理由,供後人覆核)

以下被四面掃描命中、但經判定不是 ext 形態,記錄理由避免後續重複爭論:

命中面 排除理由
*_trans 六張(module_frames_transworkflow_templates_transsurvey_folders_transsurvey_pages_transsurvey_questions_transsurveys_trans i18n 翻譯表,1:N(每語系一列)非 1:1。且屬 jedi-common 既有 translatable-fields 機制(failover query),是刻意設計不是老做法
config.detection_profile_versions ①② 版本表,1:N。「每 profile 至多一 current」是部分唯一索引(uq_dpv_profile_current WHERE is_current)造成的誤命中;DEV 現況每 profile 1 版是資料巧合
public.tenant_drive_integrations 每租戶一組 Drive 整合設定,但是獨立的整合實體(含 OAuth token、webhook 狀態、同步游標),非「替 tenants 多記幾個值」。且已有自己的 RLS 4 policy。⚠️ 見下方註記
config.log_forwarding_settings 同上——設定實體,且支援 global(tenant_id IS NULL)與 per-tenant 兩種 scope,形態非側掛。屬 jedi-log-forwarding 且完全 self-contained(連 API route 都在套件內,主專案零資料存取,已有 test/test_module_boundaries.py:186 邊界守衛)。另注意其 schema 名非寫死__table_args__ = {"schema": SETTINGS_SCHEMA},預設 config.log_forwarding_settings 定義在 common/settings.py:32,宿主可用環境變數覆寫
oscal.metadata 299 列 = 各 OSCAL 文件型別列數總和(ssp 86+catalog 81+profile 77+ap 30+ar 16+poam 9),確為 1:1;但它被 10 張表共用引用(roles/parties/resources/catalogs/profiles/...),是共享的規格節點非單一主表的側掛
oscal.ssp_reference_documents 同一 context 最多 13 列,1:N(程序書池)
compliance.project_summary_reports 同一 project 最多 36 列,1:N(報告歷史)
compliance.evidence_classification_runscompliance.job_evidencesconfig.job_execution_detection_toolspublic.drive_folder_mappings 唯一鍵是外部系統識別碼run_folder_iddrive_file_iddrive_folder_id)或執行識別,非對主表的 1:1 掛載
public.user_auth_providerspublic.user_change_password_requestpublic.user_change_pwd_logspublic.login_logs auth 族但皆 1:N:user_auth_providers 一人多 provider(DEV 0 列)、change_password_request 20 列/7 人、change_pwd_logs 為歷史流水、login_logs 為事件流水(DEV 0 列)
各類 *_mapping*_participantsrole_capabilities 多對多關聯表,卡片已明示不算

兩則排除表的搬遷註記(雖不收斂,但後續動到它們時容易踩):

  • tenant_drive_integrations 有兩支容器外裸連線 SQLscripts/evidence/classify/classify_evidence_drive.py:161scripts/evidence/classify/docker/container_entrypoint.py:134:140 有註解自承「裸連線、不走 session_scope 避 RLS」)。這兩處不在任何 repo/ORM 掃描範圍內,任何改名或搬 schema 都會漏。此表另有 8 支 app service 經 domain service 消費,是本次候選池中扇出最廣的一張。
  • user_change_password_request 有跨套件 readerjedi-login/.../domain/service/login_domain_service.py 讀 jedi-auth 的表(登入時判斷是否強制改密碼)。另 app/setup/service/setup_wizard_service.py:181-205 會刻意清掉 add_user() 自動建的 FIRST_REGISTER 列——是主專案對此表的行為依賴。兩點都與 2.5 階段 jedi-iam 合併相關(該階段正是要把 auth/login 併起來),記於此供參。

5. 對 design.md §7 的一項回饋:JSONB 容器路徑在現況下無適用對象

design.md §7 第二階段設定的分類是「純展示型 → JSONB 容器;帶業務邏輯 → 正式欄位;有理由 → 留置」,並預期 JSONB 容器(profile_extras 型)是主要收斂手段。

盤點結果是:9 張 ext 形態表中,0 張屬純展示型。

原因可以歸納為一句話——這個系統的側掛表不是「客製欄位」長出來的,是「跨套件邊界」長出來的

  • project_extensions 之所以存在,不是因為某客戶要多記幾個值,而是因為 projects 主表屬 jedi-project 套件、GRC 專屬欄位無處可放。它掛的每一欄都是產品核心邏輯(軟刪除、OSCAL 導航錨點、權限),自然不會是純展示。
  • OSCAL 那幾張是規格映射,擴充機制早已是規格自帶的 props/links。
  • auth 那批根本不是側掛表。

這對 FR-069.12 的含意:第二階段的實作重點應從「建 JSONB 容器來吸收客製欄位」,調整為「把跨套件邊界造成的側掛欄位收回主表正式欄位」。JSONB 容器機制(以及 FR-069.11 要做的 JSONB 查詢層能力)仍有價值——它是「未來新客製一律走容器、不再開側表」的預防性基礎設施,而非用來消化存量。

這不是推翻 design.md 的判斷(判斷口訣本身正確且好用),而是存量與口訣預期的分佈不同。是否要據此調整 §7 的措辭與 FR-069.12 的卡片內容,屬 user 決策,本卡不擅自改 design.md。


6. 附帶發現(不屬本卡範圍,建議另案)

6.1 assessment_plan_extensions 193 列全孤兒

見 §3 B2。建議開獨立 case 查證是 v1 遺留死表還是活功能斷鏈。在查清前 FR-069.12 應跳過此表。

6.2 public.users.topt_secret 是死欄位

  • DB:39 列全為 NULL
  • 程式:全 codebase(主專案+26 支 jedi 套件)零引用——grep -rn "topt_secret" --include="*.py" 只命中 jedi-mfa 的參數名topt_secret_domain_service),非欄位存取。
  • 同能力的實際實作在 public.totp_secrets 表(jedi-mfa)。
  • 拼字亦有誤(topt 應為 totp)。

殭屍欄位,性質同 design.md D9 順手清理項③的 roles.is_admin。建議併入 2.5 階段 jedi-iam 合併時一起清(該階段本就要動 users 與 mfa)。

6.3 compliance.projects RLS 已停用但 4 條 policy 尚存

  • pg_class.relrowsecurity = f未啟用),但 pg_policies 仍有 projects_select/insert/update/delete 四條,內容如 app_tenant_allowed_for_session(tenant_id)
  • 對照:public.usersrelrowsecurity = t(真正啟用)。
  • 基線庫 guidant_ai 實查結果相同(projects = f),故非 DEV 環境獨有,是出貨基線的狀態。
  • 意義:policy 存在會讓人誤以為受保護,實際上未生效——任何直查 compliance.projects 的程式都無租戶隔離。

性質與 CM-1445(user_roles/user_tenants/user_org_units 無 RLS)同類,建議併入 CM-1445 一起追蹤。⚠️ 本項僅為唯讀觀察,未做任何變更;是否為刻意設計(例如靠應用層過濾)需 user 判斷。


7. 給 FR-069.12(CM-1449)的開工建議

順位 對象 動作 備註
1 compliance.project_extensions 轉主表正式欄位(四欄) 唯一明確的收斂對象;需動 jedi-project 套件,依規範先請示 user
assessment_plan_extensions 跳過 等 §6.1 獨立 case 結論
ssp_system_characteristics 跳過 等 §3 C4 查證;預期結論為留置
其餘 6 張 不動 留置理由已記錄於 §3

卡片原訂「auth/user 優先批」的實際情形:經盤點,auth/user 域沒有需要收斂的 ext 表——totp_secrets 應留置(安全邊界),user_tenants/user_org_units 根本不是 ext 形態。該域的實際待辦是兩項清理(§6.2 死欄位、CM-1445 RLS),性質不同且已有歸屬。因此 FR-069.12 的首批實作對象建議改為 project_extensions——它是全庫唯一明確的收斂對象,也是「跨套件邊界造成側掛」這個模式最典型的案例,適合當第二階段的示範棒。

此調整涉及 FR-069.12 卡片範圍變更,待 user 拍板


附錄一:驗證清單(後人重跑用)

# 1. 環境(唯讀)
export PGPASSWORD='<查 .env.bak 的 DB_SECRET.rds_master_password>'
PSQL='psql postgresql://cmmgr@192.168.50.188:25432/guidant_ai_dev'

# 2. 表總數應為 190
$PSQL -tAc "SELECT count(*) FROM information_schema.tables
  WHERE table_type='BASE TABLE' AND table_schema NOT IN ('pg_catalog','information_schema');"

# 3. 面①應回 8 張(SQL 見 §1 面①)
# 4. 面②應回 15 張(SQL 見 §1 面②)
# 5. 面④應回 35 張(SQL 見 §1 面④)

# 6. 關鍵事實復驗
$PSQL -tAc "SELECT count(*) FROM compliance.project_extensions;"            -- 211
$PSQL -tAc "SELECT count(*) FROM compliance.projects;"                      -- 211
$PSQL -tAc "SELECT count(*) FROM compliance.assessment_plan_extensions e
  LEFT JOIN oscal.assessment_plans a ON a.id=e.assessment_plan_id
  WHERE a.id IS NULL;"                                                      -- 193(全孤兒)
$PSQL -tAc "SELECT count(*) FROM public.users WHERE topt_secret IS NOT NULL;" -- 0(死欄位)
$PSQL -tAc "SELECT relname, relrowsecurity FROM pg_class
  WHERE relname IN ('projects','users');"                                   -- projects=f, users=t

# 7. 程式面
grep -rhn "__tablename__" --include="*.py" . | grep -viE "test" | wc -l     -- 66(主專案)
cd ~/Projects/Jedicogy/module/jedi-python-package && \
  grep -rln "__tablename__" --include="*.py" . | grep -v "/test" | wc -l    -- 148(套件)

# 8. project_extensions 的 raw SQL JOIN 密度(收斂風險主因)
grep -rn "JOIN compliance.project_extensions" --include="*.py" . | grep -v "\.venv" | wc -l   -- 14
grep -rn "JOIN compliance.project_extensions" --include="*.py" . | grep -v "\.venv" \
  | cut -d: -f1 | sort -u | wc -l                                                            -- 9(檔)

# 9. OSCAL 表繞過 DI 的直接 new(搬套件時會斷的 import)
grep -rn "ApReviewedControlsRepoImpl" --include="*.py" . | grep -v "\.venv"                  -- 6 行 / 3 檔

# 10. tenant_drive_integrations 的容器外裸 SQL(repo 掃描掃不到)
grep -rn "tenant_drive_integrations" scripts/ --include="*.py"                               -- 含 :161 與 :134 兩處

附錄二:本次掃描的分工與可信度

部分 執行方式
DB 結構四面偵測、列數/孤兒率/填充率/RLS 實查 本 session 直接對 DEV 唯讀查詢,SQL 全文載於 §1
13 張候選表的讀寫端全量 grep(主專案+26 支套件) 派 subagent 專責掃描
關鍵數字覆核 本 session 親自復驗——raw SQL JOIN 計數(subagent 報 12、實際 14,已採實測值)、DI 繞道 6 行 / 3 檔、裸 SQL 兩處,皆重跑指令確認

覆核發現的差異已於正文修正;未經本 session 復驗的細部 file:line 以 subagent 掃描結果呈現,重跑指令見附錄一。