# SSP Versioning Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 讓每輪稽核 (AP) 1:1 對應一個獨立的 SSP 版本，使編輯本輪 SSP 不會影響歷史輪次的 audit trail。

**Architecture:**
- `oscal.system_security_plans` 加 `group_id` (UUID, identity) + `version_no` (INT)，仿 jedi-survey 模式
- 新增 `SspVersioningService` 封裝「整份 SSP deep clone」邏輯（含 system_characteristic / system_implementations / CI / CIO / pool / mappings / project_system_characteristic_mapping）
- `launch_new_round` 改呼叫 `SspVersioningService.clone_to_new_version(old_ssp, new_ap)` 取代現有 `_clone_control_doc_mappings` / `_clone_ssp_objectives`
- v_{n+1} 從 v_n deep clone（不從 module_frame template 重抓），保留 user 上輪填寫內容

**Tech Stack:**
- Python 3.11 + SQLAlchemy 2.0 + PostgreSQL 16
- jedi-oscal package（路徑：`~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/`）— 需進版
- Flask + Flask-RESTful（API 層）
- pytest（測試）

**Spec reference:** `docs/features/FR-019-2604-ssp-versioning/design.md`

**遵守事項：**
- 完成作業後**不自動 commit**，除非 user 明確要求
- 每次功能變更需在 `docs/changelog/` 建立紀錄（`YYYY-MM-DD-<簡述>.md`）
- DDD 層級規範：Route 不查 DB、App Service 用 `@transaction`、Repo 不在 `__init__` 呼叫 `get_session()`
- jedi-oscal 套件改完後**需 user 明確指示**才能進版發佈

---

## Phase 0 — Pre-flight Verification

### Task 0.1: jedi-oscal 進版批准確認

**Files:** none（純對話確認）

- [ ] **Step 1:** 跟 user 確認可以動 jedi-oscal 套件並批准進版（依 user feedback `feedback_jedi_package_publish_flow.md`，套件改動需明示批准）

  Expected: user 確認可進版。若 user 暫不批准，本 plan 暫停或改走「主專案 monkey-patch + 等套件進版」變通方案。

### Task 0.2: 確認 oscal_metadata / oscal_documents 不 clone 是 OK 的

**Files:**
- Read: `~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/infra/model/base/oscal_metadata.py`
- Read: `~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/infra/model/base/oscal_document.py`
- Grep: `OscalMetadata`、`OscalDocument` 在主專案的所有使用點

- [ ] **Step 1: 讀套件內 model 定義**

  Run:
  ```bash
  cat ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/infra/model/base/oscal_metadata.py
  cat ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/infra/model/base/oscal_document.py
  ```

  Expected: 確認這兩張表是「文件 origin metadata」（檔案來源、上傳資訊），跟「系統識別」無關

- [ ] **Step 2: 確認跨 version 共享不會破壞語意**

  Use Grep to find `metadata_id`、`document_id` 的所有寫入點。確認沒有「per-version 必須不同」的場景。

  Expected: 共享安全。如有疑慮，回 spec §11.2 重新討論。

### Task 0.3: 確認 ProjectSystemCharacteristicMapping schema 跟 cascade

**Files:**
- Read: `infra/associations/model/project_system_characteristic.py`
- Read: `domain/associations/repository/project_system_characteristic_mapping.py`

- [ ] **Step 1: 讀 model + 確認 FK / unique constraint**

  Read `infra/associations/model/project_system_characteristic.py` 整份。

  Expected: 確認 unique constraint（如 `(project_id, characteristic_id)`）跟 `ON DELETE CASCADE` 行為。記下這些 constraint 給 Phase 3 clone 設計用。

- [ ] **Step 2: 確認 clone 不會撞重複鍵**

  關鍵：clone 時新 row 的 `characteristic_id` 是新 SSP 版本的新 characteristic，**不是** old 的，所以 `(project_id, characteristic_id)` 不會撞。記入 Phase 3 設計筆記。

### Task 0.4: 確認 jedi-oscal 內其他依賴 system_security_plan 的 model

**Files:**
- Glob: `~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/**/*.py`

- [ ] **Step 1: 列出所有 FK 到 `system_security_plans.id` 的 model**

  Run:
  ```bash
  grep -rn "system_security_plans.id" ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/
  ```

  Expected: 完整列表 — 確認 Phase 3 clone 範圍涵蓋所有子表，沒有漏

---

## Phase 1 — jedi-oscal 套件改動

### Task 1.1: 加 group_id / version_no 欄位到 OscalSystemSecurityPlan

**Files:**
- Modify: `~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/infra/model/ssp/ssp.py`
- Test: `~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/tests/test_ssp_versioning_fields.py`（新建）

- [ ] **Step 1: Write the failing test**

  ```python
  # tests/test_ssp_versioning_fields.py
  import uuid
  from jedi_oscal.infra.model.ssp.ssp import OscalSystemSecurityPlan

  def test_ssp_has_group_id_and_version_no():
      ssp = OscalSystemSecurityPlan()
      assert hasattr(ssp, "group_id")
      assert hasattr(ssp, "version_no")

  def test_ssp_default_version_is_1():
      # 在 SQLAlchemy mapper 層級檢查 default
      column = OscalSystemSecurityPlan.__table__.c.version_no
      assert column.default.arg == 1
  ```

- [ ] **Step 2: Run test to verify it fails**

  Run: `cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal && pytest tests/test_ssp_versioning_fields.py -v`

  Expected: FAIL（欄位不存在）

- [ ] **Step 3: Add fields to model**

  在 `ssp.py` 的 `OscalSystemSecurityPlan` class 內，`description` 欄位之後加：

  ```python
  group_id: Mapped[uuid.UUID] = mapped_column(
      UUID(as_uuid=True),
      nullable=False,
      default=uuid.uuid4,
      index=True,
      comment="SSP identity（跨版本不變，串起同系統的所有版本）",
  )

  version_no: Mapped[int] = mapped_column(
      Integer,
      nullable=False,
      default=1,
      comment="版本號（1, 2, 3...，每輪稽核 launch 時 +1）",
  )
  ```

  並在 `__table_args__` 加 unique constraint：

  ```python
  __table_args__ = (
      Index("ix_system_security_plans_uid", "uid"),
      Index("ix_system_security_plans_profile_id", "profile_id"),
      Index("ix_system_security_plans_status", "status"),
      Index("ix_system_security_plans_metadata_id", "metadata_id"),
      Index("ix_system_security_plans_document_id", "document_id"),
      Index("ix_system_security_plans_group_id", "group_id"),
      UniqueConstraint("group_id", "version_no", name="uq_ssp_group_version"),
      {"schema": "oscal", "comment": "OSCAL系統安全計劃表：儲存系統的安全基線和控制項實施資訊"},
  )
  ```

  記得在 import 區加 `from sqlalchemy import UniqueConstraint`。

- [ ] **Step 4: Run test to verify it passes**

  Run: `cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal && pytest tests/test_ssp_versioning_fields.py -v`

  Expected: PASS

### Task 1.2: 更新對應 entity / mapper（如有）

**Files:**
- Read: `~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/domain/entity/ssp/ssp_entity.py`（若有）
- Read: `~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/infra/mapper/ssp/ssp_mapper.py`（若有）

- [ ] **Step 1: 確認 entity / mapper 是否需要同步加欄位**

  Run:
  ```bash
  grep -rn "system_security_plan" ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/domain/entity/
  grep -rn "system_security_plan" ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/infra/mapper/
  ```

- [ ] **Step 2: 若有對應 entity / mapper，加上 group_id / version_no 欄位**

  Same TDD pattern：先寫測試確認欄位存在，再加。

### Task 1.3: 跑完整套件測試

- [ ] **Step 1:**

  Run: `cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal && pytest`

  Expected: 全綠

### Task 1.4: 進版（user 批准後）

- [ ] **Step 1: 確認 user 批准進版**（前面 Task 0.1 已批准的話跳過，否則再確認一次）

- [ ] **Step 2: 依 jedi-oscal 既有發版流程進版**

  參考 `~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/` 的 README 或 pyproject.toml。
  通常是：bump version → build → publish to Nexus

- [ ] **Step 3: 主專案更新 jedi-oscal 版本**

  Modify `pyproject.toml`：bump jedi-oscal 版本

  Run: `poetry lock && poetry install`

  Expected: 主專案能 import 新版 jedi-oscal

---

## Phase 2 — DB Migration

### Task 2.1: 寫 migration SQL

**Files:**
- Create: `scripts/sql/YYYY-MM-DD（實作當日）-ssp-versioning.sql`（檔名 X 換成實際日期）

- [ ] **Step 1: 寫 migration SQL**

  ```sql
  -- Date: YYYY-MM-DD（實作當日）
  -- SSP Versioning: 加 group_id / version_no 欄位 + backfill 既有資料

  -- 1. 加欄位 (YYYY-MM-DD（實作當日）)
  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 (YYYY-MM-DD（實作當日）)
  UPDATE oscal.system_security_plans
      SET group_id = gen_random_uuid()
      WHERE group_id IS NULL;

  -- 3. NOT NULL constraint (YYYY-MM-DD（實作當日）)
  ALTER TABLE oscal.system_security_plans
      ALTER COLUMN group_id SET NOT NULL;

  -- 4. Indexes + unique constraint (YYYY-MM-DD（實作當日）)
  CREATE INDEX IF NOT EXISTS ix_system_security_plans_group_id
      ON oscal.system_security_plans (group_id);

  CREATE UNIQUE INDEX IF NOT EXISTS uq_ssp_group_version
      ON oscal.system_security_plans (group_id, version_no);
  ```

### Task 2.2: 套用 migration

- [ ] **Step 1: 套用到 dev DB**

  Run（使用 `BE/.env` 連接資訊）:
  ```bash
  cd /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be
  set -a; source .env; set +a
  psql "$DB_HOST" -U cm_app -d compliance_manager -f scripts/sql/YYYY-MM-DD（實作當日）-ssp-versioning.sql
  ```

  Expected: 0 errors

- [ ] **Step 2: 驗證 backfill 結果**

  Run:
  ```bash
  psql ... -c "SELECT COUNT(*) FROM oscal.system_security_plans WHERE group_id IS NULL;"
  psql ... -c "SELECT COUNT(*), version_no FROM oscal.system_security_plans GROUP BY version_no;"
  psql ... -c "SELECT COUNT(*), COUNT(DISTINCT group_id) FROM oscal.system_security_plans;"
  ```

  Expected:
  - `group_id IS NULL` count = 0
  - 所有 row `version_no = 1`
  - `COUNT(*) == COUNT(DISTINCT group_id)`（每個 SSP 自己一個 group）

- [ ] **Step 3: 驗證 unique constraint 生效**

  Run:
  ```bash
  psql ... -c "SELECT indexname FROM pg_indexes WHERE tablename = 'system_security_plans' AND schemaname = 'oscal';"
  ```

  Expected: `uq_ssp_group_version` 出現

---

## Phase 3 — SspVersioningService（核心 clone 邏輯）

> **Test fixture 約定**：本 phase 所有測試的 fixture（`sample_ssp`、`create_dummy_ap`、
> `apc_pairs`、`full_ssp_fixture` 等）依 `test/test_oscal_project_service_template_copy_integration.py`
> 既有 pattern：用 SQLAlchemy session + factory function 建立。實作 Task 3.2 前先建立
> 共用 fixture module（`test/fixtures/ssp_versioning_fixtures.py`），後續 task 直接 import。

### Task 3.1: 建立 SspVersioningService 骨架

**Files:**
- Create: `app/oscal/service/ssp_versioning_service.py`
- Create: `test/test_ssp_versioning_service.py`

- [ ] **Step 1: Write the failing test (skeleton)**

  ```python
  # test/test_ssp_versioning_service.py
  import pytest
  from app.oscal.service.ssp_versioning_service import SspVersioningService

  def test_service_has_clone_method():
      assert hasattr(SspVersioningService, "clone_to_new_version")
  ```

- [ ] **Step 2: Run test to verify it fails**

  Run: `pytest test/test_ssp_versioning_service.py -v`

  Expected: FAIL（class 不存在）

- [ ] **Step 3: Create skeleton**

  ```python
  # app/oscal/service/ssp_versioning_service.py
  """SSP 版本化 Service：每輪稽核 launch 時把 v_n 整份 deep clone 成 v_{n+1}"""
  import logging
  from typing import Optional

  from jedi_oscal.infra.model.ssp.ssp import OscalSystemSecurityPlan
  from jedi_oscal.infra.model.ap.assessment_plan import OscalAssessmentPlan


  class SspVersioningService:
      """封裝『將 SSP v_n deep clone 成 v_{n+1}』的邏輯。

      此 service 不負責建立 new_ap，caller (OscalProjectService.launch_new_round)
      在 clone SSP 後才建 new_ap，並把 new_ap.ssp_id 指向新 SSP。
      """

      def clone_to_new_version(
          self,
          session,
          old_ssp: OscalSystemSecurityPlan,
          new_ap: OscalAssessmentPlan,
          curr_user: str,
          logger: Optional[logging.Logger] = None,
      ) -> OscalSystemSecurityPlan:
          """把 old_ssp deep clone 成新 version，回傳新 SSP row。

          Caller 必須在 @transaction scope 內呼叫。
          """
          raise NotImplementedError
  ```

- [ ] **Step 4: Run test to verify it passes**

  Run: `pytest test/test_ssp_versioning_service.py -v`

  Expected: PASS

### Task 3.2: clone SSP 主表（new row, 同 group_id, version_no+1）

**Files:**
- Modify: `app/oscal/service/ssp_versioning_service.py`
- Modify: `test/test_ssp_versioning_service.py`

- [ ] **Step 1: Write the failing test**

  ```python
  def test_clone_to_new_version_creates_new_row_same_group_incremented_version(db_session, sample_ssp):
      """sample_ssp fixture: SSP with group_id=UUID, version_no=1"""
      service = SspVersioningService()
      new_ap = create_dummy_ap()  # helper
      new_ssp = service.clone_to_new_version(db_session, sample_ssp, new_ap, "test_user")

      assert new_ssp.id != sample_ssp.id
      assert new_ssp.uid != sample_ssp.uid
      assert new_ssp.group_id == sample_ssp.group_id
      assert new_ssp.version_no == sample_ssp.version_no + 1
      assert new_ssp.profile_id == sample_ssp.profile_id  # 共享
      assert new_ssp.metadata_id == sample_ssp.metadata_id  # 共享
      assert new_ssp.document_id == sample_ssp.document_id  # 共享
  ```

  **Test fixture 注意**：fixture 設計參考 `test/test_oscal_project_service_template_copy_integration.py` 的既有模式（用 SQLAlchemy session + factory）。

- [ ] **Step 2: Run test to verify it fails**

  Run: `pytest test/test_ssp_versioning_service.py::test_clone_to_new_version_creates_new_row_same_group_incremented_version -v`

  Expected: FAIL（NotImplementedError）

- [ ] **Step 3: Implement SSP main row clone**

  ```python
  def clone_to_new_version(self, session, old_ssp, new_ap, curr_user, logger=None):
      logger = logger or logging.getLogger(__name__)

      # 1. clone SSP 主表
      new_ssp = OscalSystemSecurityPlan(
          uid=uuid.uuid4(),
          profile_id=old_ssp.profile_id,
          metadata_id=old_ssp.metadata_id,
          document_id=old_ssp.document_id,
          description=old_ssp.description,
          status=old_ssp.status,
          template_module_frame_id=old_ssp.template_module_frame_id,
          group_id=old_ssp.group_id,
          version_no=old_ssp.version_no + 1,
          created_user=curr_user,
          updated_user=curr_user,
      )
      session.add(new_ssp)
      session.flush()  # 取得 new_ssp.id

      logger.info(
          "Cloned SSP %s (v%d) → new SSP %s (v%d), group=%s",
          old_ssp.id, old_ssp.version_no,
          new_ssp.id, new_ssp.version_no,
          old_ssp.group_id,
      )

      # 後續 clone steps 依序呼叫 _clone_xxx helper（Task 3.3-3.9）
      return new_ssp
  ```

  記得加 `import uuid`。

- [ ] **Step 4: Run test to verify it passes**

  Run: `pytest test/test_ssp_versioning_service.py::test_clone_to_new_version_creates_new_row_same_group_incremented_version -v`

  Expected: PASS

### Task 3.3: clone system_characteristic + 旗下子表

**Files:**
- Modify: `app/oscal/service/ssp_versioning_service.py`
- Modify: `test/test_ssp_versioning_service.py`

- [ ] **Step 1: Read existing system_characteristic schema**

  Run: `cat ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/infra/model/ssp/ssp_system_characteristic.py`

  記下所有子表（components / users / inventory items 等）跟 cascade 行為。

- [ ] **Step 2: Write the failing test**

  ```python
  def test_clone_includes_system_characteristic(db_session, sample_ssp_with_characteristic):
      service = SspVersioningService()
      new_ap = create_dummy_ap()
      new_ssp = service.clone_to_new_version(db_session, sample_ssp_with_characteristic, new_ap, "test_user")

      assert new_ssp.system_characteristic is not None
      assert new_ssp.system_characteristic.id != sample_ssp_with_characteristic.system_characteristic.id
      # 內容相同
      assert new_ssp.system_characteristic.system_name == sample_ssp_with_characteristic.system_characteristic.system_name
      # 編 v2 不影響 v1
      new_ssp.system_characteristic.system_name = "modified"
      db_session.flush()
      db_session.refresh(sample_ssp_with_characteristic.system_characteristic)
      assert sample_ssp_with_characteristic.system_characteristic.system_name != "modified"
  ```

- [ ] **Step 3: Run test to verify it fails**

  Expected: FAIL（沒 clone）

- [ ] **Step 4: Implement clone helper**

  在 `SspVersioningService` 內新增 `_clone_system_characteristic(session, old_ssp, new_ssp, curr_user)`，
  從 `clone_to_new_version` 呼叫。

  實作模式：deep copy old_ssp.system_characteristic 跟所有子表（components / users / inventory items），
  把 FK 指向 new_ssp.id。

  **重要**：用 SQLAlchemy ORM `session.merge()` 不適合（會 upsert by primary key），用 manual instantiate + session.add() pattern。

- [ ] **Step 5: Run test to verify it passes**

  Expected: PASS

### Task 3.4: clone system_implementations + 旗下

**Files:**
- Modify: `app/oscal/service/ssp_versioning_service.py`
- Modify: `test/test_ssp_versioning_service.py`

- [ ] **Step 1-5:** 同 Task 3.3 模式 — 寫測試 → 實作 `_clone_system_implementations` helper

### Task 3.5: clone project_system_characteristic_mapping

**Files:**
- Modify: `app/oscal/service/ssp_versioning_service.py`
- Modify: `test/test_ssp_versioning_service.py`
- Read: `infra/associations/model/project_system_characteristic.py`

- [ ] **Step 1-5:** 同模式。實作 `_clone_project_characteristic_mapping(session, old_ssp, new_ssp)`：
  - 撈所有 mapping where `system_characteristic_id == old_ssp.system_characteristic.id`
  - 各 INSERT 一筆新 mapping，`system_characteristic_id` 指 `new_ssp.system_characteristic.id`
  - `project_id` 不變

  **依 Task 0.3 確認**：unique constraint `(project_id, characteristic_id)` 不會撞（new characteristic id 是新的）

### Task 3.6: clone control_implementations (CI) + 建 ci_id_map

**Files:**
- Modify: `app/oscal/service/ssp_versioning_service.py`
- Modify: `test/test_ssp_versioning_service.py`

- [ ] **Step 1: Write the failing test**

  ```python
  def test_clone_returns_ci_id_map(db_session, sample_ssp_with_cis):
      service = SspVersioningService()
      new_ap = create_dummy_ap()
      result = service.clone_to_new_version(db_session, sample_ssp_with_cis, new_ap, "test_user", return_id_maps=True)

      assert "ci_id_map" in result
      assert len(result["ci_id_map"]) == len(sample_ssp_with_cis.control_implementations)
      for old_ci in sample_ssp_with_cis.control_implementations:
          new_ci_id = result["ci_id_map"][old_ci.id]
          new_ci = db_session.get(OscalSystemSecurityPlanControlImplementation, new_ci_id)
          assert new_ci.system_security_plan_id == result["new_ssp"].id
          assert new_ci.implementation_description == old_ci.implementation_description
  ```

  **Note:** 修改 `clone_to_new_version` signature 加 `return_id_maps=False` 參數。
  預設 `False` 只回新 SSP；`True` 時回 `{"new_ssp": ..., "ci_id_map": {...}, "cio_id_map": {...}, "pool_doc_id_map": {...}}`。

- [ ] **Step 2-5:** 失敗 → 實作 `_clone_control_implementations` → PASS

### Task 3.7: clone ssp_control_implementation_objectives (CIO) 用 ci_id_map

**Files:**
- Modify: `app/oscal/service/ssp_versioning_service.py`
- Modify: `test/test_ssp_versioning_service.py`

- [ ] **Step 1-5:** 同模式。`_clone_objectives(session, old_ssp, new_ssp, ci_id_map)`：
  - 撈所有 CIO where `system_security_plan_id == old_ssp.id`
  - 各 INSERT 一筆，`control_implementation_id` 用 `ci_id_map[old.control_implementation_id]` 重對映
  - `system_security_plan_id` 改新 SSP id
  - `reference_documents` JSONB 直接 deep copy（裡面是 file 引用，不是 id ref）

### Task 3.8: clone ssp_reference_documents (池) + 建 pool_doc_id_map

**Files:**
- Modify: `app/oscal/service/ssp_versioning_service.py`
- Modify: `test/test_ssp_versioning_service.py`
- Read: `infra/grc/model/ssp_reference_document.py`

- [ ] **Step 1-5:** 同模式。`_clone_pool_docs(session, old_ssp, new_ssp, curr_user)`：
  - 撈池 where `context_type='ssp' AND context_id=old_ssp.id`
  - 各 INSERT 一筆，`context_id` 改 new_ssp.id，**file_id 共享**
  - 回 `pool_doc_id_map: {old_pool_doc.id → new_pool_doc.id}`

### Task 3.9: clone ssp_reference_document_mappings 用所有 id_maps

**Files:**
- Modify: `app/oscal/service/ssp_versioning_service.py`
- Modify: `test/test_ssp_versioning_service.py`

- [ ] **Step 1: 修改 service signature 接受 apc_id_map / apt_id_map**

  ```python
  def clone_to_new_version(
      self, session, old_ssp, new_ap, curr_user,
      apc_id_map: dict = None,  # {old_apc.id → new_apc.id}, caller 提供
      apt_id_map: dict = None,  # {old_apt.id → new_apt.id}, caller 提供
      logger=None,
      return_id_maps=False,
  ):
      ...
  ```

  **重要**：`apc_id_map` / `apt_id_map` 由 caller (`launch_new_round`) 提供，因為 apc/apt clone
  不在本 service 範圍（在 `OscalProjectService.launch_new_round` 既有邏輯內）。

- [ ] **Step 2: Write the failing test**

  ```python
  def test_clone_remaps_mapping_context_ids(db_session, sample_ssp_with_mappings):
      """測試 mapping 的 context_id 用 apc_id_map / apt_id_map / pool_doc_id_map 正確重對映"""
      service = SspVersioningService()
      new_ap = create_dummy_ap()
      apc_id_map = {old_apc.id: new_apc_id_fake for old_apc in sample_ssp_with_mappings.apcs}
      apt_id_map = {old_apt.id: new_apt_id_fake for old_apt in sample_ssp_with_mappings.apts}

      result = service.clone_to_new_version(
          db_session, sample_ssp_with_mappings, new_ap, "user",
          apc_id_map=apc_id_map, apt_id_map=apt_id_map,
          return_id_maps=True,
      )

      # 驗證新 mapping 的 context_id 已重對映
      from infra.grc.model.ssp_reference_document_mapping import SspReferenceDocumentMapping
      new_mappings = db_session.query(SspReferenceDocumentMapping).filter(
          SspReferenceDocumentMapping.reference_document_id.in_(result["pool_doc_id_map"].values())
      ).all()
      for m in new_mappings:
          if m.context_type == "control_implementation":
              assert m.context_id in apc_id_map.values()
          elif m.context_type == "objective":
              assert m.context_id in apt_id_map.values()
  ```

- [ ] **Step 3-5:** 失敗 → 實作 `_clone_mappings(session, old_ssp, new_ssp, pool_doc_id_map, apc_id_map, apt_id_map, curr_user)` → PASS

  實作邏輯：
  1. 撈所有 mappings where `reference_document_id IN old_pool_doc_ids`
  2. 對每筆 mapping：
     - 新 `reference_document_id = pool_doc_id_map[old.reference_document_id]`
     - 若 `context_type='control_implementation'`：新 `context_id = apc_id_map[old.context_id]`
     - 若 `context_type='objective'`：新 `context_id = apt_id_map[old.context_id]`
     - 其他 context_type 跳過 + log warning
  3. INSERT 新 mapping rows

### Task 3.10: 整合測試（end-to-end full clone）

**Files:**
- Modify: `test/test_ssp_versioning_service.py`

- [ ] **Step 1: Write integration test**

  ```python
  def test_full_clone_end_to_end(db_session, full_ssp_fixture):
      """fixture: 完整 SSP，含所有子表都至少一筆"""
      service = SspVersioningService()
      new_ap = create_dummy_ap()
      apc_id_map = {old.id: new.id for old, new in apc_pairs(full_ssp_fixture)}
      apt_id_map = {old.id: new.id for old, new in apt_pairs(full_ssp_fixture)}

      result = service.clone_to_new_version(
          db_session, full_ssp_fixture, new_ap, "user",
          apc_id_map=apc_id_map, apt_id_map=apt_id_map,
          return_id_maps=True,
      )
      new_ssp = result["new_ssp"]

      # 全部子表都 clone 了
      assert new_ssp.system_characteristic is not None
      assert len(new_ssp.system_implementations) == len(full_ssp_fixture.system_implementations)
      assert len(new_ssp.control_implementations) == len(full_ssp_fixture.control_implementations)
      # 池
      assert len(_query_pool(db_session, new_ssp.id)) == len(_query_pool(db_session, full_ssp_fixture.id))
      # mappings
      assert len(_query_mappings(db_session, new_ssp.id)) == len(_query_mappings(db_session, full_ssp_fixture.id))

      # 編 v2 不影響 v1
      new_ssp.control_implementations[0].implementation_description = "modified in v2"
      db_session.flush()
      db_session.refresh(full_ssp_fixture.control_implementations[0])
      assert full_ssp_fixture.control_implementations[0].implementation_description != "modified in v2"
  ```

- [ ] **Step 2: Run all SspVersioningService tests**

  Run: `pytest test/test_ssp_versioning_service.py -v`

  Expected: 全綠

---

## Phase 4 — launch_new_round 整合

### Task 4.1: 把 SspVersioningService inject 到 OscalProjectService

**Files:**
- Modify: `app/project/service/oscal_project_service.py`
- Modify: `di_containers/oscal/oscal_container.py`（或對應 container）
- Modify: `di_containers/project/project_container.py`（或對應 container）

- [ ] **Step 1: 註冊 SspVersioningService 到 DI container**

  Find oscal container 加：
  ```python
  ssp_versioning_service = providers.Factory(SspVersioningService)
  ```

- [ ] **Step 2: 在 OscalProjectService `__init__` 加參數**

  ```python
  def __init__(
      self,
      ...,
      ssp_versioning_service: SspVersioningService,
  ):
      ...
      self._ssp_versioning_service = ssp_versioning_service
  ```

- [ ] **Step 3: 在 project container wire dependency**

  注入 oscal container 的 `ssp_versioning_service` 給 OscalProjectService。

- [ ] **Step 4: Smoke test — 啟 server**

  Run: `cd /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be && set -a; source .env; set +a; python main_app.py`

  Expected: 啟動成功，無 DI 錯誤

  **Note**：依 user feedback `feedback_di_no_bidirectional_override.md`，避免雙向 DI override；用單向注入。

  **Note**：依 user feedback `feedback_backend_restart_orphan_pids.md`，restart 用 `lsof -i :8000` 找實際 listener 而非 `pgrep`。

### Task 4.2: 修改 launch_new_round 呼叫 SspVersioningService

**Files:**
- Modify: `app/project/service/oscal_project_service.py:447` (`launch_new_round`)

- [ ] **Step 1: 讀現有 launch_new_round 完整邏輯**

  Read `app/project/service/oscal_project_service.py:447-687`（含 `_clone_ssp_objectives`、`_clone_control_doc_mappings`）。

  記下：
  - 哪一步建立 new_ap
  - 哪一步建立 new apc / apt（apc_id_map / apt_id_map 應該在哪裡產生）
  - 現有 `_clone_ssp_objectives` 跟 `_clone_control_doc_mappings` 在哪一步呼叫

- [ ] **Step 2: 改寫 launch_new_round 流程**

  > **與 spec §3.2 順序的偏離**：spec 描述「先 clone SSP（step 2）→ 後建 new_ap（step 4）」
  > 是邏輯順序（強調 mapping clone 必須在 new_apc/apt 之後）。實作上反向比較順：先建
  > new_ap（既有邏輯）+ apc/apt（取 apc_id_map / apt_id_map）→ 再呼叫 SspVersioningService。
  > new_ap.ssp_id 暫設舊值，clone 完再 UPDATE。功能等價。

  新流程：
  1. 讀 old_ap、old_ssp
  2. 建 new_ap（先暫不設 ssp_id，或先設舊 ssp_id 待 clone 完更新）
  3. clone apc → **建 apc_id_map**（必須在 step 6 前完成）
  4. clone apt → **建 apt_id_map**（必須在 step 6 前完成）
  5. clone workflow_template / workflow_executions（既有邏輯）
  6. **呼叫 `self._ssp_versioning_service.clone_to_new_version(session, old_ssp, new_ap, curr_user, apc_id_map=apc_id_map, apt_id_map=apt_id_map, return_id_maps=True)`** → 取得 new_ssp
  7. UPDATE `new_ap.ssp_id = new_ssp.id`
  8. 移除既有 `_clone_ssp_objectives` 跟 `_clone_control_doc_mappings` 呼叫

- [ ] **Step 3: 跑現有 launch_new_round integration test**

  Run: `pytest test/test_oscal_project_service_template_copy_integration.py -v`

  Expected: 既有測試會 FAIL（因為流程改了），這預期內。Phase 4.4 會 update 測試。

### Task 4.3: 移除既有 `_clone_ssp_objectives` 跟 `_clone_control_doc_mappings`

**Files:**
- Modify: `app/project/service/oscal_project_service.py:588`（`_clone_ssp_objectives`）
- Modify: `app/project/service/oscal_project_service.py:658`（`_clone_control_doc_mappings`）

- [ ] **Step 1: 確認新流程已涵蓋這兩段邏輯**

  Verify：
  - `_clone_ssp_objectives` 邏輯 = `SspVersioningService._clone_objectives`
  - `_clone_control_doc_mappings` 邏輯 = `SspVersioningService._clone_mappings` 的 control 部分

- [ ] **Step 2: 刪除這兩個 method**

- [ ] **Step 3: Smoke test**

  Run: `python -c "from app.project.service.oscal_project_service import OscalProjectService"`

  Expected: import 成功，無 reference error

### Task 4.4: 改寫 integration test for launch_new_round

**Files:**
- Modify: `test/test_oscal_project_service_template_copy_integration.py`

- [ ] **Step 1: 列出失效的測試**

  Run: `pytest test/test_oscal_project_service_template_copy_integration.py -v 2>&1 | grep FAIL`

- [ ] **Step 2: 對每個失效測試**：依新流程 update assertion
  - 期待 new_ssp 是新 row（uid != old）
  - new_ssp.version_no = old + 1
  - new_ssp.group_id = old.group_id
  - new_ap.ssp_id = new_ssp.id
  - 所有子表都 clone 了

- [ ] **Step 3: 加新測試**

  ```python
  def test_launch_new_round_creates_new_ssp_version_independent_of_old():
      """launch_new_round 後編輯 v2 不影響 v1"""
      project = create_test_project()
      ap1 = launch_initial_ap(project)
      old_ssp = ap1.ssp

      ap2 = service.launch_new_round(project.uid, user_id=1, title="Round 2")
      new_ssp = ap2.ssp

      assert new_ssp.id != old_ssp.id
      assert new_ssp.group_id == old_ssp.group_id
      assert new_ssp.version_no == 2

      # 編 v2 不影響 v1
      pool_service.delete_from_pool(_some_doc_uid_in_v2(new_ssp))
      assert _pool_count(old_ssp) == _original_count
  ```

- [ ] **Step 4: 跑全測試套件**

  Run: `pytest test/ -v`

  Expected: 全綠

---

## Phase 5 — API Response 加 new_ssp_uid

### Task 5.1: launch_new_round response 加新 SSP 資訊

**Files:**
- Read: `api/grc/routes/grc_project_route.py`（找 `launch-new-round` route）
- Modify: launch_new_round route 的 response serializer

- [ ] **Step 1: Find launch_new_round route**

  Run:
  ```bash
  grep -rn "launch-new-round\|launch_new_round" api/grc/
  ```

- [ ] **Step 2: 確認 service 層回傳結構含 new_ssp 資訊**

  Modify `OscalProjectService.launch_new_round` 回傳 dict：
  ```python
  return {
      "new_ap": new_ap,  # 既有
      "new_ssp_uid": str(new_ssp.uid),
      "new_ssp_version": new_ssp.version_no,
  }
  ```

- [ ] **Step 3: 改 route 把這些欄位放進 response**

- [ ] **Step 4: Test API endpoint**

  Run（用 dev 帳號 blsadmin / Billows@123!）:
  ```bash
  # 1. 取 token
  # 2. POST /grc/projects/<uid>/launch-new-round
  # 3. 確認 response 含 new_ssp_uid / new_ssp_version
  ```

  Expected: response 含新欄位

---

## Phase 6 — get_project_full_by_uid 確認

### Task 6.1: 驗證現況

**Files:**
- Read: `infra/grc/repository/grc_project_repo_impl.py:622-638`

- [ ] **Step 1: 確認現況已是用 latest AP 的 ssp_id**

  Pre-flight 已確認該方法用 `ap.ssp_id`，且 ap 取自 `latest AP by created_at desc`（`papm` 變數）。
  不需改動。

- [ ] **Step 2: 加單元測試保護**

  寫測試：建專案 + 兩個 AP（指向 v1 / v2 不同 SSP）→ `get_project_full_by_uid` 回 v2

  Run + verify。

---

## Phase 7 — Frontend Migration

### Task 7.1: Grep 所有 `project.ssp_uid` 使用點

**Files:** `~/Projects/Billows/Audit-Manager/compliance-manager-fe/`

- [ ] **Step 1: 全專案 grep**

  Run:
  ```bash
  cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
  grep -rn "project.ssp_uid\|project\.ssp_uid\|ssp_uid" src/ --include="*.vue" --include="*.js" --include="*.ts"
  ```

- [ ] **Step 2: 列出受影響檔案 + 標記來源**

  對每個 hit 標記：
  - 🟢 已從 AP context 取（不用改）
  - 🔴 從 project.ssp_uid 取（要改）

- [ ] **Step 3: 把列表存到 plan 註記**

  把 🔴 列表寫成 Task 7.2 - 7.N 的 sub-task

### Task 7.2-N: 對每個 🔴 檔案，改成從 currentAp 取

每個檔案一個 sub-task：

- [ ] **Step 1:** Read 檔案
- [ ] **Step 2:** 確認 currentAp / activeAp 在該頁面是否可用（從 store / props / route）
- [ ] **Step 3:** 改 ssp_uid 來源
- [ ] **Step 4:** 在 browser 測試該頁面（按 CLAUDE.md「UI 變更要在瀏覽器測過」）

### Task 7.X: launch_new_round 後跳轉

**Files:**
- Modify: 對應的 Vue component（之前負責呼叫 `launch_new_round` API 的地方）

- [ ] **Step 1: 改用 response 的 new_ssp_uid 跳轉**

  既有：跳到新 AP 詳情頁
  新增：用 `new_ssp_uid` 切到新版本 SSP context

- [ ] **Step 2: 在 browser 測試 — launch Round 2 → 切到新 AP / 新 SSP version**

---

## Phase 8 — Cleanup + Final Test

### Task 8.1: 跑完整測試套件

- [ ] **Step 1:**

  Run: `pytest test/ -v`

  Expected: 全綠

### Task 8.2: 檢查 dead code

- [ ] **Step 1: Grep 確認無 reference 到已刪 method**

  Run:
  ```bash
  grep -rn "_clone_control_doc_mappings\|_clone_ssp_objectives" app/ infra/ api/
  ```

  Expected: 0 hits

### Task 8.3: Manual e2e smoke test

- [ ] **Step 1: 啟 BE + FE**

  Run BE: `python main_app.py`
  Run FE: `cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe && npm run dev`

- [ ] **Step 2: 在瀏覽器執行情境**

  1. 用 blsadmin / Billows@123! 登入
  2. 啟動新專案
  3. AP1 編輯實施現況、上傳程序書、加 mapping
  4. Launch Round 2
  5. AP2 編輯實施現況（改個值）、刪一份程序書
  6. 切回 AP1 紀錄頁面 → **驗證**：實施現況、程序書都還是 AP1 的內容

  Expected: AP1 完全不受 AP2 編輯影響

---

## Phase 9 — Documentation

### Task 9.1: 寫 changelog

**Files:**
- Create: `docs/changelog/YYYY-MM-DD（實作當日）-ssp-versioning.md`

- [ ] **Step 1:**

  ```markdown
  # YYYY-MM-DD（實作當日） — SSP Versioning

  ## 需求
  每輪稽核 (AP) 1:1 對應一個 SSP 版本，避免編輯本輪 SSP 影響歷史輪次。

  ## 設計決策
  ... (摘要 spec 重點)

  ## 變更檔案
  | 檔案 | 變更 |
  |---|---|
  | `jedi-oscal/jedi_oscal/infra/model/ssp/ssp.py` | 加 `group_id` / `version_no` 欄位 |
  | `app/oscal/service/ssp_versioning_service.py` | 新建：full SSP clone service |
  | `app/project/service/oscal_project_service.py` | `launch_new_round` 改呼叫 SspVersioningService |
  | `scripts/sql/YYYY-MM-DD（實作當日）-ssp-versioning.sql` | DB migration |
  | （前端各檔）| `project.ssp_uid` 改成從 AP context 取 |

  ## API 變更
  - `POST /grc/projects/<uid>/launch-new-round` response 新增 `new_ssp_uid` / `new_ssp_version`

  ## 教訓
  - SSP 池 per-SSP（不是 per-AP）的設計，當「一專案一 SSP」時隱性沒問題，但版本化時必須整份 clone
  - jedi-survey 的 `group_id + version_no` 模式適用於任何「需要版本快照」的 entity
  ```

### Task 9.2: 更新 design.md §6.7（若存在）

**Files:**
- Modify: `docs/api/module-frame/design.md`

- [ ] **Step 1: 確認 §6.7 是否存在**

  Run:
  ```bash
  grep -n "Option X\|6.7" docs/api/module-frame/design.md
  ```

  若不存在跳過此 task。

- [ ] **Step 2: 若存在，把「Option X — Path A (latest AP only)」標記為 deprecated**

  改成：「已被『SSP 版本化』整體方案取代，見 `docs/features/FR-019-2604-ssp-versioning/design.md`」

### Task 9.3: 更新 spec / SD（如有 SSP 相關 docs）

**Files:**
- 若有 `docs/api/oscal/design.md` 之類，新增 SSP versioning 章節

### Task 9.4: 更新 MEMORY.md（指向新 spec）

**Files:**
- Modify: `/Users/chouraymond/.claude/projects/-Users-chouraymond-Projects-Billows-Audit-Manager-compliance-manager-be/memory/MEMORY.md`

- [ ] **Step 1:** 在「進行中 / 已上線 feature 座標」加一筆 SSP versioning entry

---

## 任務間依賴 / 並行性

```
Phase 0 (verification) → Phase 1 (jedi-oscal) → Phase 2 (DB migration)
                                                       ↓
                                              Phase 3 (SspVersioningService)
                                                       ↓
                                              Phase 4 (launch_new_round 整合)
                                                       ↓
                              Phase 5 (API response) ─┴─ Phase 6 (get_project_full_by_uid)
                                                       ↓
                                              Phase 7 (Frontend)
                                                       ↓
                                              Phase 8 (Cleanup) → Phase 9 (Docs)
```

Phase 5 / 6 可並行。其餘需序列。

---

## Risk Watch List

- **jedi-oscal 套件進版**：依 user feedback 需明示批准（Task 0.1）
- **launch_new_round 既有測試 FAIL**：預期內，Task 4.4 統一改寫
- **FE migration 範圍**：grep 結果可能比預期多，Task 7.1 完成後重估剩餘工作量
- **大量 deep copy 對 perf 影響**：先不擔心，PostgreSQL 跟 SQLAlchemy 應對得了；觀察測試耗時
- **跨 schema FK**：`project_system_characteristic_mapping` 在 associations schema，cascade 要 verify（Task 0.3）
