# SSP Versioning Design

> 每輪稽核 (AP) 1:1 對應一個 SSP 版本，使每輪稽核時的 SSP 狀態能完整保留為 audit trail，
> 並避免「編輯本輪 SSP 影響歷史輪次」的資料污染問題。

**狀態**：Approved（待 implementation plan）
**Date**：2026-04-26
**Branch**：`feature/google-drive-sync`
**相關 commits**：a96b621（前次 Round 2 freeze 部分實作，本設計會吸收併入）

---

## 1. Background

### 1.1 問題現況

目前 schema 設計：
- 一個專案 → 一筆 `system_security_plans` (SSP)
- 一個 SSP → 多個 `assessment_plans` (AP，代表多輪稽核)
- 所有 SSP 子層資料 (`control_implementations`, `ssp_control_implementation_objectives`,
  `ssp_reference_documents` 池, `ssp_reference_document_mappings`,
  `system_characteristic`, `system_implementations`) 全部掛在 SSP 主表底下

導致的 bug：
- 在 AP2（第二輪）的程序書管理刪除一份程序書 → AP1 也看不到（因為池是 per-SSP）
- 編輯 AP2 的「實施現況」(`implementation_description`) → AP1 的紀錄也被改寫
- 歷史輪次無法保留當時的 SSP 真實狀態 → audit trail 失真

### 1.2 OSCAL 觀點

OSCAL 標準的 SSP 包含四大區塊：

| OSCAL 區塊 | 我們的 schema | 性質 |
|---|---|---|
| `system-characteristics` | `system_characteristic` + 旗下 | 系統識別（名稱、規模、components） |
| `system-implementation` | `system_implementations` + 旗下 | 系統建置（users、components、inventory） |
| `control-implementation` | `control_implementations` (CI) + `ssp_control_implementation_objectives` (CIO) | 控制項實施現況 |
| `back-matter` (resources) | `ssp_reference_documents` + mappings | 程序書 / 佐證資料 |

OSCAL 規範裡 SSP 主表有 `metadata.version` 字串欄位，但**沒有規定歷史快照怎麼存** —
完全由 implementer 決定。本設計用 `group_id + version_no` 補上這個機制，
不違背 OSCAL 標準（屬於 OSCAL 允許但未明定的範圍）。

### 1.3 前置決策（已確認）

| # | 決策 | 結論 |
|---|---|---|
| 1 | Identity vs Version 範圍 | 整個 SSP（含 system_characteristic、system_implementations）全 versioning |
| 2 | URL contract | 每版本自己的 uid（`/ssp/<uid>` 直接打到指定版本） |
| 3 | Version 建立時機 | 1 round = 1 version，僅在 `launch_new_round` 時 clone |
| 4 | 歷史版本 UI | 不另做 version selector，從現有「過往 AP 紀錄」頁面自然進入（讀 AP.ssp.uid 即拿到對應版本） |
| 5 | system_characteristic mapping | 每版本各自 clone 一份 |
| 6 | 既有資料 migration | 全部當作 v1，現有 AP 全部繼續指向 v1 |
| 7 | v_{n+1} 內容來源 | 從 v_n deep clone（不從 module_frame template 重抓），保留 user 上輪填寫的「實施現況」與程序書，符合多輪稽核「追蹤改善」的業務語意 |

---

## 2. Architecture

### 2.1 Schema 變更

#### `oscal.system_security_plans` 新增欄位

```sql
ALTER TABLE oscal.system_security_plans
    ADD COLUMN group_id UUID,
    ADD COLUMN version_no INT NOT NULL DEFAULT 1;

-- Backfill: 每個現有 SSP 自己一個 group
UPDATE oscal.system_security_plans SET group_id = gen_random_uuid() WHERE group_id IS NULL;

ALTER TABLE oscal.system_security_plans
    ALTER COLUMN group_id SET NOT NULL;

CREATE UNIQUE INDEX uq_ssp_group_version
    ON oscal.system_security_plans (group_id, version_no);

CREATE INDEX ix_ssp_group_id
    ON oscal.system_security_plans (group_id);
```

#### 不變的 FK 行為

- `assessment_plans.ssp_id` 含義變成「鎖到的 SSP 版本」（schema 不動）
- 所有 SSP 子表 FK（CI / CIO / Pool / Mapping / system_characteristic 等）不動 —
  自然會跟著主表 cascade，每版本各自一份

### 2.2 Identity 概念

| 概念 | 對應欄位 | 含義 |
|---|---|---|
| **Identity（真身份）** | `group_id` | 跨版本不變，串起一個系統的所有 SSP 版本 |
| **Version（版本）** | `version_no` | 該 identity 下的版本號，1, 2, 3... |
| **Per-version uid** | `uid` | 每個版本自己的 UUID，FE 對接時用這個 |

`group_id` 對應 OSCAL `system-id` 的概念（系統的 stable identifier），
`version_no` 對應「每輪稽核時的 snapshot 版本」。

---

## 3. Lifecycle / Flow

### 3.1 新建專案 (`start_oscal_project`)

不變。建立第一份 SSP 時：
- `group_id = gen_random_uuid()`
- `version_no = 1`
- ModuleFrameTemplateCopyService 跑一次填入 default 內容
- 建立 AP1，`AP1.ssp_id = v1.id`

### 3.2 啟動新輪次 (`launch_new_round`)

新流程（取代/吸收現有 `_clone_control_doc_mappings` 跟 `_clone_ssp_objectives`）：

```
1. 讀取 old_ap、old_ssp（v_n）

2. clone SSP 主表 → 新 row（同 group_id, version_no = n+1, 新 id, 新 uid）→ v_{n+1}

3. clone v_n 的 SSP 內部結構（不依賴 AP-level id 的部分）：
   3.1 system_characteristic + 旗下 components / users / inventory
   3.2 system_implementations + 旗下
   3.3 project_system_characteristic_mapping → 指向新 characteristic
   3.4 control_implementations (CI) → 新 ssp_id
       → 同時建 ci_id_map: {old_ci.id → new_ci.id}
   3.5 ssp_control_implementation_objectives (CIO) → 用 ci_id_map 重對映 control_implementation_id
   3.6 ssp_reference_documents (池) → 新 ssp_id, file_id 共享
       → 同時建 pool_doc_id_map: {old_pool_doc.id → new_pool_doc.id}（用 step 3.6 的 INSERT...RETURNING 配對 old_id）

4. 建立 new_ap，new_ap.ssp_id = v_{n+1}.id

5. clone AP-level (apc / apt / workflow_template / workflow_executions) — 維持現有邏輯
   → 同時建 apc_id_map: {old_apc.id → new_apc.id}
   → 同時建 apt_id_map: {old_apt.id → new_apt.id}

6. clone ssp_reference_document_mappings（必須在 step 5 之後，因為要新 apc/apt id）：
   - context_type='control_implementation': context_id 用 apc_id_map 重對映 old_apc.id → new_apc.id
   - context_type='objective': context_id 用 apt_id_map 重對映 old_apt.id → new_apt.id
   - reference_document_id 用 pool_doc_id_map 重對映 old_pool_doc.id → new_pool_doc.id

7. ModuleFrameTemplateCopyService 不再呼叫（v_{n+1} 從 v_n clone 已含所有 default 內容）
```

**Clone 來源決策**：v_{n+1} 的內容**從 v_n deep clone**，不從 module_frame template 重抓。

理由：
- 多輪稽核的核心價值是追蹤改善（「上輪 partial → 這輪 implemented」），
  從 template 重抓會讓 user 上輪填寫的「實施現況」全部消失
- 程序書同理：上輪掛的程序書沿用，user 自己決定要不要改版/刪舊掛新
- Template 中途更新的情況（例如 module_frame 加了新控制項）**不在這次處理**：
  v_{n+1} 不會自動帶進新的 template default。未來若有需求，另做一個明確的
  「Sync template updates」user action（不混在 `launch_new_round` 裡）

**重要**：mapping clone（step 6）必須在 AP-level clone（step 5）之後執行，
因為 mapping 的 `context_id` 指向新 apc/apt id，這些 id 在 step 5 才產生。

**id_map 建構模式**：所有 clone 都用 `INSERT ... RETURNING id` + 跟原始 row 配對，
建立 `{old_id: new_id}` dict 供後續步驟查詢。不要假設「同 file_id 唯一」之類的隱含約束。

### 3.3 編輯 SSP（任何輪次中）

不變。FE 從 `currentAp.ssp.uid` 拿 ssp_uid，打 `/ssp/<uid>/...` 系列 API，
backend 直接寫到該版本。因為每個版本是獨立 row，編 v2 不會影響 v1。

### 3.4 查看歷史輪次

不變。FE「過往 AP 紀錄」頁面已用 AP context 拿 ssp_uid，自然指到該 AP 鎖的版本。
歷史 AP 因為已歸檔，FE 本來就只給看不給編 → 自然唯讀。

---

## 4. API Impact

### 4.1 不變的 API

所有 `/ssp/<ssp_uid>/...` URL contract — `ssp_uid` 改成版本 uid 即可，handler 邏輯不動：
- `GET/POST/DELETE /ssp/<uid>/document-pool`
- `GET/POST/DELETE /ssp/<uid>/control-implementation/<ctrl_id>/document-mappings`
- `GET/POST/DELETE /ssp/<uid>/control-implementation/<ctrl_id>/objective/<stmt_id>/document-mappings`
- `GET/POST/PUT /ssp/<uid>/control-implementations/...`
- `GET/POST /ssp/<uid>/control-implementations/export|import`

### 4.2 要改的 API

| Endpoint | 改動 |
|---|---|
| `POST /grc/projects/<uid>/launch-new-round` | response 加新 SSP 版本資訊（`new_ssp_uid`、`new_ssp_version`），FE 跳轉用 |
| `GET /grc/projects/<uid>` (`get_project_full_by_uid`) | 確認返回的 SSP 是「latest AP 鎖的 SSP 版本」。Day-1 plan task：read `infra/grc/repository/grc_project_repo_impl.py:622-638` 實作，確認是 join `assessment_plans` ORDER BY created_at DESC 取 ssp_id，**不是** 直接取 `project.ssp_id`。如果是後者，要改成前者。 |

### 4.3 Service / Repo 層改動

| 層 | 改動 |
|---|---|
| `OscalProjectService.launch_new_round` | 新增「clone 整份 SSP」邏輯，吸收 `_clone_control_doc_mappings` / `_clone_ssp_objectives` |
| `OscalProjectService._clone_control_doc_mappings`（commit a96b621 加的） | 刪除，併入新的整份 clone 流程 |
| `SspDocumentPoolQuery.get_ap_control_id_by_identifier` | `ORDER BY created_at DESC` 邏輯仍保留（取最新 AP 的 apc.id），語意不變 |
| `OscalSystemSecurityPlan` ORM | 加 `group_id`、`version_no` 欄位 |
| 新增 `SspVersioningService`（建議） | 封裝「clone SSP 整份到新版本」的邏輯，給 launch_new_round 呼叫 |

---

## 5. Frontend Impact

### 5.1 不變

- 所有 SSP CRUD 頁面（document-pool、control-implementation、planning view 等）
- 「過往 AP 紀錄」頁面（自然會拿到對應版本）

### 5.2 要改

| 頁面 | 改動 |
|---|---|
| 任何從 `project.ssp_uid` 取 ssp_uid 的頁面 | 改成從 `currentAp.ssp_uid` 取 |
| ProjectAuditorOverview | 驗證 ssp_uid 來源 |
| ProjectPlanningView | 驗證 ssp_uid 來源 |
| DashboardView | 驗證 ssp_uid 來源 |
| `launch_new_round` 後跳轉 | 用 response 的 `new_ssp_uid` 跳新 AP 的 SSP context |

實作時要全 grep `project.ssp_uid` / `ssp_uid` 用法，逐一確認來源是 AP context。

---

## 6. Migration

### 6.1 Schema migration

```sql
-- scripts/sql/2026-04-XX-ssp-versioning.sql
-- Date: 2026-04-XX

-- 1. 加欄位 (2026-04-XX)
ALTER TABLE oscal.system_security_plans
    ADD COLUMN group_id UUID,
    ADD COLUMN version_no INT NOT NULL DEFAULT 1;

-- 2. Backfill: 每個現有 SSP 一個獨立 group_id (2026-04-XX)
UPDATE oscal.system_security_plans
    SET group_id = gen_random_uuid()
    WHERE group_id IS NULL;

-- 3. 加 NOT NULL + unique constraint (2026-04-XX)
ALTER TABLE oscal.system_security_plans
    ALTER COLUMN group_id SET NOT NULL;

CREATE UNIQUE INDEX uq_ssp_group_version
    ON oscal.system_security_plans (group_id, version_no);

CREATE INDEX ix_ssp_group_id
    ON oscal.system_security_plans (group_id);
```

**安全性備註**：backfill 為每個 SSP 給獨立 group_id，`(group_id, version_no=1)` 全表唯一，
unique index 不會破。如果未來重跑此 migration（例如資料還原後再跑），需先確認沒有
殘留的 `group_id IS NULL` row。

### 6.2 既有資料行為

- 所有現有 SSP 自動成為 v1（version_no=1）
- 所有現有 AP 繼續指向現有 SSP（= v1）→ 無需動 `assessment_plans.ssp_id`
- **既有 Round 2 資料的 bug 不回頭修**：之前已 launch 過 Round 2 的專案，
  AP1/AP2 仍共用 v1，他們的歷史輪次資料污染問題無從還原（無從得知 AP1 / AP2 各自當時看到什麼）
- **從本變更上線後的下一次 launch_new_round 開始**：才會 clone v2，從那時起 freezing 生效

### 6.3 Pre-production 條件

User 確認目前還在開發階段未上線，可放寬 migration 嚴格度。
不需要為「修復歷史污染資料」做特殊處理，按 6.2 邏輯處理即可。

---

## 7. Edit Lockdown

| 項目 | 決策 |
|---|---|
| 過往 AP 鎖的 SSP 版本，FE 是否禁止編輯？ | FE 控管（過往 AP 頁面本來就唯讀，自然鎖死） |
| Backend 是否加 AP-state 檢查？ | 不加（YAGNI，相信 FE caller） |

未來若有需求（例如多人共用、需要防誤操作），可在 SSP write APIs 加上：
「resolve 該 SSP 版本對應的 AP，若 AP status ∈ {archived, completed} 則拒絕」。
本次不做。

---

## 8. Out of Scope

- 版本選擇器 UI（歷史已可從過往 AP 查）
- Backend AP-state write lockdown（YAGNI）
- File body GC（之前討論過，留未來）
- Profile 變更導致的 SSP 重建（換 baseline 是另一個 use case）
- Cross-version diff / 比對 UI
- **Sync template updates**：module_frame template 中途新增/修改的 default
  不會自動進新版本 SSP。未來若有需求另做明確 user action

---

## 9. Testing Strategy

### 9.1 Unit / Integration tests

- `test_oscal_project_service_launch_new_round.py`：
  - launch_new_round → 新 SSP 版本 row 建立，version_no = old + 1
  - clone 完整：CI / CIO / Pool / Mapping / system_characteristic / mapping 都有對應新版本 row
  - 編輯新版本內容 → 舊版本不受影響
  - mapping context_id 正確重對映（apc/apt/pool_doc id 對齊）
- 既有 `test_oscal_project_service_template_copy_integration.py` 中 Round 2 freeze 相關測試 →
  改寫為新流程
- 新增測試：v1 內容 → launch v2 → 確認 v2 的內容是 v1 當下狀態的 deep copy（非 reference）

### 9.2 Migration tests

- 針對 backfill SQL 寫驗證：執行後每個 SSP 都有 group_id、version_no=1
- Unique constraint 測試：嘗試插入相同 group_id + version_no 應失敗

### 9.3 E2E（compliance-manager-test 專案）

- 啟動專案 → AP1 編輯程序書 → launch Round 2 → AP2 編輯程序書 → 切回 AP1 看，原資料不變
- AP1 刪除程序書 → AP2 程序書不受影響（測試池版本獨立）

---

## 10. Affected Files (預估)

### Backend
- **jedi-oscal 套件內** `OscalSystemSecurityPlan`（路徑：`~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/infra/model/ssp/ssp.py`）：加 `group_id`、`version_no` — **權威路徑為套件內**，主專案不另存 local copy；改完套件需走進版流程（user 明確指示需批准）
- `app/project/service/oscal_project_service.py`：`launch_new_round` 重寫 clone 邏輯
- 新增 `app/oscal/service/ssp_versioning_service.py`：封裝整份 clone
- `infra/grc/repository/ssp_document_pool_query.py`：保留，邏輯不動
- `scripts/sql/2026-04-XX-ssp-versioning.sql`：migration
- 移除 `_clone_control_doc_mappings`、`_clone_ssp_objectives` 內聯邏輯

### Frontend
- 全 grep `project.ssp_uid` 用法 → 改 `currentAp.ssp_uid`
- `launch_new_round` 後跳轉用新 ssp_uid

### Docs
- `docs/changelog/2026-04-XX-ssp-versioning.md`：新增變更紀錄
- `docs/api/module-frame/design.md`：§6.7 從 Option X 更新為「整份 SSP 版本化」
- `docs/api/oscal/`（如有）：補 SSP versioning 章節

### Tests
- `test/test_oscal_project_service_launch_new_round.py`：新增 SSP 版本化測試
- `test/test_oscal_project_service_template_copy_integration.py`：改寫 Round 2 freeze 測試

---

## 11. Risk & Open Questions

### 11.1 Risk

| Risk | Mitigation |
|---|---|
| Clone 流程漏 clone 某個子表 | 在實作前先列完整子表清單，code review 對表 |
| Cross-table id 重對映出錯（apc/apt/pool_doc） | 用 dict lookup pattern + unit test 覆蓋 |
| FE 多處用 `project.ssp_uid` 沒改全 → bug 「永遠看 v1」 | 全 grep + 人工 review |
| jedi-oscal SSP model 在套件內，加欄位需發新版 | 確認改動範圍，必要時走套件進版流程 |

### 11.2 Day-1 Plan Tasks（plan 寫之前要先解的）

下列三項在 implementation plan 第一階段就要驗證 / 解決，不是 ambient open questions：

1. **jedi-oscal 套件進版流程** — `OscalSystemSecurityPlan` 在套件內，加 `group_id`/`version_no`
   需動套件。Plan 第一步：跟 user 確認套件進版可批准，再規劃實作順序（套件 → 主專案）
2. **`oscal_metadata` / `oscal_documents` 不 clone 的最終確認** —
   讀套件內 model 跟使用該欄位的所有地方，確認這兩張表是「文件 origin metadata」性質
   （非系統識別），跨 version 共享不會破壞語意
3. **`ProjectSystemCharacteristicMapping` cascade 行為** — 確認其 FK 跟 cascade 設定，
   clone 時要不要同步 `ON DELETE CASCADE`、unique constraint 跟 (project_id, characteristic_id)
   的關係，避免 v2 clone 時撞重複鍵
