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_plansgroup_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 明確指示才能進版發佈

§1

Phase 0 — Pre-flight Verification

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

Files: none(純對話確認)

  • 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: OscalMetadataOscalDocument 在主專案的所有使用點
  • Run:

    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」(檔案來源、上傳資訊),跟「系統識別」無關

  • Use Grep to find metadata_iddocument_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
  • Read infra/associations/model/project_system_characteristic.py 整份。

    Expected: 確認 unique constraint(如 (project_id, characteristic_id))跟 ON DELETE CASCADE 行為。記下這些 constraint 給 Phase 3 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
  • Run:

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

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


§2

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(新建)
  • # 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
  • Run: cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal && pytest tests/test_ssp_versioning_fields.py -v

    Expected: FAIL(欄位不存在)

  • ssp.pyOscalSystemSecurityPlan class 內,description 欄位之後加:

    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:

    __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

  • 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(若有)
  • Run:

    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/
  • Same TDD pattern:先寫測試確認欄位存在,再加。

Task 1.3: 跑完整套件測試

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

    Expected: 全綠

Task 1.4: 進版(user 批准後)

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

  • Modify pyproject.toml:bump jedi-oscal 版本

    Run: poetry lock && poetry install

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


§3

Phase 2 — DB Migration

Task 2.1: 寫 migration SQL

Files:

  • Create: scripts/sql/YYYY-MM-DD(實作當日)-ssp-versioning.sql(檔名 X 換成實際日期)
  • -- 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

  • Run(使用 BE/.env 連接資訊):

    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

  • Run:

    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)
  • Run:

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

    Expected: uq_ssp_group_version 出現


§4

Phase 3 — SspVersioningService(核心 clone 邏輯)

Test fixture 約定:本 phase 所有測試的 fixture(sample_sspcreate_dummy_apapc_pairsfull_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
  • # 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")
  • Run: pytest test/test_ssp_versioning_service.py -v

    Expected: FAIL(class 不存在)

  • # 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
  • 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
  • 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)。

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

    Expected: FAIL(NotImplementedError)

  • 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

  • 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
  • Run: cat ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/infra/model/ssp/ssp_system_characteristic.py

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

  • 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"
  • Expected: FAIL(沒 clone)

  • 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。

  • Expected: PASS

Task 3.4: clone system_implementations + 旗下

Files:

  • Modify: app/oscal/service/ssp_versioning_service.py
  • Modify: test/test_ssp_versioning_service.py

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
    • 撈所有 mapping where system_characteristic_id == old_ssp.system_characteristic.id
    • 各 INSERT 一筆新 mapping,system_characteristic_idnew_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
  • 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": {...}}

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
    • 撈所有 CIO where system_security_plan_id == old_ssp.id
    • 各 INSERT 一筆,control_implementation_idci_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
    • 撈池 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
  • 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 既有邏輯內)。

  • 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()
  • 實作邏輯:

    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
  • 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"
  • Run: pytest test/test_ssp_versioning_service.py -v

    Expected: 全綠


§5

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)
  • Find oscal container 加:

    ssp_versioning_service = providers.Factory(SspVersioningService)
  • def __init__(
        self,
        ...,
        ssp_versioning_service: SspVersioningService,
    ):
        ...
        self._ssp_versioning_service = ssp_versioning_service
  • 注入 oscal container 的 ssp_versioning_service 給 OscalProjectService。

  • 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)
  • 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 在哪一步呼叫
  • 與 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 呼叫
  • 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
  • Verify:

    • _clone_ssp_objectives 邏輯 = SspVersioningService._clone_objectives
    • _clone_control_doc_mappings 邏輯 = SspVersioningService._clone_mappings 的 control 部分
  • 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
  • Run: pytest test/test_oscal_project_service_template_copy_integration.py -v 2>&1 | grep FAIL

    • 期待 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 了
  • 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
  • Run: pytest test/ -v

    Expected: 全綠


§6

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
  • Run:

    grep -rn "launch-new-round\|launch_new_round" api/grc/
  • Modify OscalProjectService.launch_new_round 回傳 dict:

    return {
        "new_ap": new_ap,  # 既有
        "new_ssp_uid": str(new_ssp.uid),
        "new_ssp_version": new_ssp.version_no,
    }
  • Run(用 dev 帳號 blsadmin / Billows@123!):

    # 1. 取 token
    # 2. POST /grc/projects/<uid>/launch-new-round
    # 3. 確認 response 含 new_ssp_uid / new_ssp_version

    Expected: response 含新欄位


§7

Phase 6 — get_project_full_by_uid 確認

Task 6.1: 驗證現況

Files:

  • Read: infra/grc/repository/grc_project_repo_impl.py:622-638
  • Pre-flight 已確認該方法用 ap.ssp_id,且 ap 取自 latest AP by created_at descpapm 變數)。 不需改動。

  • 寫測試:建專案 + 兩個 AP(指向 v1 / v2 不同 SSP)→ get_project_full_by_uid 回 v2

    Run + verify。


§8

Phase 7 — Frontend Migration

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

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

  • Run:

    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"
  • 對每個 hit 標記:

    • 🟢 已從 AP context 取(不用改)
    • 🔴 從 project.ssp_uid 取(要改)
  • 把 🔴 列表寫成 Task 7.2 - 7.N 的 sub-task

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

每個檔案一個 sub-task:

Task 7.X: launch_new_round 後跳轉

Files:

  • Modify: 對應的 Vue component(之前負責呼叫 launch_new_round API 的地方)
  • 既有:跳到新 AP 詳情頁 新增:用 new_ssp_uid 切到新版本 SSP context


§9

Phase 8 — Cleanup + Final Test

Task 8.1: 跑完整測試套件

  • Run: pytest test/ -v

    Expected: 全綠

Task 8.2: 檢查 dead code

  • Run:

    grep -rn "_clone_control_doc_mappings\|_clone_ssp_objectives" app/ infra/ api/

    Expected: 0 hits

Task 8.3: Manual e2e smoke test

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

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

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


§10

Phase 9 — Documentation

Task 9.1: 寫 changelog

Files:

  • Create: docs/changelog/YYYY-MM-DD(實作當日)-ssp-versioning.md
  • # 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
  • Run:

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

    若不存在跳過此 task。

  • 改成:「已被『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

§11

任務間依賴 / 並行性

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 可並行。其餘需序列。


§12

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 FKproject_system_characteristic_mapping 在 associations schema,cascade 要 verify(Task 0.3)