SSP OSCAL Alignment — Design

項目 內容
版本 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 偏差全紀錄

1. 背景

docx-import-parity Stage 1 上線後 BE 能從 docx 解出完整欄位、Excel 樣板擴到 14+ cols,但有兩個本質性的不對齊問題:

問題 1:所有外部依賴擠在「同一張 entity 表」

目前 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):

  • 同一個 FedRAMP-authorized 雲服務(如 Crowdstrike)會同時寫成 <component> + <leveraged-authorization>,並用 prop[name="leveraged-authorization-uuid"] 互連
  • 一個非 FedRAMP 服務(如印表機)只寫 <component>

問題 2:設備 (devices) 跟 資訊系統 (info_systems) 是兩個 OSCAL 元素

我們概念 OSCAL 對應 層級
資訊系統 (SHEET_INFO_SYSTEMS) <component> type 層級「有什麼」
設備 (SHEET_DEVICES) <inventory-item> instance 層級「長怎樣」

兩者透過 <implemented-component component-uuid="..."> 連結。目前我們的 entity model 沒有這個 reference,等同丟失 OSCAL audit chain。

問題 3:Component type enum 不完整

我之前在 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 個

2. 目標

完整對齊 OSCAL Implementation Layer,使 SSP 能完整 round-trip export → OSCAL JSON → re-import。

具體成果:

  1. DB schema 拆 3 + 1 join table(components / leveraged-authorizations / inventory-items / inventory_implemented_components)
  2. ParsedExcelEntityBundle 重設計、Docx adapter 跟著重接
  3. Excel + Docx 樣板拆 sheet / 加表
  4. FE 預覽 UI tab 重設計,reuse excel preview component
  5. 新增 BE OSCAL export endpoint
  6. jedi-oscal 套件對齊(components / leveraged-authorizations / inventory-items entity + service + repo)

3. 階段切割

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。

Phase 1 — Foundation

1.1 jedi-oscal 套件異動

⚠️ (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 用 trivial IBaseRepo[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

  1. Phase 1.1 (套件側 3 PR per entity,ordering: LeveragedAuth → Component → InventoryItem):每個 PR 含 entity + repo + service + ORM model + mapper 一個縱切片
  2. Phase 1.3 (主專案 migration):建新表 + 拆寫資料 + e2e 驗證 + DROP 舊表 同一 phase 完成
  3. Phase 1.4 (套件側 cleanup PR):刪除 SspSystemImplementationItemEntity + 對應 repo / service / model / mapper

不掛 @deprecated marker — 直接刪。caller(譬如 docx-import-parity Stage 1 既有的 leveraged-services 寫入點)一併改 import 到新 entity。

dev 環境有 DB 備份 + 環境切分,新表 e2e 驗證 OK 即可 drop,無需 6 月觀察期或雙寫雙讀過渡。

1.2 主專案 DB migration

⚠️ (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 一律用 cmmgrcm_app 受 RLS 擋,會 INSERT 0 rows 靜默失敗 — 參考 CLAUDE.md「跑 SQL migration 一律用 cmmgr」)。

1.3 既有資料遷移腳本 + cutover

⚠️ (updated 2026-05-24 — see §11.10 ⭐ 最大 reconciliation):本段 SQL 是寫設計時的 sketch,跟實際 DB schema 大幅偏離(8+ columns 不存在、SSP 表沒 tenant_id、category 95% NULL)。不要從本段抄 SQL——實際 migration 已 ship in scripts/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 後再跑。

1.4 ParsedExcelEntityBundle 新 shape

⚠️ (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_leveraged

Phase 2 — Import Pipeline 對接

2.1 Docx Adapter 重寫

domain/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'),
        ...
    ))

2.2 Excel Parser 對接新 sheet(見 Phase 3)

2.3 SspEntityReconciliationOrchestrator 補三條 strategy

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

2.3.1 FK Resolve 規則 — Warn but Allow

ParsedComponent.leveraged_authorization_ref (title 字串) 或 ParsedInventoryItem.implemented_component_refs (title list) 指向 bundle 內不存在的 title 時,採 warn-but-allow:

  • 該 FK 欄位寫 NULL(component.leveraged_authorization_uid)或 skip M2M join row(inventory ↔︎ component)
  • 寫一筆 entry 到 parse_jobs.parsed_result.import_warnings
    {
      "ref": "Crowdstrike",
      "reason": "leveraged_authorization not found in bundle",
      "context": {"component_title": "AcmeCorp Backup Service"}
    }
  • 不拋 exception、import 繼續進行
  • FE preview 顯示這些 warnings 給 user
  • 下次 import 補上 Crowdstrike leveraged auth 後重 confirm,reconciler 走 idempotent re-link(component.leveraged_authorization_uid 變回正確 uid)

2.3.2 同 SSP 內 Component title 衝突

兩筆 ParsedComponent.title 在 bundle 內重複時(譬如 user Excel 寫了兩行 "Crowdstrike"),reconciler 用 (component_type, title) 複合 key 配對:

  • 視為兩筆獨立 components(DB 寫兩筆 uid 不同的 row)
  • inventory.implemented_component_refs = ["Crowdstrike"] 解析時若 bundle 有多筆同 title component → 取 first match by component_type=service(其他 type 不被選),寫 warning 「ambiguous component title, picked first service」
  • 完全相同 (type, title) 重複時:dedup,取第一筆,第二筆寫 warning「duplicate component skipped」

2.4 Confirm path schema_version 分流

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 的角色限縮成:

  • deploy 前的 in-flight parse_jobs (pre-Phase1+2 上線時尚未 confirm 的 job) 沒這 key → 預設 v1-ssp → 走 _legacy_confirm()
  • deploy 後的新 parse 一律寫 v2-bundle → 走 orchestrator

_legacy_confirm() + parse_jobs legacy shape support 在 deploy 後觀察 14 天(或 in-flight job 全清)即可移除,下個 cleanup PR 處理。

Phase 3 — Excel / Docx 樣板重設計

3.1 Excel sheet 重組

舊 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 指 06a

TEMPLATE_VERSION bump 到 v3.0.0(major bump — breaking schema change)。

3.2 Docx 樣板加表

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 下的新表"""

3.3 Reference template 重產

scripts/regenerate_reference_templates.py 跑出 v3.0.0 樣板。

Phase 4 — FE 預覽 UI 重設計

4.1 Tab 結構(新)

[基本資料] [受評標的] [參與人員] [元件 + 授權 + 資產] [控制項]

舊 5 tab → 新 5 tab,但「元件 + 授權 + 資產」內部分 3 sub-panel:

4.2 第四 tab 內部結構

┌─ 第四 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)    │
│                                                            │
└────────────────────────────────────────────────────────────┘

4.3 元件 component 主 component 重設計

SspComponentsLeveragedInventoryTab.vue 取代既有:

  • LeveragedSection.vue(廢棄)
  • 沒新增 InfoSystemsSection / DevicesSection(因為原本沒)

用 PrimeVue <TabView> 內巢狀 <Accordion><Splitter> 排版。

4.4 Reuse excel preview component

Excel side 已有 SheetPreviewLeveraged.vue / 等。Stage 4 把這些 generalize 後 docx-import-v2 共用,避免維護兩套。

Phase 5 — OSCAL Export ⏸ DEFERRED(2026-05-25)

狀態:本 phase 已 deferred — user 2026-05-25 拍板「Phase 5 不用做,等全部定案後再做」。

以下 §5.1 / §5.2 / §5.3 內容保留為歷史 reference,不在本期 task arc scope;之後 user 拍板開工時可從這裡繼續。

5.1 jedi-oscal 加 export 函式

# 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

5.2 BE endpoint

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

5.3 E2E round-trip test

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
    ...

4. Schema Migration / Compatibility

4.1 既有 SSP 資料

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

4.2 Excel template

動作 影響
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() 同步移除)

4.3 Docx 樣板

動作 影響
新增 components_table / inventory_items_table 客戶要重下新樣板。Stage 1 解析現有 Table 1/2 仍 work(舊路徑保留)
Table 2 是否移除 Phase 4 收尾時拍板(owner: 主開發者)。建議保留作為 fallback,等 Phase 4 FE 預覽 UI 上線後依使用情況評估

5. 風險

風險 緩解
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 標清楚

6. Success Criteria

7. 不在本期 scope

  • Component Definition Model(OSCAL 另一個模型,跨 SSP reusable component library)
  • Capability grouping
  • AP / AR / POA&M 整合(這幾個是 Assessment Layer,獨立 feature)
  • 既有 SSP edit UI 完整重寫(保留現有 UX,只接新 entity)

8. 參考資源


11. Implementation Reality / Reconciliation(Phase 1 Tasks 2-9 + Phase 2, 2026-05-24 ~ 2026-05-25)

本節記錄 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 也要收尾)。


11.1 ORM style:SQLAlchemy 2.0 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)


11.2 UUID type:native UUID(as_uuid=True) 取代 VARCHAR(36)

原 plan/spec 寫(§1.2 SQL / plan §Task 2.3):所有 uid / party_uuid / leveraged_authorization_uidVARCHAR(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)


11.3 Repo interface:trivial 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]):
    pass

Repo 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)


11.4 Domain service init param:<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)


11.5 DI repo provider: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)


11.6 Parse-time _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)


11.7 Mapper signature 多 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)


11.8 Task 4 M2M observability:log unresolved + return actually-written list

原 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)


11.9 ParsedBundle _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


11.10 ⭐ Tasks 7-9 migration SQL:design.md §1.3 跟 actual DB schema 大幅偏離

這是本 session 最大的 reconciliation——關係到 Tasks 10-14 接手者

原 spec §1.3 寫(CREATE TABLE + INSERT SELECT 完整 SQL):

  • 假設 oscal.ssp_system_implementation_itemsi.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_planss.tenant_id / s.org_unit_id
  • Discriminator 用 i.category

實際 DB schema(2026-05-24 直接 query 確認)

  • 舊表 column 名是 system_security_plan_id不是 ssp_id),title 欄位是 name(NOT NULL)+ title(nullable,33/394 populated)。
  • 上面列的 FedRAMP 8 個欄位 完全不存在——這些 v2.5+ Excel template 欄位只活在 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)。
  • 53 rows / 8 SSPs 是 orphan dev 測試資料(blsadmin/blsit fixtures),沒 project linkage → WHERE p.tenant_id IS NOT NULL 過濾掉。

實際 migration mapping

  • component_typeCASE implementation_type WHEN 'hardware' THEN 'hardware' WHEN 'system' THEN 'system' WHEN 'leveraged-authorization' THEN 'service' ELSE 'other' END
  • titleCOALESCE(i.title, i.name)
  • tenant_id / org_unit_id ← 4-table chain 取 p.tenant_id / p.org_unit_id
  • 結果:12 leveraged_authorizations + 341 components 寫入(53 orphan rows skipped)

完整 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:

  1. Task 10 E2E test → 假設 SSP detail GET API 已切到讀新表(須 verify),test 用「12 leveraged + 341 components」這個 known seed state
  2. Task 11 cleanup → grep system_implementation_item_repo / SspSystemImplementationItemEntity 8 callers(Task 5 留下的 + 其他)改新表
  3. Task 12 DROP → 注意 53 orphan rows 是否要 archive 還是直接隨表 DROP(建議 archive 到 oscal.ssp_system_implementation_items_archive 表先)
  4. Task 13 進版 → jedi-oscal 17 commits + main project 5 commits 都還沒 push
  5. Task 14 handoff/changelog → 本檔 §11 已含主要 deviations,changelog 只要列「做了什麼」即可

11.11 Tasks 7-9 controller 流程教訓(process-level)

做錯的事:第一次 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

  • DB-touching task:controller 先親查 schema + sample data + tenant resolvability + verify branch on BOTH repos (主專案 + jedi-oscal),再寫 prompt。
  • Spec vs reality 對不上時,default 是 controller 親查補正 SQL/code 進 prompt,包裝成 user 決策題。除非真的有 trade-off。

11.12 Phase 2 Task 6 Minimal scope — orchestrator + leveraged only, skip device/info_system reconcilers

原 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 整檔刪掉。

原因

  1. 評估「filter by type 後 wrap 餵舊 reconciler」vs「直接刪掉 reconciler 由 ComponentWriteStrategy 在 write 階段自己做 reconcile」後,後者更簡潔 — Component 寫入時自帶 _resolve_or_create_by_name 既有邏輯,不需要先 reconcile 一輪。
  2. Phase 2 Task 5B 的 ComponentWriteStrategy 內建 LA reference resolution(commit d8c9a19d),實質上把 reconciliation 行為遷到 write 階段。雙寫 reconcile 是 redundancy。
  3. Leveraged 仍保留 reconciler 是因為 LA 跨 SSP 共用、需要 _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)


11.13 ⭐ Excel parser v1→v2 dict shape gap — Phase 3 §3.X 才能 close

原 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」。

原因

  1. Phase 2 plan 把 parser 改造默認為 Phase 1 已完成的 ParsedExcelEntityBundle restructure (Phase 1 Task 5, commit 95e9efd2)。實際上 Phase 1 Task 5 只改了 dataclass 定義 + 加 ParsedComponent / ParsedLeveragedAuthorization / ParsedInventoryItem,沒改 Excel parser 的輸出 dict shape
  2. 發現此 gap 時 Phase 2 主軸(confirm_service / orchestrator / write strategies / Docx wiring)都完成了,重點是「先把 pipeline 跑通」,parser 改造拆到 Phase 3。

影響

  • Phase 2 Ship 時 Excel import 從 200 變 412(需 user 重新上傳)。
  • Phase 3「Excel/Docx 樣板重設計」內加 §3.X:Excel parser 輸出對齊 v2-bundle shape。改完 Excel 就跟 Docx 同流程跑。

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。


11.14 共用 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。

原因

  1. Brainstorm 收尾時 user 確認「三層分離(collect warnings / normalize dedup / dispatch confirm)值得獨立 module」— 避免邏輯混在 confirm_service.confirm() 單一巨函數。
  2. Phase 3/4/5 後續 phase(FE preview UI / OSCAL export)可直接 reuse normalizer + warnings collector,不必重寫 dedup / ref resolve 邏輯。
  3. SourceTable enum 在 Task 11B reviewer follow-up 才加(原本是 bare string),review 提示「未來新增 source 時 magic string 易漏」— enum-able 後 IDE auto-complete 也通。

影響

  • Phase 2 增加 4 個新 file(warnings.py / normalizer.py / bundle_restore.py / confirm_service.py),但每檔 < 200 LOC,職責清晰。
  • Phase 3+ FE preview UI(design.md §4)可直接消費 ImportWarning shape 顯示 warning chip。

Commits: aee0e991 (Task 2 ImportWarnings), ec9d3b20 (Task 3 ParsedBundleNormalizer), f6069f83 (Task 4 SspImportConfirmService), 1e3abaee (Task 11B SourceTable enum)


11.15 Phase 2 Task 11 cleanup scope 比 plan 大 — 連帶刪 reconcilers + obsolete tests + dataclasses

原 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 沒列)
  • 4 個 obsolete test 檔(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

原因

  1. Task 6 採 Minimal scope(§11.12)後,device + info_system reconciler 從 Task 6 commit 起就已是 dead code — Task 11 cleanup 不刪等於留 dead code。
  2. 4 個 obsolete test 檔全部 test 已刪的 reconciler / write strategy / dataclass — 留著只會被 pytest collect 撞 ImportError。

影響

  • Net diff: Task 11A 一個 commit -1826 +53 LOC(删 18 file mod + 9 file del)。
  • Phase 2 收尾時 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)


11.16 Phase 1 RLS policy delimiter bug — slash vs comma mismatch

這是 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_scopeapp.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]

Migrationscripts/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 也一併修正。

教訓

  1. 未來新增 RLS policy 一律 cm_app verify — 不可只用 cmmgr (superuser) — superuser 不 enforce RLS policy,等於沒測。
  2. session 變數格式跨套件假設要在 spec 明寫app.allowed_tenant_paths 由 jedi-common session_scope() 設定為 '/1/102/'(slash-delimited LTree-like path),policy 表達式必須 match 這個 format。
  3. Bug A 與本 bug 連動:Bug A 沒 fix 時 confirm path 從沒寫到這 4 張表 → RLS 從沒實際 evaluate → bug latent;Bug A fix 後立即顯現。先 fix Bug A 才能 surface 本 bug,反過來說:本 fix 是 Bug A fix 的真正 unblock 步驟。

Commit: d12bd705 (本段 §11.16 + migration script + §1.2 警告註解一併入版)


11.17 Phase 2 Task 9 Bug A — docx _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 直接 skip

SspImportConfirmService.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」步驟。

教訓

  1. v2 → v3 過渡期,source_type 處理對所有 branch 都要 wire-up(不只主測 path)— 同樣的 pattern 之後 Phase 5 export 端也要注意。
  2. App service review 不可全 mock:mocked test source_type 多半固定 → 真實 flow 才會踩到 branch coverage gap。E2E smoke 應在 phase 收尾前跑(Task 10 跳過是另一個風險源,本案就是它的 fallout)。
  3. 抽 SspShellService 是順帶把 Excel/docx duplicate logic 提取:未來 Phase 3/4 改 Excel app service 時可以一併 migrate 到新 domain service。

Commit: ba0cdd7a (本段 §11.17 + domain/oscal/service/ssp_shell_service.py 新檔 + docx app service __init__ / _run_v2_bundle_confirm 改造)


11.18 Phase 3 整體 — v3.0.0 BREAKING bump + parser derive v1 → v3 + docx extractors + Bug C content_overrides

這條 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.pyextract_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 causessp_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)


11.19 Phase 4 L5/L1a — preview 元件「新建 3 + 舊保留」+ inventory M2M silent skip

這條 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 才整批刪舊三檔。

原因

  1. 新 3 entity 的 column 結構跟舊三檔差很多(OSCAL 14 enum / impact_level 4 enum / implemented_components multi-select 等)—「generalize」實作上是 partial rewrite,不如「並列新建」乾淨。
  2. 舊三檔保留給 L3 cleanup phase 整批處理,避免 L5 改造 PR 太大。
  3. Excel/Docx 共用 generic preview 是 design intent,但 docx preview 端 component (在 SspDocxImportPage)跟 Excel preview component (SheetPreview*) 本來就分離 — Phase 4.4 沒整合兩端,留 future 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:

  • BE SspInventoryItemsAppService.update_item 收到 payload 含 implemented_component_uids → log warning + 不 raise(silent skip 對 caller transparent)
  • FE 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)


11.20 Phase 4 L4 — 5-tab 結構三 sub-item 全 skip(spec under-defined + user 拍板)

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

教訓

  1. design spec 提出 N-tab structure 時,每個 tab 必須含「對應 vue 元件 / 內容 schema / 跟既有 caller 的接點」三項,不能只列 tab label。
  2. 後續 phase 真要做 5-tab 需先補 UX spec:(a) SC 拆 vs 不拆的 user-visible diff (b) 兩 party_type 合併後的 filter UX (c) 控制項 tab 是 link vs inline panel 的取捨。
  3. 「現狀正確 (4-tab 對齊既有 user flow)」勝過「未對齊強推完整度 (5-tab spec)」— 本期維持 4-tab 是 minimal-surprise 做法。

Commit: 無(本 task 全 skip,僅在本檔記錄決定)


11.21 Phase 4 L6 — vue-i18n fallback 是 dead code,但補 i18n key 仍正確 cleanup

原 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)

  1. 新建 ssp-components-leveraged-inventory.json (zh-tw + en) 補 ~30 個 key
  2. 註冊到 src/config/locales/index.js
  3. 移除 vue 檔 69/71 fallback(保留 2 處非 i18n 用途的 ??:severity 預設 / error msg 萃取)
  4. lang.common.action 進 common.json(其他 callers 可能也 fallback)

原因:dead-code fallback 對 user 沒幫助(missing key 仍顯示 key 字串),正確做法是補 key + 移除 fallback。

教訓

  1. vue-i18n 對 missing-key 的兜底機制是 messages.<locale>.fallback(fallbackLocale = 'en')+ console warning,不是元件內 nullish coalescing。
  2. 後續寫 vue 元件不要再放 ?? '中文' pattern — 直接走 t('lang.xxx.yyy'),missing key 在 dev 時 console warning + production fallbackLocale 接住。
  3. Phase 4 L2 ship 時急著補 dialog 改用 fallback 自我保護是 understandable,但 L6 cleanup 必收尾 — 不能讓 fallback pattern 成 codebase convention。

Commit: 05b6fdf (本段 §11.21 + 新 JSON 雙語檔 + index.js 註冊 + common.json action key + vue 檔 fallback 移除)


11.22 Phase 4 Bug B — FE diff stepper 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 才覆寫。

測試結果

  • FE vite build pass(7.21s,SspDocxImportPage chunk 75.25 kB)
  • 行為驗證:user 端重做 docx import → preview 5 parties 預設打勾(accept)→ confirm → BE log decision=use_docx ×5 → DB oscal.parties ≥ 5 筆

教訓

  1. Phase 1+2 SUMMARY 推測「FE diff stepper 預設 skip」應立即 fix,不該延後:handoff 把 fix 丟到「下個 phase 順手做」往往會被優先排程吃掉,落地時間從預估 1 hr 變成跨 session debt(本案從 5/20 推到 5/25)。
  2. BE log 含「decision=skip / no parties marked use_docx」是 FE 預設行為的 signal,不是 BE bug:前 session 一度誤判要修 _filter_parties_for_write 邏輯,實際 root cause 在 FE。後續看到「BE 收到全 skip」要先反查 FE init logic。
  3. 避免「caveat 甩鍋」:把預設行為 bug 包裝成「請 user 手動 accept」迴避修正 = 給 user 重複勞動的 friction,是 anti-pattern。

Commit: 本段 §11.22 + stores/sspDocxImportStore.js (_defaultPartyAction helper + initDefaultDecisions 改寫) + SspDocxImportPage.vue (過時 comment 更新)


11.23 Phase 4 Issue 1 — PartyDiffCard 缺電話 / 職稱 / 地址欄位 render

症狀:與 §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 直接 telephoneParsedParty entity 走 telephone_number。Mirror 既有 email 處理(email vs email_address)。

對 organization party 行為title / telephone 對 org 多半空 → formatValue 回空字串 → template 已有 || '—' fallback render,顯示「—」一致表示空欄位,不破壞 layout。

測試結果

  • FE vite build pass(同 §11.22 build run)
  • 行為驗證:preview 5 parties 顯示完整「姓名 / 職稱 / 角色 / Email / 電話 / 地址 / 對應使用者」7 列;person party 7 列都有值,org party title / telephone 顯示「—」

教訓

  1. BE extractor 規格與 FE render schema 要 1:1 對齊(含跨 stage feature):docx-import-parity Stage 1 補了 BE 5 fields,FE PartyDiffCard fields 也該同步更新。「BE ship → FE 不知道有新欄位」是 cross-repo feature 常見 gap。
  2. Diff 階段 BE 把欄位 mask 為 null(既有 annotate 行為)干擾根因判斷:debug 「parties preview 缺資料」時要先驗 parse_job.parsed_result JSONB 原始欄位齊全(avoid 誤怪 extractor),再驗 FE render schema,最後才看 BE diff annotate logic。
  3. Bug B + Issue 1 同一 stepper 元件一起 fix 邊際成本最低:跨 root cause 但同檔案的小修正 batch 處理比拆兩 PR 更省 review cycle。

Commit: 本段 §11.23 + PartyDiffCard.vue (fields 擴展 + formatValue telephone alias)


11.24 Phase 4 Bug B 第二段 — _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 return

caller write_parties:530role_id=parsed.role or ""parsed.role 來自 _dict_to_parsed_parties:1104role=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 rolep.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 合計)

  1. 「寫成功」的計數要驗到最底層的 link 表,不能停在中間層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 之前。
  2. Cross-source feature fix 要做 consistency audit:Excel 2026-05-21 修 default role bug 時,docx 同 pattern 沒一起 fix,留到 4 天後同個用戶踩到。未來 import-related bug fix 必檢查所有 source path(Excel / docx / 未來 OSCAL JSON / 任何 future format)是否有同邏輯漏洞。
  3. 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)。
  4. Subagent 修 single-bug 卻造 cascade bug 是 process problem:我們前 session 把 Bug B 標 caveat 迴避 → 這 session 修 §11.22 部分根因 → 又踩 §11.24 second-段。整段時間 (2 sessions / 5 天) 都在追同一個 user-facing symptom (template-edit parties 空白)。下次 bug handoff 要 enforce 「root cause 走到 DB side-effect verified」才能算 fix done。

Commit: 本段 §11.24 + app/oscal/service/ssp_docx_import_app_service.py:_dict_to_parsed_parties (default role per party_type)

11.25 Phase 4 Bug D — docx Table #7 (Category) 行只 emit Component,沒 emit LeveragedAuthorization → user 視角 4 筆漏 3 筆

症狀:§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 causedomain/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_itemsSspLeveragedContextService._fetch_items),且該表的手動新增 UI 接受 category=external-service / interconnection / apiproduct 已採「廣義 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_onlytest_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)

  1. BE pytest 140 passed (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)
  2. BE restart → 用 Playwright + 同份 user docx 重跑「合規資源庫 → 新增 → 從文件建立」flow
  3. parse_job 148 lev_auths=4 ✅(pre-fix 是 1)
  4. confirm 後 new mf 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 | 有線網路         |               |
  5. template-edit page「外部利用服務 (4)」tab 顯示 4 筆完整 ✅

Bug D 教訓

  1. Adapter 兩條對稱 path 一定 cross-source check:T6 emit LA + Component,T7 卻只 emit Component — 這種「Table A 走 path X,Table B 走 path Y」的 adapter design 必須一開始就同時驗「兩條 path 寫到 DB 的最終形狀對 user-facing UI 有沒有 holes」。Phase 2 Task 8 落地時沒 surface 這個 gap,到 Phase 4 末用戶才踩到。
  2. 「FE preview 數字」跟「DB 寫入數字」必須 1:1:FE 預覽「外部利用服務 (4)」是 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。
  3. OSCAL 語意純粹 vs Product 語意現實:OSCAL 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。
  4. §11.24 Bug B 教訓直接套用:「寫成功」計數騙人,必驗到 DB 表。Bug D fix 後我們即時 query 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


11.26 Phase 4 Bug H — Word import 4 子 FE bug:parties label 雜亂 + 電話 / 地址欄位 preview / template-edit 兩端未 render

症狀 (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.jsonparties_organization_section_title「單位」→「負責單位」、parties_person_section_title「人員」→「參與人員」
  • src/config/locales/i18n/zh-tw/module-frame.jsontab_responsible_org「責任單位」→「負責單位」、tab_responsible_person「責任人員」→「參與人員」
  • src/components/grc/ssp-docx-import-v2/sections/PartiesSection.vueROLE_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):

  • mf 372 template-edit「負責單位 (1)」+「參與人員 (4)」tab — DataTable 含電話 / 地址 column ✅
  • Edit Dialog org 5 label / person 6 label 含電話 + 地址 ✅
  • docx import preview 5 card 全顯示電話 / 地址 input value ✅

Bug H 教訓

  1. 三元件對齊 ≠ 一處改完 — preview / template-edit / diff stepper 三元件 render 同 party 欄位但獨立維護;Bug B Issue 1 只修 diff stepper 不夠,本期 Bug H 補完三元件,列入未來新增 party 元件 / 欄位的 checklist
  2. i18n key value 改字串 ≠ 改 key 名 — 只動 zh-tw 4 個 key value,vue 端 t() 呼叫 0 動,CodeMod cost 0

Commit: 本段 §11.26 + 4 FE 檔(純 FE,BE 不動)


11.27 Phase 4 Bug H-5 — BE docx parser 解 email 漏掉(substring collision bug)

症狀: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 causedomain/oscal/parser/docx_section_extractors.py:_PARTY_LABEL_MAP substring matching ordering bug:

  • Label「e-Mail Address:」 normalize 後 = '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 field
  • 因 Office Address row 已先填 party['address'] → email value 被 line 297 if value_text and matched_field not in party 條件 silently dropped

Fix

  • 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 教訓

  1. substring matching 的順序敏感性 — dict-as-priority-map 的 substring match 對 declaration order 敏感。「短 substring 排在長 substring 之前」就會偷走 match('address''emailaddress')。Weak test「any field 含 @」太鬆讓 bug 滑過;強 test 必精確指定 expected field
  2. 「之前是好的」要實證、不能憑感覺 — user 直覺說「之前 email 能抓到」但 9 個歷史 parse_job 全 0;實證快速排除「regression」假設

Commit: 本段 §11.27 + BE 2 檔(parser + regression test)


11.28 Phase 4 Bug H-6 — jedi-common SQLAlchemy event hook 自動填 org_unit_id 造成假鉤稽

症狀:mf 372 template-edit「負責單位 (1)」tab 顯示屏東飛機維修廠 鉤稽到「System Administration Department」— user 沒手動鉤稽過。

Root causejedi_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_itemsorg_unit_id業務語意「鉤稽到哪個部門」兩種語意衝突

影響範圍 (fix 前 DB 證據):

  • 33/1137 person party org_unit_id=98(person 根本不該有 org_unit_id — UI 用 matched_user_id 鉤稽)
  • 4/4 org party org_unit_id IN (98, 99)
  • 36/36 leveraged_authorization / 391/391 components / 3/3 inventory_items 全 org_unit_id IN (98, 99)

Fix (Option D — opt-out flag,跨 jedi-common + jedi-oscal 兩套件)

  1. jedi_common/session/database/db_mw.pyset_tenant_info_before_insert__skip_auto_org_unit__ class attribute check(其他 model 行為不變、保留 tenant_id auto-fill 不影響 RLS)
  2. 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__ = True
  3. 主專案 pyproject.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 教訓

  1. shared infra hook 的雙語意陷阱 — jedi-common set_tenant_info_before_insertorg_unit_id 當 tenant scope 自動填(對大多數 model 正確),但對 OSCAL 把 org_unit_id 當「鉤稽到的部門」的 model 是災難。Hook 不能假設「同名欄位 = 同語意」。Opt-out flag (__skip_auto_org_unit__) 是正確設計(caller-driven,不破壞 hook 原意)
  2. 隱形 bug 借 UI 補新欄位才浮出 — Bug H-6 從 oscal table ship 第一天就埋著,但 person UI 不看 org_unit_id 鉤稽(用 user_id),org 也沒人特地檢查;借 Bug H-4 把鉤稽欄完整顯示才浮出。「user 看不到的 bug 不是沒 bug,只是暫時沒人查」
  3. 跨套件 bug 也要動套件 — per memory feedback_jedi_package_modify_allowed:「正確修改 > 最小變更」,root cause 在 jedi-common shared infra 就在那改,不該為了「不動套件」在主專案弄 wrapper / sidecar

Commit: 本段 §11.28 + jedi-common 1 檔 + jedi-oscal 4 檔 + BE pyproject.toml + DB cleanup SQL (467 row)


11.29 Phase 4 Bug I — revert Bug D §11.25 + 廢棄 3 過渡 TabPanel(落實 §1 + §4.2 long-term state)

症狀 (user 反映「外部利用服務」3 子 bug):

  • I-1 設計層:T6 (Leveraged FedRAMP) + T7 (External Systems/Services) 兩種 OSCAL 元素被合塞 ssp_leveraged_authorizations,FE 一個 DataTable 同 column 結構 → T6 row 看「類別/協定」空、T7 row 看「FedRAMP/影響等級」空 → user「欄位對不起來」
  • I-2:預覽編輯類別後重 import,DB 沒更新
  • I-3:template-edit Edit Dialog 沒帶出「類別 / 服務名稱」

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_lastest_adapt_to_bundle_t6_only_emits_la_t7_only_component;保留 user docx 4 筆 regression 但 expected: 1 LA (T6) + 4 Component

Bug I 教訓

  1. short-term tactical fix 留 follow-up,user 踩到 trade-off 才升級 — §11.25 教訓 3 明說「長期拆兩 tab」但 deferred;user 看到代價才正式做。下次寫教訓段時若標「非本期 scope」要明確列追蹤項,避免長期遺忘
  2. §4.2 既有設計元件不要為了快 ship 延遲落實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


11.30 Phase 4 Bug J — module_frame_template_ssp_app_service silent AttributeError 導致 template SSP uid 永遠 null

症狀:FE 「元件清冊」tab 永遠顯示「尚未匯入任何 docx/Excel」hint,即使該 MF 已 import 過。

Root causeapp/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_idtry/except: pass 默吞 AttributeErrorssp_uid 永遠 None → FE check !templateSspExists || !templateSspUid 走 hint 分支。Phase 4 E3 ship 以來從沒人見過真正的 panel

Fix:改 method 呼叫名 + 加註解標 Bug J 來源。

Bug J 教訓

  1. try/except: pass 默吞 caller 錯誤是 anti-pattern — silent failure 讓 bug 隱形數週。即使要 graceful fallback,至少要 log.warninglog.exception 留 trace
  2. Phase 4 E3「reuse」需 verify — 設計文件寫「reuse SSP endpoint for MF template」但 ship 時沒 verify endpoint 對 MF context 跑得通;Bug J + Bug K 都是這個信任未驗證的結果

Commit: 本段 §11.30 + app/oscal/service/module_frame_template_ssp_app_service.py (get_by_idget_ssp_by_id)


11.31 Phase 4 Bug K (K1) — SSP CRUD endpoint 對 module-frame template SSP 404,新增雙模式 SspContextResolver

症狀: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

  • Project SSP:啟動專案時 init_ap_controls 建立 AP → resolver pass
  • Template SSP:屬於 module_frame、沒 project、自然沒 AP → resolver 永遠 404

設計層意義 (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/apmodule_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_managerrequire_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 surfacetests/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 教訓

  1. Wrapper / routing 概念不該複製成獨立 domain — MF 只是 SSP 入口的另一層,不該為每個 SSP sub-resource 寫一份 MF mirror endpoint(會累積 thin facade 技術債)。SSP endpoint 設計時就應該識別「caller 屬什麼 context」走對應 policy
  2. Permission resolver 對 context 應 explicit — 舊 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


11.32 Phase 4 Bug L — preview status edit 沒帶到 template-edit + LA Card → Table

症狀

  • L-1:template-edit「利用授權」sub-panel 用 Card list 視覺,跟元件清冊 / 資產清冊(DataTable)不對稱,user 反映「很怪異」
  • L-2:preview 編輯 row 的 status / category 後 confirm,template-edit 沒帶到那些改動

Root cause

L-1SspComponentsLeveragedInventoryTab.vue:612-660 利用授權 sub-panel 用 <article> Card grid,跟另兩 sub-panel 不對稱。

L-2:兩層問題:

  • FE SspDocxImportPage.vue:606 confirm payload content_overrides: draft.controlOverrides完全沒包含 draft.leveraged_services 編輯
  • 即使有包含,BE _apply_v2_bundle_overrides_V2_BUNDLE_ROW_KEYS = (components, leveraged_authorizations, inventory_items, parties)leveraged_services v2 key(v2 legacy,BE 寫入只看 v3)
  • FE LeveragedSection 之前讀 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 教訓

  1. FE preview edits 對應 BE 寫入 path keys 必須對齊 — v2 legacy keys (leveraged_services) 跟 v3 bundle keys (leveraged_authorizations + components) 共存時,FE 編輯哪個直接決定能不能寫入。Phase 4 v2 → v3 transition 沒清掉 v2 wire-up 是 latent bug
  2. 使用者熟悉的詞 > spec 直譯 — sub-panel 命名跟 user 走 Word docx 原文 (Leveraged External Systems and Services 利用的外部系統與服務) + 業務語意(「外部正式授權」vs「系統依賴元件」)對齊比 OSCAL spec 直譯(「Leveraged Authorization / Component」)更貼 user 心智模型
  3. UI 規範記文件 — user 提的「新增類按鈕用 outlined」規則記到 FE CLAUDE.md,未來 0 解釋成本

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 規範)


11.33 Phase 4 Bug M — docx 1.1 Introduction 段落 system_characteristic 未匯入 + docx confirm path 從沒寫 SC

症狀: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_levelParsedSystemCharacteristic;缺 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_MAPextract_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 ParsedSystemCharacteristicsystem_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_FIELDSdescription + system_identifier mapping(原 comment「Excel sheet 沒這兩個 column」過時 — docx 有抽到)

順手做的兩件 polish(user 反饋):

  • 廢棄「文件資訊」preview TabPanel — docx metadata 直接走 BE confirm 寫入,user 不需 preview/edit
  • 基本資料 tab icon 重疊修正 — 拿掉裝飾性 pi-info-circle、只保留 pi-exclamation-circle warning(且改文字之前更顯眼)

驗證(亞航 docx):

  • parse_job 159 parsed_result.system_characteristic 含 security_sensitivity_level: 'moderate' + description 1791 chars ✅
  • user 預覽編輯 system_name + system_identifier 後 confirm → mf template-edit「受評標的」tab 顯示完整欄位 ✅

Bug M 教訓

  1. Excel-only feature 不該預設覆蓋 docx importsystem_characteristic_write_strategy 設計時注解「Excel sheet 沒 description / system_identifier」就一刀切排除,沒考慮 docx 也走同 strategy。Field filter 應該對 parsed 內容開放,而不是限制能來自哪種 source
  2. Inline-only vs cross-paragraph lookup tradeoff — 早期 pending_label_field 機制設計上想處理「label only + value 下一段」pattern 但容易踩 section heading 變 value 的坑;簡化成「inline value 才接」雖然可能 miss 些 case,但避免 false positive,trade-off 對 user docx 觀察良好
  3. 整段 chain 缺一斷一(extractor / dataclass / adapter / confirm wire / write filter 五層)— extractor 補完不夠,要把整條 import → 寫入 path 都對齊。Phase 4 ship 時這條 chain 從沒測過(user template SSP 進 mf template-edit 顯示空白沒人查)— borrow Bug J 教訓「reuse 概念要 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)


11.34 Phase 4 Bug O — docx 重 import 差異比對缺系統元件 / 受評標的 / 利用授權 / 資產 + UI 改 TabView pattern

狀態:本段 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,

  • 系統依賴元件 (components)
  • 外部正式授權服務 (leveraged_authorizations)
  • 資產清冊 (inventory_items)
  • 受評標的 (system_characteristic)

跳過 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

  • BE ssp_docx_diff_service.annotate_parse_result 只跑 matched_controls + parties 兩 source;build_diff_summary 只算 controls / objectives / parties 3 key
  • FE useSspDocxImportStore.decisions 只有 controls + parties 兩 sub-map
  • FE diff sections 只有 ControlDiffSection.vue + PartiesDiffSection.vue
  • DiffResolutionStep 直接 inline render 2 section(無 tab container)

整段 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_statusunchanged/changed/added/gone + default_actionuse_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
- decisionscomponents / 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

  • 既有 diff card pattern:PartyDiffCard.vue / ControlDiffCard.vue 抄結構
  • BE diff annotation pattern:ssp_docx_diff_service._annotate_party line 130~ 抄 current vs parsed compare logic
  • v3 bundle 寫入 path:ssp_components_app_service / ssp_inventory_items_app_service / ssp_leveraged_app_service(已支援 idx-based override per Bug L Phase C)
  • system_characteristic write strategy:system_characteristic_write_strategy.py(per Bug M-C 已寫 docx import path)

不在 scope(per O 收尾界線)

  • preview 階段 user 在 SystemCharacteristicSection / LeveragedSection 等編輯後重 import → 維持 v2-bundle content_overrides 的編輯路徑不動(這是 Bug L-2 + M-B 解的,跟 O 的 diff stepper 不同 phase — diff 是「重 import 對既有 SSP 的覆蓋決策」,content_overrides 是「single-import 的 inline edit」)
  • v2 legacy leveraged_services key — 已 Bug L 廢棄,不在 O 重啟
  • 跨 source 跟 Excel import diff 對齊 — Excel import 走自己 diff path(如有),不在 O 範圍

Bug O 教訓 (2026-05-25 完工後補)

  1. 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 的假設才能評估能否覆寫。

  2. 同一 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。

  3. FE store 三處(state / getter / initDefaultDecisions / setter)key 命名要 1:1 對齊 — store getter 改 <key>_diff 後,initDefaultDecisions 內三個 for-of 仍讀 raw key → decisions.components map 永遠空 → bulkApplyObject.keys(map) 跑 0 次 → 「全部採用新值」按鈕視覺無變化。正解:FE store 改 key 時 grep 該 key 所有 read 位置統一改;review 時三段(state init / getter / action)必同步檢查。

  4. 「對齊既有 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。

  5. 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

  • BE 3 commits:
    • a0408de2 Phase A — diff service 4 key annotation + summary
    • bedbb91c Phase B — confirm decisions filter(overwrite caveat)
    • b0032e46 Bug O regression fix — 4 v3 list 用 <key>_diff 獨立 key + GET 路徑 wire
  • FE 4 commits:
    • 4c5ff7d Phase C — store decisions schema
    • 7e34aeb Phase D — 4 DiffSection + 3 DiffCard
    • 061a742 Phase E — DiffResolutionStep TabView refactor
    • 5f6458b regression fix + UI 微調(store _diff 對齊 / banner i18n / Step 3 確認按鈕綠色 / tab name 對齊 template-edit)
  • 跨 4 repo? 本期只動 BE + FE — jedi-* 套件無異動,無 path dep 殘留

11.35 Bug P — docx import preview 端 PartiesSection 「連結」按鈕 wording 對 organization 失準

症狀:對既有 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 漏跟著分流
  • icon 也只用通用 pi-link,沒對 type 分(人 vs 建築)

Fix

  • PartiesSection.vue org row button :label 改用 party_link_button_org / party_relink_button_org,icon 改 pi-building
  • PartiesSection.vue person row button :label 改用 party_link_button_user / party_relink_button_user,icon 改 pi-user
  • 新增 5 個 i18n key (zh-tw + en):party_link_button_user/_orgparty_relink_button_user/_orgrole_select_placeholder
  • 保留舊 party_link_button / party_relink_button key 不刪(避免其他元件 reference 斷裂)

Bug P 教訓

  1. dialog title 對 type 分流時 button label 必須同步跟進:PartyLinkDialog 自己內部很乾淨(type 分流 dialog title / API / onSelect 都對),但 caller (PartiesSection) 那層 button label 漏掉跟著分流 — 是常見的「設計分流 propagate 不完整」漏網點。改 dialog wording 對 type 分流時,grep 所有 open dialog 的 caller,button / link / menu 等 trigger 也要對齊
  2. 「Error」報告未必是真 throw:user 報「跳 Error」code reasoning 推測是「wording 錯 → 開錯 dialog → 搜不到 → 認知為 Error」的 UX 困惑,不是 JS 真 throw。reproduce 受 jedi-oscal docx revision normalize tmp file bug 擋無法走完 confirm flow(job 172 status=failed but BE 回 200),標 follow-up;但 wording fix 已足夠 cover user 報告

Commit clause

  • FE 1 commit (與 Bug Q 合 commit):
    • fa43b79 fix(ssp-oscal-alignment): Bug Q + Bug P — PartiesSection 補 role Dropdown + button wording 對 party_type 分流
  • 跨 repo? 本期只動 FE — BE / jedi-* 無異動

11.36 Bug Q — docx import preview 端 PartiesSection 缺 role Dropdown

症狀:對 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,從來沒有過 Dropdown
  • User「原本好像有」記憶來源應該是 template-edit 端的 ModuleFramePartiesPanel.vue:438-446 — 那邊早就有完整 Dropdown + menuStore.sspPartyRoleMenu (9 role keys, C1 system_menus 'ssp_party_role')
  • 兩處 enum 走樣:preview 5 hardcoded keys vs template-edit 9 keys from master

Fix

  • PartiesSection.vue 刪掉 hardcoded ROLE_LABEL map
  • 加 import menuStore.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 共用同份)
  • org row + person row template:保留現值(keep_current)時維持 read-only label,編輯模式給 Dropdown(v-model 走 onFieldEdit(idx, 'role', v) emit 給父 update)
  • 新增 i18n key role_select_placeholder「(選擇角色)」

Bug Q 教訓

  1. FE 兩處 UI render 同個 entity 欄位(role)時必須共用同個 master data source:preview 跟 template-edit 都 render party role,但前者 hardcode 5 個、後者走 menuStore master 9 個。改 master(C1 已清過 manager/auditor/viewer 等專案角色,因為它們不該出現在 OSCAL party role)時 hardcode 端不會跟進 → 不一致。正解:FE render OSCAL enum / master data 一律走 menuStore,禁止 hardcode label map。設計新元件時 check 是否已有對應 menuStore key
  2. 「user 記憶 = 真的有過」要先 git log verify 才能 trust:CLAUDE.md memory feedback_plan_vs_reality_verify_first 已記錄。本 Bug Q 一開始假設「H-N refactor 移除 role dropdown」是 regression,git log 一查發現是 from day-1 就缺。沒先 verify 就動手追 regression history 會浪費時間

Commit clause

  • FE 1 commit (與 Bug P 合 commit):
    • fa43b79 (同 §11.35)
  • 跨 repo? 本期只動 FE — BE / jedi-* 無異動

11.37 Bug R — confirm 匯入後 template-edit / project-planning 端 parties 顯示 stale 舊資料

症狀:對既有 mf 重 docx import → 改 party name → Step 3 確認匯入 → 成功 router push 回 template-edit page → 「責任單位 / 責任人員」tab 顯示舊 name,需手動 reload 才看到新值。

Root cause(雙重 verify)

  1. BE 真有寫
    • log line 683-718:[ssp-confirm] calling ModuleFrameWriteStrategy.write_parties with 5 parties... [ssp-confirm] strategy.write_parties returned parties_written=5
    • DB oscal.ssp_docx_parse_jobs.import_summary 顯示 parties_written: 5parties_unlinked: 0
  2. FE cache 故意永久
    • useModuleFrameParties.js 注解 line 8-12 明說「2026-05-21:原本 5s TTL 造成 panel 重 mount → cache 過期 → 重抓 迴圈,改成永久 cache(依使用情境 — parties 唯一 mutator 是本元件)」
    • 同樣模式:useSspParties.js (C5 phase) 也是 module-scope 永久 cache + 並發合併
    • 但「永久 cache」設計只有 ModuleFramePartiesPanel 自己 CRUD 時 call invalidate() — docx import 這條 「外部寫入後 router.push 回 template-edit」path 從沒呼叫 invalidate
  3. SspDocxImportPage.vue:649-691 confirm fn 寫完只做 draftRef.clear(parseUid) + parseUid=null + router.push(...)沒 invalidate parties cache
  4. 結果:router.push 回 template-edit → ModuleFramePartiesPanel onMounted → loadParties cache hit 拿到 stale data → user 看不到新值

Fix

  • SspDocxImportPage.vue import 加 useModuleFrameParties + useSspParties
  • confirm fn 在 draftRef.clear() 之後、router.push 之前加:
    • update-mf / create mode → invalidateMfParties(targetMfUid)
    • update-ssp mode → invalidateSspParties(targetSspUid)
  • 兩 composable 都已內建 cache + 並發合併邏輯,invalidate 後下一次 loadParties 會真打 API

Bug R 教訓

  1. 永久 cache 設計必須伴隨「每個寫入 path 都 invalidate」SOP:把 5s TTL 改永久 cache 是為了治「重 mount 重抓 迴圈」(合理),但代價是「寫入後失效」變成 explicit duty,必須在 every mutation 收尾 call invalidate — 漏掉就 stale。本 Bug R 是「外部元件寫入 → router 跳轉」這條 path 沒收尾,CRUD path 倒是有。改 cache 策略時 grep 該 entity 所有 write site 列出 invalidate checklist
  2. 三段 verify root cause:「BE 沒寫 / FE cache / Bug O regression」三選一前,先用 BE log + DB import_summary + DB row count 三個 source 驗 BE 寫的真實狀態,再決定怎修 — 避免靠直覺猜(per memory feedback_verify_db_state_before_writing_fixes)。本期 BE log + import_summary 直接確認 BE 寫 OK 0 ambiguity,省一輪追錯
  3. 跨 cache context (MF + SSP) 都要 cover:composable 有兩份 useModuleFrameParties + useSspParties(per ModuleFramePartiesPanel 註釋 line 7:「Phase C/B (2026-05-23) 加 apiBase prop 後同元件服務 MF + SSP 兩個 context」),fix 兩 mode 都要 invalidate 對的那份

Commit clause

  • FE 1 commit:
    • 551c7dc fix(ssp-oscal-alignment): Bug R — confirm 後 invalidate ModuleFrame / SSP parties cache
  • 跨 repo? 本期只動 FE — BE / jedi-* 無異動

11.35-37 跨 bug 收尾 follow-up(jedi-oscal)

reproduce 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 causeModuleFrameWriteStrategy.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。


11.39 Bug T+U — docx import 對 mf source components/leveraged 不寫入 + 衍生 SSP shell 1:1 invariant 失守 (architecture root fix)

症狀(連環):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)。

規範條文(給未來工程師)

  1. 每個 mf 最多 1 個 template SSP shelltemplate_module_frame_id 非 NULL 唯一)
  2. create_shell 是 mf creation flow 專屬 — 其他 flow(docx / Excel import / launch_new_round / version bump)只能 resolve;找不到 shell 是 caller 設計錯誤,不該 fallback create
  3. resolve_existing_shell self-heal — 找到 ssp 但 sys_impl / SC / metadata 缺都該補建,不拋 412
  4. Caller 直接呼叫 create_shell(跳過 ensure_shell)會被 L2 idempotency 早 raise + L1 DB constraint 守底線
  5. ensure_shell 的 fallback create 只在「mf 從沒任何 shell」場景 fires — 經 L3 self-heal 強化後,正常 path 永遠不該 trigger fallback

改動範圍(commit 對照)

  • jedi-oscal SystemSecurityPlanQueryEntitytemplate_module_frame_id field(643d446
  • BE SspShellService.create_shelltemplate_module_frame_id=mf.id + L2 idempotency check(e20bb9c9 + 後續)
  • BE SspShellService.resolve_existing_shell 優先用 template_mf + L3 self-heal(e20bb9c9 + 後續)
  • BE api/oscal/serializers/ssp/ssp_docx_import.py 加 4 個 v3 decisions field(a91f1eb3
  • FE SspDocxImportPage.vue payload wire 4 個 v3 decisions(9b64c02
  • DB migration scripts/sql/2026-05-26-bug-t-cleanup-ssp-orphans-and-unique-constraint.sql:清 7 個 orphan/empty shell + 建 L1 unique index
  • Tests:test_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.40 Bug T Phase 2 — mf template defaults schema 對稱化(根治廢物 SSP 累積 + user 寫的看不到)

緣由:§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_confirmsc_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)

  • Before:mf template-edit 寫的 components/LA/SC 存到「mf 對應的某筆 SSP」;重做 docx import 可能找到不同 SSP → user 寫的看不到;每次 import 可能建新 orphan SSP
  • After:mf template-edit 讀寫 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

教訓

  1. Schema 對稱性是防 hack 的根本 — 4 個 tab 沒有對應 mf-scoped 表,造成 code-level hack 累積 6 個 orphan SSP + user 寫的看不到。補表是根治,不是補 code 繞過
  2. SC insert 不能只 UPDATE — 多層 clone flow(start_oscal_project → _setup_ssp_system_implementation → _clone_sc_from_template)都依賴「前一層先建 SC shell」,任一環失敗就 rowcount=0。直接 upsert(先查再 INSERT/UPDATE)才是穩定解
  3. 多 entity 共用 NULL role_id 需 per-entity 去重 — idempotency check 不能只用 role_id 一維;NULL role_id 多筆場景下必須 per (party_uuid, role_id) 組合做精確追蹤
  4. diff 現值來源必須跟 write path 同源 — write 改到 mf-default,read(現值比對)也必須從 mf-default 讀;兩個路徑不同源直接產生「現值不正確」的視覺 bug

Status: ✅ FIXED — 2026-05-26 結案


11.41 Excel 匯入匯出全面對齊(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 description
  • AO 現況:existing_obj_map lookup 從 assessment UUID 改對齊 FE aoLetterKey() 邏輯([a]"(a)"
  • 受評標的:_sc_write_strategy 補注入,preview reconcile 補 _preview_reconcile_controlsmatched_catalog_control_id

docx import 補存修正_run_mf_default_confirm):

  • Component props(protocol/port_ranges/security_auth)之前 comp_dict.get("props") 永遠 None,改從 flat fields 打包
  • LA props(provider/fedramp_package_id/impact_level 等 6 欄)同樣補存

API response 修正

  • ModuleFrameLeveragedService._to_dict:props 扁平化,補回 provider/fedramp 等頂層欄位(FE 讀 data.providerdata.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 頁面能看到資料。

啟動專案重複問題修正

  • 根本原因:Step 6c 同時寫 template SSP OSCAL tables + MF defaults;啟動專案兩條 clone 路徑都執行 → 重複
  • 修法:確立 MF defaults 為 single source of truth_setup_ssp_system_implementation Step 3-5 有 MF defaults 資料時 skip template SSP clone
  • module_frame_template_copy_service.copy() Phase 2 獨擔從 MF defaults → project SSP 的寫入

FE 修正

  • ModuleFrameTemplateEditView:移除 templateSsp gate,直接 mount ModuleFrameComponentsLeveragedInventoryTab
  • SheetPreviewParties:Tab 名稱對齊 Excel,補欄位顯示(職稱/電話/地址),角色改用 menuStore.sspPartyRoleMenu
  • 資產清冊 DataTable:補顯示資產標籤/FQDN/主機名稱/軟體/作業系統欄位

架構決策:MF defaults = single source of truth

template-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

Commits(BE 16個 + FE 4個 + package bumps)

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 資產清冊補欄位

教訓

  1. write path 和 read path 必須同源 — mf-default write 後,export/display 也要讀 mf-default;兩者不同源立即產生「空白」bug
  2. 同時寫兩份資料必然產生雙重問題 — Step 6c 同時寫 template SSP + MF defaults,啟動專案時兩條 clone path 都觸發;確立 single source of truth 才是根治
  3. Excel key-value 對應要同時做 export 轉換和 import 反向 mapping — 不能只改 enum_values 沒改 parser,或只改 export 沒改 import
  4. API response 欄位巢狀 vs 扁平要跟 FE 確認_to_dictprops:{...} 但 FE 讀 data.provider,debug 難度很高;設計時先確認 FE 讀哪一層

Status: ✅ FIXED — 2026-05-26 結案