| 項目 | 內容 |
|---|---|
| 版本 | v1.2 (2026-05-24 Phase 1 Tasks 2-9 實作後加 §11 reconciliation) |
| 日期 | 2026-05-24 |
| Status | Phase 1 Tasks 1-9 shipped (local commits, not pushed);Tasks 10-14 remaining |
| 估時 | ~25d (5-6 週) |
| 前置條件 | docx-import-parity Stage 1 已完成(commit 57019d3 + 後續欄位補齊 ebfcce3 / 078f227 / c280982 / d388476) |
| Brainstorm 紀錄 | docs/analysis/2026-05-24-ssp-oscal-alignment-phase1-brainstorm.md |
| Reconciliation | §11(本檔下方)— 實作中發現的 spec/reality 偏差全紀錄 |
docx-import-parity Stage 1 上線後 BE 能從 docx 解出完整欄位、Excel 樣板擴到 14+ cols,但有兩個本質性的不對齊問題:
目前 SspSystemImplementationItemEntity 一張表用 category enum 區分 leveraged-authorization / external-service / interconnection / api / cli。但這個區分在 OSCAL 規範裡分屬不同 element:
OSCAL system-implementation
├── <leveraged-authorization> ← 法律層級「ATO 繼承」
│ (有 FedRAMP 等正式授權的服務才放這)
├── <component> ← 元件層級「我用了什麼」
│ (所有外部依賴都放這 — 包含上面 leveraged-auth 對應的元件)
└── <inventory-item> ← 實例層級「具體資產」
(含 implemented-components 指回 component)
OSCAL 正確寫法(驗證自 usnistgov/OSCAL main branch metaschema + shared-constraints/allowed-values-component-type.ent):
<component> + <leveraged-authorization>,並用 prop[name="leveraged-authorization-uuid"] 互連<component>| 我們概念 | OSCAL 對應 | 層級 |
|---|---|---|
資訊系統 (SHEET_INFO_SYSTEMS) |
<component> |
type 層級「有什麼」 |
設備 (SHEET_DEVICES) |
<inventory-item> |
instance 層級「長怎樣」 |
兩者透過 <implemented-component component-uuid="..."> 連結。目前我們的 entity model 沒有這個 reference,等同丟失 OSCAL audit chain。
我之前在 docs/features/FR-027-2605-docx-import-parity/design.md §A 列的 component type 含 api / cli — 但官方 OSCAL enum 沒這兩個。完整 14 個 type 是:
this-system / system / interconnection / software / hardware / service /
policy / physical / process-procedure / plan / guidance / standard /
validation / network
allow-other="yes" 允許框架擴充(FedRAMP / CMMC 可加 prop 標 namespace),但第一公民 enum 只有上述 14 個。
完整對齊 OSCAL Implementation Layer,使 SSP 能完整 round-trip export → OSCAL JSON → re-import。
具體成果:
4 個 phase,依序執行(Phase 5 OSCAL Export 已 deferred — user 2026-05-25 拍板「等全部定案後再做」,本文件 §5 章節保留為歷史 reference 但不在本期 scope):
Phase 1 — Foundation (DB + jedi-oscal + dataclass) 5d
Phase 2 — Import Pipeline 對接 6d
Phase 3 — Excel / Docx 樣板重設計 4d
Phase 4 — FE 預覽 UI 重設計 5d
─────────────────────────────────────────────────────────────────
小計(本期 scope) ~20d
(deferred) Phase 5 — OSCAL Export 5d
Deploy unit:Phase 1 + Phase 2 合併 deploy(避免 adapter 已輸出 v2-bundle 但 confirm path 還沒上的中間態)。Phase 3 / 4 可各自獨立 ship。Phase 5 待定案後另議 deploy plan。
⚠️ (updated 2026-05-24 — see §11.1, §11.2, §11.3, §11.4, §11.7):實際實作對齊既有 jedi-oscal pattern,跟本段文字敘述若干偏離(ORM
Mapped[]不是Column()、UUID 不是 VARCHAR(36)、repo interface 用 trivialIBaseRepo[T,Q]: pass不是手寫 abstracts、domain service init 用<entity>_repo=不是repository=)。
新增 entity / service / repo(套件側):
jedi_oscal/
├── domain/
│ ├── entity/base/
│ │ ├── oscal_component_entity.py (新)
│ │ ├── oscal_leveraged_authorization_entity.py (新)
│ │ └── oscal_inventory_item_entity.py (新)
│ ├── repository/base/
│ │ ├── component.py (新)
│ │ ├── leveraged_authorization.py (新)
│ │ └── inventory_item.py (新)
│ └── services/base/
│ ├── component_domain_service.py (新)
│ ├── leveraged_authorization_domain_service.py (新)
│ └── inventory_item_domain_service.py (新)
└── infra/
├── model/base/
│ ├── oscal_component.py (新)
│ ├── oscal_leveraged_authorization.py (新)
│ ├── oscal_inventory_item.py (新)
│ └── oscal_inventory_implemented_component.py (新 join)
├── mapper/base/
│ └── (對應 mapper)
└── repository/base/
└── (對應 repo impl)
舊 SspSystemImplementationItemEntity 處置:dev 階段不走漸進 deprecation,Phase 1 內直接 cutover:
SspSystemImplementationItemEntity + 對應 repo / service / model / mapper不掛 @deprecated marker — 直接刪。caller(譬如 docx-import-parity Stage 1 既有的 leveraged-services 寫入點)一併改 import 到新 entity。
dev 環境有 DB 備份 + 環境切分,新表 e2e 驗證 OK 即可 drop,無需 6 月觀察期或雙寫雙讀過渡。
⚠️ (updated 2026-05-24 — see §11.2, §11.10):實際 DDL 已 ship in
scripts/sql/2026-05-24-ssp-oscal-alignment-create-tables.sql,跟下面 SQL 差異主要在 UUID type(不是 VARCHAR(36))+ audit field 具體寫法 + M2M join 用 EXISTS subquery RLS policy。
-- /scripts/sql/2026-05-XX-ssp-oscal-alignment.sql
-- 1. components: 所有外部依賴 + 系統元件
CREATE TABLE oscal.ssp_components (
id SERIAL PRIMARY KEY,
uid VARCHAR(36) UNIQUE NOT NULL,
ssp_id INT NOT NULL REFERENCES oscal.system_security_plans(id) ON DELETE CASCADE,
component_type VARCHAR(40) NOT NULL,
-- enum: this-system / system / interconnection / software / hardware /
-- service / policy / physical / process-procedure / plan /
-- guidance / standard / validation / network
title VARCHAR(255) NOT NULL,
description TEXT,
purpose TEXT,
status VARCHAR(20),
-- enum: operational / under-development / under-major-modification /
-- disposition / other
leveraged_authorization_uid VARCHAR(36),
-- 指向 ssp_leveraged_authorizations.uid (僅 FedRAMP 等繼承授權服務有值)
props JSONB,
-- 框架專屬屬性:{ protocol, port_ranges, security_auth, mac_address, ... }
tenant_id INT NOT NULL,
org_unit_id INT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at, created_user, updated_at, updated_user
);
-- 2. leveraged-authorizations: 有 FedRAMP 等正式授權的服務
CREATE TABLE oscal.ssp_leveraged_authorizations (
id SERIAL PRIMARY KEY,
uid VARCHAR(36) UNIQUE NOT NULL,
ssp_id INT NOT NULL REFERENCES oscal.system_security_plans(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
party_uuid VARCHAR(36),
-- 指向 oscal.parties.uid (CSP / vendor 公司)
date_authorized DATE,
props JSONB,
-- { fedramp_package_id, impact_level, data_types, nature_of_agreement,
-- authorized_users, ... }
remarks TEXT,
tenant_id INT NOT NULL,
org_unit_id INT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at, created_user, updated_at, updated_user
);
-- 3. inventory-items: 實體資產
CREATE TABLE oscal.ssp_inventory_items (
id SERIAL PRIMARY KEY,
uid VARCHAR(36) UNIQUE NOT NULL,
ssp_id INT NOT NULL REFERENCES oscal.system_security_plans(id) ON DELETE CASCADE,
description TEXT NOT NULL,
-- OSCAL inventory-item.description 必填
props JSONB,
-- { asset_id, asset_tag, ipv4_address, ipv6_address, mac_address,
-- fqdn, hostname, software_name, os_name, ... }
tenant_id INT NOT NULL,
org_unit_id INT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at, created_user, updated_at, updated_user
);
-- 4. inventory_implemented_components: M2M (inventory → components)
CREATE TABLE oscal.ssp_inventory_implemented_components (
inventory_item_id INT NOT NULL REFERENCES oscal.ssp_inventory_items(id) ON DELETE CASCADE,
component_id INT NOT NULL REFERENCES oscal.ssp_components(id) ON DELETE CASCADE,
PRIMARY KEY (inventory_item_id, component_id)
);
CREATE INDEX idx_ssp_components_ssp ON oscal.ssp_components(ssp_id, is_active);
CREATE INDEX idx_ssp_components_lev ON oscal.ssp_components(leveraged_authorization_uid)
WHERE leveraged_authorization_uid IS NOT NULL;
CREATE INDEX idx_ssp_leveraged_ssp ON oscal.ssp_leveraged_authorizations(ssp_id, is_active);
CREATE INDEX idx_ssp_inventory_ssp ON oscal.ssp_inventory_items(ssp_id, is_active);
GRANT SELECT, INSERT, UPDATE, DELETE ON
oscal.ssp_components,
oscal.ssp_leveraged_authorizations,
oscal.ssp_inventory_items,
oscal.ssp_inventory_implemented_components
TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE
oscal.ssp_components_id_seq,
oscal.ssp_leveraged_authorizations_id_seq,
oscal.ssp_inventory_items_id_seq
TO cm_app;
-- ⚠️ WARNING (2026-05-24):以下 RLS policy 表達式有 delimiter bug — 用 ','
-- 但實際 session_scope 設的 `app.allowed_tenant_paths` 是 slash-delimited
-- path 格式 '/1/102/'。Phase 1 Task 9 用 cmmgr (superuser) verify 沒踩到,
-- Phase 2 Bug A fix 後 cm_app 第一次真寫即炸 InvalidTextRepresentation。
-- 正確表達式(trim '/' + split by '/')見 §11.16,已 ship in
-- `scripts/sql/2026-05-24-ssp-oscal-alignment-fix-rls-delimiter.sql`。
-- RLS (mirror oscal.system_security_plans policy pattern)
ALTER TABLE oscal.ssp_components ENABLE ROW LEVEL SECURITY;
CREATE POLICY ssp_components_rls ON oscal.ssp_components
FOR ALL TO cm_app
USING (
current_setting('app.is_super_admin', TRUE) = 't'
OR tenant_id = ANY(string_to_array(current_setting('app.allowed_tenant_paths', TRUE), ','))::INT[]
);
ALTER TABLE oscal.ssp_leveraged_authorizations ENABLE ROW LEVEL SECURITY;
CREATE POLICY ssp_leveraged_authorizations_rls ON oscal.ssp_leveraged_authorizations
FOR ALL TO cm_app
USING (
current_setting('app.is_super_admin', TRUE) = 't'
OR tenant_id = ANY(string_to_array(current_setting('app.allowed_tenant_paths', TRUE), ','))::INT[]
);
ALTER TABLE oscal.ssp_inventory_items ENABLE ROW LEVEL SECURITY;
CREATE POLICY ssp_inventory_items_rls ON oscal.ssp_inventory_items
FOR ALL TO cm_app
USING (
current_setting('app.is_super_admin', TRUE) = 't'
OR tenant_id = ANY(string_to_array(current_setting('app.allowed_tenant_paths', TRUE), ','))::INT[]
);
-- Join table: 透過 parent inventory_items 拿 tenant,自身不需 RLS column
-- (PG RLS 對 PK-only 表 ENABLE 仍可,access path 走 inventory_items policy)Migration 帳號:跑這段 SQL 一律用 cmmgr(cm_app 受 RLS 擋,會 INSERT 0 rows 靜默失敗 — 參考 CLAUDE.md「跑 SQL migration 一律用 cmmgr」)。
⚠️ (updated 2026-05-24 — see §11.10 ⭐ 最大 reconciliation):本段 SQL 是寫設計時的 sketch,跟實際 DB schema 大幅偏離(8+ columns 不存在、SSP 表沒 tenant_id、
category95% NULL)。不要從本段抄 SQL——實際 migration 已 ship inscripts/sql/2026-05-24-ssp-oscal-alignment-migrate-data.sql+...-verification.sql。完整 column mapping 跟 4-table tenant chain 看 §11.10。本段保留作 design intent 紀錄,但任何接手都該以 §11.10 + 實際 .sql 檔為準。
四個 step 同一 phase 完成(一個 migration script 內順序執行,或拆連續 script):
Step 1 — Leveraged authorizations 先寫(component FK ref 它):
INSERT INTO oscal.ssp_leveraged_authorizations
(uid, ssp_id, title, date_authorized, props, remarks,
tenant_id, org_unit_id, is_active, created_at, created_user, updated_at, updated_user)
SELECT
gen_random_uuid()::TEXT, i.ssp_id, i.service_name,
i.date_authorized,
jsonb_strip_nulls(jsonb_build_object(
'fedramp_package_id', i.fedramp_package_id,
'impact_level', i.impact_level,
'data_types', i.data_types,
'nature_of_agreement', i.nature_of_agreement,
'authorized_users', i.authorized_users
)),
i.remarks,
s.tenant_id, s.org_unit_id, i.is_active,
i.created_at, i.created_user, i.updated_at, i.updated_user
FROM oscal.ssp_system_implementation_items i
JOIN oscal.system_security_plans s ON s.id = i.ssp_id
WHERE i.category = 'leveraged-authorization' AND i.is_active = TRUE;Step 2 — Components 寫入(含對應 leveraged_authorization_uid FK):
INSERT INTO oscal.ssp_components
(uid, ssp_id, component_type, title, description, purpose, status,
leveraged_authorization_uid, props,
tenant_id, org_unit_id, is_active, created_at, created_user, updated_at, updated_user)
SELECT
gen_random_uuid()::TEXT, i.ssp_id,
CASE
WHEN i.category = 'leveraged-authorization' THEN 'service'
WHEN i.category IN ('api', 'cli', 'external-service') THEN 'service'
WHEN i.category = 'interconnection' THEN 'interconnection'
ELSE 'other'
END,
i.service_name, i.description, NULL, 'operational',
la.uid, -- FK 指 Step 1 寫的 leveraged auth
jsonb_strip_nulls(jsonb_build_object(
'protocol', i.protocol,
'port_ranges', i.port_ranges,
'security_auth', i.security_auth,
-- 保留原始 framework category 供 OSCAL export 標 cmmc:ssp namespace prop
'cmmc:category', CASE WHEN i.category IN ('api', 'cli')
THEN i.category ELSE NULL END
)),
s.tenant_id, s.org_unit_id, i.is_active,
i.created_at, i.created_user, i.updated_at, i.updated_user
FROM oscal.ssp_system_implementation_items i
JOIN oscal.system_security_plans s ON s.id = i.ssp_id
LEFT JOIN oscal.ssp_leveraged_authorizations la
ON la.ssp_id = i.ssp_id AND la.title = i.service_name
WHERE i.is_active = TRUE;Step 3 — Verification(DROP 前必跑):
-- 雙邊 row count 一致
DO $$
DECLARE
items_count INT;
components_count INT;
leveraged_count INT;
BEGIN
SELECT count(*) INTO items_count
FROM oscal.ssp_system_implementation_items WHERE is_active = TRUE;
SELECT count(*) INTO components_count
FROM oscal.ssp_components WHERE is_active = TRUE;
SELECT count(*) INTO leveraged_count
FROM oscal.ssp_leveraged_authorizations WHERE is_active = TRUE;
IF items_count != components_count THEN
RAISE EXCEPTION 'Migration row count mismatch: items=% components=%',
items_count, components_count;
END IF;
RAISE NOTICE 'Migration verified: items=%, components=%, leveraged=%',
items_count, components_count, leveraged_count;
END $$;如果 Phase 1 e2e (Excel import + docx import + GET SSP detail roundtrip) 全綠,再進 Step 4。
Step 4 — DROP 舊表 + 套件側刪除 entity:
DROP TABLE oscal.ssp_system_implementation_items CASCADE;
-- 同 deploy 中:套件側 PR 刪除 SspSystemImplementationItemEntity / repo / service / model / mapper注意:DROP CASCADE 會把 FK referencing 一起拿掉。Phase 1.3 前需先 grep 整個 codebase(含套件側)所有對 SspSystemImplementationItemEntity / ssp_system_implementation_items 的引用,全部改成新 entity 後再跑。
⚠️ (updated 2026-05-24 — see §11.6):parse-time
_ref/matched_party_uuid刻意保持str不升uuid.UUID(parse → reconcile → write 三段 pipeline 設計);reconciler 之後才解 ref string → uid。Tasks 2-4 entity 的 UUID upgrade 是 reconcile-time 之後的事,這層 parse-time 不跟。
# domain/oscal/parser/ssp_intermediate.py
@dataclass
class ParsedComponent:
"""對應 OSCAL <component>。Excel 06a sheet / docx components 表。"""
title: str
component_type: str
# OSCAL 14 enum + 'other'
description: Optional[str] = None
purpose: Optional[str] = None
status: Optional[str] = None
# operational / under-development / under-major-modification /
# disposition / other
leveraged_authorization_ref: Optional[str] = None
# 暫存 ref (title 字串);reconciler resolve 成 uid
# framework-specific props
protocol: Optional[str] = None
port_ranges: Optional[str] = None
security_auth: Optional[str] = None
# reconciler 填
matched_party_uuid: Optional[str] = None
match_method: MatchMethod = MatchMethod.UNMATCHED
match_confidence: float = 0.0
@dataclass
class ParsedLeveragedAuthorization:
"""對應 OSCAL <leveraged-authorization>。Excel 06b sheet / docx Table 1。"""
title: str
provider: Optional[str] = None # → party (CSP)
date_authorized: Optional[date] = None
fedramp_package_id: Optional[str] = None
impact_level: Optional[str] = None # low / moderate / high / li-saas
data_types: Optional[str] = None
nature_of_agreement: Optional[str] = None
authorized_users: Optional[str] = None
remarks: Optional[str] = None
@dataclass
class ParsedInventoryItem:
"""對應 OSCAL <inventory-item>。Excel 04 sheet / docx inventory 表。"""
description: str # 必填
asset_id: Optional[str] = None
asset_tag: Optional[str] = None
ipv4_address: Optional[str] = None
mac_address: Optional[str] = None
fqdn: Optional[str] = None
hostname: Optional[str] = None
software_name: Optional[str] = None
os_name: Optional[str] = None
# OSCAL <implemented-component> 多對多
implemented_component_refs: list[str] = field(default_factory=list)
# 暫存 component title / uid 字串列表;reconciler resolve
# 取代既有 ParsedDevice / ParsedInformationSystem / ParsedLeveraged 三類
@dataclass
class ParsedExcelEntityBundle:
"""v3.0 (2026-XX-XX, ssp-oscal-alignment)"""
parsed_system_characteristic: Optional[ParsedSystemCharacteristic] = None
parsed_parties: list[ParsedParty] = field(default_factory=list)
parsed_components: list[ParsedComponent] = field(default_factory=list)
parsed_leveraged_authorizations: list[ParsedLeveragedAuthorization] = field(default_factory=list)
parsed_inventory_items: list[ParsedInventoryItem] = field(default_factory=list)
parsed_controls: list[ParsedControl] = field(default_factory=list)
parsed_metadata: Optional[dict] = None
# removed in v3.0 bundle: parsed_devices / parsed_info_systems / parsed_leverageddomain/oscal/adapter/cmmc_ssp_adapter.py 完整重寫 — output 從 ParsedSsp 改成 ParsedExcelEntityBundle。
Section extractor 輸出 dict → adapter map 到新 dataclass:
# Docx Table #6 (CSP/CSO 7-col) → ParsedLeveragedAuthorization + ParsedComponent (paired)
for row in extract_leveraged_csp_table(doc):
la = ParsedLeveragedAuthorization(
title=row['service_name'],
provider=row.get('provider'),
fedramp_package_id=row.get('fedramp_package_id'),
impact_level=normalize(row.get('impact_level')),
...
)
bundle.parsed_leveraged_authorizations.append(la)
# 同時建一個 component 指回 la
bundle.parsed_components.append(ParsedComponent(
title=row['service_name'],
component_type='service',
leveraged_authorization_ref=la.title, # reconciler 會 resolve 成 uid
...
))
# Docx Table #7 (Category 5-col) → ParsedComponent only
for row in extract_leveraged_category_table(doc):
cat_zh_to_oscal = {
'外部服務': 'service',
'互連': 'interconnection',
'API': 'service', # OSCAL enum 沒 api,走 service + framework prop
'CLI': 'service',
}
bundle.parsed_components.append(ParsedComponent(
title=row['name_description'],
component_type=cat_zh_to_oscal.get(row.get('category'), 'service'),
...
))# domain/oscal/service/write_strategy/
class ComponentWriteStrategy:
"""ParsedComponent → ssp_components row。"""
class LeveragedAuthorizationWriteStrategy:
"""ParsedLeveragedAuthorization → ssp_leveraged_authorizations row。"""
class InventoryItemWriteStrategy:
"""ParsedInventoryItem → ssp_inventory_items + M2M join rows。"""執行順序(重要 — 互相 FK):
1. parties (already done)
2. leveraged_authorizations ← 先寫,拿 uid map
3. components ← 寫,含 leveraged_authorization_uid FK
4. inventory_items ← 寫
5. inventory_implemented_components (M2M)
6. controls (already done)
整段 wrapped in @transaction(app service 層),任一 step fail 整批 rollback。
ParsedComponent.leveraged_authorization_ref (title 字串) 或 ParsedInventoryItem.implemented_component_refs (title list) 指向 bundle 內不存在的 title 時,採 warn-but-allow:
NULL(component.leveraged_authorization_uid)或 skip M2M join row(inventory ↔︎ component)parse_jobs.parsed_result.import_warnings:
{
"ref": "Crowdstrike",
"reason": "leveraged_authorization not found in bundle",
"context": {"component_title": "AcmeCorp Backup Service"}
}兩筆 ParsedComponent.title 在 bundle 內重複時(譬如 user Excel 寫了兩行 "Crowdstrike"),reconciler 用 (component_type, title) 複合 key 配對:
inventory.implemented_component_refs = ["Crowdstrike"] 解析時若 bundle 有多筆同 title component → 取 first match by component_type=service(其他 type 不被選),寫 warning 「ambiguous component title, picked first service」parse_jobs.parsed_result JSONB 加 schema_version 頂層 key:
def confirm_import(self, parse_uid, payload, user_context):
parsed = job.parsed_result
schema_version = parsed.get("schema_version", "v1-ssp")
if schema_version == "v2-bundle":
# 新 path
bundle = self._dict_to_bundle(parsed["bundle"])
return self._orchestrator.orchestrate(bundle, ...)
else:
# 舊 path — 既有 ParsedSsp 邏輯不動
return self._legacy_confirm(...)Deploy timing:Phase 1 + Phase 2 合併 deploy(不拆中間態 — 詳見 §3 phase 表)。所以 schema_version 的角色限縮成:
parse_jobs (pre-Phase1+2 上線時尚未 confirm 的 job) 沒這 key → 預設 v1-ssp → 走 _legacy_confirm()v2-bundle → 走 orchestrator_legacy_confirm() + parse_jobs legacy shape support 在 deploy 後觀察 14 天(或 in-flight job 全清)即可移除,下個 cleanup PR 處理。
| 舊 sheet | 新 sheet | 變動 |
|---|---|---|
| 04_設備 | 04_資產清冊 | 改名;欄位 mapping 重設計(OSCAL inventory-item props) |
| 05_資訊系統 | (刪除) | 合併進 06a_元件清冊 |
| 06_外部利用服務 | 06a_元件清冊 + 06b_利用授權 | 拆兩個 |
新 sheet 欄位:
# SHEET_COMPONENTS (06a_元件清冊)
ColumnDef("component_type", "field.comp_type",
enum_values=("this-system", "system", "service", "interconnection",
"hardware", "software", "network", "policy", "physical",
"process-procedure", "plan", "guidance", "standard",
"validation", "other"),
required=True),
ColumnDef("title", "field.comp_title", required=True),
ColumnDef("description", "field.comp_description"),
ColumnDef("purpose", "field.comp_purpose"),
ColumnDef("status", "field.comp_status",
enum_values=("operational", "under-development",
"under-major-modification", "disposition", "other")),
ColumnDef("leveraged_authorization_ref", "field.comp_leveraged_ref",
lookup_source=LookupSource.LEVERAGED_AUTHS), # VLOOKUP 指 06b
ColumnDef("protocol", ...),
ColumnDef("port_ranges", ...),
ColumnDef("security_auth", ...),
# SHEET_LEVERAGED_AUTHS (06b_利用授權)
ColumnDef("title", "field.la_title", required=True),
ColumnDef("provider", "field.la_provider"),
ColumnDef("date_authorized", "field.la_date_authorized"),
ColumnDef("fedramp_package_id", "field.la_fedramp_package_id"),
ColumnDef("impact_level", "field.la_impact_level",
enum_values=("low", "moderate", "high", "li-saas")),
ColumnDef("data_types", "field.la_data_types"),
ColumnDef("nature_of_agreement", "field.la_nature_of_agreement"),
ColumnDef("authorized_users", "field.la_authorized_users"),
ColumnDef("remarks", "field.la_remarks"),
# SHEET_INVENTORY_ITEMS (04_資產清冊)
ColumnDef("description", "field.inv_description", required=True),
ColumnDef("asset_id", "field.inv_asset_id"),
ColumnDef("asset_tag", "field.inv_asset_tag"),
ColumnDef("ipv4_address", "field.inv_ipv4_address"),
ColumnDef("mac_address", "field.inv_mac_address"),
ColumnDef("fqdn", "field.inv_fqdn"),
ColumnDef("hostname", "field.inv_hostname"),
ColumnDef("software_name", "field.inv_software_name"),
ColumnDef("os_name", "field.inv_os_name"),
ColumnDef("implemented_components", "field.inv_implemented_components",
lookup_source=LookupSource.COMPONENTS), # multi-VLOOKUP 指 06aTEMPLATE_VERSION bump 到 v3.0.0(major bump — breaking schema change)。
H2 章節下加表(樣板 docx 要實際編輯):
H2 System Components 系統元件
[新表 components_table] 對應 06a
columns: 類型 / 名稱 / 描述 / 用途 / 狀態 / 利用授權 / 協定 / 安全機制
H2 Hardware and Software Maintenance and Ownership
[新表 inventory_table] 對應 04
columns: 描述 / Asset ID / Tag / IP / MAC / FQDN / Hostname / 軟體 / OS /
對應元件
H2 Leveraged External Systems and Services
[Table 1 (既有)] → leveraged_authorizations
[Table 2 (既有)] → 移除(內容跟 System Components 重疊)
H2 Software components 軟體元件
→ 可移除(合併進 System Components)
新 anchor 加進 docx_section_extractors.py:
def extract_components_table(doc):
"""H2 System Components 下的新表"""
def extract_inventory_items_table(doc):
"""H2 Hardware...Maintenance 下的新表"""scripts/regenerate_reference_templates.py 跑出 v3.0.0 樣板。
[基本資料] [受評標的] [參與人員] [元件 + 授權 + 資產] [控制項]
舊 5 tab → 新 5 tab,但「元件 + 授權 + 資產」內部分 3 sub-panel:
┌─ 第四 tab: 元件 + 授權 + 資產 ──────────────────────────────┐
│ │
│ [元件清冊 (Components)] │
│ DataTable: type / title / description / status / │
│ leveraged_auth_ref / protocol / security_auth │
│ │
│ [利用授權 (Leveraged Authorizations)] │
│ Card list (通常只 1-3 筆): │
│ ▸ Crowdstrike FedRAMP FR18078583629 [moderate] │
│ 授權日期 2024-01-15 │
│ 資料類型: FCI, System Logs │
│ ↳ 已被 3 個元件 reference │
│ │
│ [資產清冊 (Inventory Items)] │
│ DataTable: description / asset_id / ipv4 / mac / │
│ implemented_components (multi-select chip) │
│ │
└────────────────────────────────────────────────────────────┘
新 SspComponentsLeveragedInventoryTab.vue 取代既有:
LeveragedSection.vue(廢棄)InfoSystemsSection / DevicesSection(因為原本沒)用 PrimeVue <TabView> 內巢狀 <Accordion> 或 <Splitter> 排版。
Excel side 已有 SheetPreviewLeveraged.vue / 等。Stage 4 把這些 generalize 後 docx-import-v2 共用,避免維護兩套。
狀態:本 phase 已 deferred — user 2026-05-25 拍板「Phase 5 不用做,等全部定案後再做」。
以下 §5.1 / §5.2 / §5.3 內容保留為歷史 reference,不在本期 task arc scope;之後 user 拍板開工時可從這裡繼續。
# jedi_oscal/app/service/ssp_oscal_exporter.py
class SspOscalExporter:
def export(self, ssp_uid) -> dict:
ssp = ssp_service.get_with_full_tree(ssp_uid)
return {
"system-security-plan": {
"uuid": ssp.uid,
"metadata": self._build_metadata(ssp),
"import-profile": {...},
"system-characteristics": self._build_sc(ssp),
"system-implementation": {
"users": [...],
"components": [
self._component_to_oscal(c)
for c in ssp.components
],
"leveraged-authorizations": [
self._la_to_oscal(la)
for la in ssp.leveraged_authorizations
],
"inventory-items": [
self._inventory_to_oscal(i)
for i in ssp.inventory_items
],
},
"control-implementation": {...},
}
}
def _component_to_oscal(self, c):
out = {
"uuid": c.uid,
"type": c.component_type,
"title": c.title,
}
if c.description: out["description"] = c.description
if c.status:
out["status"] = {"state": c.status}
# leveraged-authorization-uuid prop (FedRAMP 等)
props = []
if c.leveraged_authorization_uid:
props.append({
"name": "leveraged-authorization-uuid",
"value": c.leveraged_authorization_uid,
})
# framework-specific props
if c.props:
for k, v in c.props.items():
if v:
props.append({"name": k, "ns": "cmmc:ssp", "value": v})
if props:
out["props"] = props
return out
def _la_to_oscal(self, la):
out = {
"uuid": la.uid,
"title": la.title,
}
if la.party_uuid:
out["party-uuid"] = la.party_uuid
if la.date_authorized:
out["date-authorized"] = la.date_authorized.isoformat()
props = []
if la.props and la.props.get("fedramp_package_id"):
props.append({
"name": "package-id",
"ns": "https://fedramp.gov/ns/oscal",
"value": la.props["fedramp_package_id"],
})
# CMMC props (cmmc:ssp namespace)
for k in ("impact_level", "data_types", "nature_of_agreement", "authorized_users"):
if la.props and la.props.get(k):
props.append({
"name": k.replace("_", "-"),
"ns": "cmmc:ssp",
"value": la.props[k],
})
if props:
out["props"] = props
return out
def _inventory_to_oscal(self, i):
out = {
"uuid": i.uid,
"description": i.description,
}
# implemented-components (M2M)
if i.implemented_components:
out["implemented-components"] = [
{"component-uuid": ic.uid} for ic in i.implemented_components
]
# OSCAL standard props
props = []
oscal_prop_names = {
"asset_id": "asset-id",
"asset_tag": "asset-tag",
"ipv4_address": "ipv4-address",
"mac_address": "mac-address",
"fqdn": "fqdn",
"hostname": "host-name",
"software_name": "software-name",
"os_name": "os-name",
}
if i.props:
for k, oscal_name in oscal_prop_names.items():
if i.props.get(k):
# asset-id 是 string 直接放,其他用 prop
props.append({"name": oscal_name, "value": i.props[k]})
if props:
out["props"] = props
return out# api/oscal/routes/ssp_export_route.py
class SspOscalExportRoute(MethodResource):
"""GET /api/1.0/ssp/<uid>/export?format=oscal-json"""
@jwt_required()
@inject
def get(self, uid, app_service: SspExportAppService = Provide[...]):
fmt = request.args.get("format", "oscal-json")
if fmt != "oscal-json":
raise BadRequestError(GrcErrorCode.GRC_EXPORT_FORMAT_UNSUPPORTED)
oscal_dict = app_service.export_oscal(uid, get_user_context())
return jsonify(oscal_dict)def test_ssp_oscal_roundtrip():
# 1. Excel import → SSP DB rows
excel_resp = client.post("/ssp-excel-imports/parse", ...)
confirm_resp = client.post(f"/ssp-excel-imports/{parse_uid}/confirm", ...)
ssp_uid = confirm_resp.json["data"]["ssp_uid"]
# 2. Export OSCAL JSON
export_resp = client.get(f"/ssp/{ssp_uid}/export?format=oscal-json")
oscal_doc = export_resp.json
# 3. Validate OSCAL schema
validator = OscalJsonValidator()
assert validator.validate(oscal_doc)
# 4. (Optional) Re-import same OSCAL JSON → should produce equivalent DB rows
...dev 階段不走漸進 deprecation(有 DB 備份 + 環境切分),Phase 1 內一口氣 cutover:
| 對象 | 處理 |
|---|---|
既有 ssp_system_implementation_items rows |
Phase 1.3 migrate 到 ssp_components + ssp_leveraged_authorizations → e2e 驗證 → DROP 舊表 |
套件側 SspSystemImplementationItemEntity / repo / service / model / mapper |
Phase 1.4 cleanup PR 整批刪除(不加 @deprecated marker) |
| 既有 SSP edit dialog (SspLeveragedSection.vue) | Phase 4 重寫接新 component endpoint |
| 既有 GET SSP detail API | Phase 1+2 deploy 後直接讀新表 — 不雙讀、沒 fallback |
| 既有 docx-import-parity Stage 1 對舊 entity 的 caller | Phase 1.4 cleanup PR 同步改 import 到新 entity |
| 動作 | 影響 |
|---|---|
| v2.6.0 → v3.0.0 | TEMPLATE_VERSION major bump |
| Parser 對 v2.x 樣板的處理 | 拒絕(is_supported 改 MIN=v3.0.0),user 必須重下新樣板。理由:v2.x 樣板沒新 sheet (06a/06b/04 改名) ,parser 沒法 fallback;強制重下唯一安全 path |
| 既有 in-flight parse_job (v2.x) | 用 schema_version 走舊路徑 confirm;觀察期 14 天(同 §2.4 — parse_jobs legacy shape support + _legacy_confirm() 同步移除) |
| 動作 | 影響 |
|---|---|
| 新增 components_table / inventory_items_table | 客戶要重下新樣板。Stage 1 解析現有 Table 1/2 仍 work(舊路徑保留) |
| Table 2 是否移除 | Phase 4 收尾時拍板(owner: 主開發者)。建議保留作為 fallback,等 Phase 4 FE 預覽 UI 上線後依使用情況評估 |
| 風險 | 緩解 |
|---|---|
| jedi-oscal 套件大改 — 跨主專案模組影響 | Path dependency 開發、3 PR per entity (LeveragedAuth → Component → InventoryItem) 切細 review、smoke 通過後才正式發版 |
| DB migration 一次性 cutover 後資料丟失 | 跑 migration 前 dev DB snapshot 備份;Step 3 verification SQL row count 不一致就 abort;Phase 1 e2e (Excel+docx import + GET SSP detail roundtrip) 全綠才跑 Step 4 DROP |
| Phase 1.4 刪套件舊 entity 漏 caller → ImportError | grep 整個 codebase(含套件側 jedi-compliance / jedi-flow-engine 等)對 SspSystemImplementationItemEntity 引用,全改完才送 cleanup PR |
| FK resolve fail 累積靜默資料 | import_warnings 結構化記錄、FE preview 明顯標示、idempotent re-link 允許後續修正 |
| Phase 4 FE 改動大 | tab 結構保留既有,內部 component reuse |
| Schema_version v2.x 樣板被擋 | 預先公告 + 提供新樣板下載點 |
| OSCAL export 不符合官方 schema | 用官方 JSON schema 跑 validator;CMMC 框架 prop ns 標清楚 |
docs/features/FR-027-2605-docx-import-parity/design.mddocs/features/FR-027-2605-docx-import-parity/handoff/2026-05-24-stage1-overnight-SUMMARY.md本節記錄 Phase 1 + Phase 2 實作過程中與 §1-§8 / implementation-plan-phase2.md 原始設計的所有重大偏差及其原因。Mirror SSP Update Diff design.md §11 樣式。
與 changelog 的差異:changelog 說「做了什麼」,本節說「為什麼跟 design 不同」。
下個 session 接手 Phase 3 / Phase 4 前先讀本節,重點看 §11.13(Excel parser 還沒輸出 v2-bundle,Phase 3 §3.X 接手)、§11.16(Phase 1 RLS delimiter bug — 已修但教訓必看)、§11.17(Phase 2 Bug A — docx confirm source_type 處理漏掉 module_frame branch)、§11.22 + §11.23(Phase 4 Bug B 第一段 + Issue 1)、§11.24(Phase 4 Bug B 第二段 — _upsert_responsible_party silent skip + docx adapter 漏補 default role,Cross-source consistency 教訓)、§11.25(Phase 4 Bug D — docx Table #7 Category 行漏 emit LeveragedAuthorization,4 → 1 寫入,Adapter 兩條對稱 path cross-source check 教訓)、§11.26(Phase 4 Bug H — Word parties label 雜亂 + 電話/地址欄位 preview/template-edit 未 render,三元件對齊教訓)、§11.27(Phase 4 Bug H-5 — docx parser email substring collision,dict-as-priority-map 順序敏感性教訓)、§11.28(Phase 4 Bug H-6 — jedi-common auto-fill org_unit_id 假鉤稽,shared infra hook 雙語意陷阱)、§11.29(Phase 4 Bug I — revert §11.25 short-term tactical fix + 廢棄 Phase 4 過渡 dual TabPanel,落實 §1 + §4.2 long-term state)、§11.30(Phase 4 Bug J — module_frame_template_ssp_app_service silent AttributeError,try/except: pass anti-pattern)、§11.31(Phase 4 Bug K K1 — SSP CRUD endpoint 對 module-frame template SSP 404,新增雙模式 SspContextResolver,wrapper/routing 概念不該複製成獨立 domain 教訓)、§11.32(Phase 4 Bug L — preview status edit 沒帶到 template-edit (FE 走 v2 keys / BE 寫 v3) + LA Card→Table + sub-panel 命名走 Word docx 原文)、§11.33(Phase 4 Bug M — docx 1.1 system_characteristic 整段 chain 缺漏:extractor / dataclass / adapter / confirm wire / write filter 五層)、§11.34(Phase 4 Bug O — diff stepper 補 4 key (sc / components / leveraged / inventory) annotation + decision filter + TabView refactor 完成;5 教訓:annotated vs raw key 不能共用 / annotate 多 call site 要全 wire / FE store 命名三處同步 / tab name 對齊既有 UI 結構優先於 spec / banner wording 中文化補新 key)、§11.35(Bug P — PartiesSection 對 org row 用「連結帳號」wording 失準 + button label 漏跟著 dialog title 對 type 分流;教訓:dialog title 對 type 分流時必須 propagate 到 caller 的所有 trigger button)、§11.36(Bug Q — preview 端 PartiesSection 自初版就缺 role Dropdown + hardcoded 5 keys vs template-edit menuStore 9 keys;教訓:FE 兩處 UI render 同 entity 欄位必須共用同個 menuStore master,禁止 hardcode label map)、§11.37(Bug R — useModuleFrameParties / useSspParties 永久 cache + confirm path 沒 invalidate 造成 stale;教訓:永久 cache 設計必須伴隨「每個寫入 path 都 invalidate」SOP,外部 mutation 也要收尾)。
Mapped[] 取代 1.x Column()原 plan/spec 寫(§1.1 / plan §Task 2.3):ORM 用 Column(Integer, ...) 1.x 風格 + from jedi_oscal.infra.model.base.base_model import Base(路徑虛構不存在)。
實際做:mirror jedi-oscal 既有 oscal_party.py / ssp_system_implementation_items.py 等模組——Mapped[] + mapped_column(...) SQLAlchemy 2.0 style + from jedi_common.session.database.declarative_base import Base。
原因:jedi-oscal 套件早已遷移到 2.0 風格,plan 寫的 import 路徑也不存在。Pre-flight cat 既有 entity 就能確認。
Commits: bf12dc9 (LeveragedAuth ORM), fdfd317 (Component ORM), 9d0db75 (InventoryItem ORM), c358d8e (M2M join ORM)
UUID(as_uuid=True) 取代 VARCHAR(36)原 plan/spec 寫(§1.2 SQL / plan §Task 2.3):所有 uid / party_uuid / leveraged_authorization_uid 用 VARCHAR(36)。
實際做:全部用 Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), default=uuid.uuid4, ...)。Entity attribute 也是 Optional[uuid.UUID],tests 用 uuid.uuid4() 而非字串 placeholder。SQL migration(Task 7-9)寫 UUID 不是 VARCHAR(36),gen_random_uuid() 不需 ::TEXT cast。
原因:mirror oscal_party.py 既有 UUID 慣例(套件早已從 VARCHAR(36) 升級)。leveraged_authorization_uid 跟 leveraged_auth.uid 型別必須一致才能 join。
Commits: ab66af0 (LeveragedAuth entity), bf12dc9 (LA ORM), 157d2bc (Component entity), fdfd317 (Component ORM)
IBaseRepo[T,Q]: pass 取代手寫 abstracts原 plan/spec 寫(plan §Task 2.5):repo interface 手寫 get_one(query) / get_list(query) / deactivate(uid) 等 abstract methods。
實際做:trivial subclass:
class ILeveragedAuthorizationRepo(IBaseRepo[T, Q]):
passRepo impl 透過 BaseRepositoryImpl 自動拿到 get_one_by_fields / get_all_by_fields / get_by_uid / add / update(locale) / delete_by_uid / 等 13+ 方法。
原因:手寫 abstracts 跟 IBaseRepo 的 abstract methods 衝突 → TypeError: Can't instantiate abstract class。Mirror IPartyRepo 既有 minimal pattern。
Commits: fed9970 (LA repo), f498af1 (Component repo), 646cc38 (Inventory repo + M2M)
<entity>_repo= 取代 repository=原 plan/spec 寫(plan §Task 6.2):DI wire 用 LeveragedAuthorizationDomainService(repository=...)。
實際做:套件 domain service __init__ 簽名是 (self, leveraged_authorization_repo: ILeveragedAuthorizationRepo)。DI wire 用 leveraged_authorization_repo=。Mirror PartyDomainService(party_repo=...) 既有慣例。
原因:所有 jedi-oscal 既有 domain service 都用 <entity>_repo= naming,沒有用 repository= 的。Plan §6.2 純屬寫錯(plan 也寫錯 Singleton vs Factory,見 §11.5)。
Commits: a748205 (LA service), 29b3bf0 (Component service), 2162af2 (Inventory service), 2e6849f (DI wire)
providers.Singleton 取代 plan 警告的 providers.Factory原 plan/spec 寫(plan §Task 6.2):「⚠️ 不要把 repo 註冊成 Singleton...repo 第一次實例化早於 @transaction 開啟 scope 會炸。providers.Factory 是正確選項。」
實際做:3 個新 repo 都用 providers.Singleton(...)。BaseRepositoryImpl 用 @property session lazy load,__init__ 不碰 session → Singleton 安全。既有 oscal_party_repo / catalog_control_repo 等 70+ repo provider 全部用 Singleton 已驗證 production OK。
原因:Plan §6.2 警告是 generic project lore(CLAUDE.md 內也有),但對 jedi-oscal 不適用,因 BaseRepositoryImpl 已是 lazy session pattern。Factory 反而會變一次性 outlier 讓未來 maintainer 困惑。Section comment 已 baked in 解釋。
Commits: 2e6849f (DI wire)
_ref / matched_party_uuid 保持 str 不升 uuid.UUID原 plan/spec 寫(§1.4):ParsedComponent.leveraged_authorization_ref / ParsedInventoryItem.implemented_component_refs / ParsedComponent.matched_party_uuid — type 用 Optional[str] / list[str]。
實際做:保持 str,不跟著 Tasks 2-4 entity 一起升級成 uuid.UUID。Parser 階段拿到的是 title 字串,reconciler 才解成 uid。
原因:parse → reconcile → write 三段 pipeline 設計刻意。Parser 不該假設 reconciler 已跑完。若升級 UUID,parser 寫 entity 時要先做 lookup,破壞 pipeline 分層。Test 用真實 string "nginx-component" 確認 type discipline。
Commits: 95e9efd (Task 5 ParsedBundle)
model=None optional param + audit field 處理慣例原 plan/spec 寫(plan §Task 2.4 example):to_model(entity) -> OscalLeveragedAuthorizationModel 純建新 model。
實際做:簽名是 to_model(entity, model=None) -> Model,支援既有 model in-place update(mirror PartyMapper)。Audit fields (created_at / updated_at) 由 server-side func.now() + onupdate 處理,to_model 不寫;created_user / updated_user 只在 entity 有值時寫。
原因:BaseRepositoryImpl.update() 需要 mutate 既有 model 才能讓 SQLAlchemy 偵測到變動。Audit field exclusion 對齊 update_exclude_fields = ["id", "uid", "created_at", "updated_at", "created_user"] (base_repository_impl.py:400)。
Commits: 773c5eb (LA mapper), 697a460 (Component mapper), 1b22843 (Inventory mapper)
原 plan/spec 寫(plan §Task 4.4):_write_join_rows 拿 component_uids → IN query → for each match session.add(join_row)。add() override 把 entity.implemented_component_uids (input list) 重新 attach 回 saved。
實際做:_write_join_rows 改 return List[uuid.UUID] of actually-resolved uids;add() 用 return 值(resolved list)重新 attach,不是 request list。Unresolved uids 觸發 logging.warning(...) 列名。set(component_uids) 開頭 dedupe 避免 composite PK IntegrityError。
原因:原寫法的 saved entity 對 caller 謊稱「全部 uids 都寫了」,但其實 silent-skip 過。違反 read-your-writes 語義。觀察性差(drop 沒 log signal)。reviewer code quality review 2 Important findings 一起 harden。
Commits: ac1d860 (M2M contract hardening follow-up)
_v2_bundle.py 命名 vs v3.0 spec(cosmetic)原 spec(§1.4):v3.0 bundle。
實際做:test file 命名 test_ssp_intermediate_v2_bundle.py,跟 v3.0 對不上(v2 是版本前綴錯誤)。Spec / module docstring 是 v3.0。功能無影響;遺留 minor cosmetic 不修。
原因:implementer 沿用 v2 prefix 寫 test name 沒對齊 bundle 內容版本。reviewer 標 minor non-blocking。
Commits: 95e9efd
這是本 session 最大的 reconciliation——關係到 Tasks 10-14 接手者。
原 spec §1.3 寫(CREATE TABLE + INSERT SELECT 完整 SQL):
oscal.ssp_system_implementation_items 有 i.ssp_id / i.service_name / i.fedramp_package_id / i.impact_level / i.data_types / i.nature_of_agreement / i.authorized_users / i.protocol / i.port_ranges / i.security_auth / i.remarks / i.is_active 12 個欄位oscal.system_security_plans 有 s.tenant_id / s.org_unit_idi.category實際 DB schema(2026-05-24 直接 query 確認):
system_security_plan_id(不是 ssp_id),title 欄位是 name(NOT NULL)+ title(nullable,33/394 populated)。parsed_result JSONB,從沒寫成 DB column。is_active column(不能 WHERE is_active = TRUE)。system_security_plans 表 沒有 tenant_id / org_unit_id — tenant 解析必須走 4-table chain: items → assessment_plans (cast ::text) → project_assessment_plan_mapping → projects.tenant_id。category 95% NULL(377/394),不能當 discriminator;改用 implementation_type(NOT NULL,3 values: hardware/186, system/184, leveraged-authorization/24)。WHERE p.tenant_id IS NOT NULL 過濾掉。實際 migration mapping:
component_type ← CASE implementation_type WHEN 'hardware' THEN 'hardware' WHEN 'system' THEN 'system' WHEN 'leveraged-authorization' THEN 'service' ELSE 'other' ENDtitle ← COALESCE(i.title, i.name)tenant_id / org_unit_id ← 4-table chain 取 p.tenant_id / p.org_unit_id完整 SQL 已 commit 在 scripts/sql/2026-05-24-ssp-oscal-alignment-*.sql 3 個檔案。Tasks 10-14 接手者不要再從 design.md §1.3 抄 SQL,直接看那 3 個 .sql 檔案。
原因:design.md v1.0/v1.1 寫 §1.3 時沒 verify actual DB schema(推測是基於想像中的「應該長這樣」的舊表結構),跟 v2.5+ 重構後的真實 state 對不上。FedRAMP 欄位本來規劃寫進 DB column 但實作走 JSONB 路線了。這個失準是本 session 最大 trap,先 implementer BLOCKED、user 糾正、controller 親查 schema 後 re-dispatch 才解開。
Commits: a9712a1 (Task 7 create tables), 567efe7 (Task 8 migrate data), c5b43fb (Task 9 verification)
Tasks 10-14 接手 checklist:
system_implementation_item_repo / SspSystemImplementationItemEntity 8 callers(Task 5 留下的 + 其他)改新表oscal.ssp_system_implementation_items_archive 表先)做錯的事:第一次 dispatch Task 7-9 implementer 時,controller 沒先親查 dev DB schema 就寫 prompt,導致 implementer 跑 pre-flight 才發現 8+ column 對不上,回報 BLOCKED。Controller 又把 BLOCKED 結論包裝成「7 個 user 決策題」拋給 user,浪費 user 時間。
該做的事:controller 任何涉及 DB schema 的 task 開工前,親自 \d 對應表 + 跑簡單 query 確認 row counts,再寫 baked-in SQL 進 prompt。Subagent 角色是執行不是解謎。
Pattern:trust-but-verify 不只是「subagent 報告 DONE 後我跑 git status / pytest 確認」,也包含「dispatch 前 controller pre-flight verify environment state」。Task 7-9 第二輪 dispatch 用了這原則才順利完工。
未來規則 baked into 剩餘 task prompts:
原 plan/spec 寫(implementation-plan-phase2.md §Task 6):orchestrator 內部「filter components by type 後 dispatch 給既有 device_reconciler / information_system_reconciler / leveraged_reconciler 三個 reconciler」,reconcilers 自身 signature 改吃 List[ParsedComponent]。
實際做:Task 6(commit 56f35d83)採 Minimal scope — orchestrator 只保留 leveraged_reconciler dispatch,device + information_system reconcilers 完全 skip(不過濾、不 dispatch、不改 signature)。下游 Task 11A(commit 9165b866)直接把 device_reconciler.py + information_system_reconciler.py 整檔刪掉。
原因:
_resolve_or_create_by_name 既有邏輯,不需要先 reconcile 一輪。ComponentWriteStrategy 內建 LA reference resolution(commit d8c9a19d),實質上把 reconciliation 行為遷到 write 階段。雙寫 reconcile 是 redundancy。_in_uid 批次 dedup,這個邏輯不適合塞進 write strategy。影響:Task 6 從原計 5d 縮到 1d;Task 11 cleanup scope 連帶擴大(見 §11.15)。
Commits: 56f35d83 (Task 6 minimal), d8c9a19d (Task 5B Component write strategy + LA resolve), 9165b866 (Task 11A 刪 reconcilers)
原 plan/spec 寫(implementation-plan-phase2.md §Task 7):Excel app service _write_all_data 在 _write_all_data 偵測 parsed_result.get("schema_version") — v2-bundle → 新 path;其他 → legacy path(保留既有 reconciler/strategy)。Implicit assumption:parser 已輸出 schema_version=v2-bundle + components / leveraged_authorizations / inventory_items keys。
實際做(Phase 2 結束時):app service dispatch logic 已落地(Task 7 commit f4cb9b10),但 Excel parser 本身還沒改 — 仍輸出 v1 shape (parsed_devices / parsed_info_systems / parsed_leveraged 三個 raw list)。結果是 Excel 走 legacy path → 拋 PreconditionFailedError(GRC_DOCX_PARSE_JOB_LEGACY_SHAPE, 412)。
Docx 端 Task 8(commit e6bc9ede)有同步加 CmmcSspAdapter.adapt_to_bundle() 輸出 v3 bundle,所以 Docx flow 全通。Excel parser 沒做對應改造,目前實際上「Phase 2 整片完工但 Excel import 從 200 變 412」。
原因:
95e9efd2)。實際上 Phase 1 Task 5 只改了 dataclass 定義 + 加 ParsedComponent / ParsedLeveragedAuthorization / ParsedInventoryItem,沒改 Excel parser 的輸出 dict shape。影響:
Commits: f4cb9b10 (Excel dispatch logic — legacy 412 path), e6bc9ede (Docx adapter adapt_to_bundle), c681e3a8 (Docx wire confirm_service)
Phase 3 入手點:domain/oscal/parser/excel/cmmc_excel_parser.py(或對應檔),輸出 parsed_result["schema_version"] = "v2-bundle" + components/leveraged_authorizations/inventory_items 三 list。
import_pipeline/ 模組 — 三層 collector / normalizer / confirm_service 落地(user-approved extension)原 plan/spec 寫(design.md §3 / implementation-plan-phase2.md §Task 4):Phase 2 spec 只寫 SspImportConfirmService.confirm(parsed_result, ssp_id, catalog_id, user_context) 單一公開介面 + bundle_restore helper。Normalizer / Warnings collector / SourceTable enum 等內部結構沒明說。
實際做:Phase 2 Task 2-4 三 commit 把 import_pipeline 拆成獨立 module:
domain/oscal/import_pipeline/
├── warnings.py — ImportWarnings collector + SourceTable StrEnum + ImportWarning dataclass
├── normalizer.py — ParsedBundleNormalizer (dedup + ref resolve + warnings emit)
├── bundle_restore.py — dict → ParsedExcelEntityBundle + schema_version constants
└── confirm_service.py — SspImportConfirmService(dispatch by schema_version + LegacyConfirmRequired)
SourceTable 是 string-based StrEnum(commit 1e3abaee)— JSONB serialization byte-identical to bare string,但消除了 magic literal。
原因:
影響:
ImportWarning shape 顯示 warning chip。Commits: aee0e991 (Task 2 ImportWarnings), ec9d3b20 (Task 3 ParsedBundleNormalizer), f6069f83 (Task 4 SspImportConfirmService), 1e3abaee (Task 11B SourceTable enum)
原 plan/spec 寫(implementation-plan-phase2.md §Task 11):cleanup scope 含「刪 device_write_strategy.py / information_system_write_strategy.py + base.py AbstractSspComponentWriteStrategy + 三 v2 dataclass (ParsedDevice / ParsedInformationSystem / ParsedLeveraged) + helper 三 method」。
實際做(Task 11A commit 9165b866 + Task 11B commit 1e3abaee):plan scope 全做 + 額外刪:
domain/oscal/service/reconciliation/device_reconciler.py(plan 沒列)domain/oscal/service/reconciliation/information_system_reconciler.py(plan 沒列)test_a4_write_all_data_pipeline.py / test_a4_reconciliation_device.py / test_a4_reconciliation_information_system.py / test_a4_boundary_cases.py)共 1057 LOC test code原因:
影響:
-1826 +53 LOC(删 18 file mod + 9 file del)。domain/oscal/service/reconciliation/ 目錄只剩 catalog_control_reconciler.py / assessment_objective_reconciler.py / leveraged_reconciler.py / system_characteristic_reconciler.py / ssp_entity_orchestrator.py 五檔。Commits: 9165b866 (Task 11A scope expansion), 1e3abaee (Task 11B reviewer minor follow-ups)
這是 Phase 1 真實 trap,Phase 2 Bug A fix 後第一次觸發。
原 spec §1.2 RLS policy 寫:
USING (
current_setting('app.is_super_admin', TRUE) = 't'
OR tenant_id = ANY(string_to_array(current_setting('app.allowed_tenant_paths', TRUE), ','))::INT[]
)實際 jedi-common session_scope 設:app.allowed_tenant_paths = '/1/102/'(slash 格式 path)
錯誤:split by , 得到 single-element ['/1/102/'],cast ::INT[] 炸:
psycopg.errors.InvalidTextRepresentation:
invalid input syntax for type integer: "/1/102/"
Phase 1 Task 9 verify 用 cmmgr (superuser bypasses RLS) 沒踩到 → false-positive ship。
Phase 2 Bug A fix 後,docx confirm 第一次從 cm_app 真寫 ssp_components / ssp_leveraged_authorizations / ssp_inventory_items → trigger RLS check → 炸。
正確 policy(trim '/' + split by '/'):
USING (
current_setting('app.is_super_admin', TRUE) = 't'
OR tenant_id = ANY(
string_to_array(
trim(both '/' from current_setting('app.allowed_tenant_paths', TRUE)),
'/'
)::integer[]
)
)驗證 chain:trim('/1/102/', '/') → '1/102' → string_to_array('1/102', '/') → ['1', '102'] → ::integer[] → [1, 102]。
Migration:scripts/sql/2026-05-24-ssp-oscal-alignment-fix-rls-delimiter.sql(DROP + recreate 4 policies — ssp_components_rls / ssp_leveraged_authorizations_rls / ssp_inventory_items_rls / ssp_inventory_implemented_components_rls)
M2M join table policy 同步補強:原 spec 沒明列 ssp_inventory_implemented_components 的 policy(comment 寫「不需 RLS column」),但實際 Phase 1 Task 7-9 ship 的 SQL 已含一條 policy。本次 migration 同樣 DROP + recreate 該 policy(EXISTS subquery 走 parent ssp_inventory_items.tenant_id),表達式內的 delimiter 也一併修正。
教訓:
app.allowed_tenant_paths 由 jedi-common session_scope() 設定為 '/1/102/'(slash-delimited LTree-like path),policy 表達式必須 match 這個 format。Commit: d12bd705 (本段 §11.16 + migration script + §1.2 警告註解一併入版)
_run_v2_bundle_confirm 只 handle project_ssp source_type症狀:Phase 2 Task 9 (c681e3a8) ship 後跑 docx end-to-end,import 200 + 後端 log 顯示 ConfirmService 跳過 LA/Component/Inventory writes;template-edit 「外部利用服務」tab 空白。
Root cause:
ssp_docx_import_app_service._run_v2_bundle_confirm 第一版只寫 source_type='project_ssp' 的 branch,從 job.source_uid 直接拿 ssp_uid → 解到 ssp_id 後 delegate SspImportConfirmService.confirm(ssp_id=...)。但 docx import 的主流場景是 source_type='module_frame'(從 module_frame 帶版而出 SSP shell),這個 branch 沒實作:
# Task 9 第一版
if job.source_type == "project_ssp":
ssp = self._ssp_domain_service.get_one(SspQueryEntity(uid=job.source_uid))
ssp_id = ssp.id
else:
ssp_id = None # ← module_frame 走進來變這條,confirm_service 拿不到 ssp_id 直接 skipSspImportConfirmService.confirm() 開頭 if ssp_id is None: log.warning(...); return ImportWarnings() — 對 caller silent fail。
為何沒在 Task 9 review 抓到:Task 9 reviewer 主跑 test_ssp_docx_import_app_service.py 32 個既有 test,全綠(mock 階段 source_type 多半是 project_ssp);module_frame 真實 flow 要跑 e2e 才會踩到。Phase 2 收尾才有空跑 end-to-end。
Fix(ba0cdd7a):
抽出 domain/oscal/service/ssp_shell_service.py(DDD domain-service 層,無 infra import / 無 session_scope,符合 §11 既有的 DDD 邊界規範):
class SspShellService:
"""SSP shell 建立 / 取得(superset / update / module_frame 通用)。"""
def can_build(self, source_type: str, source_uid: str | None) -> bool: ...
def create_shell(self, source_type, source_uid, ...) -> SspEntity: ...
def resolve_existing_shell(self, source_type, source_uid) -> SspEntity | None: ...
def ensure_shell(self, source_type, source_uid, ...) -> SspEntity:
"""resolve-or-build convenience — caller 不需區分 superset / update。"""ssp_docx_import_app_service.__init__ 多 kwarg ssp_shell_service=None(None = legacy path 保留);_run_v2_bundle_confirm module_frame branch 改呼叫 self._ssp_shell_service.ensure_shell(...) 取 ssp_id;ensure 過程 Exception → log warning + 跳過(不破壞 confirm 主流程)。
為何不直接複用 Excel app service 的 _create_ssp_shell / _resolve_existing_ssp_shell:那兩個是 app service 層 private method,直接 import 違反 DDD 邊界(app → app 橫向依賴)。抽到 domain/oscal/service 層後 docx + Excel 共用一份 logic。本次先 docx 走新 service;Excel inline logic 不動(32 既有 tests 保留),後續 cleanup 再 migrate Excel。
Bug A 與 §11.16 連動:
Bug A 是表面症狀(FE 空白);§11.16 RLS delimiter 是埋更深的 latent bug。Bug A 沒 fix 時 confirm path 從沒實際對 4 張新表 INSERT → RLS policy 從沒被 evaluate → §11.16 bug latent;Bug A fix 後 cm_app 第一次真寫進去才 surface 出 RLS DataError。換言之,§11.16 是 Bug A 的「真正 unblock」步驟。
教訓:
Commit: ba0cdd7a (本段 §11.17 + domain/oscal/service/ssp_shell_service.py 新檔 + docx app service __init__ / _run_v2_bundle_confirm 改造)
這條 cover Phase 3 三段子 phase(§3.1 / §3.2 / §3.3)+ 過渡期相關 Bug fix。
§3.1 v3.0.0 BREAKING bump + 雙路兼容:
原 spec §3 寫:Excel sheet 從 v2.6.0 升 v3.0.0,移除「設備 / 資訊系統 / 外部利用服務」,新加「元件清冊 / 利用授權 / 資產清冊」。
實際 ship (f7eaa09f):照 spec 走,但 Excel parser (8ac3e35b) 同時對 v1.x 樣板輸入 derive v3 keys——domain/oscal/parser/excel/v2_bundle.py line 70-95 對 v1.x parsed_result(含 parsed_devices/parsed_info_systems/parsed_leveraged)轉成 v3 bundle keys(components/leveraged_authorizations/inventory_items),同時 set schema_version='v2-bundle'。
原因:in-flight v2.x parse_jobs(user 在 Phase 3.1 ship 前已上傳的 job 還沒 confirm)需要不被擋。derive 後 confirm path 一律走 v2-bundle,避免維護兩條 confirm logic(spec §2.4 寫的 _legacy_confirm() fallback 路徑等於不被觸發)。
影響:FE wrapper ImportExcelPreviewPage.vue 直接讀 v3 keys 即可(Phase 4 L5 做的),不需要 v1 兼容分支。
§3.2 Docx extractors 加 components_table + inventory_items_table:
原 spec §3.2 寫:H2 章節下加 components_table / inventory_items_table;docx 樣板要實際編輯加 H2 區段。
實際 ship (c115b7bc):domain/oscal/parser/docx_section_extractors.py 加 extract_components_table + extract_inventory_items_table;adapter (domain/oscal/adapter/cmmc_ssp_adapter.py) adapt_to_bundle() 呼叫新 extractors。樣板 .docx 檔案沒實際加 H2 區段 — extractor 對 missing section 返 [],不影響既有 T6 (CSP/CSO) / T7 (Category) 路徑。
原因:樣板 .docx 編輯(圖形 Word)成本高且需 user 操作;code 端先 ready,user 之後加 H2 區段就生效。
影響:本期 docx import 沒寫入 components / inventory_items(只寫 leveraged_authorizations),這是有意的 throttle,不是 bug。
§Phase 3 Bug C — v2-bundle confirm 套 content_overrides:
症狀:user 在 docx preview UI 改 component 類型 / LA 欄位 → submit 後 DB 仍是 parse 時原值。
Root cause:ssp_docx_import_app_service._run_v2_bundle_confirm 入口直接 dict_to_bundle(parsed_result["bundle"]);沒套 content_overrides。對比 Excel side _apply_content_overrides 有完整 metadata / system_characteristic / sheet rows overrides handling。
Fix (8e8d5624):在 _run_v2_bundle_confirm 入口加 _apply_v2_bundle_overrides() helper,對 bundle.components / bundle.leveraged_authorizations / bundle.inventory_items 三 list 做 row-level update(mirror Excel _apply_content_overrides 的 sheet-row loop logic)。
教訓:v2 → v3 過渡期,Excel / docx 兩條 confirm path 必須對齊 override handling。Phase 3 spec 沒明寫此項——是收尾 e2e 才踩到的 latent bug。後續加 v3 features (Phase 4+) 必須對 Excel/docx wire-up 對齊。
Commits: f7eaa09f (§3.1),8ac3e35b (Excel parser v2-bundle dict shape derive),c115b7bc (§3.2 docx extractors),910b7f5a (§3.3 reference regen),8e8d5624 (Bug C content_overrides fix)
這條 cover Phase 4 demo-readiness 兩個關鍵 task: L5 (FE preview v3 對齊) + L1a (BE inventory CRUD M2M wire-up)。
§L5 preview 元件「新建 3 + 舊保留」:
原 spec §4.4 寫:「Reuse excel preview component」— generalize 三檔 generic + docx 共用,避免維護兩套。
實際做:新建 3 個元件(SheetPreviewComponents.vue / SheetPreviewLeveragedAuth.vue / SheetPreviewInventoryItems.vue),舊 SheetPreviewDevices/InfoSystems/Leveraged.vue 保留檔案不刪(wrapper ImportExcelPreviewPage.vue 不再 import 它們)。L3 cleanup phase 才整批刪舊三檔。
原因:
§L1a inventory.update_item M2M silent skip + UX 顯式提示:
原 plan §Task 4.4 跟 entity docstring 寫:BaseRepositoryImpl.update() ignores implemented_component_uids(not a column)。M2M change 必須走 delete + recreate。
實際 wire-up:
SspInventoryItemsAppService.update_item 收到 payload 含 implemented_component_uids → log warning + 不 raise(silent skip 對 caller transparent)SspComponentsLeveragedInventoryTab.vue Edit dialog 對 implemented_components MultiSelect :disabled + 顯示 hint「(編輯模式下不可變更 — 需刪除後重建)」+ update payload 不送 M2M field原因:M2M 變動是 OSCAL implementation 跨層 design 決定(BaseRepositoryImpl 對 M2M by-design 不支援 mutate),FE 該對齊這個 contract 而不是 invent 新 pattern 對抗 design。
教訓:M2M update contract 跨層 (entity ↔︎ repo ↔︎ app service ↔︎ FE dialog) wire-up 必須一致;entity docstring 寫的 contract,UX 端要忠實反映給 user 看(不要讓 user 改了 M2M 以為生效)。
Commits: FE L5 (bfab64b),BE L5 (eedad8ab),BE L1a (b02f833e),FE L2 (合併 L3 commit 00c2587)
原 spec §4.1 寫:[基本資料][受評標的][參與人員][元件+授權+資產][控制項] 5-tab structure。
現狀本期接受 4-tab:[基本資料][單位][參與人員][元件+授權+資產]。三個 sub-item 全 skip 理由分別:
| Sub-item | 原 spec intent | 本期決定 | 理由 |
|---|---|---|---|
| 受評標的 (SC) 從 SspBasicSection 拆出 | 獨立 tab 顯示 SystemCharacteristic schema | SKIP | SspBasicSection.vue 內部已是 SystemCharacteristic editor(class docstring 第 1-3 行明寫),「拆出」不知道指 rename / 真 split / 還是其他;需 user UX 對齊才能 execute |
| 單位 + 參與人員 合併 | 一個 tab 顯示兩個 party_type | SKIP | 涉及 ModuleFramePartiesPanel 加 mode 切換大改 + UX 決策(兩 party_type 怎麼 layout / filter / 切換) |
| 加控制項 tab | OSCAL SSP model 完整性 intent | SKIP | user 2026-05-25 拍板:「本來 SSP 編輯就沒有控制項 tab」(Phase D docstring 明寫「適用控制項:屬合規資源庫 (MF) 層級設定,不能在專案規劃內調整」);控制項實際編輯場已在 TaskSetupView (/project/projects/:id/ap/:apUid/task-setup),雙 entry point 反而 UX 困惑(user 點進空殼 tab 才看到 button 跳轉) |
Spec drift root cause:design.md §4.1 提出 5-tab spec 時,每個 tab 內具體內容跟對應 vue 元件接點沒寫清楚——只列 tab label。實作時才發現 spec 細節不夠 actionable。
教訓:
Commit: 無(本 task 全 skip,僅在本檔記錄決定)
原 plan / spec 沒講 i18n fallback handling。L2 ship 時 (00c2587) SspComponentsLeveragedInventoryTab.vue 內 71 處走 ?? '中文 fallback' pattern(safety net 寫法)。
vue-i18n 行為:對 missing key 預設 return key 字串本身(non-nullish)+ 在 console emit warning。Nullish coalescing ?? 只對 null / undefined trigger → fallback 永遠不會 trigger → fallback 是 dead code(user 看到的是 lang.ssp_components_leveraged_inventory.xxx 字串,不是 fallback 中文)。
作法 L6 (05b6fdf):
ssp-components-leveraged-inventory.json (zh-tw + en) 補 ~30 個 keysrc/config/locales/index.js??:severity 預設 / error msg 萃取)lang.common.action 進 common.json(其他 callers 可能也 fallback)原因:dead-code fallback 對 user 沒幫助(missing key 仍顯示 key 字串),正確做法是補 key + 移除 fallback。
教訓:
messages.<locale>.fallback(fallbackLocale = 'en')+ console warning,不是元件內 nullish coalescing。?? '中文' pattern — 直接走 t('lang.xxx.yyy'),missing key 在 dev 時 console warning + production fallbackLocale 接住。Commit: 05b6fdf (本段 §11.21 + 新 JSON 雙語檔 + index.js 註冊 + common.json action key + vue 檔 fallback 移除)
added parties 預設 decision=null → 全 skip → 0 寫入症狀:Phase 4 + E option ship 後 user 跑 docx import(亞航 CMMC SSP,5 parties),preview 顯示 5 parties,confirm 後 DB oscal.parties + oscal.responsible_parties 對應 SSP shell 全空;MF /template-edit 「責任單位 / 責任人員」tab 空白。BE log 證據:
[ssp-confirm] _filter_parties_for_write parties_decisions count=5 map={
'new-...': 'skip', 'new-...': 'skip', ... # 5 筆全 skip
}
[ssp-confirm] annotated party uid=new-... diff_status=added decision=skip
... (5 次)
[ssp-confirm] no parties marked use_docx — nothing to write
Root cause(在 FE,非 BE):
stores/sspDocxImportStore.js:initDefaultDecisions() 把 decisions.parties[uid].action 初值設為 p.default_action ?? null。BE 對 added parties 不送 default_action(沒「先前值」可推),於是 added party 的 action 全是 null;buildConfirmPayload._safeAction(null) → 'skip'(同 SspDocxImportPage.vue 內 ?? 'skip' 兜底),導致 confirm payload 5 筆全 'skip'。BE 收到後 _filter_parties_for_write 過濾掉 → strategy.write_parties 收到 0 筆 → DB 空。
為何 Phase 1+2 SUMMARY §8 L2 推測「FE diff stepper 預設 skip」沒兌現:當時推測在 Phase 4 順手 fix,但 Phase 4 L4~L6 + E option 全被優先排程,Bug B 一直被標為「caveat:請 user 手動 accept」迴避。User 在 Phase 4 完工後再次踩到才正式 handoff fix(見 handoff/2026-05-25-bug-b-and-parties-stepper-handoff.md)。
Fix:
// stores/sspDocxImportStore.js — helper 在 defineStore 上方
function _defaultPartyAction(party) {
if (party.default_action != null) return party.default_action
if (party.diff_status === 'added') return 'use_docx' // 新 party 預設加入
if (party.diff_status === 'gone') return 'keep_current' // 既有 link 預設不動
return 'skip' // conflict/changed/unchanged 要 user 確認
}
// initDefaultDecisions 內
decisions.parties[p.party_uid] = { action: _defaultPartyAction(p) }SspDocxImportPage.vue:559+ 同步把過時 comment(解釋為何 null fallback 'skip')改為說明 store 已 seed default。
為何 Option B 而非 Option A(全 added 也預設 skip):
Option A(保守,全 skip)會延續 Bug B 行為 — user 還是要手動 accept;Option B(按 diff_status 給不同 default)符合 BE strategy.write_parties 哲學(added → write / conflict → 由 user 拍板),UX 也避免 user 漏點某個 party。Option B 對既有資料安全 — conflict / changed 仍預設 skip,user 必須主動 accept 才覆寫。
測試結果:
7.21s,SspDocxImportPage chunk 75.25 kB)decision=use_docx ×5 → DB oscal.parties ≥ 5 筆教訓:
_filter_parties_for_write 邏輯,實際 root cause 在 FE。後續看到「BE 收到全 skip」要先反查 FE init logic。Commit: 本段 §11.22 + stores/sspDocxImportStore.js (_defaultPartyAction helper + initDefaultDecisions 改寫) + SspDocxImportPage.vue (過時 comment 更新)
症狀:與 §11.22 Bug B 同期暴露。BE party extractor(domain/oscal/parser/docx_section_extractors.py:318+ extract_party_tables)按 docs/features/FR-027-2605-docx-import-parity/design.md §4.1 規格 extract 5 欄位(name / title / address / telephone / email),但 FE PartyDiffCard.vue fields 陣列只 render 4 個(name / role / email / matched_user_id),title / address / telephone 三個欄位被丟掉,user 在 diff stepper 看到的 party info 不完整 → 沒理由 accept 預覽中的新 party。
為何 docx-import-parity Stage 1 ship 時沒發現:Stage 1 主要驗 BE parse_job.parsed_result JSONB 內欄位齊全(5 parties × 5 fields 都在),沒走完 FE diff preview path;diff 階段 BE annotate_parse_result 又把 name 替換成 null(既有 diff 行為),導致前面以為「BE 端漏資料」而沒回頭看 FE render schema。
Fix:
// PartyDiffCard.vue — fields 從 4 個擴 7 個
const fields = [
{ key: 'name', label: '姓名' },
{ key: 'title', label: '職稱' }, // ← new
{ key: 'role', label: '角色' },
{ key: 'email', label: 'Email' },
{ key: 'telephone', label: '電話' }, // ← new
{ key: 'address', label: '地址' }, // ← new
{ key: 'matched_user_id', label: '對應使用者' }
]
// formatValue 新增 telephone alias 處理(ParsedParty entity 用 telephone_number;raw extractor dict 用 telephone)
if (key === 'telephone') return raw || values.telephone_number || ''為何同時 handle 兩個 key alias:BE 兩條 path 序列化欄位名不同 — raw extractor dict 直接 telephone;ParsedParty entity 走 telephone_number。Mirror 既有 email 處理(email vs email_address)。
對 organization party 行為:title / telephone 對 org 多半空 → formatValue 回空字串 → template 已有 || '—' fallback render,顯示「—」一致表示空欄位,不破壞 layout。
測試結果:
title / telephone 顯示「—」教訓:
parse_job.parsed_result JSONB 原始欄位齊全(avoid 誤怪 extractor),再驗 FE render schema,最後才看 BE diff annotate logic。Commit: 本段 §11.23 + PartyDiffCard.vue (fields 擴展 + formatValue telephone alias)
_upsert_responsible_party 在空 role silent skip + docx adapter 漏補 default role症狀:§11.22 FE store fix ship 後,user 在 /module-frame/import-docx (create mode) retry,BE log 顯示 decision=use_docx ×5 + parties_written=5(看起來成功),但 mf template-edit 「責任單位 (0) / 責任人員 (0)」仍空。DB 驗證:
SELECT id FROM public.module_frames WHERE uid='fc51dd08-...'; -- id=367
SELECT count(*) FROM oscal.oscal_parties WHERE id BETWEEN 1266 AND 1270; -- 5 (寫了)
SELECT count(*) FROM oscal.oscal_responsible_parties
WHERE context_type='module_frame' AND context_id=367; -- 0 (沒建 link!)oscal_parties 寫成功 5 筆但 oscal_responsible_parties link 一筆都沒建 — write_parties 回傳的 parties_written=5 是只算 party row 寫成功的計數,沒驗 link 建立成功,是 silent-failure 計數,混淆 caller。
Root cause:
module_frame_write_strategy._upsert_responsible_party:662-663:
if not role_id or not party_uid:
return # ← role_id="" 直接 silent returncaller write_parties:530 傳 role_id=parsed.role or ""。parsed.role 來自 _dict_to_parsed_parties:1104 的 role=p.get("role")。docx party JSONB blob 由 domain/oscal/parser/docx_section_extractors._parse_party_table 抓出來,只 set name / title / address / telephone / email / party_type,沒 set role → p.get("role") 是 None → 整條 chain None → "" → silent skip。
為何 Excel 沒踩同一坑 / docx 漏修:
app/oscal/service/ssp_excel_import_app_service.py:1956-1968 註解清楚記錄 2026-05-21 已修同一 bug — Excel 02_單位 sheet 也沒 role 欄位,當時 fix 在 _build_parties adapter 補 role=(o.get("role") or "responsible-organization")。但 docx adapter (_dict_to_parsed_parties) 從沒套這個 fix — 大概是 Excel fix 時沒做 cross-source consistency 檢查(兩個 import path 各自獨立 adapt parser dict → ParsedParty,沒共用 helper)。
為何 §11.22 fix 不夠 — Bug B 其實有兩段:
| 段 | 位置 | 症狀 | 修法 | Commit |
|---|---|---|---|---|
| §11.22 | FE store initDefaultDecisions |
added parties decision=null → payload skip → BE 收 skip → _filter_parties_for_write 過濾掉 → 0 parties to write |
per-diff_status seed default action | f31d0cf |
| §11.24 | BE _dict_to_parsed_parties |
decision=use_docx 通過 §11.22 但 ParsedParty.role=None → _upsert_responsible_party silent skip → oscal_parties 寫 5 但 link 0 |
mirror Excel pattern 補 default role | 本 commit |
Fix:
# app/oscal/service/ssp_docx_import_app_service.py:_dict_to_parsed_parties
party_type = p.get("party_type", "person")
default_role = "responsible-organization" if party_type == "organization" else "system-user"
out.append(ParsedParty(
name=p.get("name", ""),
party_type=party_type,
role=p.get("role") or default_role,
...
))org 對齊 Excel pattern;person 給 'system-user'(OSCAL spec 認可 role-id 自由字串,FE ROLE_LABEL 可選擇後續補對應中文 label 或讓 user 在 template-edit 內手動改 role)。
為何選 caller-side fix(option 1)而非 extractor-side fix(option 2):
| Option | 位置 | Pro | Con |
|---|---|---|---|
| 1 (採用) | _dict_to_parsed_parties adapter |
mirror Excel 已 ship pattern;不動 extractor / JSONB blob shape;reconciler / diff service 不受影響 | 兩條 import path 各自 fallback,沒 share helper |
| 2 | docx_section_extractors._parse_party_table |
一處 fix,blob 內就有 role 下游一致 | 影響 JSONB blob shape,可能 break 既有 reconciler 對「無 role 欄位 = 該欄位 absent」的假設;retroactive 改既存 parse_job blob 也是 issue |
選 option 1 — minimum surface area,跟 Excel 一致。Cross-source 共用 helper 列入未來 cleanup task(非本期 scope)。
Bug B 整體教訓(§11.22 + §11.24 合計):
parties_written=5 騙了我們兩段時間 — 第一段以為 FE 沒送 use_docx;fix 完 FE 後第二段以為 BE 寫了但讀不到(誤判 GET endpoint bug)。最後 query DB 兩張表才看到 oscal_parties=5 + responsible_parties=0 的真實 split。計數 metric 要 echo 最終 side-effect,不能 stop 在 silent-skip 之前。if not X: return 是危險的 silent-skip pattern:_upsert_responsible_party 對空 role 直接 return 是邊界守門,但沒 raise/warn/log,caller 100% 沒辦法察覺。改 raise ValueError 或至少 log.warning 比較安全。本段 §11.24 fix 不動 silent-skip 本身(avoid scope creep),但列入 follow-up(jedi-oscal 進版時順手把 silent skip 改有 warning)。Commit: 本段 §11.24 + app/oscal/service/ssp_docx_import_app_service.py:_dict_to_parsed_parties (default role per party_type)
症狀:§11.24 Bug B 第二段 fix ship 後,user 用同份 docx (亞航-CMMC-SSP-20260520-1會議討論版.docx) 走「合規資源庫 → 新增 → 從文件建立」流程。FE Step 2 預覽顯示「外部利用服務 (4)」— 4 筆服務(MDR / Microsoft Windows Update / Fortinet / 印表機)。Confirm 後跳轉 template-edit page,「外部利用服務 (1)」tab 只顯示 1 筆 MDR,其他 3 筆消失。
DB 驗證(fix 前的 parse_job 146, mf 368, ssp 266):
SELECT jsonb_array_length(parsed_result->'leveraged_services'), -- 4
jsonb_array_length(parsed_result->'leveraged_authorizations') -- 1
FROM oscal.ssp_docx_parse_jobs WHERE id=146;
SELECT count(*) FROM oscal.ssp_leveraged_authorizations WHERE ssp_id=266;
-- 1 (預期 4)Root cause:domain/oscal/adapter/cmmc_ssp_adapter.py:adapt_to_bundle 把 docx 內兩種 leveraged 表分成不對稱的兩 path:
| Table | 內容 | 原 emit | 結果 |
|---|---|---|---|
| Table #6 (CSP/CSO 7-col) | FedRAMP service (provider / impact_level / fedramp_package_id) | ParsedLeveragedAuthorization + paired ParsedComponent |
LA 寫到 DB |
| Table #7 (Category 5-col) | 一般外部服務 / 互連 / API (category / protocol / port_ranges / security_auth) | ParsedComponent only — 無 LA |
LA 完全沒寫 |
User docx 4 筆 services 分佈:1 筆 T6 (MDR) + 3 筆 T7 (Microsoft Windows Update / Fortinet / 印表機)。Adapter 把 T6 → 1 LA,T7 → 0 LA,導致 confirm path 只有 1 筆 LA 可寫。但 user-facing「外部利用服務」tab 讀 oscal.ssp_leveraged_authorizations 表(module_frame_leveraged_service.list_items → SspLeveragedContextService._fetch_items),且該表的手動新增 UI 接受 category=external-service / interconnection / api — product 已採「廣義 leveraged_authorization」語意,包含 T6 + T7 兩種。Adapter 分流的 OSCAL 嚴格語意(LA = FedRAMP ATO 限定)跟 product 語意不一致,導致 docx import 寫入只覆蓋一半。
為何 Excel 沒踩同坑 / docx 漏修:
Excel 端 SHEET_LEVERAGED 每行 = 1 LA(1:1),沒有 T6/T7 拆兩表。docx 拆兩 table 是 docx 特有版型,從 Phase 2 Task 8 cmmc_ssp_adapter.adapt_to_bundle 一刀切到現在(commit ba0cdd7a)。Phase 4 Bug B fix Cross-source consistency 教訓(§11.24)談的是 party adapter;leveraged adapter 同樣 cross-source 不一致但被忽略到 user 踩。
Fix Options:
| Option | 改動 | Pro | Con |
|---|---|---|---|
| A(採用) | T7 也 emit LA(mirror T6 pattern)+ ParsedLeveragedAuthorization.props 新欄位帶 category / protocol / port_ranges / security_auth |
一處修,user 視角一次解;非 OSCAL 標準欄位透過 props JSONB 走,跟 product 既有 manual add UI 對齊 | LA dataclass 多 props 欄位(小成本);T7 LA 跟 T6 LA 語意混淆(但 product 已 accept 廣義語意) |
| B | FE 預覽「外部利用服務 (N)」改成讀 Component 集合 + 寫入時 component → LA 同時建 | OSCAL 語意純粹 | FE 動的多,跨 repo;跟 manual add UI 不對稱(manual add 寫 LA,import 寫 Component) |
| C | 只在 BE 把 Component / LA 兩個 source 在 GET 端 union 起來 | 寫入路徑不動 | 「外部利用服務」變成虛擬 view,刪除 / 編輯 UI 要走兩條 path,user-facing 體驗劣化 |
選 Option A — minimum surface area,跟 manual add UI 行為一致。Component pair 仍 emit(保留 OSCAL component 概念)+ Component 用 leveraged_authorization_ref=la.title 連回 LA(mirror T6 pattern),未來若要嚴格做 Component / LA 概念區分有 ref 可走。
Fix 改動範圍:
| 檔案 | 改動 |
|---|---|
domain/oscal/parser/ssp_intermediate.py |
ParsedLeveragedAuthorization 新增 props: Optional[Dict[str, Any]] = None |
domain/oscal/adapter/cmmc_ssp_adapter.py:adapt_to_bundle |
Table #7 loop 也 append ParsedLeveragedAuthorization(含 props={"category": ..., "purpose": ..., "protocol": ..., "port_ranges": ..., "security_auth": ...} 非空欄位),保留 Component pair 並補 leveraged_authorization_ref=title |
domain/oscal/import_pipeline/bundle_restore.py:_restore_la |
round-trip props=d.get("props") |
domain/oscal/service/write_strategy/leveraged_write_strategy.py:write |
merge parsed.props 進 entity props JSONB;OSCAL 標準欄位(fedramp_package_id / impact_level / data_types / nature_of_agreement / authorized_users)priority 高,於 props key collision 時不被覆蓋 |
tests/test_cmmc_ssp_adapter_v3_bundle.py |
更新 test_adapt_to_bundle_t7_category_creates_component_only → test_adapt_to_bundle_t7_category_creates_la_and_component;新增 test_adapt_to_bundle_t6_t7_mixed_yields_all_las 覆蓋 user docx 4 筆 regression |
tests/test_ssp_write_strategy.py |
新增 test_leveraged_write_merges_parsed_props 驗 OSCAL precedence;test_leveraged_write_props_only_yields_props 驗 T7 pure-props row 行為 |
驗證 (2026-05-25 E2E):
test_cmmc_ssp_adapter_v3_bundle + test_ssp_write_strategy + test_parsed_bundle_normalizer + test_ssp_intermediate_v2_bundle + test_ssp_docx_import_app_service + test_ssp_import_pipeline_smoke + test_a4_reconciliation_leveraged + test_a4_reconciliation_orchestrator)lev_auths=4 ✅(pre-fix 是 1)f489a145-47da-4506-8675-5cf45a6f218b (id=369, ssp_id=267) DB query:
id | title | category | protocol | fedramp | impact
21 | MDR、病毒碼與威脅情資同步更新 | | | FR18078583629 | moderate
22 | Microsoft Windows Update | service | HTTPS (Port 443) | |
23 | Fortinet | service | HTTPS (Port 443) | |
24 | 印表機 | interconnection | 有線網路 | |Bug D 教訓:
parsed_result.leveraged_services,BE confirm 寫的是 parsed_result.leveraged_authorizations — 兩 key 同來源 parsed_result 但 data source 不同。Confirm 前後 user 看到的數字應該一致,pre-confirm 預覽計數應該對應實際 confirm 後寫到 DB 的計數。下次 designer 在 parsed_result 裡放兩個語意接近的 list 必須附 cross-check。leveraged_authorization 規範是 FedRAMP / agency MOU 等正式授權,product 「外部利用服務」tab 採廣義(含 printer / interconnection)— 兩者不一致時優先 product 語意(user-facing concept = single source of truth),OSCAL 純粹語意走 props JSONB 容納例外。長期看若要拉回 OSCAL spec,得改 product UI 語意(拆兩個 tab:「FedRAMP 授權」+「外部連線元件」),但非本期 scope。ssp_leveraged_authorizations WHERE ssp_id=267 確認 4 筆而非看 BE log written_las 計數,避免重蹈 Bug B 兩段時間追錯方向的覆轍。為何 §11.22~§11.24 Bug B fix 沒順手解 Bug D:
Bug B 是「parties 0 筆」問題(party adapter chain),Bug D 是「leveraged 4 → 1」問題(leveraged adapter T6/T7 拆分),雖然 user 觀感都是「docx 寫的 N 筆 → 看到 M 筆 (M < N)」但 adapter path 完全不同(_dict_to_parsed_parties vs adapt_to_bundle.parsed_las)。Bug B 修 party path,Bug D 修 leveraged path,沒重疊。但兩者都源於 cross-source / cross-tab adapter consistency 缺檢查 — Bug D 教訓 1 直接呼應 Bug B 教訓 2。
Commit: 本段 §11.25 + domain/oscal/parser/ssp_intermediate.py (ParsedLeveragedAuthorization.props) + domain/oscal/adapter/cmmc_ssp_adapter.py (T7 emit LA) + domain/oscal/import_pipeline/bundle_restore.py (round-trip props) + domain/oscal/service/write_strategy/leveraged_write_strategy.py (merge props) + tests
症狀 (2026-05-25 晚,user 手測 §11.25 ship 後):
| 子 bug | 描述 |
|---|---|
| H-1 | org party label 散落「責任單位 / 單位 / 單位名稱」3 寫法 |
| H-2 | org party preview card / DataTable / Dialog 三處缺電話 + 地址 (BE parsed_result.parties JSONB 已含 telephone_number + address,純 FE 沒接) |
| H-3 | person party label 散落「責任人員 / 人員」2 寫法 |
| H-4 | person party 同 H-2 缺電話 + 地址 |
Root cause:preview (PartiesSection.vue) / template-edit (ModuleFramePartiesPanel.vue) / diff stepper (PartyDiffCard.vue) 三元件 render 同 5 個 party 欄位但獨立維護。§11.23 Bug B Issue 1 只 fix diff stepper,其他兩元件留缺。
Fix scope (純 FE):
src/config/locales/i18n/zh-tw/ssp-docx-import.json — parties_organization_section_title「單位」→「負責單位」、parties_person_section_title「人員」→「參與人員」src/config/locales/i18n/zh-tw/module-frame.json — tab_responsible_org「責任單位」→「負責單位」、tab_responsible_person「責任人員」→「參與人員」src/components/grc/ssp-docx-import-v2/sections/PartiesSection.vue — ROLE_LABEL.responsible-organization「責任單位」→「負責單位」;org card + person card 各 append 2 個 <div>(電話 + 地址 InputText)src/components/grc/ModuleFramePartiesPanel.vue — DataTable column header「單位名稱」→「負責單位名稱」;DataTable append 2 <Column>(電話 + 地址);Dialog append 2 input(電話 + 地址,org + person 都顯示)驗證 (2026-05-25 E2E):
Bug H 教訓:
t() 呼叫 0 動,CodeMod cost 0Commit: 本段 §11.26 + 4 FE 檔(純 FE,BE 不動)
症狀:Bug H-4 fix 後 preview 出現「電子郵件」欄位但全空 — user 預期看到 info@sncorp.com / hmtseng@airasia.com.tw 等 4 個 person email。
實證:9 個歷史 parse_job (140-148) 全部 email_filled=0,從 2026-05-23 第一天就有的 bug,不是 regression — 借 Bug H-4 把 email input 顯式 render 後才被 user 看見。
Root cause:domain/oscal/parser/docx_section_extractors.py:_PARTY_LABEL_MAP substring matching ordering bug:
'emailaddress'_parse_party_table line 290-293 用 substring match (if pat in norm: ...; break),map 內 'address' 排在 'email' / 'emailaddress' 之前'address' in 'emailaddress' → True → 先匹中 'address' → mis-classify 成 address fieldparty['address'] → email value 被 line 297 if value_text and matched_field not in party 條件 silently droppedFix:
domain/oscal/parser/docx_section_extractors.py — 重排 _PARTY_LABEL_MAP 把 email patterns 放在 address patterns 之前 + 加註解警告tests/test_docx_section_extractors.py — 收緊現有 weak email test(「any field 含 @」→「email field 含 @」),加 substring collision regression test (test_parties_email_address_label_not_mis_classified_as_address)驗證:parse_job 153 (fix 後新 import) email_filled=4(vs fix 前 9 個 parse_job 全 0);preview 5 card 4 person email 全 render;168 pytest passed 含新 regression
Bug H-5 教訓:
'address' 偷 'emailaddress')。Weak test「any field 含 @」太鬆讓 bug 滑過;強 test 必精確指定 expected fieldCommit: 本段 §11.27 + BE 2 檔(parser + regression test)
症狀:mf 372 template-edit「負責單位 (1)」tab 顯示屏東飛機維修廠 鉤稽到「System Administration Department」— user 沒手動鉤稽過。
Root cause:jedi_common/session/database/db_mw.py:set_tenant_info_before_insert 在 SQLAlchemy before_flush event hook 對所有 session.new 自動填:
if hasattr(obj, "org_unit_id") and getattr(obj, "org_unit_id", None) is None:
obj.org_unit_id = user_ctx.org_unit_id # admin = 98設計上是 tenant scope 用途,但 oscal_parties / ssp_leveraged_authorizations / ssp_components / ssp_inventory_items 的 org_unit_id 是業務語意「鉤稽到哪個部門」,兩種語意衝突。
影響範圍 (fix 前 DB 證據):
org_unit_id=98(person 根本不該有 org_unit_id — UI 用 matched_user_id 鉤稽)org_unit_id IN (98, 99)org_unit_id IN (98, 99)Fix (Option D — opt-out flag,跨 jedi-common + jedi-oscal 兩套件):
jedi_common/session/database/db_mw.py — set_tenant_info_before_insert 加 __skip_auto_org_unit__ class attribute check(其他 model 行為不變、保留 tenant_id auto-fill 不影響 RLS)jedi_oscal/infra/model/base/oscal_party.py + oscal_leveraged_authorization.py + oscal_component.py + oscal_inventory_item.py — 4 個 model 設 __skip_auto_org_unit__ = Truepyproject.toml — jedi-common 切 path dep(jedi-oscal 已是 path dep)髒資料清理:單 transaction 把 4 表全部 org_unit_id IS NOT NULL 清成 NULL — 467 row(37 party + 36 LA + 391 component + 3 inventory)。理由:所有非 NULL 值都 IN (98, 99) = 系統 admin/IT dept 自動填,無一筆是 user 真正手動鉤稽過的。
驗證:mf 372 template-edit 兩 tab 所有 row「未鉤稽」(fix 前顯示「System Administration Department」假鉤稽)
Bug H-6 教訓:
set_tenant_info_before_insert 把 org_unit_id 當 tenant scope 自動填(對大多數 model 正確),但對 OSCAL 把 org_unit_id 當「鉤稽到的部門」的 model 是災難。Hook 不能假設「同名欄位 = 同語意」。Opt-out flag (__skip_auto_org_unit__) 是正確設計(caller-driven,不破壞 hook 原意)org_unit_id 鉤稽(用 user_id),org 也沒人特地檢查;借 Bug H-4 把鉤稽欄完整顯示才浮出。「user 看不到的 bug 不是沒 bug,只是暫時沒人查」feedback_jedi_package_modify_allowed:「正確修改 > 最小變更」,root cause 在 jedi-common shared infra 就在那改,不該為了「不動套件」在主專案弄 wrapper / sidecarCommit: 本段 §11.28 + jedi-common 1 檔 + jedi-oscal 4 檔 + BE pyproject.toml + DB cleanup SQL (467 row)
症狀 (user 反映「外部利用服務」3 子 bug):
ssp_leveraged_authorizations,FE 一個 DataTable 同 column 結構 → T6 row 看「類別/協定」空、T7 row 看「FedRAMP/影響等級」空 → user「欄位對不起來」Root cause:Bug D §11.25 short-term tactical fix 為了「user-facing 4 → 1 missing」走 Option A 把 T7 也 emit LA,違反 §1 原設計(T7 該是 <component>、T6 才是 <leveraged-authorization>)。§11.25 教訓 3 line 1658 明說「長期看要拆兩 tab」但當時 deferred;Phase 4 ship 又走過渡 dual TabPanel(設備 / 資訊系統 / 外部利用服務 + 新「元件清冊」並存)— user 看到 4 個 tab 內容重複 + 欄位混亂。
Fix scope (跨 BE + FE,user 拍板「順手收 Phase 4 過渡 dual tabs」):
| Phase | 改動 |
|---|---|
| A — BE adapter revert | domain/oscal/adapter/cmmc_ssp_adapter.py:adapt_to_bundle — Table #7 不再 emit LA、只 emit ParsedComponent(含 props.protocol/security_auth/purpose/component_type);T6 不動(仍 emit LA + paired Component) |
| B — FE 廢棄 3 TabPanel | src/views/module_frame/ModuleFrameTemplateEditView.vue — 刪除「設備 / 資訊系統 / 外部利用服務」3 TabPanel + 對應 import + reactive state;保留「元件清冊」(用既有 SspComponentsLeveragedInventoryTab.vue 含 3 sub-panel) |
| C — FE preview 拆兩 section | src/components/grc/ssp-docx-import-v2/sections/LeveragedSection.vue — 拆「利用授權」(T6 / FedRAMP fields) + 「外部元件 / 互連」(T7 / OSCAL component_type 14 enum) |
| D — DB cleanup | scripts/sql/2026-05-25-bug-i-revert-t7-la.sql — 刪 mf 372 + 373 各 3 個 T7 LA row(剩 1 T6 MDR),T7 對應 Component pair 已存於 ssp_components 含 props.protocol/security_auth |
| Tests | tests/test_cmmc_ssp_adapter_v3_bundle.py — 改 test_adapt_to_bundle_t6_t7_mixed_yields_all_las → test_adapt_to_bundle_t6_only_emits_la_t7_only_component;保留 user docx 4 筆 regression 但 expected: 1 LA (T6) + 4 Component |
Bug I 教訓:
SspComponentsLeveragedInventoryTab.vue 含 3 sub-panel 早在 Phase 4 設計 ship,但 implementation 走過渡 dual 並存 → 後續才補。設計元件齊全時應 commit 切過去,避免過渡狀態長期積累Commit: 本段 §11.29 + BE adapter + FE 4 檔 (view + LeveragedSection + 2 i18n) + DB cleanup SQL
module_frame_template_ssp_app_service silent AttributeError 導致 template SSP uid 永遠 null症狀:FE 「元件清冊」tab 永遠顯示「尚未匯入任何 docx/Excel」hint,即使該 MF 已 import 過。
Root cause:app/oscal/service/module_frame_template_ssp_app_service.py:73 呼叫 self._ssp.get_by_id(ssp_id) — 但 SspService (jedi-oscal app/services/ssp/ssp_service.py:54) 的 method 名稱是 get_ssp_by_id,不是 get_by_id。try/except: pass 默吞 AttributeError → ssp_uid 永遠 None → FE check !templateSspExists || !templateSspUid 走 hint 分支。Phase 4 E3 ship 以來從沒人見過真正的 panel。
Fix:改 method 呼叫名 + 加註解標 Bug J 來源。
Bug J 教訓:
try/except: pass 默吞 caller 錯誤是 anti-pattern — silent failure 讓 bug 隱形數週。即使要 graceful fallback,至少要 log.warning 或 log.exception 留 traceCommit: 本段 §11.30 + app/oscal/service/module_frame_template_ssp_app_service.py (get_by_id → get_ssp_by_id)
症狀:Bug J 修完後 FE 拿到 template ssp_uid,開始呼叫 SSP CRUD endpoints,全 404:
GET /api/1.0/ssp/<template_ssp_uid>/components → 404 GRC_404015 稽核計畫不存在
GET /api/1.0/ssp/<template_ssp_uid>/leveraged → 404 同上
GET /api/1.0/ssp/<template_ssp_uid>/inventory-items → 404 同上
Root cause:3 個 SSP CRUD endpoint 用 SspProjectResolver.resolve(ssp_uid) 做 permission 入口,該 resolver 內部 (line 89-91) 強制要求 SSP 有對應 AssessmentPlan 記錄 — 不然拋 GRC_AP_NOT_FOUND:
init_ap_controls 建立 AP → resolver pass設計層意義 (user 拍板長期方向):MF 是 routing wrapper,所有資料 source-of-truth 在 SSP;未來新增任何 OSCAL sub-resource,FE 統一掛 SSP endpoint + ssp_uid 即可,不該為每個 SSP 概念寫一份 MF mirror endpoint。
Fix scope:
| Phase | 改動 |
|---|---|
| A — 新 resolver | domain/oscal/service/ssp_context_resolver.py 新檔 — SspContextResolver.resolve(ssp_uid) 識別 SSP 屬 project 還是 module_frame template(reverse query: ssp → profile_id → module_frames.oscal_profile_uid),回 SspContext(含 context_type + ssp + project/ap 或 module_frame 對應 fields) |
| B — Permission 雙模式 | common/middleware/permission/ssp_permission.py — 新增 require_read_access / require_write_access,project SSP 沿用既有 participant gate;template SSP 走 mf-level permission(dev 階段 is_admin,未來考慮 mf owner role)。保留既有 require_participant / require_manager 供漸進 migrate |
| C — 3 endpoint 改用新 method | app/oscal/service/ssp_components_app_service.py / ssp_inventory_items_app_service.py / ssp_leveraged_app_service.py — sed-rename 改用 require_read_access / require_write_access |
| DI | di_containers/oscal/oscal_containers.py — wire SspContextResolver 為 SspPermissionChecker 新 dep |
| Tab rename | i18n/{zh-tw,en}/ssp-components-leveraged-inventory.json — tab header 加新 i18n key tab_header「系統元件與資產」(避開既有 section_components 跟 sub-panel 1 重名);對應 view 改 t() 呼叫 |
| Tests fix | tests/test_ssp_leveraged_app_service.py — sed-rename mocks require_participant/require_manager → require_read_access/require_write_access (5 fails 修,168 pytest passed) |
Edge case 發現:SystemSecurityPlanEntity.template_module_frame_id 雖然 entity 有此 field 但 Phase 4 E3 ship 時沒 wire(DB column 100% null)— 改走「ssp.profile_id → module_frames.oscal_profile_uid」反向 query。Future fix 可一併補 entity field + 加 fallback。
Pre-existing fails surface:tests/test_ssp_resources_app_service.py 2 + tests/test_ssp_system_characteristic_app_service.py 2 — 我沒動這 2 service,是 pre-existing 不關 K1,列為 future cleanup item。
驗證:mf 372 (2c4ba0b8-...) template-edit「系統元件、外部授權與資產」tab 3 sub-panel 全 render、0 console errors、0 SSP endpoint 404
Bug K 教訓:
SspProjectResolver 隱式假設「所有 SSP 都屬 project」、template SSP 一律 404。新 SspContextResolver explicit 兩 mode + caller-side 走對應 policy,未來新增第三 context(如系統共用 template)也容易擴Commit: 本段 §11.31 + 新 resolver + permission 改 + 3 service + DI wire + i18n + tests
症狀:
Root cause:
L-1:SspComponentsLeveragedInventoryTab.vue:612-660 利用授權 sub-panel 用 <article> Card grid,跟另兩 sub-panel 不對稱。
L-2:兩層問題:
SspDocxImportPage.vue:606 confirm payload content_overrides: draft.controlOverrides — 完全沒包含 draft.leveraged_services 編輯_apply_v2_bundle_overrides 的 _V2_BUNDLE_ROW_KEYS = (components, leveraged_authorizations, inventory_items, parties) — 沒 leveraged_services v2 key(v2 legacy,BE 寫入只看 v3)viewModel.leveraged_services (v2),跟 BE 寫入 path (leveraged_authorizations + components v3 keys) 完全不同 source → user 編輯 v2 list 對 BE 寫入無效Fix scope (Option a — FE 改寫 v3 keys,BE 不動):
| 子 | 改動 |
|---|---|
| L-1 Card → Table | SspComponentsLeveragedInventoryTab.vue — 利用授權 sub-panel 改 <DataTable>(含 服務名稱 / 提供者 / FedRAMP Package ID / 影響等級 / 資料類型 / 授權日期 / 關聯元件數 / 操作 8 column) |
| L-2 LeveragedSection 改 props/emits | LeveragedSection.vue — 改 props 收 leveraged-authorizations (T6) + components (T7 filter !leveraged_authorization_ref);改 emits update-la + update-comp |
| L-2 SspDocxImportPage 改 viewModel + payload | viewModel 加 v3 keys; confirm payload 用 _buildIdxOverrides helper 把 draft 跟 base 比對轉成 BE 期望的 {"<idx>": {field: value}} shape 餵進 content_overrides.leveraged_authorizations + content_overrides.components |
| Sub-panel rename (順手) | 3 i18n key + tab header — user 經 Word docx 對照拍板:「系統元件、外部授權與資產」(tab) / 「系統依賴元件」(sub-panel 1 = OSCAL component) / 「外部正式授權服務」(sub-panel 2 = OSCAL <leveraged-authorization> / FedRAMP) / 「資產清冊」(sub-panel 3 不動) |
| Add button outlined (user 規範) | 3 個新增按鈕全加 outlined prop;同步加 FE CLAUDE.md「Add / Create Action Button Style」section 寫死規範 |
Bug L 教訓:
leveraged_services) 跟 v3 bundle keys (leveraged_authorizations + components) 共存時,FE 編輯哪個直接決定能不能寫入。Phase 4 v2 → v3 transition 沒清掉 v2 wire-up 是 latent bugLeveraged External Systems and Services 利用的外部系統與服務) + 業務語意(「外部正式授權」vs「系統依賴元件」)對齊比 OSCAL spec 直譯(「Leveraged Authorization / Component」)更貼 user 心智模型Commit: 本段 §11.32 + SspComponentsLeveragedInventoryTab.vue (Card→Table) + LeveragedSection.vue (改 v3 props/emits) + SspDocxImportPage.vue (viewModel v3 keys + idx overrides build) + 2 i18n locale (rename + add labels) + FE CLAUDE.md (新增按鈕 outlined 規範)
症狀:user 完成 docx import 後進 mf template-edit「受評標的」tab,所有欄位(範圍名稱 / 識別碼 / 安全分類等級 / 描述 / 授權邊界 / ...)全空(或顯示 SSP shell default fallback),即使 user docx Introduction H1 段落有 inline value(如「System Categorization: 「 Moderate 」Impact for Confidentiality」)。
Root cause(三層):
| 層 | 缺漏 |
|---|---|
| BE extractor | extract_system_characteristic_from_metadata 只抽 Table #0 organization_name,沒解 Introduction H1 段落內的 "System Name/Title:" / "System Categorization:" / "System Unique Identifier:" / "General Description/Purpose:" 等 label-value paragraph |
| BE adapter wire | cmmc_ssp_adapter.adapt_to_bundle line 226-232 只 wire system_name + security_sensitivity_level 進 ParsedSystemCharacteristic;缺 system_identifier + description 對應 |
| BE docx confirm path | system_characteristic_write_strategy 只被 Excel import 接 (ssp_excel_import_app_service.py:1693-1708),docx import 從沒呼叫 — ssp_docx_import_app_service __init__ 沒 system_characteristic_write_strategy param,confirm path _run_v2_bundle_confirm 沒 wire write call |
| BE write strategy filter | _PARSED_TO_ENTITY_FIELDS 排除 description + system_identifier(comment「Excel sheet 沒這兩個 column」),即便 parsed_sc 有也被丟掉 |
Fix scope (跨 BE 3 phase + FE 2 phase):
| Phase | 改動 |
|---|---|
| M-A 1 — Extractor enhance | domain/oscal/parser/docx_section_extractors.py 新增 _extract_introduction_fields() paragraph walker + _normalize_categorization() + _is_sc_placeholder() filter + _SC_LABEL_MAP;extract_system_characteristic_from_metadata 合併 intro_fields 進 result。設計選擇:只接 inline value(label: value 同行),不做「label-only 段 + 下一段為 value」cross-paragraph lookup — 避免把下一個 section heading(如「Responsible Organization 專案負責單位」)誤抓為 value |
| M-A 2 — Dataclass field | ParsedSystemCharacteristic 加 system_identifier + description 兩 optional field |
| M-A 3 — Adapter wire | cmmc_ssp_adapter.adapt_to_bundle 把新 fields 進 parsed_sc |
| M-B — FE preview section | 新 src/components/grc/ssp-docx-import-v2/sections/SystemCharacteristicSection.vue(含 範圍名稱 / 識別碼 / 安全分類等級 Dropdown / 描述 Textarea 4 欄位);SspDocxImportPage.vue 加 「受評標的」TabPanel + viewModel 加 system_characteristic + draft.system_characteristic + confirm payload content_overrides.system_characteristic (shallow merge) |
| M-C — docx confirm 接 SC write | ssp_docx_import_app_service.__init__ 加 system_characteristic_write_strategy param + helper _dict_to_parsed_system_characteristic (mirror Excel);_run_v2_bundle_confirm 結尾呼叫 sc write strategy;DI container wire |
| M-C fix — write strategy filter | system_characteristic_write_strategy._PARSED_TO_ENTITY_FIELDS 加 description + system_identifier mapping(原 comment「Excel sheet 沒這兩個 column」過時 — docx 有抽到) |
順手做的兩件 polish(user 反饋):
pi-info-circle、只保留 pi-exclamation-circle warning(且改文字之前更顯眼)驗證(亞航 docx):
security_sensitivity_level: 'moderate' + description 1791 chars ✅Bug M 教訓:
system_characteristic_write_strategy 設計時注解「Excel sheet 沒 description / system_identifier」就一刀切排除,沒考慮 docx 也走同 strategy。Field filter 應該對 parsed 內容開放,而不是限制能來自哪種 sourcereuse 概念要 verify end-to-end」Commit: 本段 §11.33 + BE 4 檔(parser + dataclass + adapter + write strategy + app service + DI)+ FE 2 檔(新 section + page wiring)+ 順手 polish 1 檔(SspDocxImportPage tab header icon)
狀態:本段 spec 由 2026-05-25 收尾 session 寫,code 改動由下個 session 接手(per CLAUDE.md「換 session」mode + handoff docs/features/FR-028-2605-ssp-oscal-alignment/handoff/2026-05-25-bug-o-diff-stepper-expansion-handoff.md)。理由:scope 大(跨 BE + FE,~10+ 檔)、跟 H-N batch commit 解耦比較乾淨、避免 context 越堆越混亂。
症狀:user 對既有 mf 重跑 docx import (update mode) 進 diff stepper(DiffResolutionStep)只看到 controls / objectives / parties 3 種 diff sections,缺:
跳過 diff 後 confirm,docx 端值會直接覆寫既有 SSP 內這 4 種資料(user 已手動編輯過的內容會被 silently overwritten),且 user 無從 preview 差異。
並且 user 反饋 diff stepper UI 全部 sub-section stack 上去太長,要改 TabView 模式(mirror template-edit 內 sub-panel)。
Root cause:
ssp_docx_diff_service.annotate_parse_result 只跑 matched_controls + parties 兩 source;build_diff_summary 只算 controls / objectives / parties 3 keyuseSspDocxImportStore.decisions 只有 controls + parties 兩 sub-mapControlDiffSection.vue + PartiesDiffSection.vue整段 chain 從 BE diff annotation → FE store → FE UI → BE confirm 過濾 decisions 都沒 cover v3 bundle keys (components / leveraged_authorizations / inventory_items / system_characteristic)。
Fix scope (Option A 全套):
| Phase | 改動 |
|---|---|
| O-A: BE diff service 擴展 | app/oscal/service/ssp_docx_diff_service.py:- annotate_parse_result 加 4 個新 annotation loop(mirror parties pattern:current vs parsed 比對、產 diff_status ∈ unchanged/changed/added/gone + default_action ∈ use_docx/keep_current)- build_diff_summary 加對應 4 key counts- confirm_import validation:_validate_decisions 加 4 個新 decision list 對應 schema |
| O-B: BE confirm 接 4 個新 decisions | ssp_docx_import_app_service.confirm_import 收 payload components_decisions / leveraged_authorizations_decisions / inventory_items_decisions / system_characteristic_decision (單筆) → 改 _apply_v2_bundle_overrides filter(decision=skip 不寫;decision=keep_current 拿 current value 覆蓋 parsed;decision=use_docx 用 parsed 值) |
| O-C: FE diff store 擴展 | src/stores/sspDocxImportStore.js:- decisions 加 components / leveraged_authorizations / inventory_items / system_characteristic 4 sub-map- initDefaultDecisions 加對應 init logic(per-diff_status default action)- buildConfirmPayload 加對應 decision list 進 payload- diffSummary getter 加新 key surface |
| O-D: FE 4 個新 diff section 元件 | 新檔(mirror PartyDiffCard / ControlDiffCard pattern): - SystemCharacteristicDiffSection.vue(單筆 diff card,含 4 fields)- ComponentsDiffSection.vue(list of ComponentDiffCard,含 component_type / title / ref / props)- LeveragedAuthorizationsDiffSection.vue(list of LaDiffCard,含 FedRAMP fields)- InventoryItemsDiffSection.vue(list of InventoryDiffCard,含 asset_id / IP / MAC / impl_components) |
| O-E: DiffResolutionStep TabView refactor | DiffResolutionStep.vue 把既有 2 section + 新 4 section 全包進 <TabView>;每 tab header 顯示對應 changed/added/gone badge;無 diff 的 tab disable 或隱藏;step description 更新 |
| O-F: Tests | BE test_ssp_docx_diff_service 加 4 key annotation + summary test cases;FE store 對應 unit test |
Cross-references for handoff:
PartyDiffCard.vue / ControlDiffCard.vue 抄結構ssp_docx_diff_service._annotate_party line 130~ 抄 current vs parsed compare logicssp_components_app_service / ssp_inventory_items_app_service / ssp_leveraged_app_service(已支援 idx-based override per Bug L Phase C)system_characteristic_write_strategy.py(per Bug M-C 已寫 docx import path)不在 scope(per O 收尾界線):
leveraged_services key — 已 Bug L 廢棄,不在 O 重啟Bug O 教訓 (2026-05-25 完工後補):
Annotated entry 跟 raw shape 不能共用 dict key — Phase O-A subagent 把 annotated entry list(含 row_uid / current_values / parsed_values)直接覆寫 parsed_result["components"] 等 raw list,破壞 Bug L Step 3 預覽 UI(讀 raw ParsedComponent .title / .provider)→ 顯示「(未命名)」+ 兩邊空。SC 從 day-1 就走獨立 key (system_characteristic_diff),3 list 沒比照辦理。正解:annotated 永遠用 <key>_diff 獨立 suffix,raw list 不動。Reviewer 端要 read 既有 caller 行為對 raw shape 的假設才能評估能否覆寫。
同一 annotate function 有多個 call site 時必須全部 wire 同樣 param — Phase O-B 只把 _load_current_v3_lists + 6 個 kwargs wire 進 confirm_import(line 593),漏掉 get_parse_result(line 515)。結果 FE GET 拿 diff 時 BE 只 annotate parties,4 個 v3 key 仍 raw → FE 拿到沒 row_uid 的 entry → diff stepper 顯示 (未命名) + 兩邊空。正解:grep 所有 annotate_parse_result( call site,逐一驗 param 對齊;plan 應寫「先 grep 出所有 call site」才 wire。
FE store 三處(state / getter / initDefaultDecisions / setter)key 命名要 1:1 對齊 — store getter 改 <key>_diff 後,initDefaultDecisions 內三個 for-of 仍讀 raw key → decisions.components map 永遠空 → bulkApply 的 Object.keys(map) 跑 0 次 → 「全部採用新值」按鈕視覺無變化。正解:FE store 改 key 時 grep 該 key 所有 read 位置統一改;review 時三段(state init / getter / action)必同步檢查。
「對齊既有 UI 結構」優先於「設計 spec naming」 — Bug O spec 內 4 tab 命名「系統依賴元件 / 外部正式授權服務 / 資產清冊 / 受評標的」是直譯 OSCAL — user 提出對齊 template-edit 既有 tab「系統元件、外部授權與資產」會更直覺。Step 3 預覽 tab 同樣 (外部利用服務 (N) → 系統元件與外部授權服務 (N))。正解:plan 階段先把 tab name 候選給 user pick;spec 命名只是 default。
diff_summary banner wording 不能漏中文化 + 不能漏新 key — 既有 banner Parties:5 已修改 用 raw OSCAL term,user 看不懂;且 Bug O 加 4 個 v3 key 後 banner 還只 surface 3 key (controls + objectives + parties)。正解:擴展 banner 時順手把所有 section label 中文化 + 補 v3 4 key(共 7 key)。
Commit clause:
a0408de2 Phase A — diff service 4 key annotation + summarybedbb91c Phase B — confirm decisions filter(overwrite caveat)b0032e46 Bug O regression fix — 4 v3 list 用 <key>_diff 獨立 key + GET 路徑 wire4c5ff7d Phase C — store decisions schema7e34aeb Phase D — 4 DiffSection + 3 DiffCard061a742 Phase E — DiffResolutionStep TabView refactor5f6458b regression fix + UI 微調(store _diff 對齊 / banner i18n / Step 3 確認按鈕綠色 / tab name 對齊 template-edit)症狀:對既有 mf 重 docx import → Step 3 預覽 → 「參與人員與單位」tab → organization party row → 點「連結帳號」按鈕跳出「連結到組織單位」dialog,搜不到 "屏東飛機維修廠" 顯示「查無對應」→ user 認知為 Error。
Root cause:
PartiesSection.vue org row 跟 person row 共用同個 i18n key party_link_button = 「連結帳號」(org 應該是「連結組織單位」)PartyLinkDialog.vue 內部 type 分流邏輯 本來就 OK(line 25-28 watch + line 45-66 runSearch + line 74-80 onSelect + line 91-93 dialog title 都已對 party_type 分流到 ORG_UNITS / USERS)— 問題只在 PartiesSection 那層的 button label 漏跟著分流pi-link,沒對 type 分(人 vs 建築)Fix:
PartiesSection.vue org row button :label 改用 party_link_button_org / party_relink_button_org,icon 改 pi-buildingPartiesSection.vue person row button :label 改用 party_link_button_user / party_relink_button_user,icon 改 pi-userparty_link_button_user/_org、party_relink_button_user/_org、role_select_placeholderparty_link_button / party_relink_button key 不刪(避免其他元件 reference 斷裂)Bug P 教訓:
Commit clause:
fa43b79 fix(ssp-oscal-alignment): Bug Q + Bug P — PartiesSection 補 role Dropdown + button wording 對 party_type 分流症狀:對 docx import Step 3 預覽 → 「參與人員與單位」tab → person / organization row → 「角色」是 read-only 文字,不能改。User 反映「原本好像有 dropdown」。
Root cause:
PartiesSection.vue 自初版 commit 47e9489 (feat(ssp-docx): v2 Phase 6-10) 起就只有 hardcode 的 ROLE_LABEL map(5 個 entry:responsible-organization / information-provider / information-receiver / system-owner / system-security-officer)+ <span>{{ ROLE_LABEL[p.role] || p.role || '—' }}</span> read-only label,從來沒有過 DropdownModuleFramePartiesPanel.vue:438-446 — 那邊早就有完整 Dropdown + menuStore.sspPartyRoleMenu (9 role keys, C1 system_menus 'ssp_party_role')Fix:
PartiesSection.vue 刪掉 hardcoded ROLE_LABEL mapmenuStore.sspPartyRoleMenu + roleLabel(key) fn(沿用 ModuleFramePartiesPanel pattern:i18n key lang.oscal_role.ssp_party_role.<key>.label 命中翻譯,沒命中 fallback raw key)onMounted(() => menuStore.fetchSspPartyRoleMenu())(menuStore 已內建 cache + 並發合併,同頁多 panel 共用同份)keep_current)時維持 read-only label,編輯模式給 Dropdown(v-model 走 onFieldEdit(idx, 'role', v) emit 給父 update)role_select_placeholder「(選擇角色)」Bug Q 教訓:
feedback_plan_vs_reality_verify_first 已記錄。本 Bug Q 一開始假設「H-N refactor 移除 role dropdown」是 regression,git log 一查發現是 from day-1 就缺。沒先 verify 就動手追 regression history 會浪費時間Commit clause:
fa43b79 (同 §11.35)症狀:對既有 mf 重 docx import → 改 party name → Step 3 確認匯入 → 成功 router push 回 template-edit page → 「責任單位 / 責任人員」tab 顯示舊 name,需手動 reload 才看到新值。
Root cause(雙重 verify):
[ssp-confirm] calling ModuleFrameWriteStrategy.write_parties with 5 parties... [ssp-confirm] strategy.write_parties returned parties_written=5oscal.ssp_docx_parse_jobs.import_summary 顯示 parties_written: 5、parties_unlinked: 0useModuleFrameParties.js 注解 line 8-12 明說「2026-05-21:原本 5s TTL 造成 panel 重 mount → cache 過期 → 重抓 迴圈,改成永久 cache(依使用情境 — parties 唯一 mutator 是本元件)」useSspParties.js (C5 phase) 也是 module-scope 永久 cache + 並發合併invalidate() — docx import 這條 「外部寫入後 router.push 回 template-edit」path 從沒呼叫 invalidateSspDocxImportPage.vue:649-691 confirm fn 寫完只做 draftRef.clear(parseUid) + parseUid=null + router.push(...),沒 invalidate parties cacheloadParties cache hit 拿到 stale data → user 看不到新值Fix:
SspDocxImportPage.vue import 加 useModuleFrameParties + useSspPartiesdraftRef.clear() 之後、router.push 之前加:
update-mf / create mode → invalidateMfParties(targetMfUid)update-ssp mode → invalidateSspParties(targetSspUid)Bug R 教訓:
feedback_verify_db_state_before_writing_fixes)。本期 BE log + import_summary 直接確認 BE 寫 OK 0 ambiguity,省一輪追錯useModuleFrameParties + useSspParties(per ModuleFramePartiesPanel 註釋 line 7:「Phase C/B (2026-05-23) 加 apiBase prop 後同元件服務 MF + SSP 兩個 context」),fix 兩 mode 都要 invalidate 對的那份Commit clause:
551c7dc fix(ssp-oscal-alignment): Bug R — confirm 後 invalidate ModuleFrame / SSP parties cachereproduce Bug P 時意外發現:對既有 mf 重 docx import 上傳 bug-o-e2e-fixture.docx 時,BE 回 200 但 ssp_docx_parse_jobs.id=172 status=failed,log 顯示「docx revision normalize skipped: Package not found at '/var/folders/.../tmpm2kuaosj.docx'」— jedi-oscal docx revision normalize 路徑下游 tmp file 已被刪卻仍引用。
不在本期 P/Q/R scope(純 jedi-oscal 內部 bug,跟 parties form 無關),但擋住本期 Phase 0.1 / 0.2 playwright e2e reproduce 整段流程,導致 Bug P / Bug R fix 後的 e2e verify 必須留 user 手測 — 截圖跟 confirm flow 走完版本只能等 jedi-oscal fix 後。
標 follow-up:jedi-oscal docx_revision_normalize tmp file lifecycle bug,下次 jedi-oscal 進版時順手檢修。
症狀:mf 343 (b8065ad5 / 艾爾航空) docx import 走 mix-decision(部分 use_docx 部分 keep_current)的場景,confirm 後 user 看 template-edit 「參與人員」tab 發現「保留現值」的 party 鉤稽資料消失(連結 user_id / org_unit_id 像被刪)。實際 oscal_parties row 還在,只是 oscal_responsible_parties link 被砍。
Root cause:ModuleFrameWriteStrategy.write_parties L539-544 stale-link cleanup:
# 對 existing_link_map 內所有未在 processed_uuids 的 link 直接 delete_by_id
for uuid, link in existing_link_map.items():
if uuid not in processed_uuids:
self._responsible_party.delete_by_id(link.id)caller (_filter_parties_for_write) 對 keep_current 不收進 parties list 是預期行為(這些 party 不該被 write),但 cleanup 卻把這些「沒寫入」誤判為「該砍」。Mix-decision 場景下,keep_current party 從 link 表消失 → user 看不到 → 以為「被刪除」。
Fix(commit 4c2ea9c1):拿掉 L539-544 整段 stale-link cleanup。caller 端 _collect_parties_to_unlink 已明確收集 diff_status='gone' + action='use_docx' 走 unlink_parties path — write_parties 不該自作主張砍 link。Mirror SspWriteStrategy.write_parties — 本來就沒這個 cleanup。
Test: tests/test_module_frame_write_strategy_v2_parties.py::test_write_parties_does_not_unlink_existing_when_called_with_subset — mock existing 3 link (A/B/C) + caller 只傳 A → 驗 B/C link 不被 delete_by_id。
症狀(連環):mf 343 docx import → STEP 2 全採用新值 → STEP 3 確認 → 回 template-edit 「系統元件」/「外部利用服務」/「受評標的」全部沒匯入。同樣 reproduce 發現有 8 個 SSP shell 累積在 DB(5/3 ~ 5/26 期間):5/3 + 5/13 各 1 個 legacy template shell(template_module_frame_id=343, empty 無 sys_impl), 5/25 ~ 5/26 五個 docx import 過程建的 orphan(template_mf=NULL, 有 sys_impl + components + LA),加 5/26 02:43 本次第一輪 fix 後新建的 285。
Root cause 鏈(4 個獨立缺陷疊加):
| 缺陷層 | 內容 | Layer |
|---|---|---|
| #1 Schema 層 | DB 沒 unique constraint (template_module_frame_id) — 沒當「mf ↔︎ template SSP shell 1:1」是 invariant,純靠 code-level 自律 |
L1 |
| #2 Service 層 | 舊版 create_shell 沒 wire template_module_frame_id(mapper.to_model 早 wire 但 caller 沒傳 entity ctor) → DB column 永遠 NULL → mf template-edit 用此 column 過濾查不到新 shell |
L2 |
| #3 Resolve 層 | resolve_existing_shell 找到 ssp 但 sys_impl 缺 → 拋 PreconditionFailedError → ensure_shell 接到 → fallback create_shell 建新 shell。Legacy 219/228 沒 sys_impl(不同 creation flow 建的 contract mismatch) → 永遠 resolve fail → 每次 import 都建 orphan |
L3 |
| #4 Query 層 | 舊版 resolve_existing_shell 只用 profile_id query(多 mf share 同 profile 場景拿錯 shell)— 已在初版 Bug T fix e20bb9c9 加 template_mf 優先 + profile_id fallback |
(前 fix) |
Bug U(同期,FE wire 漏 4 v3 decisions):SspDocxImportPage.vue 手刻 confirm payload 漏 components_decisions / leveraged_authorizations_decisions / inventory_items_decisions / system_characteristic_decision 4 個 v3 keys → BE schema (SspDocxImportConfirmRequestSchema) 又漏定義 → marshmallow unknown=RAISE 400 「Unknown field」。FE fix commit 9b64c02,BE schema fix commit a91f1eb3。
4 層防護策略(per user 拍板「未來不再發生」):
L1 — DB unique constraint (最強 last-line defense)
CREATE UNIQUE INDEX uq_ssp_template_module_frame_id
ON oscal.system_security_plans (template_module_frame_id)
WHERE template_module_frame_id IS NOT NULL;
Partial index — NULL 多筆合法(保留給 version control / 非 template shell 用途)
L2 — Service idempotency
create_shell 內加 existing check → 撞到拋 ConflictError (GRC_409032)
caller 應走 ensure_shell(含 resolve fallback)才對
L3 — Resolve self-heal
resolve_existing_shell 找到 ssp 但 sys_impl 缺 → 補建 sys_impl row 不拋 412
legacy launch_new_round 建的 shell 即使 contract mismatch 也能 self-heal
L4 — Design.md invariant 文檔化(本段)
未來工程師看 design.md 就理解:mf ↔ template SSP shell 1:1 invariant
creation flow owner 集中規範:mf creation flow 才能 create shell,
import flow 只能 resolve(含 self-heal)。
規範條文(給未來工程師):
template_module_frame_id 非 NULL 唯一)create_shell 是 mf creation flow 專屬 — 其他 flow(docx / Excel import / launch_new_round / version bump)只能 resolve;找不到 shell 是 caller 設計錯誤,不該 fallback createresolve_existing_shell self-heal — 找到 ssp 但 sys_impl / SC / metadata 缺都該補建,不拋 412create_shell(跳過 ensure_shell)會被 L2 idempotency 早 raise + L1 DB constraint 守底線ensure_shell 的 fallback create 只在「mf 從沒任何 shell」場景 fires — 經 L3 self-heal 強化後,正常 path 永遠不該 trigger fallback改動範圍(commit 對照):
SystemSecurityPlanQueryEntity 加 template_module_frame_id field(643d446)SspShellService.create_shell 寫 template_module_frame_id=mf.id + L2 idempotency check(e20bb9c9 + 後續)SspShellService.resolve_existing_shell 優先用 template_mf + L3 self-heal(e20bb9c9 + 後續)api/oscal/serializers/ssp/ssp_docx_import.py 加 4 個 v3 decisions field(a91f1eb3)SspDocxImportPage.vue payload wire 4 個 v3 decisions(9b64c02)scripts/sql/2026-05-26-bug-t-cleanup-ssp-orphans-and-unique-constraint.sql:清 7 個 orphan/empty shell + 建 L1 unique indextest_ssp_shell_service.py 加 L2 conflict + L3 self-heal regression tests為什麼 219/228 沒 sys_impl 是 contract mismatch(給未來補強):
219/228 由 launch_new_round flow 建(per ssp_versioning_service.py 邏輯),那條 path 只建 ssp + metadata + system_characteristic,不建 sys_impl。docx / Excel import flow 預期 sys_impl 存在 → resolve fail → 過去拋 412 fallback create。L3 self-heal 修補了這個 contract gap。未來補強方向:統一 mf creation flow / launch_new_round flow 也建 sys_impl,讓 contract 一致;但本期暫不動 launch_new_round(範圍太大),靠 L3 self-heal 兜底。
緣由:§11.39 Bug T 的「stop-gap fix」(L1-L4 四層防護)解決了 SspShellService 的 1:1 invariant 問題,但沒解決根本 schema 缺陷:mf template-edit 的 4 個 tab(系統元件 / 外部利用服務 / 受評標的 / 資產)沒對應的 mf-scoped default 表。BE 舊有 hack 直接走「mf → profile_id → SSP.get_one → 寫 SSP 子表」,造成廢物 SSP 累積的真正源頭。
Phase 2 根治策略:補 4 張 compliance.module_frame_*_defaults 表,完成 schema 對稱性。
新增 4 張 DB 表(compliance schema):
| 表名 | mirror | 用途 |
|---|---|---|
module_frame_component_defaults |
oscal.ssp_components |
元件 CRUD |
module_frame_leveraged_authorization_defaults |
oscal.ssp_leveraged_authorizations |
外部利用服務 CRUD |
module_frame_inventory_item_defaults |
oscal.ssp_inventory_items |
資產 CRUD |
module_frame_system_characteristic_defaults |
oscal.system_security_plans_system_characteristics |
受評標的 (1:1 per mf) |
改動範圍:
| 元件 | 改動 |
|---|---|
| BE entity/repo/service | 4 套 × 7 檔(model / entity / query_entity / mapper / repo_abstract / repo_impl / domain_service) + DI wire |
ModuleFrameTemplateCopyService.copy() |
加 Step 4-7:PROJECT 啟動時從 mf-default 複製 LA/components/inventory/SC 到新 SSP |
ModuleFrameLeveragedService |
整個改寫,從 hack(profile→SSP)改成讀 mf_la_default 表 |
ModuleFrameSystemCharacteristicService |
同上,改讀 mf_sc_default 表 |
新增 ModuleFrameComponentsService + route |
GET/POST/PUT/DELETE /module-frame/<uid>/components |
新增 ModuleFrameInventoryService + route |
GET/POST/PUT/DELETE /module-frame/<uid>/inventory |
SspDocxImportAppService._run_v2_bundle_confirm |
mf source 改走 _run_mf_default_confirm(),不再建 SSP shell |
新增 _load_mf_default_v3_lists() |
diff 比對現值改從 mf-default tables 讀,不再從 legacy project SSP |
FE ModuleFrameComponentsLeveragedInventoryTab.vue |
新建 mf-side 元件,取代傳 sspUid 的舊 SspComponentsLeveragedInventoryTab |
| DB migration + data migration | 4 張表 + mf 343 資料遷移 + 砍 6 個 orphan SSP (280-285) |
修復的 follow-up bugs(Phase 2 過程發現):
| Bug | Root cause | Fix |
|---|---|---|
| SC name 空白 | _run_mf_default_confirm 用 sc_dict.get("name") 但 ParsedSystemCharacteristic key 是 system_name |
改 sc_dict.get("system_name") or sc_dict.get("name") |
| diff 現值不正確 | _load_current_v3_lists module_frame 路徑走 resolve_existing_shell → 找到 SSP 219/228(project SSP),載入 project 資料當「現值」 |
新增 _load_mf_default_v3_lists() 直接從 mf-default 表讀 |
la_uid_to_title KeyError |
_load_mf_default_v3_lists return dict 少 la_uid_to_title key |
補齊 return shape |
| SC not copied to new project | copy() Step 7 是純 UPDATE;SSP 沒 SC shell row(_clone_system_characteristic_from_template 讀舊 SSP 無 SC)→ 0 rows |
Step 7 改 upsert:先查是否存在,不存在則 INSERT from mf-default |
| 責任人員只 clone 2/6 | _clone_module_frame_parties_to_ssp idempotency check 用 (role_id, context_type, context_id);5 筆 role_id=NULL 的 parties → 第一筆 INSERT 後,後 4 筆誤 skip |
改 per (party_uuid, role_id) set 追蹤 |
行為差異(before vs after):
compliance.module_frame_*_defaults 表;docx import 也直接寫 mf-default;diff 比對從 mf-default 讀現值;OSCAL project 啟動時 copy() 從 mf-default 帶 LA/components/inventory/SC 到 project SSP;責任人員 6 筆全複製Commits(本 branch feature/mf-template-defaults-phase2):
| Commit | 說明 |
|---|---|
f18561a3 |
SQL migration — 4 張 mf-scoped default 表 |
7a9e2727 |
4 套 mf-default entity/repo/service + DI + 單元測試 |
0402a8a2 |
copy_service 加 Step 4-7 |
93645fb1 |
BE endpoint 改寫 + 新增 (Phase 4) |
cc80952d |
mf 343 資料遷移 + orphan SSP 清理 |
a0cadb74 |
docx import mf source 改寫 mf-default 表 |
a2db006b |
Bug 1+2 — SC name key + diff 現值來源 |
dbfe161b |
_load_mf_default_v3_lists 補 la_uid_to_title key |
657a9b7f |
SC not cloned + responsible_parties 只 clone 2/6 |
FE 5741ab1 |
FE mf-side components/leveraged/inventory tab |
教訓:
Status: ✅ FIXED — 2026-05-26 結案
緣由:§11.40 Phase 2 完成後,Excel template 匯出/匯入功能有多處與 web UI 不對齊,包含 sheet 名稱、欄位名稱、資料來源、匯入預覽顯示等問題。
Excel 樣板(v3.0.0 → v3.0.6):
| 版本 | 改動 |
|---|---|
| v3.0.1 | Sheet 名稱對齊 web:單位→負責單位、元件清冊→系統元件、利用授權→外部授權、控制項與AO→適用控制項;補 28 個 v3.0.0 缺失 i18n key |
| v3.0.2 | 資產清冊 → 資產 |
| v3.0.3 | 受評標的加「範圍識別碼」欄 |
| v3.0.4 | 角色對齊 OSCAL ssp_party_role(OSCAL 10 個角色);單位加角色欄;人員補 title/telephone/address 欄位 |
| v3.0.5 | 角色改中文 label(key-value 雙向對應)— Excel 顯示中文,import 時 mapping 回 OSCAL key |
| v3.0.6 | 元件類型(component_type)改中文(本系統/服務/硬體等),同樣 key-value 對應 |
匯出資料來源修正:
generate() MF-scoped:components/LA/SC/inventory 全改讀 mf-default tables(之前走舊 OSCAL SSP path 永遠空)generate_for_ssp() SSP-scoped:補 InventoryItemDomainService 讀取,不再 hardcoded inventory_items = []_build_sc_from_mf_default 讀 mf-default SC,scope_description fallback to descriptionexisting_obj_map lookup 從 assessment UUID 改對齊 FE aoLetterKey() 邏輯([a] → "(a)")_sc_write_strategy 補注入,preview reconcile 補 _preview_reconcile_controls 設 matched_catalog_control_iddocx import 補存修正(_run_mf_default_confirm):
comp_dict.get("props") 永遠 None,改從 flat fields 打包API response 修正:
ModuleFrameLeveragedService._to_dict:props 扁平化,補回 provider/fedramp 等頂層欄位(FE 讀 data.provider 非 data.props.provider)ModuleFrameComponentsService._to_dict:add/update 從 flat payload 打包 props匯入 Step 6c — MF defaults 寫入: Excel import superset/update flow 在 _run_v2_bundle_confirm(寫 SSP OSCAL tables)後,補 Step 6c _write_mf_defaults 同步寫 MF defaults,讓 template-edit 頁面能看到資料。
啟動專案重複問題修正:
_setup_ssp_system_implementation Step 3-5 有 MF defaults 資料時 skip template SSP clonemodule_frame_template_copy_service.copy() Phase 2 獨擔從 MF defaults → project SSP 的寫入FE 修正:
ModuleFrameTemplateEditView:移除 templateSsp gate,直接 mount ModuleFrameComponentsLeveragedInventoryTabSheetPreviewParties:Tab 名稱對齊 Excel,補欄位顯示(職稱/電話/地址),角色改用 menuStore.sspPartyRoleMenutemplate-edit 讀/寫 → compliance.module_frame_*_defaults(唯一來源)
啟動專案 → copy() Phase 2 → MF defaults → project SSP OSCAL tables
↑ _setup_ssp_system_implementation 有 MF defaults 時 skip template SSP clone
template SSP OSCAL tables(oscal.ssp_components 等)是 _run_v2_bundle_confirm 的副產品,不再做為 project clone source。
| commit | 主題 |
|---|---|
9379f2a6 |
Excel 樣板 v3.0.1 sheet 名稱對齊 + 補 i18n |
cb850d11 |
v3.0.2 資產清冊→資產 |
832073b0 |
MF filled export 改讀 mf-default |
afbcf555 |
docx import mf-default confirm 補 props |
7dbd3bc6 |
LA/Component service _to_dict 扁平 + add/update 打包 props |
3b619471 |
受評標的 export + AO lookup 修正 |
3d9fe47d |
受評標的補範圍識別碼欄 |
7b5ddcee |
12 個欄位名稱對齊 web |
27c51a60 |
AO lookup 對齊 FE aoLetterKey() |
9db63df0 |
inventory component refs + controls preview catalog matching |
64be8460 |
角色對齊 OSCAL ssp_party_role v3.0.4 |
80a53a1b |
角色中文 label key-value v3.0.5 |
8d09bef5 |
Excel import 補 Step 6c 寫 MF defaults |
42bc09d3 |
MF defaults single source of truth |
1a79d6e2 |
SSP-scoped Excel 補 inventory + 舊 SSP 髒資料清理 |
7655357e |
元件類型中文化 v3.0.6 |
FE 20df46e |
移除 templateSsp gate |
FE 51701fe |
匯入預覽 Tab 名稱 + 欄位 |
FE 351edf8 |
角色下拉改 menuStore |
FE 44436ef |
資產清冊補欄位 |
教訓:
_to_dict 回 props:{...} 但 FE 讀 data.provider,debug 難度很高;設計時先確認 FE 讀哪一層Status: ✅ FIXED — 2026-05-26 結案