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_objectivesTech Stack:
~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/)— 需進版Spec reference: docs/features/FR-019-2604-ssp-versioning/design.md
遵守事項:
docs/changelog/ 建立紀錄(YYYY-MM-DD-<簡述>.md)@transaction、Repo 不在 __init__ 呼叫 get_session()Files: none(純對話確認)
Expected: user 確認可進版。若 user 暫不批准,本 plan 暫停或改走「主專案 monkey-patch + 等套件進版」變通方案。
Files:
~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/infra/model/base/oscal_metadata.py~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/infra/model/base/oscal_document.pyOscalMetadata、OscalDocument 在主專案的所有使用點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.pyExpected: 確認這兩張表是「文件 origin metadata」(檔案來源、上傳資訊),跟「系統識別」無關
Use Grep to find metadata_id、document_id 的所有寫入點。確認沒有「per-version 必須不同」的場景。
Expected: 共享安全。如有疑慮,回 spec §11.2 重新討論。
Files:
infra/associations/model/project_system_characteristic.pydomain/associations/repository/project_system_characteristic_mapping.pyRead 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 設計筆記。
Files:
~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/**/*.pyRun:
grep -rn "system_security_plans.id" ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/Expected: 完整列表 — 確認 Phase 3 clone 範圍涵蓋所有子表,沒有漏
Files:
~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/infra/model/ssp/ssp.py~/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 == 1Run: cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal && pytest tests/test_ssp_versioning_fields.py -v
Expected: FAIL(欄位不存在)
在 ssp.py 的 OscalSystemSecurityPlan 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
Files:
~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/domain/entity/ssp/ssp_entity.py(若有)~/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:先寫測試確認欄位存在,再加。
Run: cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal && pytest
Expected: 全綠
參考 ~/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
Files:
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);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.sqlExpected: 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 = 0version_no = 1COUNT(*) == 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 出現
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。
Files:
app/oscal/service/ssp_versioning_service.pytest/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 NotImplementedErrorRun: pytest test/test_ssp_versioning_service.py -v
Expected: PASS
Files:
app/oscal/service/ssp_versioning_service.pytest/test_ssp_versioning_service.pydef 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
Files:
app/oscal/service/ssp_versioning_service.pytest/test_ssp_versioning_service.pyRun: 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
Files:
app/oscal/service/ssp_versioning_service.pytest/test_ssp_versioning_service.pyFiles:
app/oscal/service/ssp_versioning_service.pytest/test_ssp_versioning_service.pyinfra/associations/model/project_system_characteristic.pysystem_characteristic_id == old_ssp.system_characteristic.idsystem_characteristic_id 指 new_ssp.system_characteristic.idproject_id 不變依 Task 0.3 確認:unique constraint (project_id, characteristic_id) 不會撞(new characteristic id 是新的)
Files:
app/oscal/service/ssp_versioning_service.pytest/test_ssp_versioning_service.pydef 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_descriptionNote: 修改 clone_to_new_version signature 加 return_id_maps=False 參數。 預設 False 只回新 SSP;True 時回 {"new_ssp": ..., "ci_id_map": {...}, "cio_id_map": {...}, "pool_doc_id_map": {...}}。
Files:
app/oscal/service/ssp_versioning_service.pytest/test_ssp_versioning_service.pysystem_security_plan_id == old_ssp.idcontrol_implementation_id 用 ci_id_map[old.control_implementation_id] 重對映system_security_plan_id 改新 SSP idreference_documents JSONB 直接 deep copy(裡面是 file 引用,不是 id ref)Files:
app/oscal/service/ssp_versioning_service.pytest/test_ssp_versioning_service.pyinfra/grc/model/ssp_reference_document.pycontext_type='ssp' AND context_id=old_ssp.idcontext_id 改 new_ssp.id,file_id 共享pool_doc_id_map: {old_pool_doc.id → new_pool_doc.id}Files:
app/oscal/service/ssp_versioning_service.pytest/test_ssp_versioning_service.pydef 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()實作邏輯:
reference_document_id IN old_pool_doc_idsreference_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]Files:
test/test_ssp_versioning_service.pydef 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: 全綠
Files:
app/project/service/oscal_project_service.pydi_containers/oscal/oscal_container.py(或對應 container)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。
Files:
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)。
記下:
_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。功能等價。
新流程:
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_sspnew_ap.ssp_id = new_ssp.id_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 測試。
_clone_ssp_objectives 跟 _clone_control_doc_mappingsFiles:
app/project/service/oscal_project_service.py:588(_clone_ssp_objectives)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
Files:
test/test_oscal_project_service_template_copy_integration.pyRun: pytest test/test_oscal_project_service_template_copy_integration.py -v 2>&1 | grep FAIL
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_countRun: pytest test/ -v
Expected: 全綠
Files:
api/grc/routes/grc_project_route.py(找 launch-new-round route)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_versionExpected: response 含新欄位
Files:
infra/grc/repository/grc_project_repo_impl.py:622-638Pre-flight 已確認該方法用 ap.ssp_id,且 ap 取自 latest AP by created_at desc(papm 變數)。 不需改動。
寫測試:建專案 + 兩個 AP(指向 v1 / v2 不同 SSP)→ get_project_full_by_uid 回 v2
Run + verify。
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 標記:
把 🔴 列表寫成 Task 7.2 - 7.N 的 sub-task
每個檔案一個 sub-task:
Files:
launch_new_round API 的地方)既有:跳到新 AP 詳情頁 新增:用 new_ssp_uid 切到新版本 SSP context
Run: pytest test/ -v
Expected: 全綠
Run:
grep -rn "_clone_control_doc_mappings\|_clone_ssp_objectives" app/ infra/ api/Expected: 0 hits
Run BE: python main_app.py Run FE: cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe && npm run dev
Expected: AP1 完全不受 AP2 編輯影響
Files:
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` 模式適用於任何「需要版本快照」的 entityFiles:
docs/api/module-frame/design.mdRun:
grep -n "Option X\|6.7" docs/api/module-frame/design.md若不存在跳過此 task。
改成:「已被『SSP 版本化』整體方案取代,見 docs/features/FR-019-2604-ssp-versioning/design.md」
Files:
docs/api/oscal/design.md 之類,新增 SSP versioning 章節Files:
/Users/chouraymond/.claude/projects/-Users-chouraymond-Projects-Billows-Audit-Manager-compliance-manager-be/memory/MEMORY.mdPhase 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 可並行。其餘需序列。
project_system_characteristic_mapping 在 associations schema,cascade 要 verify(Task 0.3)