狀態:盤點完成,待驗收|建立日期:2026-08-30|對應卡:CM-1447(FR-069.10) 母案:CM-1435 FR-069|上游依據:
design.md§7 第二階段 性質:純盤點文件,不含任何程式或 migration 異動。 收斂實作見 FR-069.12(CM-1449)。
totp_secrets(唯一 1:1 側表)+一項附帶發現的死欄位 users.topt_secret。其餘 auth 側表經查證皆非 ext 形態(是時效性關聯表或事件流水),不列入收斂。assessment_plan_extensions 193 列全孤兒疑似 v1 遺留、users.topt_secret 死欄位、compliance.projects RLS 已停用但 policy 尚存。「找 ext 表」不能只靠名字——命名為 *_extensions 的只有 2 張,但 ext 形態(一對一側掛、存附加欄位)的表未必這樣命名。因此採四個互相獨立的偵測面交叉,任一面命中即進候選池,再逐張人工判形態。
一張表若對主表的 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_extensions、config.detection_profile_versions、oscal.ap_reviewed_controls、oscal.ssp_control_implementations、oscal.ssp_system_characteristics、oscal.ssp_system_implementations、public.user_org_units、public.user_tenants。
*_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_extensions、compliance.evidence_classification_runs、compliance.job_evidences、config.job_execution_detection_tools、config.log_forwarding_settings、public.drive_folder_mappings、public.tenant_drive_integrations。
# 主專案 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_extensions、assessment_plan_extensions 兩張;*_trans(i18n 翻譯側表)6 張另計,見 §4 留置理由。
欄位數 ≤ 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:N 明細表、事件流水/歷史表、i18n 翻譯表(見 §4)。
排序依卡片要求: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。
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 進出 | 無宣告 FK(user_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_id;totp_secret_domain_service.py:19,25,40,50;infra/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 欄位(拼字為 topt 非 totp),DB 39 列全為 NULL、全 codebase 零引用——是同一能力的第二份殘骸。這是死欄位,非 ext 表問題。
風險評估:留置=零遷移風險。若日後仍要動,注意它沒有 FK,刪 user 不會連帶清 secret(既有孤兒風險,非本次引入)。
public.user_tenants、public.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/42;user_service.py:187,314,320 |
| 讀取端 | repo :47,51;relationship models/user.py:59、models/tenant.py:49(association_proxy) |
repo :47,51;org_unit_repo_impl.py:102-103(刪組織單位前的 func.count 守門,唯一跨 repo 讀取點);relationship models/user.py:65、org_unit.py:59 |
| 主專案側 | 零直接讀寫——DI di_containers/auth/auth_containers.py:146 + serializer api/auth/serializers/user.py:49;app/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 撞車。
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_id、living_ssp_id、owner_id、deleted_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) |
這張表為何存在:主表 projects 屬 jedi-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 — 注意:FlowControlProjectRepoImpl 的 model=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:186infra/flow_control/repository/flow_control_review_repo_impl.py:59infra/flow_control/repository/task_execution_query.py:246infra/flow_control/repository/auditor_dashboard_query.py:42infra/detection_tools/repository/detection_job_notify_query.py:43infra/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:42app/project/service/project_start_app_service.py:368(建專案時建 ext 列)遷移風險評估:中高
projects 主表在 jedi-project,加欄位需動套件(依 CLAUDE.md 外部套件異動規範,要先提醒 user 決策、走 path dependency dev loop、feature 完成才發版)。projects 表加四個 nullable 欄位(不刪 ext 表);UPDATE compliance.projects p SET ... FROM compliance.project_extensions e WHERE e.project_id=p.id;FlowControlProjectRepoImpl 本身 model=ProjectExtension,收斂時這個 repo 的模型基準要換,牽動範圍比單純改 JOIN 大。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_uid、flow_template_snapshot_uid + 審計欄 |
| 列數(DEV) | 193 |
| 孤兒率 | 193 / 193 = 100%——無任何一列的 assessment_plan_id 能在 oscal.assessment_plans 找到對應 |
| ID 區間佐證 | ext 側 assessment_plan_id 落在 49–277;oscal.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_id、batch_get、upsert 等)——程式是 live-wired 的,不是註解掉的死碼。另有非程式引用:scripts/init/02-schema.sql(schema dump)、docs/system-design/database/scripts/*.py(文件產生器)——這些不算消費端。
為何標「待決策」而非直接分類——兩面都成立,且答案決定完全不同的動作:
A 面:這是 v1 遺留死表,應直接退役
B 面:這是活功能的資料斷鏈,應修復
workflow_execution_uid 仍能命中現存 workflow_executions——表示這些資料曾經是有意義的。判定所需、但超出本卡權責的資訊:①該 repo 的實際呼叫端是否仍在 live 路徑上(需追呼叫鏈,屬程式分析非表盤點);②STG/POC 的同表資料是否也全孤兒(唯讀查即可,但跨環境結論屬另案);③v1→v2 AP 遷移當時的決策紀錄。
建議:開獨立 case 查證,不要塞進 FR-069.12 的收斂實作棒——它的答案可能是「刪表」而非「收斂」,兩者工序完全不同。在查清前,FR-069.12 應跳過此表。
遷移風險評估:暫不評估(分類未定)。但註記一點:若走退役路線,風險反而低——資料全是孤兒,刪除不影響任何現存 AP。
在逐張討論前,一項對四張 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 組,來自讀寫端掃描):
new v2 RepoImpl(搬套件時 import path 會斷,且 DI container 掃不到):
app/flow_control/service/assessment_plan_app_service.py:83 — self._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:193 — self._ssp_ctrl_impl_repo.add(...)(主專案唯一一處對 OSCAL 表的 ORM 寫入)ssp_control_implementations 是唯一主專案與套件雙邊都直接讀寫的 OSCAL 表——主專案 1 處 ORM 寫、4 處 raw SQL JOIN(flow_control_job_repo_impl.py:682、project_start_app_service.py:256,257,276,277)、1 處 DELETE ... USING(resource_library_app_service.py:415-419,本表作條件非刪本表)。以上皆為留置表的耦合現況記錄,不構成收斂要求。
oscal.ssp_system_implementations、oscal.ssp_control_implementations、oscal.ap_reviewed_controls(皆屬 jedi-oscal-v2)。
三張結構上確為 1:1 側表(各有 <parent>_id 的 UNIQUE CONSTRAINT + FK CASCADE),但不屬 ext 形態:
system-implementation、control-implementation 本就是獨立的具名區塊,DB 結構是在忠實映射規格。收回主表會破壞 OSCAL 匯入/匯出的序列化對應。ssp_system_implementations 被 4 張表引用(ssp_components、ssp_inventory_items、ssp_leveraged_authorizations、ssp_system_users,皆 FK CASCADE)ssp_control_implementations 被 ssp_implemented_requirements 引用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:93、oscal_io_service.py:206 |
零直接讀寫(僅經 SspService facade) |
ssp_control_implementations |
ssp_clone_service.py:98、oscal_io_service.py:208、ap_draft_service.py:79 |
1 處 ORM 寫 + 4 處 raw SQL JOIN + 1 處 DELETE...USING(見上) |
ap_reviewed_controls |
assessment_plan_service.py:87,139,141、ap_draft_service.py:143 |
5 處讀,全繞 DI(見上) |
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:90、oscal_io_service.py:205。主專案 2 支 app service(經 SspService facade,未直接觸 repo):app/oscal/service/ssp_system_characteristic_app_service.py:53-66、app/module_frame/service/module_frame_system_characteristic_service.py:66-86 |
| 讀取端 | 套件:ssp_service.py:84。主專案 5 處(皆 _ssp_service.get_system_characteristics()):project_service.py:320、ssp_system_characteristic_app_service.py:47,57、ssp_import_template_app_service.py:522、module_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:37、oscal_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 開工(無論如何都不收斂它)。
以下被四面掃描命中、但經判定不是 ext 形態,記錄理由避免後續重複爭論:
| 表 | 命中面 | 排除理由 |
|---|---|---|
*_trans 六張(module_frames_trans、workflow_templates_trans、survey_folders_trans、survey_pages_trans、survey_questions_trans、surveys_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_runs、compliance.job_evidences、config.job_execution_detection_tools、public.drive_folder_mappings |
② | 唯一鍵是外部系統識別碼(run_folder_id/drive_file_id/drive_folder_id)或執行識別,非對主表的 1:1 掛載 |
public.user_auth_providers、public.user_change_password_request、public.user_change_pwd_logs、public.login_logs |
④ | auth 族但皆 1:N:user_auth_providers 一人多 provider(DEV 0 列)、change_password_request 20 列/7 人、change_pwd_logs 為歷史流水、login_logs 為事件流水(DEV 0 列) |
各類 *_mapping、*_participants、role_capabilities 等 |
④ | 多對多關聯表,卡片已明示不算 |
兩則排除表的搬遷註記(雖不收斂,但後續動到它們時容易踩):
tenant_drive_integrations 有兩支容器外裸連線 SQL:scripts/evidence/classify/classify_evidence_drive.py:161、scripts/evidence/classify/docker/container_entrypoint.py:134(:140 有註解自承「裸連線、不走 session_scope 避 RLS」)。這兩處不在任何 repo/ORM 掃描範圍內,任何改名或搬 schema 都會漏。此表另有 8 支 app service 經 domain service 消費,是本次候選池中扇出最廣的一張。user_change_password_request 有跨套件 reader:jedi-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 併起來),記於此供參。design.md §7 第二階段設定的分類是「純展示型 → JSONB 容器;帶業務邏輯 → 正式欄位;有理由 → 留置」,並預期 JSONB 容器(profile_extras 型)是主要收斂手段。
盤點結果是:9 張 ext 形態表中,0 張屬純展示型。
原因可以歸納為一句話——這個系統的側掛表不是「客製欄位」長出來的,是「跨套件邊界」長出來的:
project_extensions 之所以存在,不是因為某客戶要多記幾個值,而是因為 projects 主表屬 jedi-project 套件、GRC 專屬欄位無處可放。它掛的每一欄都是產品核心邏輯(軟刪除、OSCAL 導航錨點、權限),自然不會是純展示。這對 FR-069.12 的含意:第二階段的實作重點應從「建 JSONB 容器來吸收客製欄位」,調整為「把跨套件邊界造成的側掛欄位收回主表正式欄位」。JSONB 容器機制(以及 FR-069.11 要做的 JSONB 查詢層能力)仍有價值——它是「未來新客製一律走容器、不再開側表」的預防性基礎設施,而非用來消化存量。
這不是推翻 design.md 的判斷(判斷口訣本身正確且好用),而是存量與口訣預期的分佈不同。是否要據此調整 §7 的措辭與 FR-069.12 的卡片內容,屬 user 決策,本卡不擅自改 design.md。
assessment_plan_extensions 193 列全孤兒見 §3 B2。建議開獨立 case 查證是 v1 遺留死表還是活功能斷鏈。在查清前 FR-069.12 應跳過此表。
public.users.topt_secret 是死欄位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)。
compliance.projects RLS 已停用但 4 條 policy 尚存pg_class.relrowsecurity = f(未啟用),但 pg_policies 仍有 projects_select/insert/update/delete 四條,內容如 app_tenant_allowed_for_session(tenant_id)。public.users 是 relrowsecurity = t(真正啟用)。guidant_ai 實查結果相同(projects = f),故非 DEV 環境獨有,是出貨基線的狀態。compliance.projects 的程式都無租戶隔離。性質與 CM-1445(user_roles/user_tenants/user_org_units 無 RLS)同類,建議併入 CM-1445 一起追蹤。⚠️ 本項僅為唯讀觀察,未做任何變更;是否為刻意設計(例如靠應用層過濾)需 user 判斷。
| 順位 | 對象 | 動作 | 備註 |
|---|---|---|---|
| 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 掃描結果呈現,重跑指令見附錄一。