狀態(2026-06-16):7 phase 全部實作 + review(spec + code quality 兩段式)+ 單元/整合測試 + BOOT OK + baseline 零新回歸 完成。 另含過夜抓到的兩個真 bug 修復(D1 docx source_type 契約、D3 by-component 敘述被丟)。⚠️ real-DB HTTP smoke 待 user 驗收(專案 266 / ssp 1482)—— 未標 FIXED / shipped。收尾文件見
docs/changelog/2026-06-16-*、SUMMARY(handoff/2026-06-16-import-ssp-restore-SUMMARY.md)、分析(docs/analysis/2026-06-16-ssp-import-restore-w2-architecture-and-bugs.md)。
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: 把 FR-011.2 已出貨的「SSP 差異更新匯入」體驗(上傳 docx/excel → 逐項差異比對 → 接受/保留決策 → 人員自動比對 → 手動指派 → 寫入)整套接回 jedi_oscal_v2,驅動目標是讓專案 SSP(piece 3)**能匯入更新;資源庫重複匯入是 by-product。
Architecture: 套件 OscalIoService.import_ssp 加 mode='update'(非空 SSP 逐子物件 upsert)+ build_ssp_snapshot(ssp_id)(current 端 OSCAL 快照)。主專案 confirm 流程改成 parse → adapter(parsed→OSCAL dict) → diff(vs snapshot) → 逐項 decision → merge 成最終 OSCAL dict → import_ssp(mode='update')。人員比對在 parse/preview 時對 parsed parties 跑三層配對並回填,confirm 時隨 merge dict 寫入。
Tech Stack: Python 3.11 / Flask-RESTful / SQLAlchemy / dependency-injector / pytest;套件 jedi_oscal_v2(dev 走 poetry path-dep,不 commit pyproject.toml);DDD 分層(api/app/domain/infra)。
決策 W:decision 在 OSCAL-dict 層 merge,單一寫入路徑 import_ssp(mode='update')(取代「復活 v1 write strategy」)
ssp_write_strategy / module_frame_write_strategy 改走 v2 SspService 子物件 CRUD、依 decisions 逐子物件寫入(= 復活 v1 那 1000+ 行 strategy)。import_ssp(mode='update') 一次寫入。import_ssp(update) 做「逐子物件 upsert」。若再復活 write strategy 做第二套子物件寫入,等於兩套寫入路徑(套件 upsert + 主專案 CRUD),違反 design.md §3「避免兩套寫入」。W2 讓 import_ssp(update) 是唯一 writer,decision 退化成 dict 選擇/合併(純資料轉換、好測),write strategy 的角色被 merge 步驟取代。v1
ssp_write_strategy/module_frame_write_strategy/i_ssp_docx_write_strategy不刪檔、停用(design.md §8);本 plan 不 wire 它們進 boot(仍 import v1 jedi_oscal entity,會撞 MetaData)。它們只當 diff/reconcile 邏輯的 port 參照。
套件 jedi_oscal_v2(jedi-oscal-v2/jedi_oscal_v2/)
app/service/io/oscal_io_service.py:
import_ssp(oscal_ssp, *, target_ssp_id, curr_user, mode='create') :247-320。mode!='create' raise :278;非空 guard(_sys_char_repo/_sys_impl_repo/_control_impl_repo 的 get_by_ssp 任一非 None)raise :289-298。_import_system_implementation(先,產 comp_map: {component-uuid → new id})→ roles / parties / system-characteristics / control-implementation。各 _import_* 全 insert-only。_import_* 清單:_import_roles :817、_import_parties :835、_import_system_characteristics :859(含 information-types nested)、_import_system_implementation :926(含 components/leveraged/inventory nested)、_import_control_implementation :988(含 IR + statements + by-components nested)、_import_by_component :1047(dual-parent 擇一:implemented_requirement_id 或 statement_id)。__init__ 注入 20 repo(_ssp_repo/_sys_char_repo/_sys_impl_repo/_component_repo/_control_impl_repo/_impl_req_repo/_statement_repo/_by_component_repo/_leveraged_repo/_inventory_repo/_info_type_repo/_metadata_repo/_role_repo/_party_repo/_resource_repo/catalog 5 + ar/poam 6)。_export_ssp / _build_metadata :323 / _build_system_characteristics / _build_system_implementation / _build_control_implementation。OSCAL key 為 kebab-case。app/service/ssp/ssp_service.py:完整 CRUD。1:1 子物件:get_system_characteristics(ssp_id) / upsert_system_characteristics(sc) :135、get_or_create_system_implementation(ssp_id) :199、get_or_create_control_implementation(ssp_id) :212。集合子物件每類 add_/list_/get_/update_/delete_:components / implemented_requirements / by_components / inventory_items / leveraged_authorizations / statements / parties。另 list_*_for_ssp(list_components_for_ssp :226 / list_inventory_items :241 / list_leveraged_authorizations :258 / list_implemented_requirements_for_ssp :279)、list_statements(ir_id) :299、list_parties(metadata_id) :328、deep_clone_ssp :347。app/service/ssp/ssp_clone_service.py:寫入順序 + by-component component_id remap pattern(component_id_map.get(bc.component_id, bc.component_id))= update-mode upsert 的寫入順序/remap 參照。(control_implementation_id, control_id);statement=(implemented_requirement_id, statement_id);by-component=(parent_id, component_id);party=(metadata_id, type, name);component=(system_implementation_id, title, type);role=(metadata_id, role_id);sys_char/sys_impl/control_impl 為 1:1 by ssp_id。parties 表(infra/model/base/oscal_party.py:14)無 matched_user_id/matched_org_unit_id 欄位 — 只有標準 OSCAL 欄 + props/external_ids/links(jsonb)。⚠️ reconciliation 結果在 v2 要存哪是 Phase 5 pre-flight 決策。tests/io_export/test_oscal_import.py(test_import_ssp_roundtrip / _nonempty_target_raises / _unknown_target_raises)。主專案 V1 disabled(port 參照,import v1 entity,不可 wire)
app/oscal/service/ssp_docx_diff_service.py:DiffStatus = Literal["unchanged","changed","added","gone"](4 值 :8)套在 7 個 section(control 敘述 / per-AO 敘述 / parties / components / leveraged / inventory / system_characteristic)。compute_default_action_for_text :31、compute_party_diff :149、compute_component_diff :433、compute_leveraged_diff :446、compute_inventory_diff :459、compute_sc_diff :477、build_diff_summary :675、annotate_parse_result :727(current 端目前讀 v1 entity → dict,要改吃 snapshot dict)。smart default:current 空+parsed 有→added/None;current 有+parsed 空→gone/keep_current;相等→unchanged/None;不等→changed/keep_current。domain/oscal/service/reconciliation/:base.py BaseReconciliationService(三層 fallback _try_user_selected_match→_try_exact_match→_try_normalized_match→_try_fuzzy_match→_apply_unmatched)、person_reconciler.py(email exact/normalized + domain fuzzy)、organization_reconciler.py(name + suffix fuzzy)、match_method.py MatchMethod(USER_SELECTED/EXACT/NORMALIZED/FUZZY_EMAIL_DOMAIN/FUZZY_NAME_PREFIX/UNMATCHED)。orchestrator domain/oscal/service/party_reconciliation_service.py reconcile(parties, tenant_id) 原地回填 matched_*_id/match_method。其餘 reconciler:catalog_control / assessment_objective / leveraged / system_characteristic / ssp_entity_orchestrator。domain/oscal/strategy/i_ssp_docx_write_strategy.py:ImportDecisions{decisions:list[dict], manual_assignments:list[dict], skipped_paragraph_idxs, predicted_controls_user_selection}、ImportResult{created,updated,skipped,manual_assigned,manual_skipped,missing_left_blank}。decisions[i] = {control_id, action:'use_docx'|'keep_current'|'skip', objectives:[{objective_key, action}]}。主專案 V2 現行管線
app/oscal/service/import_adapter/docx_to_oscal_ssp.py parsed_docx_to_oscal_ssp(parsed_result)->(oscal_dict, warnings)、excel_to_oscal_ssp.py parsed_excel_to_oscal_ssp(...)、共用 _common.py(soa_props(applicable, impl_status)、maybe_by_component(...)、assemble(...))。SoA props key:applicability(applicable/not-applicable)、implementation-status;by-component 狀態 {"state": ...}。v2_candidate_loader.py load_candidates_from_catalog(catalog_id, *, control_repo, part_repo)。app/oscal/service/ssp_excel_import_app_service.py:confirm_import :231 依 job.source_type 分派 _confirm_create_resource_library(framework_version) / _confirm_update_module_frame(module_frame);_fill_template_ssp(template_ssp_id, parsed_result, user_context) :343(寫死 mode="create",是注入更新模式的點)。_VALID_SOURCE_TYPES={"framework_version","module_frame"}。app/oscal/service/ssp_docx_import_app_service.py:_VALID_SOURCE_TYPES={"module_frame"} :49;confirm_import :223;parse 時對 parties 跑 self._reconciliation.reconcile(預覽級,不回填寫入):324-328。app/oscal/service/export/ssp_v2_content_loader.py SspV2ContentLoader.load(ssp_id, *, source_type, source_uid):已把整個 v2 SSP 讀成 SspExportDataModel(snapshot current 端可借此讀法或套件內 _export_ssp 反向)。config/di_modules.py:13 EXCLUDE_MODULES 現僅含 api.oscal.routes.ssp.ssp_scoped_excel_import_route(專案 excel,dark)+ 框架/AP/AR/profile B 系列;docx/excel 資源庫 route 已 re-enabled。api/oscal/routes/ssp/ssp_scoped_excel_import_route.py(存在)force source_type="ssp"、source_uid 取自 URL、共用 SspExcelImportAppService。同步要在 api/oscal 的 create_module() 註冊 blueprint。domain/oscal/service/ssp_excel_parse_job_domain_service.py / ssp_docx_parse_job_domain_service.py(create/get_one/update_status/write_parsed_result/write_error/write_import_summary/deactivate;parsed_result JSONB)。domain/oscal/repository/i_ssp_catalog_title_query.py(get_titles_by_ssp_id / get_ao_list_by_ssp_id / get_objective_titles_by_ssp_id),impl infra/oscal/repository/ssp_catalog_title_query.py。di_containers/oscal/oscal_containers.py 註冊 ssp_excel_import_app_service(:339-355)/ ssp_docx_import_app_service(:357-387);oscal_io_service / resource_library_app_service / framework_service 為共用 primitive。套件 jedi_oscal_v2(dev path-dep,整弧完才發 Nexus)
jedi_oscal_v2/app/service/io/oscal_io_service.py — import_ssp 加 update mode + 子物件 upsert helper + build_ssp_snapshottests/io_export/test_oscal_import_update.py — update mode upsert + round-trip(新檔)tests/io_export/test_oscal_snapshot.py — build_ssp_snapshot 鏡像 export(新檔)主專案 — diff / merge / reconcile(接 v2,新檔或改 disabled 檔的 v2 版)
app/oscal/service/import_diff/ssp_diff_service.py — port 自 ssp_docx_diff_service.py,current 端吃 snapshot dict(不 import v1 entity)app/oscal/service/import_diff/decision_merge.py — 依 ImportDecisions 把 parsed dict + snapshot dict merge 成最終 OSCAL dictdomain/oscal/reconciliation_v2/person_reconciler.py / organization_reconciler.py / base.py / match_method.py(或就地把現有 reconciliation/ 改吃 v2,視 Phase 5 pre-flight)app/oscal/service/import_diff/party_reconciliation_v2_service.py — orchestrator 吃 v2 party + 現行 user/org domain service主專案 — confirm 流程 + route(改)
app/oscal/service/ssp_excel_import_app_service.py — _VALID_SOURCE_TYPES 加 "ssp";source verify 加 ssp 分支;confirm 加 Model C;_fill_template_ssp 參數化 mode + 接 diff/mergeapp/oscal/service/ssp_docx_import_app_service.py — 同上 docx 端 "ssp"config/di_modules.py — 移除 ssp_scoped_excel_import_routeapi/oscal/__init__.py(或 create_module() 所在)— 註冊 ssp_scoped blueprintdi_containers/oscal/oscal_containers.py — wire ssp_service / catalog_title_query / snapshot / diff / reconcile_v2 進 import app servicecommon/code/grc_error_code.py(grep 最大序號續編)import_ssp(mode='update') 子物件 upsert〔A.1〕Pre-flight(開工先驗,不憑摘要)
Files:
jedi-oscal-v2/jedi_oscal_v2/app/service/io/oscal_io_service.pyjedi-oscal-v2/tests/io_export/test_oscal_import_update.py# tests/io_export/test_oscal_import_update.py
def test_import_ssp_update_upserts_into_nonempty(db_session, seeded_ssp):
svc = make_oscal_io_service(db_session)
# 先 create 一份
svc.import_ssp(SSP_DICT_V1, target_ssp_id=seeded_ssp.id, curr_user="t", mode="create")
# 同 control_id 改敘述 + 新增一個 control → update mode 不該丟錯
counts = svc.import_ssp(SSP_DICT_V2, target_ssp_id=seeded_ssp.id, curr_user="t", mode="update")
irs = svc._impl_req_repo... # 查同一 control_id 只有一筆且敘述= V2
assert <control AC-1 敘述 == V2 值 and 無重複 and 新 control 已新增>Run: cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2 && poetry run pytest tests/io_export/test_oscal_import_update.py -v Expected: FAIL unsupported mode 'update'
mode 守門放寬至 {'create','update'}(其餘仍 raise)。mode=='create' 才檢查空殼;mode=='update' 跳過。SspService.upsert_system_characteristics / get_or_create_system_implementation / get_or_create_control_implementation,集合子物件用 SspService 的 list_* + add_/update_,避免在 OscalIoService 重造 upsert(SspCloneService 已有寫入順序 + by-component remap 可參照)。除非 pre-flight 發現 io_service 直接持 repo 更順手,否則走 SspService。_import_* 抽出「寫一筆」邏輯,加 mode 參數:
get_by_ssp 有則 update(套既有欄位)、無則 add。list 現有 → 建 自然key → entity map → 每筆 parsed 命中則 update、未命中則 add。by-component 沿用 comp_map remap。updated/created 細分)。Run: poetry run pytest tests/io_export/ -v Expected: 既有 create 測試全綠 + 新 update 測試綠
cd ~/Projects/Jedicogy/module/jedi-python-package
git add jedi-oscal-v2/jedi_oscal_v2/app/service/io/oscal_io_service.py jedi-oscal-v2/tests/io_export/test_oscal_import_update.py
git commit -m "feat(oscal-v2): import_ssp update mode — 非空 SSP 逐子物件 upsert(FR-038 import-ssp P4-A1)"build_ssp_snapshot(ssp_id)〔A.2〕Pre-flight
Files:
jedi-oscal-v2/jedi_oscal_v2/app/service/io/oscal_io_service.pyjedi-oscal-v2/tests/io_export/test_oscal_snapshot.pygit add jedi-oscal-v2/jedi_oscal_v2/app/service/io/oscal_io_service.py jedi-oscal-v2/tests/io_export/test_oscal_snapshot.py
git commit -m "feat(oscal-v2): build_ssp_snapshot — current 端 OSCAL 快照(FR-038 import-ssp P4-A2)"套件改完 → 主專案 BOOT 驗:
pyproject.toml已是 path-dep,重啟即生效。跑 §6 BOOT 指令確認BOOT OK。
本 phase 完 user 能看到:專案 SSP(ssp 1482)上傳 excel/docx → 內容寫入更新(覆蓋式 upsert)。 本 phase user 還看不到:逐項差異預覽 / 接受保留決策 / 人員自動比對 / 手動指派(Phase 4~6)。這不是終點。
Pre-flight
Files:
config/di_modules.py(移除 EXCLUDE)、api/oscal/__init__.py(註冊 blueprint)app/oscal/service/ssp_excel_import_app_service.py("ssp" source_type + Model C + _fill_template_ssp 加 mode 參數)app/oscal/service/ssp_docx_import_app_service.py("ssp" source_type + Model C)di_containers/oscal/oscal_containers.py(wire ssp_service + project resolver 進 import app service)common/code/grc_error_code.py(新碼:GRC_SSP_NOT_FOUND 若無 / GRC_IMPORT_SSP_NOT_PROJECT_MEMBER 等,grep 最大序號續編)test/test_ssp_excel_import_project_ssp.py(app service 測,logger patch fixture)# test/test_ssp_excel_import_project_ssp.py
@pytest.fixture(autouse=True)
def _patch_logger(monkeypatch):
monkeypatch.setattr("app.oscal.service.ssp_excel_import_app_service.logger", logging.getLogger("test"))
def test_confirm_project_ssp_calls_update_mode(svc, ssp_job, mock_oscal_io):
svc.confirm_import(ssp_job.uid, {}, user_context_manager)
mock_oscal_io.import_ssp.assert_called_once()
assert mock_oscal_io.import_ssp.call_args.kwargs["mode"] == "update"
assert mock_oscal_io.import_ssp.call_args.kwargs["target_ssp_id"] == EXPECTED_LIVING_SSP_IDRun: pytest test/test_ssp_excel_import_project_ssp.py -v
_VALID_SOURCE_TYPES 加 "ssp"。source_type=='ssp' 分支:解析 ssp uid → ssp + 權限(manager)。confirm_import 加 if job.source_type == "ssp": return self._confirm_update_project_ssp(...)。_confirm_update_project_ssp:解析 living_ssp_id → _fill_template_ssp(ssp_id, parsed_result, user_context, mode="update")。_fill_template_ssp 加 mode="create" 參數(預設不變,向後相容),把寫死的 mode="create" 改用參數。config/di_modules.py:移除 "api.oscal.routes.ssp.ssp_scoped_excel_import_route"。api/oscal/__init__.py(或 create_module 所在):註冊 ssp_scoped(excel) blueprint。ssp_scoped_excel_import_route.py 只覆蓋 excel scoped 路徑;docx 無對應 scoped route 檔。pre-flight 先確認 docx-into-project-SSP 是「沿用既有 docx import route + payload 帶 source_uid + 'ssp' source_type 分支」還是「需新建 docx scoped route」。兩種都要 wire 到,否則 docx 落專案 SSP 只接一半。di_containers/oscal/oscal_containers.py:import app service 注入 ssp_service + project resolver(source verify 用)。Run(§6 BOOT 指令)→ Expected BOOT OK Run: poetry run python -m pytest test/ -q -p no:cacheprovider --continue-on-collection-errors 2>&1 | grep -E '^(FAILED|ERROR)' | sort > /tmp/p3.txt; comm -13 /tmp/ssprestore_base.txt /tmp/p3.txt Expected: 空輸出(無新增 FAILED/ERROR)
git add config/di_modules.py api/oscal/__init__.py app/oscal/service/ssp_excel_import_app_service.py app/oscal/service/ssp_docx_import_app_service.py di_containers/oscal/oscal_containers.py common/code/grc_error_code.py test/test_ssp_excel_import_project_ssp.py
git commit -m "feat(FR-038): 專案 SSP 檔案匯入(Model C,import_ssp update mode)+ re-enable ssp_scoped route"本 phase 完 user 能看到:重複/專案匯入時逐項差異(unchanged/changed/added/gone)+ smart default + 接受/保留逐項生效。
Pre-flight
Files:
app/oscal/service/import_diff/ssp_diff_service.py(port,current 端吃 snapshot dict)app/oscal/service/import_diff/decision_merge.pyparse → diff(vs snapshot) → decisions → merge → import_ssp(update);preview(get_parse_result)回 annotated difftest/test_ssp_diff_service.py / test/test_decision_merge.pygit add app/oscal/service/import_diff/ app/oscal/service/ssp_excel_import_app_service.py app/oscal/service/ssp_docx_import_app_service.py test/test_ssp_diff_service.py test/test_decision_merge.py
git commit -m "feat(FR-038): SSP 匯入差異標注 + 逐項決策 merge(接 v2 snapshot,import_ssp update)"本 phase 完 user 能看到:匯入的人員/單位自動配到系統 user/org,preview 顯示 match 結果 + method。
Pre-flight(最高槓桿 — 決定 match 結果存哪)
Files:
domain/oscal/reconciliation_v2/(person/org/base/match_method)或就地改 reconciliation/ 吃 v2app/oscal/service/import_diff/party_reconciliation_v2_service.pytest/test_party_reconciliation_v2.py本 phase 完 user 能看到:docx 解析出的未匹配段落,confirm 時可手動指派到控制項 / AO,內容寫入。
Pre-flight
Files:
app/oscal/service/import_diff/decision_merge.py(merge 前把 manual_assignments 注入 parsed dict 目標控制項/AO)test/test_manual_assignment_merge.py本 phase 完 = 本棒終點:專案 SSP + 資源庫重複匯入兩落點,都走 parse→diff→per-control/per-AO decision→reconcile→manual assign→update,端到端通。
Pre-flight
_try_fuzzy_match。pyproject.toml 不 commit。tests/io_export/ 單元,update upsert + snapshot 對稱 + create 回歸不變。feedback_test_logger_patch_db_handler,抄 test_ssp_excel_import_app_service.py)。comm -13 /tmp/ssprestore_base.txt <(本次 FAILED/ERROR) 必空(baseline 47f/50e=97 行)。多日實作前先確認 /tmp/ssprestore_base.txt 還在(/tmp 被清過要重跑 §6 baseline 指令重建,否則 comm 會吐整串 failure 看似大回歸)。