# 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。這是最可靠的一面。

```sql
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`。

### 面②：放寬版——單欄 unique index 且欄名為 `*_id`（涵蓋未宣告 FK 者）

跨 schema 的側表常刻意不宣告 FK（如 `assessment_plan_extensions`），面①會漏掉，故補這一面。

```sql
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 與 DB 雙查）

```bash
# 主專案 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"
```
```sql
-- 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 留置理由。

### 面④：極小表＋ORM 1:1 宣告（撿漏）

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

```sql
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 進出 | **無宣告 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（既有孤兒風險，非本次引入）。

---

### A2 / A3. `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 撞車。

---

### 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_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: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_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 遺留死表，應直接退役*
- 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: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 寫入）
2. **`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`，本表作條件非刪本表）。

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

---

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

`oscal.ssp_system_implementations`、`oscal.ssp_control_implementations`、`oscal.ap_reviewed_controls`（皆屬 jedi-oscal-v2）。

三張結構上確為 1:1 側表（各有 `<parent>_id` 的 UNIQUE CONSTRAINT ＋ FK CASCADE），但**不屬 ext 形態**：

- 它們是 **OSCAL 標準規格的結構節點**，不是「替主表多記幾個值」。OSCAL 的 SSP 定義中，`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` 引用
  - 一張表若被別人當父表 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: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**（見上） |

---

### 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: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 開工（無論如何都不收斂它）。

---

## 4. 明確排除的表（附理由，供後人覆核）

以下被四面掃描命中、但經判定**不是 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 併起來），記於此供參。

---

## 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.users` 是 `relrowsecurity = 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 拍板**。

---

## 附錄一：驗證清單（後人重跑用）

```bash
# 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 掃描結果呈現，重跑指令見附錄一。
