# C3 — 受評範圍歸屬遷移到 SSP（廢除 project_device_mapping / project_information_systems）

> **級別**：重型（Phase C 核心 refactor）
> **依賴**：C2（SSP items endpoints 可讀寫，C3 改 derived 路徑依賴 SSP items 是 source of truth）
> **被依賴**：C4（request schema 變動）、C5（UI 拿掉專案範圍編輯）、C6（FE 同步）
> **不可獨立 ship** — 必須跟 C4 同 PR / 同 release

---

## 1. 目標

把「**受評範圍**」（哪些設備 / 資訊系統屬於這次稽核）從 project 層遷移到 SSP 層（per-AP）。實務上稽核範圍會隨 AP 週期變動（年度增減設備），語意上應該 per-SSP。

**完成後**：
- `dto.audit_systems` / `dto.devices` 由 current AP 對應 SSP 的 `system_implementation_items` derive 出來
- `compliance.project_device_mapping` + `compliance.project_information_systems` 兩張表廢除
- `domain/associations/` + `infra/associations/` + `app/associations/` 整套 stack 拆除
- `jedi_information_system/...project_information_system_*` 系列同樣拆除

## 2. 已拍板的決策

| 議題 | 決策 |
|------|------|
| C3-Q1 Migration 策略 | **補洞 + idempotent**：把 project_device_mapping / project_information_systems 既有資料 INSERT 到對應 current SSP 的 `system_implementation_items`，重複資料 skip |
| C3-Q2 廢除順序 | **兩階段**：PR1 = service 改 derived + write endpoint 退 410 → 觀察 1-2 週 → PR2 = DROP TABLE + 拆 stack |
| C3-Q3 API breaking | **回 410 Gone**（寫操作）；GET 改 derived 仍可用 |
| C3-Q4 DROP scope | **完整清**：table + RLS policy + FK + index + sequence |
| C3-Q5 空資料 | **回空 list `[]`** |

## 3. 兩階段執行詳述

### Phase C3.PR1 — Service derived + write endpoints 退 410（**第一個 PR**）

**目的**：FE 仍可拿到 device / audit_systems response，但不再寫 project mapping 表

**動作清單**：
1. **資料 backfill** — 一次性 SQL：
   - 把 `project_device_mapping` 既有資料 INSERT 到對應 current SSP 的 `ssp_system_implementation_items`（type='hardware'）
   - 把 `project_information_systems` 既有資料 INSERT（type='system'，system_characteristic_id soft ref）
   - 用 `ON CONFLICT DO NOTHING` 或 `WHERE NOT EXISTS` idempotent
2. **改 Response derived**：
   - `GrcProjectService.get_project()` 內 `dto.devices` / `dto.audit_systems` 改從 current SSP 的 items 抓
   - 沒 current SSP 或 SSP 沒 items → 回 `[]`
3. **改寫操作 endpoint 退 410**：
   - `POST/PUT/DELETE /project-device` → 回 410 Gone + msg「請至 SSP 編輯頁維護」
   - `POST/PUT/DELETE /project/job-execution-device` 同上
   - `update_project` Section 3/5 寫操作改 no-op（接受 request 但不寫，回 410 也可）
4. **保留 mapping 表** — 觀察期，避免 rollback 風險
5. **此 PR 同送 C4**（拿掉 request schema 內 audit_systems / devices）

### Phase C3.PR2 — DROP TABLE + 拆 stack（**第二個 PR，1-2 週後**）

**前提**：觀察 PR1 後 user / FE 都正常運作，無 regression

**動作清單**：
1. **DROP migration SQL**：
   - `DROP TABLE compliance.project_device_mapping CASCADE`（含 RLS / FK / index / sequence）
   - `DROP TABLE compliance.project_information_systems CASCADE`
2. **拆 Python stack**：
   - `git rm -r domain/associations/`
   - `git rm -r infra/associations/`
   - `git rm -r app/associations/`
   - `git rm jedi_information_system/...project_information_system_*`
   - 對應 DI containers wiring 拿掉
3. **清理 410 endpoint** — 直接 404（移除 route 註冊）

## 4. Migration SQL 設計（PR1 含的）

### 4.1 補洞 SQL（idempotent）

檔名：`scripts/sql/2026-MM-DD-c3-backfill-ssp-items-from-project-mappings.sql`

```sql
-- Date: 2026-MM-DD
-- Purpose: C3.PR1 — 補洞：把 project_device_mapping / project_information_systems
--          既有資料 backfill 到對應 current SSP 的 ssp_system_implementation_items
-- 帳號：cmmgr（避 RLS）
-- 策略：idempotent — 重複資料 skip

BEGIN;

-- ─────────────────────────────────────────────────────────────────────────────
-- Step 1 — 找每個 project 對應的 current SSP（最新 active AP 的 SSP）(2026-MM-DD)
-- ─────────────────────────────────────────────────────────────────────────────
CREATE TEMP TABLE _project_current_ssp AS
SELECT
    p.id AS project_id,
    p.uid AS project_uid,
    ssp.id AS ssp_id,
    ssp.uid AS ssp_uid,
    si.id AS system_implementation_id  -- main row
FROM compliance.projects p
JOIN compliance.project_assessment_plan_mapping papm ON papm.project_id = p.id
JOIN oscal.assessment_plans ap ON ap.id = papm.assessment_plan_id
JOIN oscal.system_security_plans ssp ON ssp.id = ap.ssp_id
JOIN oscal.ssp_system_implementations si
  ON si.scope_type = 'ssp' AND si.scope_id = ssp.id
WHERE ap.status = 'active'   -- 取 active AP 對應 SSP
  -- 若一個 project 有多個 active AP，取最新一個
  AND ap.id = (
      SELECT ap2.id FROM oscal.assessment_plans ap2
      JOIN compliance.project_assessment_plan_mapping papm2 ON papm2.assessment_plan_id = ap2.id
      WHERE papm2.project_id = p.id AND ap2.status = 'active'
      ORDER BY ap2.created_at DESC
      LIMIT 1
  );

-- 統計：應該每個有 active AP 的專案都有對應 row
-- SELECT COUNT(*) FROM _project_current_ssp;

-- ─────────────────────────────────────────────────────────────────────────────
-- Step 2 — Backfill devices (project_device_mapping → ssp_system_implementation_items) (2026-MM-DD)
-- ─────────────────────────────────────────────────────────────────────────────
INSERT INTO oscal.ssp_system_implementation_items (
    uid, system_security_plan_id, system_implementation_id,
    scope_type, scope_id, implementation_type,
    name, device_id, created_user, updated_user
)
SELECT
    gen_random_uuid(),
    pcs.ssp_id,
    pcs.system_implementation_id,
    'ssp',
    pcs.ssp_id,
    'hardware',
    d.name,
    d.id,
    'c3-backfill-2026-MM-DD',
    'c3-backfill-2026-MM-DD'
FROM compliance.project_device_mapping pdm
JOIN _project_current_ssp pcs ON pcs.project_id = pdm.project_id
JOIN public.devices d ON d.id = pdm.device_id
WHERE NOT EXISTS (
    -- idempotent：current SSP 內已有同 device_id 就 skip
    SELECT 1 FROM oscal.ssp_system_implementation_items existing
     WHERE existing.scope_type = 'ssp'
       AND existing.scope_id = pcs.ssp_id
       AND existing.implementation_type = 'hardware'
       AND existing.device_id = d.id
);

-- ─────────────────────────────────────────────────────────────────────────────
-- Step 3 — Backfill information_systems (project_information_systems → items) (2026-MM-DD)
-- ─────────────────────────────────────────────────────────────────────────────
-- 找對應 SSP characteristic id（每個 SSP 有 1:1 characteristic）
CREATE TEMP TABLE _ssp_characteristic AS
SELECT id, system_security_plan_id
FROM oscal.system_security_plans_system_characteristics;

INSERT INTO oscal.ssp_system_implementation_items (
    uid, system_security_plan_id, system_implementation_id,
    scope_type, scope_id, implementation_type,
    name, system_characteristic_id, created_user, updated_user
)
SELECT
    gen_random_uuid(),
    pcs.ssp_id,
    pcs.system_implementation_id,
    'ssp',
    pcs.ssp_id,
    'system',
    isys.name,
    sc.id,
    'c3-backfill-2026-MM-DD',
    'c3-backfill-2026-MM-DD'
FROM compliance.project_information_systems pis
JOIN _project_current_ssp pcs ON pcs.project_id = pis.project_id
JOIN compliance.information_systems isys ON isys.id = pis.information_system_id
LEFT JOIN _ssp_characteristic sc ON sc.system_security_plan_id = pcs.ssp_id
WHERE NOT EXISTS (
    SELECT 1 FROM oscal.ssp_system_implementation_items existing
     WHERE existing.scope_type = 'ssp'
       AND existing.scope_id = pcs.ssp_id
       AND existing.implementation_type = 'system'
       AND existing.name = isys.name  -- 用 name 為 idempotent key（characteristic_id 可能 NULL）
);

-- ─────────────────────────────────────────────────────────────────────────────
-- Step 4 — 驗證 (2026-MM-DD)
-- ─────────────────────────────────────────────────────────────────────────────
-- backfill 後計數
DO $$
DECLARE
    dev_backfilled int;
    sys_backfilled int;
BEGIN
    SELECT COUNT(*) INTO dev_backfilled
      FROM oscal.ssp_system_implementation_items
     WHERE updated_user = 'c3-backfill-2026-MM-DD' AND implementation_type='hardware';

    SELECT COUNT(*) INTO sys_backfilled
      FROM oscal.ssp_system_implementation_items
     WHERE updated_user = 'c3-backfill-2026-MM-DD' AND implementation_type='system';

    RAISE NOTICE 'C3 backfill done: % hardware items, % system items', dev_backfilled, sys_backfilled;
END $$;

-- 對照 — 應該大致對等（除了沒 active AP 的專案）：
-- SELECT 'project_device_mapping', COUNT(*) FROM compliance.project_device_mapping
-- UNION ALL
-- SELECT 'project_information_systems', COUNT(*) FROM compliance.project_information_systems;

DROP TABLE _project_current_ssp;
DROP TABLE _ssp_characteristic;

COMMIT;
```

**前置條件**：
- C3.PR1 ship 後就跑這個 SQL（一次性）
- 沒 active AP 的專案 → backfill 跳過（無 ssp 可寫）
- 跑完保留 project_device_mapping / project_information_systems 表（等 PR2 才 DROP）

### 4.2 Rollback 草稿

```sql
-- C3.PR1 rollback：刪除 backfill 出來的 SSP items
BEGIN;

DELETE FROM oscal.ssp_system_implementation_items
 WHERE updated_user = 'c3-backfill-2026-MM-DD';

COMMIT;
```

## 5. Service 層改造（PR1）

### 5.1 `GrcProjectService.get_project()` — derived response

**改動前**：
```python
def get_project(self, uid, ...):
    ...
    # 從 project_device_mapping 抓 devices
    dev_mappings = self._device_mapping_service.get_all_by_project_uid(uid)
    dto.devices = [DeviceSummaryDto(...) for dm in dev_mappings if dm.device]

    # 從 project_information_systems 抓 audit_systems
    pis_entities = self._pis_domain_service.get_all(
        ProjectInformationSystemQueryEntity(project_id=entity.id)
    )
    ...
```

**改動後**：
```python
def get_project(self, uid, ...):
    ...
    # 從 current AP 對應 SSP 的 implementation_items derive
    current_ssp_id = self._resolve_current_ssp_id(project_id=entity.id)
    if current_ssp_id is None:
        dto.devices = []
        dto.audit_systems = []
    else:
        items = self._ssp_item_domain_service.get_all(
            SspSystemImplementationItemQueryEntity(
                system_security_plan_id=current_ssp_id, scope_type='ssp',
            )
        )
        # devices ← items where implementation_type='hardware'
        # audit_systems ← items where implementation_type IN ('component','system','subsystem','service','software')
        dto.devices = [self._item_to_device_summary(i) for i in items if i.implementation_type == 'hardware']
        dto.audit_systems = [self._item_to_system_summary(i) for i in items if i.implementation_type in ('component','system','subsystem','service','software')]

def _resolve_current_ssp_id(self, project_id: int) -> Optional[int]:
    # 找 project → latest active AP → SSP
    ...
```

**注意**：`_resolve_current_ssp_id` 邏輯與 C2 `ProjectCurrentSspRoute` 一致，可抽共用 helper。

### 5.2 `GrcProjectService.update_project()` — 寫操作改 410

**改動前**：Section 3 / 5 處理 audit_systems / devices 替換

**改動後（PR1）**：
- 兩 section 暫時保留邏輯但加 deprecation warn log
- 或直接拿掉 + request schema 拿掉欄位（搭配 C4 同 PR）

**建議**：搭配 C4 同 PR 一起拿掉 request schema fields，service 內對應段落同步拿掉。`/project-device` / `/project/job-execution-device` POST/PUT/DELETE endpoint 改 410。

### 5.3 `/project-device` 系列改 410（PR1）— 用 GrcErrorCode 走專案規範

**檔案**：`api/project/routes/project_device_route.py` 等

**新增 error code**（加入 `common/code/grc_error_code.py`）：
```python
# 410 Gone — endpoint 已下架
GRC_PROJECT_DEVICE_DEPRECATED = (
    "專案設備寫操作已下架，請至 SSP 編輯頁維護受評範圍",
    "GRC_410001",
)
GRC_PROJECT_INFO_SYSTEM_DEPRECATED = (
    "專案資訊系統寫操作已下架，請至 SSP 編輯頁維護受評範圍",
    "GRC_410002",
)
```

**Route 改動**：
```python
from common.code.grc_error_code import GrcErrorCode

class ProjectDeviceRoute(MethodResource):
    def post(self):
        return self._gone_response(GrcErrorCode.GRC_PROJECT_DEVICE_DEPRECATED)

    def put(self, ...):
        return self._gone_response(GrcErrorCode.GRC_PROJECT_DEVICE_DEPRECATED)

    def delete(self, ...):
        return self._gone_response(GrcErrorCode.GRC_PROJECT_DEVICE_DEPRECATED)

    @staticmethod
    def _gone_response(error_code):
        """回傳 410 Gone，FE 用 error code 查 i18n。"""
        msg, code = error_code.value  # GrcErrorCode tuple 解構
        return {
            "code": 0,
            "msg": msg,           # zh-tw fallback（FE 優先用 code 查 i18n）
            "data": {
                "error_code": code,  # FE 用此 code 查 i18n
                "ssp_endpoints": [   # 引導 FE 改用 SSP-scoped endpoints
                    "GET /projects/<project_uid>/current-ssp-uid",
                    "POST /ssp/<ssp_uid>/ssp-resources/items"
                ]
            }
        }, 410  # HTTP 410 Gone
```

**FE i18n 對應**（FE repo `src/config/locales/i18n/zh-tw/error.json`）：
```json
{
  "error": {
    "GRC_410001": "專案設備寫操作已下架，請至 SSP 編輯頁維護受評範圍",
    "GRC_410002": "專案資訊系統寫操作已下架，請至 SSP 編輯頁維護受評範圍"
  }
}
```

FE 拿到 response 後優先用 `data.error_code` 查 i18n 顯示，msg 為 fallback。

GET 操作保留正常（改 derived from SSP）。

## 6. PR1 變動檔案清單

**新檔**：
- `scripts/sql/2026-MM-DD-c3-backfill-ssp-items-from-project-mappings.sql`
- `domain/oscal/service/ssp_current_resolver.py`（_resolve_current_ssp_id 抽 helper）

**改檔**：
- `app/grc/service/project_service.py` — get_project derived；update_project 拿掉 Section 3/5
- `app/project/service/oscal_project_service.py` — start_oscal_project 拿掉 Step C
- `api/project/routes/project_device_route.py` — 寫操作回 410
- `api/project/routes/project_*.py` 其他 device write endpoints — 同上
- `api/project/__init__.py` — route 仍註冊（410 由 route 內部回，URL 仍存在）
- `api/project/serializers/project.py` — OscalProjectStartRequest 拿掉 audit_systems/devices（C4 同期）
- `api/grc/serializers/project.py` — ProjectUpdateRequestSchema 同上
- DI container 拿掉 device_mapping / pis service 注入（C4 同期）

**不動**（保留至 PR2）：
- `domain/associations/` — 整個保留
- `infra/associations/` — 整個保留
- `app/associations/` — 整個保留
- `compliance.project_device_mapping` table — 保留
- `compliance.project_information_systems` table — 保留

## 7. PR2 變動檔案清單

**新檔**：
- `scripts/sql/2026-MM-DD-c3-drop-project-mapping-tables.sql`

**刪檔**：
- `domain/associations/*` 整個
- `infra/associations/*` 整個
- `app/associations/*` 整個
- `jedi_information_system/...project_information_system_*` 系列
- `api/project/routes/project_device_route.py` — 廢檔（route 內 410 拿掉）
- `api/project/routes/project_*.py` 其他 device endpoints 同上

**改檔**：
- `api/project/__init__.py` — 拿掉 ProjectDevicesRoute / ProjectDeviceRoute 等註冊
- DI container 完整清理
- `domain/oscal/strategy/ssp_write_strategy.py:258-277` — 拿掉 `TODO Phase E.2` 段

### 7.1 DROP SQL（PR2）

```sql
-- Date: 2026-MM-DD
-- Purpose: C3.PR2 — DROP project_device_mapping + project_information_systems
-- 帳號：cmmgr

BEGIN;

-- ─────────────────────────────────────────────────────────────────────────────
-- DROP project_device_mapping (2026-MM-DD)
-- ─────────────────────────────────────────────────────────────────────────────
-- 確認沒有殘留 caller
-- SELECT COUNT(*) FROM compliance.project_device_mapping;
-- 此時可能還有資料但已無 caller 引用

-- 廢 RLS policy
DROP POLICY IF EXISTS project_device_mapping_select ON compliance.project_device_mapping;
DROP POLICY IF EXISTS project_device_mapping_insert ON compliance.project_device_mapping;
DROP POLICY IF EXISTS project_device_mapping_update ON compliance.project_device_mapping;
DROP POLICY IF EXISTS project_device_mapping_delete ON compliance.project_device_mapping;

-- 廢 FK / index（CASCADE 會處理大部分，但顯式列出較明確）
ALTER TABLE compliance.project_device_mapping
    DROP CONSTRAINT IF EXISTS uq_project_device_mapping;
DROP INDEX IF EXISTS compliance.ix_pdm_project_id;
DROP INDEX IF EXISTS compliance.ix_pdm_device_id;

-- 廢 table
DROP TABLE IF EXISTS compliance.project_device_mapping CASCADE;
-- DROP TABLE 會自動 drop sequence 跟 owned objects

-- ─────────────────────────────────────────────────────────────────────────────
-- DROP project_information_systems (2026-MM-DD)
-- ─────────────────────────────────────────────────────────────────────────────
DROP POLICY IF EXISTS project_information_systems_select ON compliance.project_information_systems;
DROP POLICY IF EXISTS project_information_systems_insert ON compliance.project_information_systems;
DROP POLICY IF EXISTS project_information_systems_update ON compliance.project_information_systems;
DROP POLICY IF EXISTS project_information_systems_delete ON compliance.project_information_systems;

ALTER TABLE compliance.project_information_systems
    DROP CONSTRAINT IF EXISTS uq_project_information_system;

DROP TABLE IF EXISTS compliance.project_information_systems CASCADE;

COMMIT;

-- 驗證
-- \dt compliance.project_device_mapping     -- 預期：Did not find any relation
-- \dt compliance.project_information_systems  -- 同上
```

## 8. 風險評估

| 風險 | 嚴重度 | 緩解 |
|------|-------|------|
| Backfill SQL 漏資料（無 active AP 的 project） | 🟡 中 | Pre-flight 跑 SQL 統計沒有 active AP 的 project 數量 + 列名單，由業務決定是否補建 AP |
| Backfill 後 SSP items 出現 user 沒預期的「設備」 | 🟡 中 | backfill 用 `c3-backfill-2026-MM-DD` 標記在 `updated_user`，可一次性 audit / cleanup |
| PR1 後 reviewer/auditor 角色看不到 device/audit_systems | 🟢 低 | derived response 對所有角色都回（permission 只擋寫），仍可看 |
| PR2 DROP 後發現有遺漏 caller | 🔴 高 | PR1 ~ PR2 之間 1-2 週觀察期；PR1 加 SQL audit query 監控 mapping 表 INSERT 是否 0 |
| 既有 dashboard / search 依賴 mapping 表（沒在 caller 盤點到） | 🔴 高 | PR1 前期需再次 grep `pg_class WHERE relname='project_device_mapping'` + RLS / view 依賴 |
| jedi-information-system 套件外被引用 | 🟡 中 | 套件層 grep 確認 |
| backfill 資料量大 timeout | 🟢 低 | dev 環境總量小（< 1000 row），prod 跑前估算 |

## 9. 監控 / Audit（PR1 期間）

PR1 ship 後到 PR2 期間，跑 SQL 監控確認 mapping 表沒有新 INSERT：

```sql
-- 應該 0（PR1 後寫操作回 410，無新增）
SELECT COUNT(*) FROM compliance.project_device_mapping
WHERE created_at > '<PR1-ship-date>';

SELECT COUNT(*) FROM compliance.project_information_systems
WHERE created_at > '<PR1-ship-date>';
```

若觀察到非 0，表示有遺漏 caller，要先補修才能進 PR2。

## 10. 決策（已 finalized）

| 編號 | 決策 |
|------|------|
| C3-D1 | ✅ Backfill SQL **人工跑**（PR1 ship 後 DBA 監督執行）|
| C3-D2 | ✅ PR1 ~ PR2 觀察期 **1 週** |
| C3-D3 | ✅ 沒 active AP 的 project：backfill **跳過**（user 啟動 AP 後 SSP 從 MF clone 帶範圍）|
| C3-D4 | ✅ 410 response 用 **GrcErrorCode pattern**（`GRC_410001` / `GRC_410002`）；FE 用 error_code 查 i18n，msg 為 zh-tw fallback。**對齊專案規範。** |
| C3-D5 | ✅ **`OscalRole` ORM 保留**（OSCAL 文件結構用，跟 `system_menus` 服務不同對象）；C1 同步 seed `oscal_roles` 9 筆 |

---

## 11. 開發後狀態

**PR1 後 (2026-05-23 上午, commit range `d2c3df2..1253a92`)**：
- Response 內 `dto.devices` / `dto.audit_systems` 從 current SSP derive
- 寫操作 endpoint 回 410（route 仍存在）
- mapping 表保留有資料
- 既有 stack（associations / project_information_system）仍在但已無 active write caller

**PR2 後 (2026-05-23 下午, commit `0960070` + `143f354`)**：
- ✅ mapping 表 DROP（`scripts/sql/2026-05-23-c3-pr2-drop-project-mapping-tables.sql` 已執行）
- ✅ associations 內 device 系列 10 檔 + jedi_information_system project_* 系列 7 檔 `git rm`
- ✅ 410 endpoint 改為 404（`api/project/__init__.py` 拿掉 register）
- ✅ DI cleanup 3 處（associations_containers / information_system_container / oscal_containers）
- ✅ ssp_write_strategy dead injection（`_project_info_sys_repo`）拿掉
- ✅ 程式碼總量縮小

**PR2 vs 原 plan T11 偏差**：plan 寫 `git rm -r app/associations/` 過度激進，實際只能拆
device 兩個系列；保留其他 6 個 mapping（org_unit / system_characteristic / assessment_plan /
task_workflow / profile_assessment_workflow）+ job_execution_device（屬「任務指派裝置」非
C3 scope）。詳見 changelog `2026-05-23-tweak-c3-pr2-drop-project-mapping-tables.md` §「Plan 矯正」。

**跳過觀察期**：原 plan §11 寫 PR1 ship 後 1-2 週觀察期再進 PR2，user 確認 DB 有備份、
production 已 0 caller，跳過直接拔。

## 12. Test Plan

C3 是重型 phase，建議獨立 test-plan：

- BE unit / integration：見 `implementation-plan-C3.md` 內 §測試規格
- E2E / regression：建專案 + 啟動 AP + derive 受評範圍 + 410 endpoint 驗證
- Migration verify：backfill 前後 row 數對等性檢查 SQL
