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: 在 A3 BaseReconciliationService 基礎上補完 5 個新 reconciler(device / info_system / leveraged / catalog_control / AO)+ 5 個 WriteStrategy(含 control / AO / device / info_system / leveraged 寫入路徑)+ superset flow 建 SSP shell + A2 §15.3 superset include_controls 反查整併;同 PR 順手 retroactive 補 A3 PersonReconciler / OrganizationReconciler 的 USER_SELECTED stage 0。
Architecture: Strategy Pattern + Generic Base + Facade — BaseReconciliationService 加 stage 0 default no-op hook,5 新 reconciler override hook;SspEntityReconciliationOrchestrator 統一 facade(enforce control → AO 依賴順序);5 個 WriteStrategy 純 domain layer、不開 @transaction(caller 開);superset flow 加 SSP shell 建立步驟(ssp / system_characteristic / system_implementation main);update flow 既有 SSP 不存在則 raise GRC_412066。
Tech Stack: Python 3.11 / SQLAlchemy / dependency-injector / pytest / openpyxl / cucumber.js(test repo)
Phase:A4(Track A 第四階段) 級別:重型(brainstorm → design → plan → 開工) 依賴:A3 BE 已 shipped(67 個新 test + BaseReconciliationService + Person/Organization Reconciler)+ design-A4.md spec review approved 預計工時:BE ~5d + cucumber 0.25d ≈ 5.25 working day(不含 plan/design 寫作) 對應 design:
docs/features/FR-011.2-2605-ssp-import-export-phase2/design-A4.md(13 章) 執行階段切分(6 session):Session A(design / done)→ B(plan / self)→ C(T0+T1+T2)→ D(T3+T4+T5)→ E(T6+T7+T8)→ F(T9+T10+T11) — 對齊 design-A4 §12
| Session | Task | 主題 | repo | 預估 | 依賴 | Commit checkpoint |
|---|---|---|---|---|---|---|
| C | T0 | Implementer pre-flight verification(design §7.2 8 項) | BE | 0.25d | — | 併入 T1 commit |
| C | T1 | base.py stage 0 + MatchMethod USER_SELECTED + _normalizers 補 4 helper + 5 dataclass + ParsedExcelEntityBundle + SspEntityReconciliationContext + ParsedParty 加 2 label 欄位 | BE | 0.5d | T0 | feat(oscal): A4 T1 stage 0 hook + dataclass + helpers |
| C | T2 | 5 reconciler skeleton + orchestrator + DI Factory(只 skeleton;fuzzy / 完整演算法留 T3) | BE | 0.75d | T1 | feat(oscal): A4 T2 reconciler + orchestrator skeleton + DI |
| D | T3 | 5 reconciler 完整演算法(Stage 0 USER_SELECTED + 1/2 + Leveraged fuzzy + system_owner 反查 + AO reconcile_with_control_map) | BE | 0.75d | T2 | feat(oscal): A4 T3 reconciler full algorithm |
| D | T4 | A3 retroactive:PersonReconciler / OrganizationReconciler override _try_user_selected_match + _dict_to_parsed_parties 加 label 傳遞 |
BE | 0.25d | T1(base.py stage 0 dispatch) | feat(oscal): A4 T4 A3 retroactive USER_SELECTED stage |
| D | T5 | A2 §15.3 整併:_confirm_superset_flow 內 include_controls 反查 |
BE | 0.25d | T3(CatalogControlReconciler) | feat(oscal): A4 T5 A2 §15.3 superset include_controls 反查 |
| E | T6 | SSP shell:_create_ssp_shell + _resolve_existing_ssp_shell + GRC_EXCEL_UPDATE_FLOW_NO_SSP error code |
BE | 0.5d | T0(jedi-oscal verify) | feat(oscal): A4 T6 SSP shell + error code |
| E | T7 | 5 WriteStrategy 完整實作(Control / AO / Device / InfoSystem / Leveraged)+ write_strategy/ sub-folder |
BE | 1.25d | T6 | feat(oscal): A4 T7 5 write strategies |
| E | T8 | _write_all_data 8-step pipeline 重整 + DI 加 11 注入 + 5 _dict_to_* helper |
BE | 0.5d | T7 | feat(oscal): A4 T8 _write_all_data 8-step pipeline |
| F | T9 | Unit + integration test ~110-140 個(reconciler 60-75 + WriteStrategy 30-40 + integration 15-20 + ParsedXxx fixture 10) | BE | 1d | T1-T8 | test(oscal): A4 T9 unit + integration tests |
| F | T10 | Cucumber regression 06-ssp-excel-import-entity-match.feature 5 scenarios |
test | 0.25d | T9 | test(compliance-manager-test): A4 T10 cucumber entity match regression |
| F | T11 | Changelog + tracker + design §11 reconciliation + final SUMMARY | BE | 0.5d | T10 | docs(ssp-import-export-phase2): A4 收尾 + SUMMARY |
並行最佳化:
- T1 是 T2-T8 共同基礎(dataclass + helpers + base.py),必須先做
- T2 結束(skeleton + DI)之後 T3、T4 可平行;T4 不依賴 T3(T4 是 A3 retroactive,動的是 A3 既有 reconciler,與 T3 動 A4 新 reconciler 互不影響)
- T5 依賴 T3(需要 CatalogControlReconciler 完整實作);可在 T4 ship 後與 T6 平行
- T6 (SSP shell) 跟 T7 (WriteStrategy) 順序:T6 必先(WriteStrategy 接 ssp_id input;T6 產 _create_ssp_shell 後 T7 才能定 WriteStrategy.write 簽章)
- T7 / T8 順序:T7 先(WriteStrategy class 完成)→ T8 再串 pipeline;不可並行
- T9 必須等 T2 / T3 / T4 / T5 / T6 / T7 / T8 都完成
- Session 切分(不可平行):B → C → D → E → F 線性推進;每 Session 結束產 handoff 給下一 Session
domain/oscal/service/reconciliation/ # A3 既有 sub-folder
├── device_reconciler.py # DeviceReconciler (~110 lines)
├── information_system_reconciler.py # InformationSystemReconciler (~130 lines)
├── leveraged_reconciler.py # LeveragedReconciler (~100 lines)
├── catalog_control_reconciler.py # CatalogControlReconciler (~90 lines)
├── assessment_objective_reconciler.py # AssessmentObjectiveReconciler (~110 lines)
└── ssp_entity_orchestrator.py # SspEntityReconciliationOrchestrator (~60 lines)
domain/oscal/service/write_strategy/ # A4 新 sub-folder
├── __init__.py
├── control_write_strategy.py # ControlWriteStrategy (~100 lines)
├── ao_write_strategy.py # AoWriteStrategy (~80 lines)
├── device_write_strategy.py # DeviceWriteStrategy (~110 lines)
├── information_system_write_strategy.py # InformationSystemWriteStrategy (~110 lines)
└── leveraged_write_strategy.py # LeveragedWriteStrategy (~100 lines)
tests/test_a4_reconciliation_helpers.py # parse_*_lookup_label / _normalize_control_id
tests/test_a4_reconciliation_device.py
tests/test_a4_reconciliation_information_system.py
tests/test_a4_reconciliation_leveraged.py
tests/test_a4_reconciliation_catalog_control.py
tests/test_a4_reconciliation_assessment_objective.py
tests/test_a4_reconciliation_orchestrator.py
tests/test_a4_write_strategy_control.py
tests/test_a4_write_strategy_ao.py
tests/test_a4_write_strategy_device.py
tests/test_a4_write_strategy_information_system.py
tests/test_a4_write_strategy_leveraged.py
tests/test_a4_ssp_shell.py
tests/test_a4_a2_integration.py # _write_all_data 8-step + §15.3 整併
tests/test_a4_parsed_entity_fixtures.py # 5 個新 dataclass + asdict serialize
tests/test_a4_a3_retroactive.py # PersonReconciler / OrganizationReconciler stage 0
docs/changelog/YYYY-MM-DD-feat-ssp-entity-reconcile-and-write.md # type: feat、modules: oscal
domain/oscal/service/reconciliation/base.py # 加 stage 0 dispatch + _try_user_selected_match default no-op
domain/oscal/service/reconciliation/match_method.py # 加 USER_SELECTED value
domain/oscal/service/reconciliation/_normalizers.py # 補 4 helper (parse_user_lookup_label / parse_device_lookup_label / parse_info_system_lookup_label / _normalize_control_id)
domain/oscal/service/reconciliation/person_reconciler.py # T4 retroactive: override _try_user_selected_match
domain/oscal/service/reconciliation/organization_reconciler.py # T4 retroactive: override _try_user_selected_match
domain/oscal/parser/ssp_intermediate.py # 加 5 dataclass + ParsedExcelEntityBundle + SspEntityReconciliationContext + ParsedParty 加 2 label 欄位
app/oscal/service/ssp_excel_import_app_service.py # T8: _write_all_data 8-step pipeline + 5 _dict_to_*;T5: _confirm_superset_flow §15.3 整併;T6: _create_ssp_shell / _resolve_existing_ssp_shell;T4: _dict_to_parsed_parties 加 label 傳遞
common/code/grc_error_code.py # 加 GRC_EXCEL_UPDATE_FLOW_NO_SSP (412)
di_containers/oscal/oscal_containers.py # 加 11 Factory(5 reconciler + 1 orchestrator + 5 WriteStrategy)+ SspExcelImportAppService 注入 11 dep
docs/features/FR-011.2-2605-ssp-import-export-phase2/README.md # tracker A4 row pending → BE shipped
docs/features/FR-011.2-2605-ssp-import-export-phase2/design-A4.md # §11 Implementation Reality / Reconciliation 補實作偏差
compliance-manager-test/
└── features/regression/module-frame/06-ssp-excel-import-entity-match.feature # 5 scenarios
+ steps/module-frame/ssp-excel-import-entity-match.steps.js # 對應 step
+ pages/module-frame/ssp-excel-import-page.js # page object 補 selector
目標:補 design 階段未驗 / brainstorm 階段假設不確定的 8 個項目(design-A4 §7.2)。Front-load critical:T0.5 / T0.7 優先驗(影響 T6 / T7 整段實作)。
grep -nA 30 "^class SystemSecurityPlanEntity" \
/Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/domain/entity/ssp/ssp_entity.py期待:含 title / profile_uid / status / metadata_id / document_id / system_security_plan_main_id 等欄位;add() method 由 SystemSecurityPlanDomainService 提供。 若不符:design §11 補偏差條目;T6 _create_ssp_shell Entity 構造參數對齊實際 schema。
grep -nA 25 "^class SspSystemCharacteristicEntity" \
/Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/domain/entity/ssp/ssp_system_characteristic_entity.py期待:含 system_security_plan_id / name / security_sensitivity_level 等欄位。 若不符:design §11 補;T6 構造參數對齊。
grep -nA 25 "^class SspSystemImplementationEntity" \
/Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/domain/entity/ssp/ssp_system_implementation_entity.py期待:含 A0.1 ship 後新 scope_type / scope_id 欄位;ORM 對應 ssp_system_implementations 表 UNIQUE on (system_security_plan_id, scope_type, scope_id)。 若不符:T6 _create_ssp_shell step 2c 對齊實際 schema。
grep -nA 25 "^class CatalogControlAssessmentQueryEntity" \
/Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/domain/entity/catalog/catalog_control_assessment_query_entity.py期待:含 catalog_control_id + objective_id 兩欄位(design §5.3 EXACT stage 假設)。 若不符:AssessmentObjectiveReconciler _try_exact_match 改用其他自然 key(如 uid 或 name);design §11 補偏差。
system_security_plan_control_implementations ORM model + Mapper signature ⭐ criticalls /Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/infra/model/ssp/ | grep -i control
grep -nA 30 "^class SspControlImplementation\|^class.*ControlImplementation" \
/Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/domain/entity/ssp/ssp_control_implementation_entity.py期待:Entity / QueryEntity / DomainService 完整;add() / update() 支援 (system_security_plan_id, catalog_control_id) upsert;含 implementation_status / implementation_description / responsible_role 欄位。 若不符:T7 ControlWriteStrategy 簽章調整;design §11 補。
ssp_control_impl_objective ORM model 欄位grep -nA 25 "^class SspControlImplObjective\|^class.*ControlImplObjective" \
/Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/domain/entity/ssp/ssp_control_impl_objective_entity.py期待:含 ssp_control_implementation_id + catalog_control_assessment_id + audit 欄位。 若不符:T7 AoWriteStrategy 簽章調整。
ssp_domain_service.get_one_by_profile_uid 是否存在 ⭐ criticalgrep -nE "def get_one_by_profile_uid|def get_one|def get_by_profile" \
/Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/domain/services/ssp/ssp_domain_service.py期待:有 get_one_by_profile_uid 或等效 method(用 profile_uid 查 SSP)。 若不符:T6 _resolve_existing_ssp_shell 改用 query entity 路徑(SystemSecurityPlanQueryEntity(profile_uid=...))或新增套件 method(需 user 拍板套件異動)。列 design §11。
DeviceQueryEntity 雙欄 (name, ip) 同時 query 支援度grep -nA 30 "^class DeviceQueryEntity" \
/Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-device/jedi_device/domain/entity/device_query_entity.py
grep -nE "name|ip" \
/Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-device/jedi_device/infra/repository/device_repo_impl.py | head -20期待:Query Entity 同時 set name=..., ip=... 時 repo 走 AND filter。 若不符:DeviceReconciler _try_exact_match 改 fallback 拉 name query → Python 端 filter ip;design §11 補偏差。
目標:把 design §3 / §4 / §5.1-§5.2 落地成可被 import 的 module + dataclass。 TDD 邊界:純函式 helper 全 TDD;dataclass 加
asdict()JSONB serialize test;base.py stage 0 dispatch 行為 test。
MatchMethod.USER_SELECTEDFiles:
domain/oscal/service/reconciliation/match_method.pyclass MatchMethod(StrEnum):
USER_SELECTED = "user_selected" # A4 新增
EXACT = "exact"
NORMALIZED = "normalized"
FUZZY_EMAIL_DOMAIN = "fuzzy_email_domain"
FUZZY_NAME_PREFIX = "fuzzy_name_prefix"
UNMATCHED = "unmatched"poetry run python3 -c "from domain.oscal.service.reconciliation.match_method import MatchMethod; print(MatchMethod.USER_SELECTED)"Expected: MatchMethod.USER_SELECTED
poetry run pytest tests/test_a3_reconciliation_normalizers.py -vExpected: 13/13 PASS
_normalizers.py(TDD)Files:
domain/oscal/service/reconciliation/_normalizers.pytests/test_a4_reconciliation_helpers.py# tests/test_a4_reconciliation_helpers.py
import pytest
from domain.oscal.service.reconciliation._normalizers import (
parse_user_lookup_label,
parse_device_lookup_label,
parse_info_system_lookup_label,
_normalize_control_id,
)
class TestParseUserLookupLabel:
def test_standard_format(self):
assert parse_user_lookup_label("Alice 陳 <alice>") == ("Alice 陳", "alice")
def test_no_angle_brackets(self):
assert parse_user_lookup_label("alice") == (None, None)
def test_none(self):
assert parse_user_lookup_label(None) == (None, None)
def test_empty_string(self):
assert parse_user_lookup_label("") == (None, None)
def test_whitespace_only(self):
assert parse_user_lookup_label(" ") == (None, None)
class TestParseDeviceLookupLabel:
def test_standard_format(self):
assert parse_device_lookup_label("Web-01 (10.0.1.5)") == ("Web-01", "10.0.1.5")
def test_no_parens(self):
assert parse_device_lookup_label("Web-01") == ("Web-01", None)
def test_none(self):
assert parse_device_lookup_label(None) == (None, None)
def test_nested_parens(self):
# 'Server (prod) (10.0.0.1)' → rsplit
assert parse_device_lookup_label("Server (prod) (10.0.0.1)") == ("Server (prod)", "10.0.0.1")
class TestParseInfoSystemLookupLabel:
def test_standard_format(self):
assert parse_info_system_lookup_label("ERP - Enterprise Resource Planning") == ("ERP", "Enterprise Resource Planning")
def test_no_separator(self):
assert parse_info_system_lookup_label("ERP") == (None, "ERP")
def test_none(self):
assert parse_info_system_lookup_label(None) == (None, None)
class TestNormalizeControlId:
def test_lowercase_dash(self):
assert _normalize_control_id("ac-1") == "AC-1"
def test_space(self):
assert _normalize_control_id("ac 1") == "AC-1"
def test_underscore(self):
assert _normalize_control_id("ac_1") == "AC-1"
def test_collapse_dashes(self):
assert _normalize_control_id("AC--1") == "AC-1"
def test_none(self):
assert _normalize_control_id(None) == ""poetry run pytest tests/test_a4_reconciliation_helpers.py -vExpected: 17/17 FAIL with ImportError / AttributeError
# domain/oscal/service/reconciliation/_normalizers.py(A3 既有,A4 補在尾端)
import re
from typing import Optional
# ... A3 既有 _strip_plus_alias / _normalize_name / _strip_org_suffix / ORG_SUFFIXES ...
def parse_user_lookup_label(label: Optional[str]) -> tuple[Optional[str], Optional[str]]:
"""'nickname <login_name>' → ('nickname', 'login_name');不符 fmt 回 (None, None)."""
if not label or '<' not in label or '>' not in label:
return (None, None)
nickname = label.split('<', 1)[0].strip()
login_name = label.split('<', 1)[1].rstrip('>').strip()
return (nickname or None, login_name or None)
def parse_device_lookup_label(label: Optional[str]) -> tuple[Optional[str], Optional[str]]:
"""'name (ip)' → ('name', 'ip');不符 fmt 回 (label, None)."""
if not label:
return (None, None)
s = label.strip()
if '(' in s and s.endswith(')'):
name = s.rsplit('(', 1)[0].strip()
ip = s.rsplit('(', 1)[1].rstrip(')').strip()
return (name or None, ip or None)
return (s or None, None)
def parse_info_system_lookup_label(label: Optional[str]) -> tuple[Optional[str], Optional[str]]:
"""'abbr - name' → ('abbr', 'name');無 ' - ' 退回 (None, label)."""
if not label:
return (None, None)
s = label.strip()
if ' - ' in s:
abbr, name = s.split(' - ', 1)
return (abbr.strip() or None, name.strip() or None)
return (None, s or None)
def _normalize_control_id(s: Optional[str]) -> str:
"""'ac 1' / 'AC_1' / 'ac-1' → 'AC-1' (upper + space/underscore→dash + collapse)."""
if not s:
return ""
s = s.strip().upper()
s = re.sub(r'[\s_]+', '-', s)
s = re.sub(r'-+', '-', s)
return spoetry run pytest tests/test_a4_reconciliation_helpers.py -vFiles:
domain/oscal/service/reconciliation/base.pytests/test_a3_reconciliation_base.py(已有,A4 加 case)# tests/test_a3_reconciliation_base.py 加在尾端
class TestStage0UserSelected:
"""A4 base.py 加 stage 0 dispatch 行為."""
def test_stage_0_user_selected_takes_priority(self):
"""override _try_user_selected_match 返回 entity 時,直接 USER_SELECTED + confidence=1.0;不再跑 stage 1-3."""
from domain.oscal.service.reconciliation.base import BaseReconciliationService
from domain.oscal.service.reconciliation.match_method import MatchMethod
class _Fake:
def __init__(self):
self.match_method = MatchMethod.UNMATCHED
self.match_confidence = 0.0
self.matched_id = None
class _R(BaseReconciliationService):
def _try_user_selected_match(self, parsed, tenant_id):
return type("Entity", (), {"id": 99})()
def _try_exact_match(self, parsed, tenant_id):
raise AssertionError("不該走到 stage 1")
def _try_normalized_match(self, parsed, tenant_id):
raise AssertionError("不該走到 stage 2")
def _try_fuzzy_match(self, parsed, tenant_id):
raise AssertionError("不該走到 stage 3")
def _apply_match(self, parsed, entity, method, confidence):
parsed.matched_id = entity.id
parsed.match_method = method
parsed.match_confidence = confidence
def _apply_unmatched(self, parsed):
pass
parsed = _Fake()
_R()._reconcile_one(parsed, tenant_id=1)
assert parsed.match_method == MatchMethod.USER_SELECTED
assert parsed.match_confidence == 1.0
assert parsed.matched_id == 99
def test_default_hook_returns_none_fallthrough(self):
"""既有 A3 reconciler 不 override → stage 0 hook return None → 走 stage 1."""
from domain.oscal.service.reconciliation.base import BaseReconciliationService
from domain.oscal.service.reconciliation.match_method import MatchMethod
class _R(BaseReconciliationService):
# 不 override _try_user_selected_match (用 default)
def _try_exact_match(self, parsed, tenant_id):
return type("Entity", (), {"id": 42})()
def _try_normalized_match(self, parsed, tenant_id):
return None
def _try_fuzzy_match(self, parsed, tenant_id):
return (None, MatchMethod.UNMATCHED)
def _apply_match(self, parsed, entity, method, confidence):
parsed.match_method = method
def _apply_unmatched(self, parsed):
pass
class _Fake:
match_method = MatchMethod.UNMATCHED
parsed = _Fake()
_R()._reconcile_one(parsed, tenant_id=1)
assert parsed.match_method == MatchMethod.EXACT# domain/oscal/service/reconciliation/base.py
class BaseReconciliationService(ABC, Generic[TParsed, TEntity]):
# ... __init__ / reconcile 不動 ...
def _reconcile_one(self, parsed: TParsed, tenant_id: Optional[int]) -> None:
# A4: stage 0 USER_SELECTED(樣板 matched_* col 反查)
candidate = self._try_user_selected_match(parsed, tenant_id)
if candidate is not None:
self._apply_match(parsed, candidate, MatchMethod.USER_SELECTED, 1.0)
return
# 既有 stage 1-3 不動
candidate = self._try_exact_match(parsed, tenant_id)
if candidate is not None:
self._apply_match(parsed, candidate, MatchMethod.EXACT, 1.0)
return
candidate = self._try_normalized_match(parsed, tenant_id)
if candidate is not None:
self._apply_match(parsed, candidate, MatchMethod.NORMALIZED, 1.0)
return
candidate, fuzzy_method = self._try_fuzzy_match(parsed, tenant_id)
if candidate is not None:
self._apply_match(parsed, candidate, fuzzy_method, 0.7)
return
self._apply_unmatched(parsed)
def _try_user_selected_match(
self, parsed: TParsed, tenant_id: Optional[int]
) -> Optional[TEntity]:
"""A4 stage 0: 樣板 matched_* col 反查 lookup label → entity. Default no-op."""
return None注意:不加 @abstractmethod(A3 既有 reconciler 不必 override;default 行為 = 不變)。
poetry run pytest tests/test_a3_reconciliation_base.py -vFiles:
domain/oscal/parser/ssp_intermediate.pytests/test_a4_parsed_entity_fixtures.py# tests/test_a4_parsed_entity_fixtures.py
import pytest
from dataclasses import asdict
from domain.oscal.parser.ssp_intermediate import (
ParsedParty, ParsedDevice, ParsedInformationSystem, ParsedLeveraged,
ParsedControl, ParsedAssessmentObjective, ParsedExcelEntityBundle,
SspEntityReconciliationContext,
)
from domain.oscal.service.reconciliation.match_method import MatchMethod
class TestParsedDevice:
def test_default_unmatched(self):
d = ParsedDevice(name="Web-01")
assert d.match_method == MatchMethod.UNMATCHED
assert d.match_confidence == 0.0
assert d.matched_device_id is None
assert d.matched_device_label is None
def test_asdict_serialize(self):
d = ParsedDevice(name="Web-01", ip="10.0.1.5", match_method=MatchMethod.EXACT, match_confidence=1.0, matched_device_id=42)
s = asdict(d)
assert s["match_method"] == MatchMethod.EXACT # StrEnum 序列化
class TestParsedInformationSystem:
def test_two_owner_fields(self):
s = ParsedInformationSystem(
name="ERP", abbreviation="ERP", matched_info_system_id=10,
matched_system_owner_user_id=99,
)
assert s.matched_info_system_id == 10
assert s.matched_system_owner_user_id == 99
class TestParsedLeveraged:
def test_party_uuid(self):
from datetime import date
l = ParsedLeveraged(
service_name="AWS GovCloud", provider="Amazon Web Services",
date_authorized=date(2025, 1, 15),
matched_party_uuid="abc-123-def",
)
assert l.matched_party_uuid == "abc-123-def"
class TestParsedControl:
def test_nested_objectives(self):
c = ParsedControl(control_id="AC-1")
c.objectives.append(ParsedAssessmentObjective(statement_id="AC-1.a", control_id="AC-1"))
assert len(c.objectives) == 1
assert c.matched_catalog_control_uid is None
assert c.matched_catalog_control_id is None
class TestParsedAssessmentObjective:
def test_statement_id_vs_matched_id(self):
ao = ParsedAssessmentObjective(
statement_id="AC-1.a.1", # parser key (string)
control_id="AC-1",
matched_catalog_control_assessment_id=42, # DB id (int)
)
assert ao.statement_id == "AC-1.a.1"
assert ao.matched_catalog_control_assessment_id == 42
class TestParsedExcelEntityBundle:
def test_construct(self):
b = ParsedExcelEntityBundle(
parsed_devices=[ParsedDevice(name="d")],
parsed_info_systems=[],
parsed_leveraged=[],
parsed_controls=[],
)
assert len(b.parsed_devices) == 1
class TestSspEntityReconciliationContext:
def test_construct(self):
ctx = SspEntityReconciliationContext(tenant_id=102, catalog_id=5)
assert ctx.tenant_id == 102 and ctx.catalog_id == 5
class TestParsedPartyA4RetroactiveFields:
def test_new_label_fields_default_none(self):
p = ParsedParty(name="A", party_type="person")
assert p.matched_user_label is None
assert p.matched_org_unit_label is None# domain/oscal/parser/ssp_intermediate.py
# A3 既有 ParsedParty 加 2 個 optional 欄位
@dataclass
class ParsedParty:
# ... 既有 A3 欄位(name / party_type / email_address / ... matched_user_id / matched_org_unit_id / target_party_uid / match_method / match_confidence)...
# A4 retroactive
matched_user_label: Optional[str] = None
matched_org_unit_label: Optional[str] = None
# A4 新增 5 個 dataclass + 2 個 context
from datetime import date
@dataclass
class ParsedDevice:
name: str
ip: Optional[str] = None
os: Optional[str] = None
device_type: Optional[str] = None
status: Optional[str] = None
purpose: Optional[str] = None
matched_device_label: Optional[str] = None
matched_device_id: Optional[int] = None
match_method: MatchMethod = MatchMethod.UNMATCHED
match_confidence: float = 0.0
@dataclass
class ParsedInformationSystem:
name: str
abbreviation: Optional[str] = None
description: Optional[str] = None
component_type: Optional[str] = None
status: Optional[str] = None
matched_info_system_label: Optional[str] = None
system_owner_label: Optional[str] = None
matched_info_system_id: Optional[int] = None
matched_system_owner_user_id: Optional[int] = None
match_method: MatchMethod = MatchMethod.UNMATCHED
match_confidence: float = 0.0
@dataclass
class ParsedLeveraged:
service_name: str
provider: str
party_label: Optional[str] = None
date_authorized: Optional[date] = None
purpose: Optional[str] = None
matched_party_uuid: Optional[str] = None
match_method: MatchMethod = MatchMethod.UNMATCHED
match_confidence: float = 0.0
@dataclass
class ParsedAssessmentObjective:
# NOTE: statement_id 是 parser 解出的 AO uid 字串(樣板 hidden column key)
# 跟 matched_catalog_control_assessment_id(DB int id)不要混淆
statement_id: str
control_id: str
objective_id: Optional[str] = None
objective_name: Optional[str] = None
impl_status: Optional[str] = None
statement: Optional[str] = None
reference_doc: Optional[str] = None
matched_catalog_control_assessment_uid: Optional[str] = None
matched_catalog_control_assessment_id: Optional[int] = None
match_method: MatchMethod = MatchMethod.UNMATCHED
match_confidence: float = 0.0
@dataclass
class ParsedControl:
control_id: str
control_name: Optional[str] = None
objective_id: Optional[str] = None
objective_name: Optional[str] = None
impl_status: Optional[str] = None
statement: Optional[str] = None
reference_doc: Optional[str] = None
include_in_profile: Optional[str] = None
_target_in_profile: bool = True
objectives: list[ParsedAssessmentObjective] = field(default_factory=list)
# 兩個都存:int 給 WriteStrategy FK,str 給 §15.3 ProfileService.add_profile UUID
matched_catalog_control_uid: Optional[str] = None
matched_catalog_control_id: Optional[int] = None
match_method: MatchMethod = MatchMethod.UNMATCHED
match_confidence: float = 0.0
@dataclass
class ParsedExcelEntityBundle:
"""5 entity typed list 集合 — 給 orchestrator 用,避免 5 個位置參數."""
parsed_devices: list[ParsedDevice]
parsed_info_systems: list[ParsedInformationSystem]
parsed_leveraged: list[ParsedLeveraged]
parsed_controls: list[ParsedControl]
@dataclass
class SspEntityReconciliationContext:
tenant_id: int
catalog_id: intpoetry run pytest tests/test_a4_parsed_entity_fixtures.py -vgit add domain/oscal/service/reconciliation/base.py \
domain/oscal/service/reconciliation/match_method.py \
domain/oscal/service/reconciliation/_normalizers.py \
domain/oscal/parser/ssp_intermediate.py \
tests/test_a4_reconciliation_helpers.py \
tests/test_a4_parsed_entity_fixtures.py \
tests/test_a3_reconciliation_base.py
git commit -m "$(cat <<'EOF'
feat(oscal): A4 T1 stage 0 hook + dataclass + helpers
base.py 加 stage 0 dispatch(_try_user_selected_match default no-op);
MatchMethod 加 USER_SELECTED;_normalizers 補 4 helper(3 label parser +
_normalize_control_id)。ssp_intermediate 加 5 個 typed dataclass + Bundle
+ Context;ParsedParty 加 2 個 optional label 欄位(retroactive)。
新增 17 helper test + 10 dataclass fixture test + 2 stage 0 base test = 29
test 全綠。
對齊 design-A4 §3.2 / §4 / §5.1 / §5.2。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"目標:建 5 reconciler class 骨架(5 hook 全部最小實作)+ orchestrator + DI Factory;hook 內部完整演算法留 T3。 TDD 邊界:skeleton 階段每個 reconciler 寫 1 個 import smoke test + 1 個 minimal
reconcile([])走通;演算法細節 test 放 T3。
Files:
domain/oscal/service/reconciliation/device_reconciler.pytests/test_a4_reconciliation_device.py# tests/test_a4_reconciliation_device.py
import pytest
from unittest.mock import MagicMock
from domain.oscal.service.reconciliation.device_reconciler import DeviceReconciler
from domain.oscal.parser.ssp_intermediate import ParsedDevice
from domain.oscal.service.reconciliation.match_method import MatchMethod
class TestDeviceReconcilerSkeleton:
def test_reconcile_empty_list(self):
r = DeviceReconciler(device_domain_service=MagicMock())
result = r.reconcile([], tenant_id=102)
assert result == []
def test_reconcile_single_unmatched(self):
"""device_domain_service 返回空 → UNMATCHED."""
mock_device = MagicMock()
mock_device.get_devices.return_value = []
r = DeviceReconciler(device_domain_service=mock_device)
d = ParsedDevice(name="Web-01")
r.reconcile([d], tenant_id=102)
assert d.match_method == MatchMethod.UNMATCHED# domain/oscal/service/reconciliation/device_reconciler.py
from typing import Optional, Tuple
from domain.oscal.parser.ssp_intermediate import ParsedDevice
from domain.oscal.service.reconciliation.base import BaseReconciliationService
from domain.oscal.service.reconciliation.match_method import MatchMethod
class DeviceReconciler(BaseReconciliationService[ParsedDevice, object]):
def __init__(self, device_domain_service):
super().__init__()
self._device = device_domain_service
def _try_user_selected_match(self, parsed, tenant_id):
return None # T3 填
def _try_exact_match(self, parsed, tenant_id):
return None # T3 填
def _try_normalized_match(self, parsed, tenant_id):
return None # T3 填
def _try_fuzzy_match(self, parsed, tenant_id) -> Tuple[Optional[object], MatchMethod]:
return (None, MatchMethod.UNMATCHED) # D8: device 無 fuzzy
def _apply_match(self, parsed, entity, method, confidence):
parsed.matched_device_id = getattr(entity, "id", None)
parsed.match_method = method
parsed.match_confidence = confidence
def _apply_unmatched(self, parsed):
parsed.match_method = MatchMethod.UNMATCHED
parsed.match_confidence = 0.0Files:
domain/oscal/service/reconciliation/information_system_reconciler.pytests/test_a4_reconciliation_information_system.py# domain/oscal/service/reconciliation/information_system_reconciler.py
class InformationSystemReconciler(BaseReconciliationService[ParsedInformationSystem, object]):
def __init__(self, is_domain_service, user_domain_service):
super().__init__()
self._is = is_domain_service
self._user = user_domain_service
# 5 hook 全 minimal (T3 填細節)
def _try_user_selected_match(self, parsed, tenant_id): return None
def _try_exact_match(self, parsed, tenant_id): return None
def _try_normalized_match(self, parsed, tenant_id): return None
def _try_fuzzy_match(self, parsed, tenant_id): return (None, MatchMethod.UNMATCHED)
def _apply_match(self, parsed, entity, method, confidence):
parsed.matched_info_system_id = getattr(entity, "id", None)
parsed.match_method = method
parsed.match_confidence = confidence
# T3 補 system_owner 反查
def _apply_unmatched(self, parsed):
parsed.match_method = MatchMethod.UNMATCHED
parsed.match_confidence = 0.0
# T3 補 system_owner 反查Files:
domain/oscal/service/reconciliation/leveraged_reconciler.pytests/test_a4_reconciliation_leveraged.pyclass LeveragedReconciler(BaseReconciliationService[ParsedLeveraged, object]):
def __init__(self, party_domain_service):
super().__init__()
self._party = party_domain_service
# 5 hook 全 minimal
def _try_user_selected_match(self, parsed, tenant_id): return None
def _try_exact_match(self, parsed, tenant_id): return None
def _try_normalized_match(self, parsed, tenant_id): return None
def _try_fuzzy_match(self, parsed, tenant_id): return (None, MatchMethod.UNMATCHED)
def _apply_match(self, parsed, entity, method, confidence):
parsed.matched_party_uuid = str(getattr(entity, "uid", None)) if getattr(entity, "uid", None) else None
parsed.match_method = method
parsed.match_confidence = confidence
def _apply_unmatched(self, parsed):
parsed.match_method = MatchMethod.UNMATCHED
parsed.match_confidence = 0.0Files:
domain/oscal/service/reconciliation/catalog_control_reconciler.pytests/test_a4_reconciliation_catalog_control.pyclass CatalogControlReconciler(BaseReconciliationService[ParsedControl, object]):
def __init__(self, catalog_control_domain_service):
super().__init__()
self._cc = catalog_control_domain_service
# base.reconcile(parsed_list, tenant_id) 的 tenant_id 位置用 catalog_id
# 對齊既有 base — 不破壞 contract,只是參數名語意切換
def _try_user_selected_match(self, parsed, tenant_id): return None # 無 lookup col
def _try_exact_match(self, parsed, tenant_id): return None # T3 填
def _try_normalized_match(self, parsed, tenant_id): return None # T3 填
def _try_fuzzy_match(self, parsed, tenant_id): return (None, MatchMethod.UNMATCHED) # 無 fuzzy
def _apply_match(self, parsed, entity, method, confidence):
parsed.matched_catalog_control_id = getattr(entity, "id", None)
parsed.matched_catalog_control_uid = str(getattr(entity, "uid", None)) if getattr(entity, "uid", None) else None
parsed.match_method = method
parsed.match_confidence = confidence
def _apply_unmatched(self, parsed):
parsed.match_method = MatchMethod.UNMATCHED
parsed.match_confidence = 0.0Files:
domain/oscal/service/reconciliation/assessment_objective_reconciler.pytests/test_a4_reconciliation_assessment_objective.pyclass AssessmentObjectiveReconciler(BaseReconciliationService[ParsedAssessmentObjective, object]):
def __init__(self, assessment_domain_service):
super().__init__()
self._ao = assessment_domain_service
self._current_parent_ctrl_id: Optional[int] = None # 給 _try_*_match 讀
def reconcile_with_control_map(
self,
parsed_aos: list,
ctrl_map: dict, # control_id 字面 → matched_catalog_control_id
) -> None:
"""A4 特殊:因 AO 需要父 control_id context,不走 base.reconcile(list, tenant_id)."""
for ao in parsed_aos:
parent_ctrl_id = ctrl_map.get(ao.control_id)
if parent_ctrl_id is None:
self._apply_unmatched(ao)
continue
self._current_parent_ctrl_id = parent_ctrl_id
self._reconcile_one(ao, tenant_id=None)
self._current_parent_ctrl_id = None
def _try_user_selected_match(self, parsed, tenant_id): return None # 無 lookup col
def _try_exact_match(self, parsed, tenant_id): return None # T3 填
def _try_normalized_match(self, parsed, tenant_id): return None # T3 填
def _try_fuzzy_match(self, parsed, tenant_id): return (None, MatchMethod.UNMATCHED) # 無 fuzzy
def _apply_match(self, parsed, entity, method, confidence):
parsed.matched_catalog_control_assessment_id = getattr(entity, "id", None)
parsed.matched_catalog_control_assessment_uid = str(getattr(entity, "uid", None)) if getattr(entity, "uid", None) else None
parsed.match_method = method
parsed.match_confidence = confidence
def _apply_unmatched(self, parsed):
parsed.match_method = MatchMethod.UNMATCHED
parsed.match_confidence = 0.0Files:
domain/oscal/service/reconciliation/ssp_entity_orchestrator.pytests/test_a4_reconciliation_orchestrator.py# tests/test_a4_reconciliation_orchestrator.py
from unittest.mock import MagicMock, call
from domain.oscal.service.reconciliation.ssp_entity_orchestrator import (
SspEntityReconciliationOrchestrator,
)
from domain.oscal.parser.ssp_intermediate import (
ParsedExcelEntityBundle, SspEntityReconciliationContext, ParsedControl, ParsedAssessmentObjective,
)
class TestOrchestrator:
def test_dispatch_order_control_first(self):
ctrl = MagicMock()
ao = MagicMock()
device = MagicMock()
info = MagicMock()
leveraged = MagicMock()
orchestrator = SspEntityReconciliationOrchestrator(
catalog_control_reconciler=ctrl,
assessment_objective_reconciler=ao,
device_reconciler=device,
information_system_reconciler=info,
leveraged_reconciler=leveraged,
)
bundle = ParsedExcelEntityBundle(
parsed_devices=[], parsed_info_systems=[],
parsed_leveraged=[], parsed_controls=[],
)
ctx = SspEntityReconciliationContext(tenant_id=102, catalog_id=5)
orchestrator.reconcile(bundle, ctx)
ctrl.reconcile.assert_called_once()
ao.reconcile_with_control_map.assert_called_once()
device.reconcile.assert_called_once()
info.reconcile.assert_called_once()
leveraged.reconcile.assert_called_once()
def test_ao_gets_ctrl_map_from_control_results(self):
ctrl = MagicMock()
ao = MagicMock()
c1 = ParsedControl(control_id="AC-1")
c1.matched_catalog_control_id = 100
c2 = ParsedControl(control_id="AC-2")
c2.matched_catalog_control_id = None # unmatched
c1.objectives.append(ParsedAssessmentObjective(statement_id="AC-1.a", control_id="AC-1"))
c2.objectives.append(ParsedAssessmentObjective(statement_id="AC-2.a", control_id="AC-2"))
orchestrator = SspEntityReconciliationOrchestrator(
catalog_control_reconciler=ctrl,
assessment_objective_reconciler=ao,
device_reconciler=MagicMock(),
information_system_reconciler=MagicMock(),
leveraged_reconciler=MagicMock(),
)
bundle = ParsedExcelEntityBundle(
parsed_devices=[], parsed_info_systems=[],
parsed_leveraged=[], parsed_controls=[c1, c2],
)
ctx = SspEntityReconciliationContext(tenant_id=102, catalog_id=5)
orchestrator.reconcile(bundle, ctx)
# ctrl_map 應該只含 matched 的 control_id
ctrl_map_arg = ao.reconcile_with_control_map.call_args[0][1]
assert ctrl_map_arg == {"AC-1": 100}# domain/oscal/service/reconciliation/ssp_entity_orchestrator.py
from domain.oscal.parser.ssp_intermediate import (
ParsedExcelEntityBundle, SspEntityReconciliationContext,
)
class SspEntityReconciliationOrchestrator:
"""A4 facade — dispatch 5 reconciler,enforce control → AO 順序.
Caller wraps in @transaction; this class never opens a session.
"""
def __init__(
self,
catalog_control_reconciler,
assessment_objective_reconciler,
device_reconciler,
information_system_reconciler,
leveraged_reconciler,
):
self._ctrl = catalog_control_reconciler
self._ao = assessment_objective_reconciler
self._device = device_reconciler
self._info_system = information_system_reconciler
self._leveraged = leveraged_reconciler
def reconcile(
self,
parsed: ParsedExcelEntityBundle,
ctx: SspEntityReconciliationContext,
) -> None:
# 1. control 先(D3 二階段)
self._ctrl.reconcile(parsed.parsed_controls, ctx.catalog_id)
# 2. AO 從 ParsedControl.objectives flatten;用 ctrl_map 取父 catalog_control_id
ctrl_map = {
c.control_id: c.matched_catalog_control_id
for c in parsed.parsed_controls
if c.matched_catalog_control_id is not None
}
parsed_aos = [ao for c in parsed.parsed_controls for ao in c.objectives]
self._ao.reconcile_with_control_map(parsed_aos, ctrl_map)
# 3. 3 個獨立 entity (D1)
self._device.reconcile(parsed.parsed_devices, ctx.tenant_id)
self._info_system.reconcile(parsed.parsed_info_systems, ctx.tenant_id)
self._leveraged.reconcile(parsed.parsed_leveraged, ctx.tenant_id)Files:
di_containers/oscal/oscal_containers.py# di_containers/oscal/oscal_containers.py(節錄)
from domain.oscal.service.reconciliation.device_reconciler import DeviceReconciler
from domain.oscal.service.reconciliation.information_system_reconciler import InformationSystemReconciler
from domain.oscal.service.reconciliation.leveraged_reconciler import LeveragedReconciler
from domain.oscal.service.reconciliation.catalog_control_reconciler import CatalogControlReconciler
from domain.oscal.service.reconciliation.assessment_objective_reconciler import AssessmentObjectiveReconciler
from domain.oscal.service.reconciliation.ssp_entity_orchestrator import SspEntityReconciliationOrchestrator
# 假設 catalog_control_domain_service / catalog_control_assessment_domain_service 已在本 container;
# device_domain_service 透過 device_container(沿 auth_container 跨 container 引用 pattern)
device_reconciler = providers.Factory(
DeviceReconciler,
device_domain_service=device_container.device_domain_service,
)
information_system_reconciler = providers.Factory(
InformationSystemReconciler,
is_domain_service=is_container.information_system_domain_service,
user_domain_service=auth_container.user_domain_service,
)
leveraged_reconciler = providers.Factory(
LeveragedReconciler,
party_domain_service=party_domain_service, # 或對應 container
)
catalog_control_reconciler = providers.Factory(
CatalogControlReconciler,
catalog_control_domain_service=catalog_control_domain_service,
)
assessment_objective_reconciler = providers.Factory(
AssessmentObjectiveReconciler,
assessment_domain_service=catalog_control_assessment_domain_service,
)
ssp_entity_reconciliation_orchestrator = providers.Factory(
SspEntityReconciliationOrchestrator,
catalog_control_reconciler=catalog_control_reconciler,
assessment_objective_reconciler=assessment_objective_reconciler,
device_reconciler=device_reconciler,
information_system_reconciler=information_system_reconciler,
leveraged_reconciler=leveraged_reconciler,
)poetry run python3 -c "from di_containers.containers import Containers; c = Containers(); print(c.oscal_container.ssp_entity_reconciliation_orchestrator)"Expected: 不抛 exception;印出 Factory provider
git add domain/oscal/service/reconciliation/device_reconciler.py \
domain/oscal/service/reconciliation/information_system_reconciler.py \
domain/oscal/service/reconciliation/leveraged_reconciler.py \
domain/oscal/service/reconciliation/catalog_control_reconciler.py \
domain/oscal/service/reconciliation/assessment_objective_reconciler.py \
domain/oscal/service/reconciliation/ssp_entity_orchestrator.py \
di_containers/oscal/oscal_containers.py \
tests/test_a4_reconciliation_device.py \
tests/test_a4_reconciliation_information_system.py \
tests/test_a4_reconciliation_leveraged.py \
tests/test_a4_reconciliation_catalog_control.py \
tests/test_a4_reconciliation_assessment_objective.py \
tests/test_a4_reconciliation_orchestrator.py
git commit -m "$(cat <<'EOF'
feat(oscal): A4 T2 reconciler + orchestrator skeleton + DI
5 個新 reconciler skeleton(hook 全 minimal,T3 填演算法)+
SspEntityReconciliationOrchestrator facade 落地(enforce control → AO 順序,
其他 3 entity 獨立);AO Reconciler 加 reconcile_with_control_map public
method 處理父 control 依賴。
DI Container 加 6 Factory(5 reconciler + 1 orchestrator);跨 container
引用 device_container / is_container / auth_container 沿 A3 line 490/491
pattern。
新增 12 個 skeleton test + 2 個 orchestrator dispatch test = 14 test 全綠。
對齊 design-A4 §3.2 / §5.3-§5.5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"當 T2 ship 後(Session C 結束):
目標:T2 skeleton 的 5 hook 全部填上實作(stage 0 / 1 / 2 / fuzzy 詳述見 design §5.3)。 TDD 邊界:每個 stage 一個 test class;fuzzy 邊界 case 全 cover。 預估 test 數:DeviceReconciler ~12 / InformationSystemReconciler ~14(含 system_owner)/ LeveragedReconciler ~14 / CatalogControlReconciler ~10 / AssessmentObjectiveReconciler ~10 = ~60 case。
Files:
domain/oscal/service/reconciliation/device_reconciler.pytests/test_a4_reconciliation_device.py# tests/test_a4_reconciliation_device.py 補
class TestDeviceUserSelected:
def test_user_selected_via_label(self):
"""matched_device_label='Web-01 (10.0.1.5)' → 反查到 Device → USER_SELECTED."""
mock = MagicMock()
mock_entity = MagicMock(id=42, name="Web-01", ip="10.0.1.5")
mock.get_devices.return_value = [mock_entity]
r = DeviceReconciler(device_domain_service=mock)
d = ParsedDevice(name="Web-01", ip="10.0.1.5", matched_device_label="Web-01 (10.0.1.5)")
r.reconcile([d], tenant_id=102)
assert d.match_method == MatchMethod.USER_SELECTED
assert d.matched_device_id == 42
def test_user_selected_label_no_match(self):
"""label parse 成功但 DB 查無 → fallback stage 1."""
# ... 略
# ... 其他 user_selected case
class TestDeviceExact:
def test_exact_match_name_and_ip(self):
"""雙欄 (name, ip) 同時匹配."""
# ...
def test_exact_match_name_only(self):
"""T0.8 verify 後若 jedi-device 支援單欄;否則 fallback Python filter."""
# ...
class TestDeviceNormalized:
def test_normalized_name_lowercase(self):
"""name='WEB-01' parsed 'web-01' → normalize 後找到."""
# ...class DeviceReconciler(BaseReconciliationService[ParsedDevice, object]):
def __init__(self, device_domain_service):
super().__init__()
self._device = device_domain_service
def _try_user_selected_match(self, parsed, tenant_id):
if not parsed.matched_device_label:
return None
name, ip = parse_device_lookup_label(parsed.matched_device_label)
if not name:
return None
try:
from jedi_device.domain.entity.device_query_entity import DeviceQueryEntity
q = DeviceQueryEntity(name=name)
if ip:
q.ip = ip
devices = self._device.get_devices(q)
for d in devices or []:
# T0.8 verify 後若雙欄不支援 → Python filter
if ip and getattr(d, "ip", None) != ip:
continue
return d
except Exception:
pass
return None
def _try_exact_match(self, parsed, tenant_id):
if not parsed.name:
return None
try:
from jedi_device.domain.entity.device_query_entity import DeviceQueryEntity
q = DeviceQueryEntity(name=parsed.name.strip())
if parsed.ip:
q.ip = parsed.ip.strip()
devices = self._device.get_devices(q)
for d in devices or []:
if parsed.ip and getattr(d, "ip", None) != parsed.ip.strip():
continue
return d
except Exception:
pass
return None
def _try_normalized_match(self, parsed, tenant_id):
from domain.oscal.service.reconciliation._normalizers import _normalize_name
normalized_name = _normalize_name(parsed.name).lower() if parsed.name else ""
normalized_ip = (parsed.ip or "").strip()
if not normalized_name or normalized_name == (parsed.name or "").strip():
return None
try:
from jedi_device.domain.entity.device_query_entity import DeviceQueryEntity
q = DeviceQueryEntity(name=normalized_name)
if normalized_ip:
q.ip = normalized_ip
devices = self._device.get_devices(q)
for d in devices or []:
return d
except Exception:
pass
return None
def _try_fuzzy_match(self, parsed, tenant_id):
return (None, MatchMethod.UNMATCHED) # D8: device 無 fuzzy
def _apply_match(self, parsed, entity, method, confidence):
parsed.matched_device_id = getattr(entity, "id", None)
parsed.match_method = method
parsed.match_confidence = confidence
def _apply_unmatched(self, parsed):
parsed.match_method = MatchMethod.UNMATCHED
parsed.match_confidence = 0.0Files:
domain/oscal/service/reconciliation/information_system_reconciler.pytests/test_a4_reconciliation_information_system.pyclass InformationSystemReconciler(BaseReconciliationService[ParsedInformationSystem, object]):
def __init__(self, is_domain_service, user_domain_service):
super().__init__()
self._is = is_domain_service
self._user = user_domain_service
def _try_user_selected_match(self, parsed, tenant_id):
if not parsed.matched_info_system_label:
return None
abbr, name = parse_info_system_lookup_label(parsed.matched_info_system_label)
if not (abbr or name):
return None
try:
from jedi_information_system.domain.entity.information_system_query_entity import InformationSystemQueryEntity
q = InformationSystemQueryEntity(name=name) if name else InformationSystemQueryEntity(abbreviation=abbr)
systems = self._is.get_information_systems(q)
for s in systems or []:
if abbr and getattr(s, "abbreviation", None) != abbr:
continue
if name and getattr(s, "name", None) != name:
continue
return s
except Exception:
pass
return None
def _try_exact_match(self, parsed, tenant_id):
if not parsed.name:
return None
try:
from jedi_information_system.domain.entity.information_system_query_entity import InformationSystemQueryEntity
systems = self._is.get_information_systems(InformationSystemQueryEntity(name=parsed.name.strip()))
for s in systems or []:
return s
except Exception:
pass
return None
def _try_normalized_match(self, parsed, tenant_id):
from domain.oscal.service.reconciliation._normalizers import _normalize_name
normalized = _normalize_name(parsed.name)
if not normalized or normalized == (parsed.name or "").strip():
return None
try:
from jedi_information_system.domain.entity.information_system_query_entity import InformationSystemQueryEntity
systems = self._is.get_information_systems(InformationSystemQueryEntity(name=normalized))
for s in systems or []:
return s
except Exception:
pass
return None
def _try_fuzzy_match(self, parsed, tenant_id):
return (None, MatchMethod.UNMATCHED) # D8: info_system 無 fuzzy
def _apply_match(self, parsed, entity, method, confidence):
parsed.matched_info_system_id = getattr(entity, "id", None)
parsed.match_method = method
parsed.match_confidence = confidence
self._resolve_system_owner(parsed)
def _apply_unmatched(self, parsed):
parsed.match_method = MatchMethod.UNMATCHED
parsed.match_confidence = 0.0
self._resolve_system_owner(parsed)
def _resolve_system_owner(self, parsed):
"""純 EXACT label 反查;不影響主 match_method."""
if not parsed.system_owner_label:
return
from domain.oscal.service.reconciliation._normalizers import parse_user_lookup_label
nickname, login_name = parse_user_lookup_label(parsed.system_owner_label)
if not login_name:
return
try:
from jedi_auth.domain.entities.user_query_entity import UserQueryEntity
users = self._user.get_users(UserQueryEntity(login_name=login_name))
for u in users or []:
if getattr(u, "is_active", True):
parsed.matched_system_owner_user_id = u.id
return
except Exception:
passFiles:
domain/oscal/service/reconciliation/leveraged_reconciler.pytests/test_a4_reconciliation_leveraged.pyclass LeveragedReconciler(BaseReconciliationService[ParsedLeveraged, object]):
def __init__(self, party_domain_service):
super().__init__()
self._party = party_domain_service
def _try_user_selected_match(self, parsed, tenant_id):
if not parsed.party_label:
return None
try:
from domain.oscal.entity.party_query_entity import PartyQueryEntity
parties = self._party.get_all(
PartyQueryEntity(party_type="organization", name=parsed.party_label.strip())
)
for p in parties or []:
return p
except Exception:
pass
return None
def _try_exact_match(self, parsed, tenant_id):
if not parsed.provider:
return None
try:
from domain.oscal.entity.party_query_entity import PartyQueryEntity
parties = self._party.get_all(
PartyQueryEntity(party_type="organization", name=parsed.provider.strip())
)
for p in parties or []:
return p
except Exception:
pass
return None
def _try_normalized_match(self, parsed, tenant_id):
from domain.oscal.service.reconciliation._normalizers import _normalize_name
normalized = _normalize_name(parsed.provider)
if not normalized or normalized == (parsed.provider or "").strip():
return None
try:
from domain.oscal.entity.party_query_entity import PartyQueryEntity
parties = self._party.get_all(
PartyQueryEntity(party_type="organization", name=normalized)
)
for p in parties or []:
return p
except Exception:
pass
return None
def _try_fuzzy_match(self, parsed, tenant_id):
"""沿 A3 OrganizationReconciler — _strip_org_suffix 後比對 candidate cache."""
from domain.oscal.service.reconciliation._normalizers import (
_normalize_name, _strip_org_suffix,
)
if not parsed.provider:
return (None, MatchMethod.UNMATCHED)
normalized = _normalize_name(parsed.provider)
stripped = _strip_org_suffix(normalized)
if not stripped or stripped == normalized:
return (None, MatchMethod.UNMATCHED)
def _loader():
from domain.oscal.entity.party_query_entity import PartyQueryEntity
return self._party.get_all(PartyQueryEntity(party_type="organization"))
candidates = self._get_or_load_candidates(_loader)
for p in candidates:
p_stripped = _strip_org_suffix(_normalize_name(getattr(p, "name", None)))
if p_stripped == stripped:
return (p, MatchMethod.FUZZY_NAME_PREFIX)
return (None, MatchMethod.UNMATCHED)
def _apply_match(self, parsed, entity, method, confidence):
uid = getattr(entity, "uid", None)
parsed.matched_party_uuid = str(uid) if uid else None
parsed.match_method = method
parsed.match_confidence = confidence
def _apply_unmatched(self, parsed):
parsed.match_method = MatchMethod.UNMATCHED
parsed.match_confidence = 0.0Files:
domain/oscal/service/reconciliation/catalog_control_reconciler.pytests/test_a4_reconciliation_catalog_control.pyclass CatalogControlReconciler(BaseReconciliationService[ParsedControl, object]):
def __init__(self, catalog_control_domain_service):
super().__init__()
self._cc = catalog_control_domain_service
def _try_user_selected_match(self, parsed, tenant_id):
return None # 樣板無 lookup col;skip stage 0
def _try_exact_match(self, parsed, tenant_id):
# NOTE: tenant_id 位置實際傳 catalog_id(orchestrator 用 ctx.catalog_id 呼叫)
catalog_id = tenant_id
if not parsed.control_id or catalog_id is None:
return None
try:
from jedi_oscal.domain.entity.catalog.catalog_control_query_entity import CatalogControlQueryEntity
ctrls = self._cc.get_all(
CatalogControlQueryEntity(catalog_id=catalog_id, control_id=parsed.control_id.strip())
)
for c in ctrls or []:
return c
except Exception:
pass
return None
def _try_normalized_match(self, parsed, tenant_id):
from domain.oscal.service.reconciliation._normalizers import _normalize_control_id
catalog_id = tenant_id
normalized = _normalize_control_id(parsed.control_id)
if not normalized or normalized == (parsed.control_id or "").strip():
return None
try:
from jedi_oscal.domain.entity.catalog.catalog_control_query_entity import CatalogControlQueryEntity
ctrls = self._cc.get_all(
CatalogControlQueryEntity(catalog_id=catalog_id, control_id=normalized)
)
for c in ctrls or []:
return c
except Exception:
pass
return None
def _try_fuzzy_match(self, parsed, tenant_id):
return (None, MatchMethod.UNMATCHED) # D3: control 無 fuzzy
def _apply_match(self, parsed, entity, method, confidence):
parsed.matched_catalog_control_id = getattr(entity, "id", None)
uid = getattr(entity, "uid", None)
parsed.matched_catalog_control_uid = str(uid) if uid else None
parsed.match_method = method
parsed.match_confidence = confidence
def _apply_unmatched(self, parsed):
parsed.match_method = MatchMethod.UNMATCHED
parsed.match_confidence = 0.0Files:
domain/oscal/service/reconciliation/assessment_objective_reconciler.pytests/test_a4_reconciliation_assessment_objective.pyclass AssessmentObjectiveReconciler(BaseReconciliationService[ParsedAssessmentObjective, object]):
def __init__(self, assessment_domain_service):
super().__init__()
self._ao = assessment_domain_service
self._current_parent_ctrl_id: Optional[int] = None
def reconcile_with_control_map(self, parsed_aos, ctrl_map):
for ao in parsed_aos:
parent_ctrl_id = ctrl_map.get(ao.control_id)
if parent_ctrl_id is None:
self._apply_unmatched(ao)
continue
self._current_parent_ctrl_id = parent_ctrl_id
try:
self._reconcile_one(ao, tenant_id=None)
finally:
self._current_parent_ctrl_id = None
def _try_user_selected_match(self, parsed, tenant_id):
return None
def _try_exact_match(self, parsed, tenant_id):
if self._current_parent_ctrl_id is None or not parsed.objective_id:
return None
try:
from jedi_oscal.domain.entity.catalog.catalog_control_assessment_query_entity import CatalogControlAssessmentQueryEntity
aos = self._ao.get_all(
CatalogControlAssessmentQueryEntity(
catalog_control_id=self._current_parent_ctrl_id,
objective_id=parsed.objective_id.strip(),
)
)
for a in aos or []:
return a
except Exception:
pass
return None
def _try_normalized_match(self, parsed, tenant_id):
# objective_id normalize: trim + upper
if self._current_parent_ctrl_id is None or not parsed.objective_id:
return None
normalized = parsed.objective_id.strip().upper()
if normalized == parsed.objective_id.strip():
return None
try:
from jedi_oscal.domain.entity.catalog.catalog_control_assessment_query_entity import CatalogControlAssessmentQueryEntity
aos = self._ao.get_all(
CatalogControlAssessmentQueryEntity(
catalog_control_id=self._current_parent_ctrl_id,
objective_id=normalized,
)
)
for a in aos or []:
return a
except Exception:
pass
return None
def _try_fuzzy_match(self, parsed, tenant_id):
return (None, MatchMethod.UNMATCHED)
def _apply_match(self, parsed, entity, method, confidence):
parsed.matched_catalog_control_assessment_id = getattr(entity, "id", None)
uid = getattr(entity, "uid", None)
parsed.matched_catalog_control_assessment_uid = str(uid) if uid else None
parsed.match_method = method
parsed.match_confidence = confidence
def _apply_unmatched(self, parsed):
parsed.match_method = MatchMethod.UNMATCHED
parsed.match_confidence = 0.0git add domain/oscal/service/reconciliation/device_reconciler.py \
domain/oscal/service/reconciliation/information_system_reconciler.py \
domain/oscal/service/reconciliation/leveraged_reconciler.py \
domain/oscal/service/reconciliation/catalog_control_reconciler.py \
domain/oscal/service/reconciliation/assessment_objective_reconciler.py \
tests/test_a4_reconciliation_device.py \
tests/test_a4_reconciliation_information_system.py \
tests/test_a4_reconciliation_leveraged.py \
tests/test_a4_reconciliation_catalog_control.py \
tests/test_a4_reconciliation_assessment_objective.py
git commit -m "$(cat <<'EOF'
feat(oscal): A4 T3 reconciler full algorithm
5 個 reconciler hook 完整實作(stage 0 USER_SELECTED + stage 1 EXACT +
stage 2 NORMALIZED + stage 3 fuzzy/no-op):
- Device / InfoSystem / Control / AO: D3+D8 三/二階段、無 fuzzy
- Leveraged: 沿 A3 OrganizationReconciler 樣板含 FUZZY_NAME_PREFIX
- InfoSystem: 附加 system_owner 反查(純 EXACT,不影響主 match_method)
- AO: reconcile_with_control_map 透過 instance attr 傳父 catalog_control_id
新增 ~60 個 reconciler test(Device 12 / InfoSystem 14 / Leveraged 14 /
Control 10 / AO 10)全綠。
對齊 design-A4 §5.3-§5.5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"目標:在 PersonReconciler / OrganizationReconciler 加 stage 0
_try_user_selected_match;同時改_dict_to_parsed_parties把 Excelmatched_user/org_unitlookup col 傳遞到 ParsedParty。 TDD 邊界:每個 reconciler 加 4 個 stage 0 test(hit / miss / no label / parse failure);A3 既有 16 person + 16 org test 全綠(USER_SELECTED 加入後不破壞)。
_try_user_selected_matchFiles:
domain/oscal/service/reconciliation/person_reconciler.pytests/test_a4_a3_retroactive.py# tests/test_a4_a3_retroactive.py
import pytest
from unittest.mock import MagicMock
from domain.oscal.service.reconciliation.person_reconciler import PersonReconciler
from domain.oscal.parser.ssp_intermediate import ParsedParty
from domain.oscal.service.reconciliation.match_method import MatchMethod
class TestPersonReconcilerStage0:
def test_user_selected_label_hit(self):
mock_entity = MagicMock(id=42, is_active=True, login_name="alice")
mock_user = MagicMock()
mock_user.get_users.return_value = [mock_entity]
r = PersonReconciler(user_domain_service=mock_user)
p = ParsedParty(
name="Alice 陳", party_type="person",
matched_user_label="Alice 陳 <alice>",
)
r.reconcile([p], tenant_id=102)
assert p.match_method == MatchMethod.USER_SELECTED
assert p.matched_user_id == 42
def test_user_selected_label_miss_fallback_to_exact(self):
"""label 解出 login_name 但 DB 查無 → fallback stage 1 (exact email)."""
# ...
def test_user_selected_no_label_skip_to_exact(self):
"""matched_user_label=None → 直接走 stage 1."""
# ...
def test_user_selected_label_parse_failure(self):
"""matched_user_label='alice' (no angle brackets) → parse fail → skip stage 0."""
# ...# domain/oscal/service/reconciliation/person_reconciler.py
class PersonReconciler(BaseReconciliationService[ParsedParty, object]):
# ... 既有 __init__ / _try_exact_match / _try_normalized_match / _try_fuzzy_match / _apply_match / _apply_unmatched 不動 ...
def _try_user_selected_match(self, parsed, tenant_id):
if not parsed.matched_user_label:
return None
from domain.oscal.service.reconciliation._normalizers import parse_user_lookup_label
nickname, login_name = parse_user_lookup_label(parsed.matched_user_label)
if not login_name:
return None
try:
from jedi_auth.domain.entities.user_query_entity import UserQueryEntity
users = self._user.get_users(UserQueryEntity(login_name=login_name))
for u in users or []:
if getattr(u, "is_active", True):
return u
except Exception:
pass
return None_try_user_selected_matchFiles:
domain/oscal/service/reconciliation/organization_reconciler.pytests/test_a4_a3_retroactive.pyclass OrganizationReconciler(BaseReconciliationService[ParsedParty, object]):
# ... 既有不動 ...
def _try_user_selected_match(self, parsed, tenant_id):
if not parsed.matched_org_unit_label:
return None
name = parsed.matched_org_unit_label.strip()
if not name:
return None
try:
from jedi_auth.domain.entities.org_unit_query_entity import OrgUnitQueryEntity
q = OrgUnitQueryEntity(name=name)
if tenant_id is not None:
q.tenant_id = tenant_id
orgs = self._org.get_org_units(q)
for o in orgs or []:
return o
except Exception:
pass
return None_dict_to_parsed_parties 加 label 傳遞Files:
app/oscal/service/ssp_excel_import_app_service.pytests/test_a4_a3_retroactive.pyclass TestDictToParsedPartiesRetroactive:
def test_org_dict_to_parsed_party_with_parent_org_label(self):
from app.oscal.service.ssp_excel_import_app_service import SspExcelImportAppService
orgs = [{"name": "Sub-Org", "parent_org": "Parent-Org"}]
result = SspExcelImportAppService._dict_to_parsed_parties(orgs, [])
assert result[0].matched_org_unit_label == "Parent-Org"
def test_person_dict_to_parsed_party_with_user_and_org_labels(self):
persons = [{"name": "Alice", "email": "a@x.com", "matched_user": "Alice <alice>", "org_unit": "Engineering"}]
result = SspExcelImportAppService._dict_to_parsed_parties([], persons)
assert result[0].matched_user_label == "Alice <alice>"
assert result[0].matched_org_unit_label == "Engineering"@staticmethod
def _dict_to_parsed_parties(orgs, persons) -> list[ParsedParty]:
result = []
for o in orgs:
name = (o.get("name") or "").strip()
if not name:
continue
result.append(ParsedParty(
name=name, party_type="organization",
role=o.get("role"), short_name=o.get("short_name"),
email_address=o.get("email"),
matched_org_unit_label=o.get("parent_org"), # A4 retroactive
))
for p in persons:
name = (p.get("name") or "").strip()
email = (p.get("email") or "").strip()
if not name and not email:
continue
result.append(ParsedParty(
name=name or email, party_type="person",
role=p.get("role"), email_address=email or None,
matched_user_label=p.get("matched_user"), # A4 retroactive
matched_org_unit_label=p.get("org_unit"), # A4 retroactive
))
return resultgit add domain/oscal/service/reconciliation/person_reconciler.py \
domain/oscal/service/reconciliation/organization_reconciler.py \
app/oscal/service/ssp_excel_import_app_service.py \
tests/test_a4_a3_retroactive.py
git commit -m "$(cat <<'EOF'
feat(oscal): A4 T4 A3 retroactive USER_SELECTED stage
PersonReconciler / OrganizationReconciler override _try_user_selected_match
hook(A4 T1 base stage 0 dispatch 已落地):
- Person: parse 'nickname <login_name>' → UserQueryEntity(login_name=...)
- Organization: parse parent_org name → OrgUnitQueryEntity(name=, tenant_id=)
_dict_to_parsed_parties 補 3 個 label 傳遞:
- org.parent_org → ParsedParty.matched_org_unit_label
- person.matched_user → ParsedParty.matched_user_label
- person.org_unit → ParsedParty.matched_org_unit_label
新增 10 個 retroactive test 全綠;A3 既有 32 person+org test 全綠(USER_SELECTED
加入後既有 EXACT / NORMALIZED / FUZZY 行為不變)。
對齊 design-A4 §5.2 / §6.5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"目標:
_confirm_superset_flow內呼叫CatalogControlReconciler反查catalog_control_uid,填入mf_payload.include_controls取代 A2 的空 list。 TDD 邊界:1 個 integration test 跑通完整 _confirm_superset_flow → include_controls 內含正確 uid。
_confirm_superset_flowFiles:
app/oscal/service/ssp_excel_import_app_service.pytests/test_a4_a2_integration.py# tests/test_a4_a2_integration.py
class TestSuperset15_3:
def test_include_controls_populated_from_matched_catalog_control_uid(self, monkeypatch):
"""A2 §15.3 整併:confirm_superset_flow 內 ControlReconciler 反查後填 include_controls."""
# mock fw_version + mf_service + catalog_control_reconciler
# 跑 confirm_superset_flow → assert mf_payload.include_controls == [uid1, uid2]
# ...def _confirm_superset_flow(self, job, parsed_result, payload, user_context):
fw_version = self._fw_version_domain.get_by_uid(job.source_uid)
if fw_version is None:
raise NotFound(GrcErrorCode.GRC_FRAMEWORK_VERSION_NOT_FOUND)
controls_with_aos = parsed_result.get("controls_with_aos") or []
target_control_ids = [
c.get("control_id") for c in controls_with_aos
if c.get("statement_id") is None
and c.get("_target_in_profile") is True
and c.get("control_id")
]
# A4 §15.3 整併
include_uids = []
if self._catalog_control_reconciler is not None and target_control_ids:
from domain.oscal.parser.ssp_intermediate import ParsedControl
parsed_for_resolve = [ParsedControl(control_id=cid) for cid in target_control_ids]
try:
self._catalog_control_reconciler.reconcile(parsed_for_resolve, fw_version.catalog_id)
except Exception as e:
logger.warning("A4 §15.3 include_controls 反查 skipped: %s", e)
include_uids = [
str(p.matched_catalog_control_uid)
for p in parsed_for_resolve
if p.matched_catalog_control_uid
]
metadata = job.metadata or {}
mf_payload = {
"name": metadata.get("target_mf_name") or f"MF from Excel {job.uid[:8]}",
"group": metadata.get("target_group", ""),
"version": metadata.get("target_version", "1.0"),
"frequency": metadata.get("target_frequency", "annual"),
"description": metadata.get("target_description", ""),
"provider": metadata.get("target_provider", "Billows-Official"),
"oscal_framework_version_uid": fw_version.uid,
"include_controls": include_uids, # ← A4 真正 subset profile
"status": "draft",
}
if target_control_ids:
logger.info(
"A4 superset flow: %d/%d control_id resolved to catalog_control_uid",
len(include_uids), len(target_control_ids),
)
mf_dto = self._mf_service.add_module_frame(...)
# ... T6 / T8 補 SSP shell + write pipelinegit add app/oscal/service/ssp_excel_import_app_service.py \
tests/test_a4_a2_integration.py
git commit -m "$(cat <<'EOF'
feat(oscal): A4 T5 A2 §15.3 superset include_controls 反查
_confirm_superset_flow 加 catalog_control_reconciler 預先反查 target
control_id → catalog_control_uid;填入 mf_payload.include_controls 取代
A2 的空 list(全納入 fallback)。
對齊 design-A2 §15.3 follow-up + design-A4 §6.4.
效果:superset upload flow 從此真正建立 subset profile(只含 user 標記
_target_in_profile=TRUE 的 control),而非整 framework catalog。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"當 T3 + T4 + T5 ship 後(Session D 結束):
目標:實作
_create_ssp_shell(superset flow)+_resolve_existing_ssp_shell(update flow)+ 新 error codeGRC_EXCEL_UPDATE_FLOW_NO_SSP(412)。 依賴:T0.1 / T0.2 / T0.3 / T0.7 verify 結果(jedi-oscal SspEntity / SystemCharacteristic / SystemImplementation main / get_one_by_profile_uid 是否存在)。
GRC_EXCEL_UPDATE_FLOW_NO_SSP error codeFiles:
common/code/grc_error_code.pyclass GrcErrorCode(BaseCode):
# ... 既有
GRC_EXCEL_UPDATE_FLOW_NO_SSP = ("Excel update flow 失敗:對應 MF 尚未建立 SSP", "GRC_412066")poetry run python3 -c "from common.code.grc_error_code import GrcErrorCode; print(GrcErrorCode.GRC_EXCEL_UPDATE_FLOW_NO_SSP)"_create_ssp_shell 實作Files:
app/oscal/service/ssp_excel_import_app_service.pytests/test_a4_ssp_shell.py# tests/test_a4_ssp_shell.py
class TestCreateSspShell:
def test_create_shell_calls_3_domain_services(self):
# mock ssp_domain / sc_domain / sys_impl_domain
# call _create_ssp_shell(mf_uid='x', metadata={...}, user='u')
# assert 3 domain.add 各呼叫 1 次
# assert 返回 (ssp_id, sys_impl_main_id)
# ...
def test_create_shell_uses_metadata_target_mf_name_for_title(self):
# ...
def test_create_shell_links_profile_uid_from_mf(self):
# ...def _create_ssp_shell(self, mf_uid: str, metadata: dict, user: str) -> tuple[int, int]:
"""Superset flow 建 SSP placeholder + system_characteristic + system_implementation main.
Caller 必須在 @transaction scope 內呼叫。
Entity 構造參數依 T0 verify 結果調整 — 若有偏差需修 design §11 補紀錄。
"""
from jedi_oscal.domain.entity.ssp.ssp_entity import SystemSecurityPlanEntity
from jedi_oscal.domain.entity.ssp.ssp_system_characteristic_entity import SspSystemCharacteristicEntity
from jedi_oscal.domain.entity.ssp.ssp_system_implementation_entity import SspSystemImplementationEntity
mf = self._mf_domain.get_module_frame_by_uid(mf_uid)
if mf is None:
raise NotFound(GrcErrorCode.GRC_MODULE_FRAME_NOT_FOUND)
# 2a — 建 SSP
ssp_dto = self._ssp_domain.add(SystemSecurityPlanEntity(
title=metadata.get("target_mf_name") or f"SSP for MF {mf_uid[:8]}",
profile_uid=mf.oscal_profile_uid,
status='draft',
), curr_user=user)
# 2b — 建 system_characteristic
self._system_characteristic_domain.add(SspSystemCharacteristicEntity(
system_security_plan_id=ssp_dto.id,
name=metadata.get("target_system_name") or ssp_dto.title,
), curr_user=user)
# 2c — 建 system_implementation main(A0.1 scope_type='ssp')
impl_main_dto = self._system_implementation_domain.add(SspSystemImplementationEntity(
system_security_plan_id=ssp_dto.id,
scope_type='ssp',
scope_id=ssp_dto.id,
), curr_user=user)
return (ssp_dto.id, impl_main_dto.id)_resolve_existing_ssp_shell 實作Files:
app/oscal/service/ssp_excel_import_app_service.pytests/test_a4_ssp_shell.pyclass TestResolveExistingSspShell:
def test_existing_ssp_returns_ids(self):
# mock mf + ssp_domain.get_one_by_profile_uid → ssp
# mock system_implementation_domain.get_one_by_ssp_id → impl_main
# assert 返回 (ssp.id, impl_main.id)
# ...
def test_no_ssp_raises_GRC_412066(self):
# ssp_domain.get_one_by_profile_uid → None
# assert raise PreconditionFailedError(GRC_EXCEL_UPDATE_FLOW_NO_SSP)
# ...
def test_no_system_implementation_raises_GRC_412066(self):
# ssp 存在 but system_implementation_domain.get_one_by_ssp_id → None
# assert raise
# ...def _resolve_existing_ssp_shell(self, mf_uid: str) -> tuple[int, int]:
mf = self._mf_domain.get_module_frame_by_uid(mf_uid)
if mf is None:
raise NotFound(GrcErrorCode.GRC_MODULE_FRAME_NOT_FOUND)
ssp = self._ssp_domain.get_one_by_profile_uid(mf.oscal_profile_uid)
if ssp is None:
raise PreconditionFailedError(GrcErrorCode.GRC_EXCEL_UPDATE_FLOW_NO_SSP)
impl_main = self._system_implementation_domain.get_one_by_ssp_id(ssp.id)
if impl_main is None:
raise PreconditionFailedError(GrcErrorCode.GRC_EXCEL_UPDATE_FLOW_NO_SSP)
return (ssp.id, impl_main.id)git add common/code/grc_error_code.py \
app/oscal/service/ssp_excel_import_app_service.py \
tests/test_a4_ssp_shell.py
git commit -m "$(cat <<'EOF'
feat(oscal): A4 T6 SSP shell + GRC_412066 error code
_create_ssp_shell(superset flow)建 3 步驟:SSP + system_characteristic +
system_implementation main(A0.1 scope_type='ssp')。
_resolve_existing_ssp_shell(update flow)查既有 SSP + system_implementation
main;任一不存在 raise PreconditionFailedError(GRC_EXCEL_UPDATE_FLOW_NO_SSP)
明確指出 import flow 不負責建第 1 版 SSP(屬 ssp_versioning_service 職責)。
新增 6 SSP shell test + 1 error code import smoke = 7 test 全綠。
對齊 design-A4 §6.2 / §6.3 + T0 verify 結果。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"目標:建
write_strategy/sub-folder + 5 WriteStrategy 完整實作。 TDD 邊界:每 WriteStrategy ~6-8 test(MATCHED upsert / UNMATCHED skip or 純文字 / batch / FK 連動 / log warning)。
write_strategy/ sub-foldermkdir -p domain/oscal/service/write_strategy
touch domain/oscal/service/write_strategy/__init__.pyFiles:
domain/oscal/service/write_strategy/control_write_strategy.pytests/test_a4_write_strategy_control.py# tests/test_a4_write_strategy_control.py
class TestControlWriteStrategy:
def test_unmatched_skip_log_warning(self):
"""matched_catalog_control_id=None → skip + log."""
# ...
def test_matched_upsert_returns_ctrl_impl_id_map(self):
"""MATCHED ParsedControl → upsert system_security_plan_control_implementations + 返回 map."""
# ...
def test_returns_count(self):
# ...
def test_implementation_status_from_parsed(self):
# ...
def test_existing_row_update_not_insert(self):
"""同 (ssp_id, catalog_control_id) 已存在 → update 不 insert."""
# ...
def test_returns_tuple_count_and_map(self):
"""返回 (written_count, ctrl_impl_id_map: dict[catalog_control_id → ssp_control_implementation_id])."""
# ...# domain/oscal/service/write_strategy/control_write_strategy.py
import logging
from typing import Optional
from domain.oscal.parser.ssp_intermediate import ParsedControl
from domain.oscal.service.reconciliation.match_method import MatchMethod
logger = logging.getLogger(__name__)
class ControlWriteStrategy:
"""寫入 system_security_plan_control_implementations。
Caller wraps in @transaction; this class never opens a session.
"""
def __init__(self, control_implementation_domain_service):
self._ci = control_implementation_domain_service
def write(
self,
parsed_controls: list[ParsedControl],
ssp_id: int,
user_context,
) -> tuple[int, dict]:
"""Returns (written_count, ctrl_impl_id_map: dict[catalog_control_id → ssp_control_implementation_id])."""
written = 0
ctrl_impl_id_map: dict[int, int] = {}
for c in parsed_controls:
if c.matched_catalog_control_id is None:
logger.warning("ControlWriteStrategy skip unmatched control_id=%s", c.control_id)
continue
try:
from jedi_oscal.domain.entity.ssp.ssp_control_implementation_entity import (
SspControlImplementationEntity,
)
from jedi_oscal.domain.entity.ssp.ssp_control_implementation_query_entity import (
SspControlImplementationQueryEntity,
)
# upsert by (ssp_id, catalog_control_id)
existing = self._ci.get_one(SspControlImplementationQueryEntity(
system_security_plan_id=ssp_id,
catalog_control_id=c.matched_catalog_control_id,
))
entity = SspControlImplementationEntity(
system_security_plan_id=ssp_id,
catalog_control_id=c.matched_catalog_control_id,
implementation_status=c.impl_status or "unknown",
implementation_description=c.statement,
)
if existing is None:
dto = self._ci.add(entity, curr_user=user_context.login_name)
ctrl_impl_id_map[c.matched_catalog_control_id] = dto.id
else:
entity.uid = existing.uid
dto = self._ci.update(entity, curr_user=user_context.login_name)
ctrl_impl_id_map[c.matched_catalog_control_id] = existing.id
written += 1
except Exception as e:
logger.warning("ControlWriteStrategy write failed for control_id=%s: %s", c.control_id, e, exc_info=True)
return (written, ctrl_impl_id_map)Files:
domain/oscal/service/write_strategy/ao_write_strategy.pytests/test_a4_write_strategy_ao.pyclass AoWriteStrategy:
"""寫入 ssp_control_impl_objective."""
def __init__(self, ssp_control_impl_objective_domain_service):
self._cio = ssp_control_impl_objective_domain_service
def write(
self,
parsed_aos: list, # List[ParsedAssessmentObjective]
ssp_id: int,
ctrl_impl_id_map: dict, # catalog_control_id → ssp_control_implementation_id
user_context,
) -> int:
written = 0
for ao in parsed_aos:
if ao.matched_catalog_control_assessment_id is None:
logger.warning("AoWriteStrategy skip unmatched control_id=%s statement_id=%s", ao.control_id, ao.statement_id)
continue
# 取父 control 的 ssp_control_implementation_id(透過 ctrl_impl_id_map)
# ao.control_id (字面) → matched_catalog_control_id(要從 parsed_controls 反查)
# 假設 caller (orchestrator + write pipeline) 把 ao 跟父 ParsedControl 已關聯;
# 簡化做法:sci_id 從 ctrl_impl_id_map 拿(key 是 catalog_control_id)
# AO 本身有 matched_catalog_control_assessment_id 但 sci_id 要 caller 拼
# ↓ 簡化 logic:實作時若需要 sci_id 反查 — 由 _write_all_data 拼好傳入
# ...
try:
from jedi_oscal.domain.entity.ssp.ssp_control_impl_objective_entity import (
SspControlImplObjectiveEntity,
)
# 期待 caller 已把 (catalog_control_assessment_id, ssp_control_implementation_id)
# 兩個 FK 一起傳入;此處需要從 ctrl_impl_id_map 查 sci_id
# 由 caller (_write_all_data) 在準備 parsed_aos 前把 ao.ssp_control_implementation_id 填好
sci_id = getattr(ao, "_ssp_control_implementation_id", None) # caller 暫填
if sci_id is None:
logger.warning("AoWriteStrategy skip — no ssp_control_implementation_id for statement_id=%s", ao.statement_id)
continue
entity = SspControlImplObjectiveEntity(
ssp_control_implementation_id=sci_id,
catalog_control_assessment_id=ao.matched_catalog_control_assessment_id,
implementation_status=ao.impl_status or "unknown",
implementation_description=ao.statement,
)
self._cio.add(entity, curr_user=user_context.login_name)
written += 1
except Exception as e:
logger.warning("AoWriteStrategy write failed: %s", e, exc_info=True)
return writtenNOTE:AO write 需要 ssp_control_implementation_id(不是 catalog_control_id);caller _write_all_data Step 6 內把 ControlWriteStrategy 返回的 ctrl_impl_id_map 套上 AO 後再呼叫 AoWriteStrategy。
Files:
domain/oscal/service/write_strategy/device_write_strategy.pytests/test_a4_write_strategy_device.pyclass DeviceWriteStrategy:
"""寫入 ssp_system_implementation_items (implementation_type='hardware')."""
def __init__(self, ssp_impl_item_domain_service):
self._item = ssp_impl_item_domain_service
def write(
self,
parsed_devices: list, # List[ParsedDevice]
ssp_id: int,
sys_impl_main_id: int,
user_context,
) -> int:
written = 0
for d in parsed_devices:
try:
from jedi_oscal.domain.entity.ssp.ssp_system_implementation_item_entity import (
SspSystemImplementationItemEntity,
)
entity = SspSystemImplementationItemEntity(
system_security_plan_id=ssp_id,
system_implementation_id=sys_impl_main_id,
implementation_type='hardware',
name=d.name,
description=d.purpose,
device_id=d.matched_device_id, # UNMATCHED 時為 None
)
self._item.add(entity, curr_user=user_context.login_name)
written += 1
except Exception as e:
logger.warning("DeviceWriteStrategy write failed: %s", e, exc_info=True)
return writtenFiles:
domain/oscal/service/write_strategy/information_system_write_strategy.pytests/test_a4_write_strategy_information_system.pyclass InformationSystemWriteStrategy:
"""寫入 ssp_system_implementation_items (implementation_type='system').
NOTE: system_characteristic_id 對應 A0.1 schema rename — 樣板 info_system 對應的
InformationSystemEntity.id 寫入此欄位(不是 OSCAL system_characteristic 主結構)。
"""
def __init__(self, ssp_impl_item_domain_service):
self._item = ssp_impl_item_domain_service
def write(
self,
parsed_info_systems: list,
ssp_id: int,
sys_impl_main_id: int,
user_context,
) -> int:
written = 0
for s in parsed_info_systems:
try:
from jedi_oscal.domain.entity.ssp.ssp_system_implementation_item_entity import (
SspSystemImplementationItemEntity,
)
entity = SspSystemImplementationItemEntity(
system_security_plan_id=ssp_id,
system_implementation_id=sys_impl_main_id,
implementation_type='system',
name=s.name,
description=s.description,
system_characteristic_id=s.matched_info_system_id,
responsible_party=str(s.matched_system_owner_user_id) if s.matched_system_owner_user_id else None,
)
self._item.add(entity, curr_user=user_context.login_name)
written += 1
except Exception as e:
logger.warning("InfoSystemWriteStrategy write failed: %s", e, exc_info=True)
return writtenFiles:
domain/oscal/service/write_strategy/leveraged_write_strategy.pytests/test_a4_write_strategy_leveraged.pyclass LeveragedWriteStrategy:
"""寫入 ssp_system_implementation_items (implementation_type='leveraged-authorization')."""
def __init__(self, ssp_impl_item_domain_service):
self._item = ssp_impl_item_domain_service
def write(
self,
parsed_leveraged: list,
ssp_id: int,
sys_impl_main_id: int,
user_context,
) -> int:
written = 0
for l in parsed_leveraged:
try:
from jedi_oscal.domain.entity.ssp.ssp_system_implementation_item_entity import (
SspSystemImplementationItemEntity,
)
entity = SspSystemImplementationItemEntity(
system_security_plan_id=ssp_id,
system_implementation_id=sys_impl_main_id,
implementation_type='leveraged-authorization',
name=l.service_name,
title=l.provider,
purpose=l.purpose,
party_uuid=l.matched_party_uuid,
date_authorized=l.date_authorized,
)
self._item.add(entity, curr_user=user_context.login_name)
written += 1
except Exception as e:
logger.warning("LeveragedWriteStrategy write failed: %s", e, exc_info=True)
return writtenFiles:
di_containers/oscal/oscal_containers.pyfrom domain.oscal.service.write_strategy.control_write_strategy import ControlWriteStrategy
from domain.oscal.service.write_strategy.ao_write_strategy import AoWriteStrategy
from domain.oscal.service.write_strategy.device_write_strategy import DeviceWriteStrategy
from domain.oscal.service.write_strategy.information_system_write_strategy import InformationSystemWriteStrategy
from domain.oscal.service.write_strategy.leveraged_write_strategy import LeveragedWriteStrategy
control_write_strategy = providers.Factory(
ControlWriteStrategy,
control_implementation_domain_service=control_implementation_domain_service,
)
ao_write_strategy = providers.Factory(
AoWriteStrategy,
ssp_control_impl_objective_domain_service=control_implementation_objective_domain_service,
)
device_write_strategy = providers.Factory(
DeviceWriteStrategy,
ssp_impl_item_domain_service=system_implementation_item_domain_service,
)
information_system_write_strategy = providers.Factory(
InformationSystemWriteStrategy,
ssp_impl_item_domain_service=system_implementation_item_domain_service,
)
leveraged_write_strategy = providers.Factory(
LeveragedWriteStrategy,
ssp_impl_item_domain_service=system_implementation_item_domain_service,
)git add domain/oscal/service/write_strategy/ \
tests/test_a4_write_strategy_*.py \
di_containers/oscal/oscal_containers.py
git commit -m "$(cat <<'EOF'
feat(oscal): A4 T7 5 write strategies
新增 domain/oscal/service/write_strategy/ sub-folder + 5 個 WriteStrategy:
- ControlWriteStrategy: 寫 system_security_plan_control_implementations,
返回 (count, ctrl_impl_id_map)
- AoWriteStrategy: 寫 ssp_control_impl_objective(依賴 sci_id)
- DeviceWriteStrategy / InformationSystemWriteStrategy / LeveragedWriteStrategy:
共寫 ssp_system_implementation_items(implementation_type 區分 hardware /
system / leveraged-authorization)
UNMATCHED 行為(D11 拍板):
- Control / AO: log warning + skip(沒 FK 寫不進去)
- Device / InfoSystem / Leveraged: 仍寫入(純文字保留 user data;FK=null)
DI Container 加 5 WriteStrategy Factory。
新增 29 個 WriteStrategy test 全綠(Control 6 + AO 5 + Device 6 + InfoSystem 6 + Leveraged 6)。
對齊 design-A4 §6.6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"_write_all_data 8-step pipeline + DI 注入目標:把 8-step pipeline 完整實作;DI
SspExcelImportAppService加 11 個依賴;5 個_dict_to_*helper 補完。
_dict_to_* helperFiles:
app/oscal/service/ssp_excel_import_app_service.py# tests/test_a4_a2_integration.py 補
class TestDictToParsed:
def test_dict_to_parsed_devices(self):
# ...
def test_dict_to_parsed_info_systems(self):
# ...
def test_dict_to_parsed_leveraged_with_date(self):
# ...
def test_dict_to_parsed_controls_with_nested_aos(self):
"""父 control + 子 AO list."""
# ...@staticmethod
def _dict_to_parsed_devices(devices: list[dict]) -> list[ParsedDevice]:
result = []
for d in devices:
name = (d.get("name") or "").strip()
if not name: continue
result.append(ParsedDevice(
name=name,
ip=d.get("ip"),
os=d.get("os"),
device_type=d.get("device_type"),
status=d.get("status"),
purpose=d.get("purpose"),
matched_device_label=d.get("matched_device"),
))
return result
@staticmethod
def _dict_to_parsed_info_systems(info_systems: list[dict]) -> list[ParsedInformationSystem]:
result = []
for s in info_systems:
name = (s.get("name") or "").strip()
if not name: continue
result.append(ParsedInformationSystem(
name=name,
abbreviation=s.get("abbreviation"),
description=s.get("description"),
component_type=s.get("component_type"),
status=s.get("status"),
matched_info_system_label=s.get("matched_info_system"),
system_owner_label=s.get("system_owner"),
))
return result
@staticmethod
def _dict_to_parsed_leveraged(leveraged: list[dict]) -> list[ParsedLeveraged]:
result = []
for l in leveraged:
service_name = (l.get("service_name") or "").strip()
provider = (l.get("provider") or "").strip()
if not service_name and not provider: continue
# date_authorized parse: Excel 可能傳 str / datetime
from datetime import date, datetime
raw_date = l.get("date_authorized")
parsed_date = None
if isinstance(raw_date, date):
parsed_date = raw_date
elif isinstance(raw_date, datetime):
parsed_date = raw_date.date()
elif isinstance(raw_date, str) and raw_date.strip():
try:
parsed_date = datetime.fromisoformat(raw_date.strip()).date()
except ValueError:
pass
result.append(ParsedLeveraged(
service_name=service_name or provider,
provider=provider or service_name,
party_label=l.get("party"),
date_authorized=parsed_date,
purpose=l.get("purpose"),
))
return result
@staticmethod
def _dict_to_parsed_controls(controls_with_aos: list[dict]) -> list[ParsedControl]:
result = []
for c in controls_with_aos:
if c.get("statement_id") is not None:
continue # 子 row 已 nested 在父 row.objectives 內,不重複
control_id = (c.get("control_id") or "").strip()
if not control_id: continue
pc = ParsedControl(
control_id=control_id,
control_name=c.get("control_name"),
objective_id=c.get("objective_id"),
objective_name=c.get("objective_name"),
impl_status=c.get("impl_status"),
statement=c.get("statement"),
reference_doc=c.get("reference_doc"),
include_in_profile=c.get("include_in_profile"),
_target_in_profile=c.get("_target_in_profile", True),
)
for ao_dict in c.get("objectives") or []:
pc.objectives.append(ParsedAssessmentObjective(
statement_id=ao_dict.get("statement_id"),
control_id=ao_dict.get("control_id"),
objective_id=ao_dict.get("objective_id"),
objective_name=ao_dict.get("objective_name"),
impl_status=ao_dict.get("impl_status"),
statement=ao_dict.get("statement"),
reference_doc=ao_dict.get("reference_doc"),
))
result.append(pc)
return result_write_all_data 重整為 8-step pipelineFiles:
app/oscal/service/ssp_excel_import_app_service.pytests/test_a4_a2_integration.pyclass TestWriteAllDataPipeline:
def test_8_step_pipeline_superset_flow(self, monkeypatch):
"""ssp_id 由 _create_ssp_shell 返回;5 reconciler.reconcile 各呼叫 1 次;5 write_strategy.write 各呼叫 1 次."""
# ...
def test_8_step_pipeline_update_flow(self, monkeypatch):
"""SSP shell 走 _resolve_existing_ssp_shell;其他步驟同上."""
# ...
def test_pipeline_skip_steps_when_dep_none(self, monkeypatch):
"""A4 optional default None — pipeline 個別步驟 dep 為 None 時 logger.warning skip."""
# ...def _write_all_data(
self, parsed_result, target_mf_uid, user_context,
is_superset_flow: bool, fw_version=None,
) -> dict:
# Step 1: party reconcile (A3 既有 + A4 retroactive)
parties_org = parsed_result.get("parties_org") or []
parties_person = parsed_result.get("parties_person") or []
all_parties = self._dict_to_parsed_parties(parties_org, parties_person)
if all_parties and self._reconciliation is not None:
try:
self._reconciliation.reconcile(all_parties, tenant_id=user_context.tenant_id)
except Exception as e:
logger.warning("Party reconcile skipped: %s", e)
# Step 2: SSP shell
if is_superset_flow:
ssp_id, sys_impl_main_id = self._create_ssp_shell(
mf_uid=target_mf_uid,
metadata=parsed_result.get("metadata") or {},
user=user_context.login_name,
)
else:
ssp_id, sys_impl_main_id = self._resolve_existing_ssp_shell(target_mf_uid)
# Step 3: parsed dict → typed dataclass
parsed_devices = self._dict_to_parsed_devices(parsed_result.get("devices") or [])
parsed_info_systems = self._dict_to_parsed_info_systems(parsed_result.get("info_systems") or [])
parsed_leveraged = self._dict_to_parsed_leveraged(parsed_result.get("leveraged") or [])
parsed_controls = self._dict_to_parsed_controls(parsed_result.get("controls_with_aos") or [])
# Step 4: A4 orchestrator reconcile
catalog_id = self._resolve_catalog_id(fw_version, target_mf_uid, is_superset_flow)
from domain.oscal.parser.ssp_intermediate import (
ParsedExcelEntityBundle, SspEntityReconciliationContext,
)
bundle = ParsedExcelEntityBundle(
parsed_devices=parsed_devices,
parsed_info_systems=parsed_info_systems,
parsed_leveraged=parsed_leveraged,
parsed_controls=parsed_controls,
)
ctx = SspEntityReconciliationContext(
tenant_id=user_context.tenant_id,
catalog_id=catalog_id,
)
if self._ssp_entity_orchestrator is not None:
try:
self._ssp_entity_orchestrator.reconcile(bundle, ctx)
except Exception as e:
logger.warning("Entity reconcile skipped: %s", e)
# Step 5: party write (A3 既有)
parties_written = 0
if all_parties:
try:
parties_written = self._mf_write.write_parties(
parties=all_parties, source_uid=target_mf_uid,
user_id=user_context.login_name,
)
except Exception as e:
logger.warning("write_parties failed: %s", e, exc_info=True)
# Step 6: A4 entity write
ctrl_written = 0
ctrl_impl_id_map: dict = {}
if self._control_write_strategy is not None:
ctrl_written, ctrl_impl_id_map = self._control_write_strategy.write(
parsed_controls, ssp_id, user_context,
)
parsed_aos = []
for c in parsed_controls:
for ao in c.objectives:
ao._ssp_control_implementation_id = ctrl_impl_id_map.get(c.matched_catalog_control_id)
parsed_aos.append(ao)
ao_written = 0
if self._ao_write_strategy is not None:
ao_written = self._ao_write_strategy.write(
parsed_aos, ssp_id, ctrl_impl_id_map, user_context,
)
devices_written = 0
if self._device_write_strategy is not None:
devices_written = self._device_write_strategy.write(
parsed_devices, ssp_id, sys_impl_main_id, user_context,
)
info_systems_written = 0
if self._info_system_write_strategy is not None:
info_systems_written = self._info_system_write_strategy.write(
parsed_info_systems, ssp_id, sys_impl_main_id, user_context,
)
leveraged_written = 0
if self._leveraged_write_strategy is not None:
leveraged_written = self._leveraged_write_strategy.write(
parsed_leveraged, ssp_id, sys_impl_main_id, user_context,
)
# Step 7: parsed_result 回填(給 A5 UI 讀)
self._update_parsed_result_with_reconcile(parsed_result, all_parties, bundle)
return {
"ssp_id": ssp_id,
"sys_impl_main_id": sys_impl_main_id,
"parties_written": parties_written,
"controls_written": ctrl_written,
"aos_written": ao_written,
"devices_written": devices_written,
"info_systems_written": info_systems_written,
"leveraged_written": leveraged_written,
}
def _resolve_catalog_id(self, fw_version, mf_uid, is_superset_flow) -> Optional[int]:
if is_superset_flow and fw_version is not None:
return getattr(fw_version, "catalog_id", None)
# update flow — 從 mf.profile.catalog_id 拿
mf = self._mf_domain.get_module_frame_by_uid(mf_uid)
if mf and mf.oscal_profile_uid:
# 透過 profile_domain_service 查 catalog_id
# 由 T0 verify 確認 profile_domain_service 存在
return None # 簡化 — T8 verify 後實作
return None
def _update_parsed_result_with_reconcile(self, parsed_result, all_parties, bundle):
"""把 reconcile 結果寫回 parsed_result JSONB;A5 預覽 UI 讀."""
# parties 回填 (A3 retroactive)
# 5 entity 回填 — asdict() 或手動 dict 映射;給 A5 看 match_method / matched_*_id
# 詳細實作見 ssp_excel_import_app_service.py T8 落地
pass # T8 step 3 補完整邏輯Files:
di_containers/oscal/oscal_containers.pyssp_excel_import_app_service = providers.Factory(
SspExcelImportAppService,
# 既有 8 個(parse_job / parser / file_upload / mf_domain / mf_service / fw_version_domain / mf_write / party_reconciliation_service)
# A4 新增 11 個:
ssp_entity_orchestrator=ssp_entity_reconciliation_orchestrator,
catalog_control_reconciler=catalog_control_reconciler,
ssp_domain_service=ssp_domain_service,
system_characteristic_domain_service=system_characteristic_domain_service,
system_implementation_domain_service=system_implementation_domain_service,
control_write_strategy=control_write_strategy,
ao_write_strategy=ao_write_strategy,
device_write_strategy=device_write_strategy,
information_system_write_strategy=information_system_write_strategy,
leveraged_write_strategy=leveraged_write_strategy,
)SspExcelImportAppService.__init__ 同步加 11 個 optional default None 參數(A2 既有 fixture 不破壞):
def __init__(
self,
parse_job_domain_service,
parser,
file_upload_service,
module_frame_domain_service,
module_frame_service,
oscal_framework_version_domain_service,
module_frame_write_strategy,
party_reconciliation_service=None,
# A4 新增 11 個
ssp_entity_orchestrator=None,
catalog_control_reconciler=None,
ssp_domain_service=None,
system_characteristic_domain_service=None,
system_implementation_domain_service=None,
control_write_strategy=None,
ao_write_strategy=None,
device_write_strategy=None,
information_system_write_strategy=None,
leveraged_write_strategy=None,
):
# 既有 attr
# A4 attr
self._ssp_entity_orchestrator = ssp_entity_orchestrator
self._catalog_control_reconciler = catalog_control_reconciler
self._ssp_domain = ssp_domain_service
self._system_characteristic_domain = system_characteristic_domain_service
self._system_implementation_domain = system_implementation_domain_service
self._control_write_strategy = control_write_strategy
self._ao_write_strategy = ao_write_strategy
self._device_write_strategy = device_write_strategy
self._info_system_write_strategy = information_system_write_strategy
self._leveraged_write_strategy = leveraged_write_strategy_confirm_*_flow 串 _write_all_data 新參數_confirm_superset_flow 內 call:
write_stats = self._write_all_data(
parsed_result=parsed_result,
target_mf_uid=mf_dto.uid,
user_context=user_context,
is_superset_flow=True,
fw_version=fw_version,
)_confirm_update_flow 內 call:
write_stats = self._write_all_data(
parsed_result=parsed_result,
target_mf_uid=existing_mf.uid,
user_context=user_context,
is_superset_flow=False,
fw_version=None,
)summary dict 用 **write_stats spread;DTO 仍維持既有 response shape。
git add app/oscal/service/ssp_excel_import_app_service.py \
di_containers/oscal/oscal_containers.py \
tests/test_a4_a2_integration.py
git commit -m "$(cat <<'EOF'
feat(oscal): A4 T8 _write_all_data 8-step pipeline
_write_all_data 重整為 8 step 完整 pipeline(design §3.3 / §6.1):
1. party reconcile(A3 + A4 retroactive)
2. SSP shell(superset 建;update resolve)
3. 5 個 _dict_to_* 轉 typed dataclass
4. A4 orchestrator reconcile
5. party write(A3 既有 ModuleFrameWriteStrategy)
6. 5 entity write(control / ao / device / info_system / leveraged)
7. parsed_result 回填 matched_*_id / match_method(給 A5 UI)
8. 統計 return dict
SspExcelImportAppService 加 11 個 optional default None 注入(既有 29
fixture 不破壞);DI Container ssp_excel_import_app_service Factory 補 11
個 wiring。_confirm_*_flow 串新參數 (is_superset_flow, fw_version)。
對齊 design-A4 §6.1 + §6.7。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"當 T6 + T7 + T8 ship 後(Session E 結束):
目標:把所有 A4 task 散落的 test 補齊到 design §8 acceptance 規模(~110-140 個)。
git add tests/test_a4_*.py
git commit -m "$(cat <<'EOF'
test(oscal): A4 T9 unit + integration tests
補齊 A4 散落 task 的 reconciler / WriteStrategy / integration 邊界 case
~30-40 個,累計 A4 新增 ~110-140 test 全綠(A3 67 + A2 29 + 既有皆無 regression)。
對齊 design-A4 §8 acceptance criteria。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"目標:在 compliance-manager-test 開 branch + 寫 5 scenarios + step + page object。 跨 repo:work 在
~/Projects/Billows/Audit-Manager/compliance-manager-test/,不汙染主 BE working tree。
cd ~/Projects/Billows/Audit-Manager/compliance-manager-test
git status --short # 確認乾淨
git checkout -b feature/a4-ssp-excel-entity-match-regressionFile: features/regression/module-frame/06-ssp-excel-import-entity-match.feature
Feature: SSP Excel 匯入 — 5 entity 鉤稽 regression
作為合規顧問,我希望 Excel 匯入時 5 種 entity(device / info_system / leveraged / control / AO)
能正確鉤稽到系統內既有 entity,避免重複建立。
Background:
Given 我已用 blsadmin 登入
And 已存在 device 'Web-01' IP '10.0.1.5'
And 已存在 information_system 'ERP'
And 已存在 OSCAL party 'AWS GovCloud' (organization)
And 已存在 catalog 'NIST-800-53-r5' 內 control 'AC-1'
Scenario: control_id 對到 catalog 內 control
Given 我上傳 Excel 樣板(superset flow)含 control_id='AC-1'
When 我 confirm import
Then parse_job.parsed_result 含 control AC-1 的 matched_catalog_control_uid 不為 null
And match_method = 'exact'
Scenario: device matched_device 下拉選定
Given 我上傳 Excel 樣板含 device matched_device='Web-01 (10.0.1.5)'
When 我 confirm import
Then parsed_result 含該 device 的 matched_device_id 不為 null
And match_method = 'user_selected'
Scenario: info_system name 對到 tenant 內 entity
Given 我上傳 Excel 樣板含 info_system name='ERP'
When 我 confirm import
Then parsed_result 含該 info_system 的 matched_info_system_id 不為 null
And match_method = 'exact'
Scenario: leveraged provider 套 fuzzy 對到 Party
Given 我上傳 Excel 樣板含 leveraged provider='AWS GovCloud 股份有限公司'
When 我 confirm import
Then parsed_result 含該 leveraged 的 matched_party_uuid 不為 null
And match_method = 'fuzzy_name_prefix'
Scenario: AO objective_id 父 control 配到後自身也配到
Given 我上傳 Excel 樣板含 control_id='AC-1' + AO objective_id='AC-1.a'
When 我 confirm import
Then parsed_result 該 AO 的 matched_catalog_control_assessment_uid 不為 null
And match_method = 'exact'
Files:
steps/module-frame/ssp-excel-import-entity-match.steps.jspages/module-frame/ssp-excel-import-page.js依沿 A3 cucumber regression pattern;上傳 + confirm + assert parsed_result。
cd ~/Projects/Billows/Audit-Manager/compliance-manager-test
npm run cucumber -- features/regression/module-frame/06-ssp-excel-import-entity-match.feature期待:5/5 PASS(env 配齊時);env 未配齊允許 partial ship — scenarios 撰寫完成即 OK。
git add features/regression/module-frame/06-ssp-excel-import-entity-match.feature \
steps/module-frame/ssp-excel-import-entity-match.steps.js \
pages/module-frame/ssp-excel-import-page.js
git commit -m "$(cat <<'EOF'
test(compliance-manager-test): A4 T10 cucumber entity match regression
新增 06-ssp-excel-import-entity-match.feature 5 scenarios:
- control_id 對到 catalog 內 control (exact)
- device matched_device 下拉選定 (user_selected)
- info_system name 對到 tenant entity (exact)
- leveraged provider fuzzy 對到 Party (fuzzy_name_prefix)
- AO objective_id 父 control 配到後自身配到 (exact)
沿 A3 partial ship pattern:env 配齊後跑通即 merge;env 未配齊允許 partial ship。
對齊 design-A4 §6.8 + compliance-manager-be A4 commit chain。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"cd ~/Projects/Billows/Audit-Manager/compliance-manager-be
git status --short # 確認乾淨目標:產出 task arc 收口必要的 4 份文件 + 對話歷史 dump。
File: docs/changelog/YYYY-MM-DD-feat-ssp-entity-reconcile-and-write.md
---
type: feat
breaking: false
modules: [oscal, ssp-import]
commit: <T1-T9 commit hash chain>
---
# Feat — 5 entity 鉤稽 + 寫入 + SSP shell + A3 retroactive(Phase 2 A4)
## 需求說明
...
## 變更範圍
...
## 行為差異
...
## 測試結果
- A4 新增 ~110-140 個 unit + integration test 全綠
- A3 既有 67 + A2 既有 29 + 全套 BE 694 test 無 regression
- Cucumber regression 5 scenarios 撰寫完成(env 配齊後跑通)
## Follow-up
- F1 docx flow 補 5 reconciler 串接 / F2 A5 預覽 UI fuzzy 拍板 / F3-F5 / F-A4-refactor / F-A4-cucumber-extra
...| A4 | devices / info_systems / leveraged / 控制項 / AO 鉤稽 + 寫入 | 重 | **BE shipped** ✅ | ✅ Session A | ✅ design-A4.md | ✅ implementation-plan-A4.md | BE: <T1-T9 chain> ; test: <T10 commit> | ✅ YYYY-MM-DD-feat-ssp-entity-reconcile-and-write.md |依 A3 / A0.1 §11 樣板:列出每個 T0 verify 帶出的差異 + 實作過程中對 design 假設的修正。§11 不能空段 — 至少 2 條(mirror SSP Update Diff §11 16 條條目樣板)。
File: docs/features/FR-011.2-2605-ssp-import-export-phase2/handoff/YYYY-MM-DD-a4-SUMMARY.md
依 A3 SUMMARY pattern 8 段:
git add docs/changelog/YYYY-MM-DD-feat-ssp-entity-reconcile-and-write.md \
docs/features/FR-011.2-2605-ssp-import-export-phase2/README.md \
docs/features/FR-011.2-2605-ssp-import-export-phase2/design-A4.md \
docs/features/FR-011.2-2605-ssp-import-export-phase2/handoff/YYYY-MM-DD-a4-SUMMARY.md \
docs/conversation-history/YYYY-MM-DD/ssp-import-export-phase2-A4/
git commit -m "$(cat <<'EOF'
docs(ssp-import-export-phase2): A4 收尾 + SUMMARY
- changelog feat-ssp-entity-reconcile-and-write.md
- tracker README A4 row → BE shipped + commit chain
- design-A4.md §11 implementation reality reconciliation
- handoff A4 SUMMARY(task arc 收口)
- conversation-history dump(A4 task arc 全對話原文)
A4 phase 完整收口;A5 phase 可在 reconcile 結果(matched_*_id / match_method
寫回 parsed_result)基礎上補預覽 UI + Confirm 拍板互動。
對齊 design-A4 §11 / §12 收口規範 / CLAUDE.md 做 summary 段。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"SspEntityReconciliationOrchestrator facade docstring + AssessmentObjectiveReconciler.reconcile_with_control_map instance attr thread-safety note 屬「non-obvious why」,允許寫| Task | 檢核點 | 處理 |
|---|---|---|
| T0 | T0.1-T0.3 jedi-oscal SspEntity 欄位不符 design 假設 | 修 design §11 補偏差;T6 Entity 構造對齊實際 schema |
| T0 | T0.4 CatalogControlAssessmentQueryEntity 無 objective_id 欄位 | AO Reconciler _try_exact_match 改用 uid 或其他自然 key;design §11 補 |
| T0 | T0.5 / T0.6 control_implementation / ssp_control_impl_objective ORM model 欄位差異 | T7 WriteStrategy 簽章對齊;可能需動 jedi-oscal — user 拍板後動 |
| T0 | T0.7 ssp_domain_service.get_one_by_profile_uid 不存在 |
T6 _resolve_existing_ssp_shell 改用 query entity 路徑;或新增套件 method(需 user 拍板) |
| T0 | T0.8 DeviceQueryEntity 不支援雙欄 (name, ip) AND filter | DeviceReconciler _try_exact_match 改 fallback:拉 name 結果 → Python filter ip |
| T1 | base.py stage 0 dispatch 加入後 A3 既有 67 test fail | base.py _try_user_selected_match default no-op,A3 reconciler 不 override → 行為不變;A3 test 應全綠 |
| T2 | DI Container build 時 catalog_control_domain_service / catalog_control_assessment_domain_service 跨 container reference 失敗 | 對齊既有 line 490/491 cross-container pattern;確認 oscal_container 自含或 reference 正確 |
| T3 | reconciler _try_exact_match 內 Query Entity 屬性 set 後 repo 拉不到 |
T0 verify Query Entity 欄位;可能需 Python 端 filter |
| T3 | LeveragedReconciler fuzzy candidate cache 在 prod 大 tenant 慢 | 不在 A4 處理;F4 follow-up(jedi-oscal 套件加 _in_* batch lookup) |
| T4 | A3 retroactive _try_user_selected_match 加入後 既有 16 person + 16 org test fail |
A3 既有 ParsedParty fixture 不帶 matched_user_label → stage 0 return None → fallback 既有 stage 1;test 應全綠 |
| T5 | A2 §15.3 ControlReconciler 沒拿到 fw_version.catalog_id | T0.1 verify FrameworkVersionEntity 有 catalog_id 欄位;T5 implementer 驗 |
| T6 | superset flow SSP shell 建立後 ssp_id / sys_impl_main_id 拿不到 | Entity DTO 返回 id;T0 verify SystemSecurityPlanDomainService.add 簽章 |
| T6 | update flow MF profile 對應 SSP 已存在但 system_implementation main 沒建(舊資料) | raise GRC_412066 → user 必先透過 ssp_versioning_service 建第 1 版 SSP |
| T7 | ControlWriteStrategy upsert by (ssp_id, catalog_control_id) — jedi-oscal repo 可能無此 query 方法 | 用 get_one(QueryEntity) 自行寫;若不支援 query → 直接 add(接受 duplicate 風險低,update flow 才會撞) |
| T7 | AoWriteStrategy 拿不到 _ssp_control_implementation_id(caller side attribute) |
T8 step 6 內把 ctrl_impl_id_map 套上 AO 後再 call AoWriteStrategy |
| T8 | _write_all_data 8-step 全長導致 method body 過大 |
沒問題 — 8 step 各有明確職責;A4 不拆 service(F-A4-refactor follow-up) |
| T8 | _resolve_catalog_id update flow 從 mf.profile.catalog_id 拿不到 |
T0 verify ProfileDomainService 含 get_by_uid + catalog_id;implementer 補完整 |
| T9 | 累計 test count < 110:corner case 漏 | 對齊 design §8 acceptance 第 16 條,逐項補;可 dispatch subagent 平行補 |
| T9 | asdict(parsed) 序列化 MatchMethod StrEnum 給 A5 拿到 "MatchMethod.UNMATCHED" |
T9.4 verify 對齊 A3 T5.2 pattern(json.dumps(d, default=str)) |
| T10 | env 未配齊跑不過 | 允許 partial ship;列 follow-up F-A4-cucumber-extra |
| T11 | §11 reconciliation 段沒實作偏差可寫 → 段落空 | 至少 2 條(jedi-oscal Entity 欄位 + 1 條從 T0 帶出的差異);mirror SSP Update Diff §11 樣板 |
| Repo | 工作 | Branch | Commit 預估 |
|---|---|---|---|
| compliance-manager-be(主) | T0 + T1 + T2 + T3 + T4 + T5 + T6 + T7 + T8 + T9 + T11 | feature/ssp-import-export-phase2 |
11 commits |
| compliance-manager-test | T10 cucumber regression | feature/a4-ssp-excel-entity-match-regression |
1 commit |
| compliance-manager-fe | 不動 | — | 0 commit |
| jedi-* 套件 | 不動(除非 T0 verify 帶出必要 method 不存在) | — | 0 commit |
CatalogControlQueryEntity._in_control_id batch lookup — follow-up F3jedi_information_system._in_id batch lookup — follow-up F4SspExcelImportAppService 拆 service — follow-up F-A4-refactor按 CLAUDE.md「做 summary 觸發完整收尾」段:
docs/features/FR-011.2-2605-ssp-import-export-phase2/handoff/YYYY-MM-DD-a4-<session 字母>-to-<下一 session>.mddocs/conversation-history/<date>/ssp-import-export-phase2-A4/(不做格式化)| 換 Session 時點 | 必產出 |
|---|---|
| B → C | handoff prompt(plan ship;T0/T1/T2 開工指引) |
| C → D | handoff prompt(T1+T2 已 ship;T3/T4/T5 開工指引) |
| D → E | handoff prompt(T3+T4+T5 已 ship;T6/T7/T8 開工指引 + T0.1-T0.3 SspEntity verify 結果攜入) |
| E → F | handoff prompt(T6+T7+T8 已 ship;T9/T10/T11 開工指引) |
| F(收口) | final SUMMARY + design §11 reconciliation + conversation-history dump |