# 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](https://github.com/usnistgov/OSCAL/blob/main/src/metaschema/oscal_implementation-common_metaschema.xml) + 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。

```sql
-- /scripts/sql/2026-05-XX-ssp-oscal-alignment.sql

-- 1. components: 所有外部依賴 + 系統元件
CREATE TABLE oscal.ssp_components (
    id                          SERIAL PRIMARY KEY,
    uid                         VARCHAR(36) UNIQUE NOT NULL,
    ssp_id                      INT NOT NULL REFERENCES oscal.system_security_plans(id) ON DELETE CASCADE,
    component_type              VARCHAR(40) NOT NULL,
        -- enum: this-system / system / interconnection / software / hardware /
        --       service / policy / physical / process-procedure / plan /
        --       guidance / standard / validation / network
    title                       VARCHAR(255) NOT NULL,
    description                 TEXT,
    purpose                     TEXT,
    status                      VARCHAR(20),
        -- enum: operational / under-development / under-major-modification /
        --       disposition / other
    leveraged_authorization_uid VARCHAR(36),
        -- 指向 ssp_leveraged_authorizations.uid (僅 FedRAMP 等繼承授權服務有值)
    props                       JSONB,
        -- 框架專屬屬性：{ protocol, port_ranges, security_auth, mac_address, ... }
    tenant_id                   INT NOT NULL,
    org_unit_id                 INT,
    is_active                   BOOLEAN NOT NULL DEFAULT TRUE,
    created_at, created_user, updated_at, updated_user
);

-- 2. leveraged-authorizations: 有 FedRAMP 等正式授權的服務
CREATE TABLE oscal.ssp_leveraged_authorizations (
    id                          SERIAL PRIMARY KEY,
    uid                         VARCHAR(36) UNIQUE NOT NULL,
    ssp_id                      INT NOT NULL REFERENCES oscal.system_security_plans(id) ON DELETE CASCADE,
    title                       VARCHAR(255) NOT NULL,
    party_uuid                  VARCHAR(36),
        -- 指向 oscal.parties.uid (CSP / vendor 公司)
    date_authorized             DATE,
    props                       JSONB,
        -- { fedramp_package_id, impact_level, data_types, nature_of_agreement,
        --   authorized_users, ... }
    remarks                     TEXT,
    tenant_id                   INT NOT NULL,
    org_unit_id                 INT,
    is_active                   BOOLEAN NOT NULL DEFAULT TRUE,
    created_at, created_user, updated_at, updated_user
);

-- 3. inventory-items: 實體資產
CREATE TABLE oscal.ssp_inventory_items (
    id                          SERIAL PRIMARY KEY,
    uid                         VARCHAR(36) UNIQUE NOT NULL,
    ssp_id                      INT NOT NULL REFERENCES oscal.system_security_plans(id) ON DELETE CASCADE,
    description                 TEXT NOT NULL,
        -- OSCAL inventory-item.description 必填
    props                       JSONB,
        -- { asset_id, asset_tag, ipv4_address, ipv6_address, mac_address,
        --   fqdn, hostname, software_name, os_name, ... }
    tenant_id                   INT NOT NULL,
    org_unit_id                 INT,
    is_active                   BOOLEAN NOT NULL DEFAULT TRUE,
    created_at, created_user, updated_at, updated_user
);

-- 4. inventory_implemented_components: M2M (inventory → components)
CREATE TABLE oscal.ssp_inventory_implemented_components (
    inventory_item_id           INT NOT NULL REFERENCES oscal.ssp_inventory_items(id) ON DELETE CASCADE,
    component_id                INT NOT NULL REFERENCES oscal.ssp_components(id) ON DELETE CASCADE,
    PRIMARY KEY (inventory_item_id, component_id)
);

CREATE INDEX idx_ssp_components_ssp ON oscal.ssp_components(ssp_id, is_active);
CREATE INDEX idx_ssp_components_lev ON oscal.ssp_components(leveraged_authorization_uid)
    WHERE leveraged_authorization_uid IS NOT NULL;
CREATE INDEX idx_ssp_leveraged_ssp ON oscal.ssp_leveraged_authorizations(ssp_id, is_active);
CREATE INDEX idx_ssp_inventory_ssp ON oscal.ssp_inventory_items(ssp_id, is_active);

GRANT SELECT, INSERT, UPDATE, DELETE ON
    oscal.ssp_components,
    oscal.ssp_leveraged_authorizations,
    oscal.ssp_inventory_items,
    oscal.ssp_inventory_implemented_components
    TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE
    oscal.ssp_components_id_seq,
    oscal.ssp_leveraged_authorizations_id_seq,
    oscal.ssp_inventory_items_id_seq
    TO cm_app;

-- ⚠️ WARNING (2026-05-24)：以下 RLS policy 表達式有 delimiter bug — 用 ','
--    但實際 session_scope 設的 `app.allowed_tenant_paths` 是 slash-delimited
--    path 格式 '/1/102/'。Phase 1 Task 9 用 cmmgr (superuser) verify 沒踩到，
--    Phase 2 Bug A fix 後 cm_app 第一次真寫即炸 InvalidTextRepresentation。
--    正確表達式（trim '/' + split by '/'）見 §11.16，已 ship in
--    `scripts/sql/2026-05-24-ssp-oscal-alignment-fix-rls-delimiter.sql`。

-- RLS (mirror oscal.system_security_plans policy pattern)
ALTER TABLE oscal.ssp_components ENABLE ROW LEVEL SECURITY;
CREATE POLICY ssp_components_rls ON oscal.ssp_components
    FOR ALL TO cm_app
    USING (
        current_setting('app.is_super_admin', TRUE) = 't'
        OR tenant_id = ANY(string_to_array(current_setting('app.allowed_tenant_paths', TRUE), ','))::INT[]
    );

ALTER TABLE oscal.ssp_leveraged_authorizations ENABLE ROW LEVEL SECURITY;
CREATE POLICY ssp_leveraged_authorizations_rls ON oscal.ssp_leveraged_authorizations
    FOR ALL TO cm_app
    USING (
        current_setting('app.is_super_admin', TRUE) = 't'
        OR tenant_id = ANY(string_to_array(current_setting('app.allowed_tenant_paths', TRUE), ','))::INT[]
    );

ALTER TABLE oscal.ssp_inventory_items ENABLE ROW LEVEL SECURITY;
CREATE POLICY ssp_inventory_items_rls ON oscal.ssp_inventory_items
    FOR ALL TO cm_app
    USING (
        current_setting('app.is_super_admin', TRUE) = 't'
        OR tenant_id = ANY(string_to_array(current_setting('app.allowed_tenant_paths', TRUE), ','))::INT[]
    );

-- Join table: 透過 parent inventory_items 拿 tenant，自身不需 RLS column
-- (PG RLS 對 PK-only 表 ENABLE 仍可，access path 走 inventory_items policy)
```

**Migration 帳號**：跑這段 SQL 一律用 `cmmgr`（`cm_app` 受 RLS 擋，會 INSERT 0 rows 靜默失敗 — 參考 CLAUDE.md「跑 SQL migration 一律用 cmmgr」）。

#### 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 它）：

```sql
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）：

```sql
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 前必跑）**：

```sql
-- 雙邊 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**：

```sql
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 不跟。

```python
# 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：

```python
# 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

```python
# 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`：
  ```json
  {
    "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：

```python
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 欄位：

```python
# 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`：

```python
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 函式

```python
# 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

```python
# 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

```python
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

- [ ] OSCAL official JSON schema validator 對 export 結果 pass
- [ ] Round-trip e2e test: Excel import → DB → OSCAL export → re-import 後資料一致
- [ ] 既有 SSP 資料 migrate 後 row count 雙邊一致（Step 3 verification SQL 通過）
- [ ] 舊 `oscal.ssp_system_implementation_items` 表 DROP、套件側 `SspSystemImplementationItemEntity` + repo + service + model + mapper 刪除
- [ ] Excel + Docx 兩條 import path 寫入同一份 DB（單一 orchestrator）
- [ ] FE 預覽 UI 對 components / leveraged-auth / inventory 三類概念可視化分離
- [ ] FK resolve fail 走 warn-but-allow，import_warnings 結構化呈現

## 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. 參考資源

- [OSCAL SSP 官方 reference](https://pages.nist.gov/OSCAL/learn/concepts/layer/implementation/ssp/)
- [OSCAL metaschema (main)](https://github.com/usnistgov/OSCAL/blob/main/src/metaschema/oscal_implementation-common_metaschema.xml)
- [Component type enum (allowed-values shared-constraint)](https://github.com/usnistgov/OSCAL/blob/main/src/metaschema/shared-constraints/allowed-values-component-type.ent)
- Stage 1 prerequisite: `docs/features/FR-027-2605-docx-import-parity/design.md`
- 客戶 docx 結構分析：`docs/features/FR-027-2605-docx-import-parity/handoff/2026-05-24-stage1-overnight-SUMMARY.md`

---

## 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_uid` 用 `VARCHAR(36)`。

**實際做**：全部用 `Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), default=uuid.uuid4, ...)`。Entity attribute 也是 `Optional[uuid.UUID]`，tests 用 `uuid.uuid4()` 而非字串 placeholder。SQL migration（Task 7-9）寫 `UUID` 不是 `VARCHAR(36)`，`gen_random_uuid()` 不需 `::TEXT` cast。

**原因**：mirror `oscal_party.py` 既有 UUID 慣例（套件早已從 VARCHAR(36) 升級）。`leveraged_authorization_uid` 跟 leveraged_auth.uid 型別必須一致才能 join。

**Commits**: `ab66af0` (LeveragedAuth entity), `bf12dc9` (LA ORM), `157d2bc` (Component entity), `fdfd317` (Component ORM)

---

### 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:
```python
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_items` 有 `i.ssp_id` / `i.service_name` / `i.fedramp_package_id` / `i.impact_level` / `i.data_types` / `i.nature_of_agreement` / `i.authorized_users` / `i.protocol` / `i.port_ranges` / `i.security_auth` / `i.remarks` / `i.is_active` 12 個欄位
- 假設 `oscal.system_security_plans` 有 `s.tenant_id` / `s.org_unit_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_type` ← `CASE implementation_type WHEN 'hardware' THEN 'hardware' WHEN 'system' THEN 'system' WHEN 'leveraged-authorization' THEN 'service' ELSE 'other' END`
- `title` ← `COALESCE(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 寫**：

```sql
USING (
    current_setting('app.is_super_admin', TRUE) = 't'
    OR tenant_id = ANY(string_to_array(current_setting('app.allowed_tenant_paths', TRUE), ','))::INT[]
)
```

**實際 jedi-common `session_scope` 設**：`app.allowed_tenant_paths = '/1/102/'`（slash 格式 path）

**錯誤**：split by `,` 得到 single-element `['/1/102/']`，cast `::INT[]` 炸：

```
psycopg.errors.InvalidTextRepresentation:
  invalid input syntax for type integer: "/1/102/"
```

**Phase 1 Task 9 verify 用 cmmgr (superuser bypasses RLS)** 沒踩到 → false-positive ship。

**Phase 2 Bug A fix 後**，docx confirm 第一次從 cm_app 真寫 `ssp_components` / `ssp_leveraged_authorizations` / `ssp_inventory_items` → trigger RLS check → 炸。

**正確 policy（trim '/' + split by '/'）**：

```sql
USING (
    current_setting('app.is_super_admin', TRUE) = 't'
    OR tenant_id = ANY(
        string_to_array(
            trim(both '/' from current_setting('app.allowed_tenant_paths', TRUE)),
            '/'
        )::integer[]
    )
)
```

驗證 chain：`trim('/1/102/', '/')` → `'1/102'` → `string_to_array('1/102', '/')` → `['1', '102']` → `::integer[]` → `[1, 102]`。

**Migration**：`scripts/sql/2026-05-24-ssp-oscal-alignment-fix-rls-delimiter.sql`（DROP + recreate 4 policies — `ssp_components_rls` / `ssp_leveraged_authorizations_rls` / `ssp_inventory_items_rls` / `ssp_inventory_implemented_components_rls`）

**M2M join table policy 同步補強**：原 spec 沒明列 `ssp_inventory_implemented_components` 的 policy（comment 寫「不需 RLS column」），但實際 Phase 1 Task 7-9 ship 的 SQL 已含一條 policy。本次 migration 同樣 DROP + recreate 該 policy（EXISTS subquery 走 parent `ssp_inventory_items.tenant_id`），表達式內的 delimiter 也一併修正。

**教訓**：

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 沒實作：

```python
# 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 邊界規範）：

```python
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.py` 加 `extract_components_table` + `extract_inventory_items_table`；adapter (`domain/oscal/adapter/cmmc_ssp_adapter.py`) `adapt_to_bundle()` 呼叫新 extractors。**樣板 .docx 檔案沒實際加 H2 區段** — extractor 對 missing section 返 `[]`，不影響既有 T6 (CSP/CSO) / T7 (Category) 路徑。

**原因**：樣板 .docx 編輯（圖形 Word）成本高且需 user 操作；code 端先 ready，user 之後加 H2 區段就生效。

**影響**：本期 docx import 沒寫入 components / inventory_items（只寫 leveraged_authorizations），這是有意的 throttle，不是 bug。

---

**§Phase 3 Bug C — v2-bundle confirm 套 content_overrides**：

**症狀**：user 在 docx preview UI 改 component 類型 / LA 欄位 → submit 後 DB 仍是 parse 時原值。

**Root cause**：`ssp_docx_import_app_service._run_v2_bundle_confirm` 入口直接 `dict_to_bundle(parsed_result["bundle"])`；**沒套 `content_overrides`**。對比 Excel side `_apply_content_overrides` 有完整 metadata / system_characteristic / sheet rows overrides handling。

**Fix (`8e8d5624`)**：在 `_run_v2_bundle_confirm` 入口加 `_apply_v2_bundle_overrides()` helper，對 bundle.components / bundle.leveraged_authorizations / bundle.inventory_items 三 list 做 row-level update（mirror Excel `_apply_content_overrides` 的 sheet-row loop logic）。

**教訓**：v2 → v3 過渡期，Excel / docx 兩條 confirm path **必須對齊 override handling**。Phase 3 spec 沒明寫此項——是收尾 e2e 才踩到的 latent bug。後續加 v3 features (Phase 4+) 必須對 Excel/docx wire-up 對齊。

**Commits**: `f7eaa09f` (§3.1)，`8ac3e35b` (Excel parser v2-bundle dict shape derive)，`c115b7bc` (§3.2 docx extractors)，`910b7f5a` (§3.3 reference regen)，`8e8d5624` (Bug C content_overrides fix)

---

### 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**：

```javascript
// 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**：

```javascript
// PartyDiffCard.vue — fields 從 4 個擴 7 個
const fields = [
    { key: 'name', label: '姓名' },
    { key: 'title', label: '職稱' },     // ← new
    { key: 'role', label: '角色' },
    { key: 'email', label: 'Email' },
    { key: 'telephone', label: '電話' },  // ← new
    { key: 'address', label: '地址' },    // ← new
    { key: 'matched_user_id', label: '對應使用者' }
]
// formatValue 新增 telephone alias 處理（ParsedParty entity 用 telephone_number；raw extractor dict 用 telephone）
if (key === 'telephone') return raw || values.telephone_number || ''
```

**為何同時 handle 兩個 key alias**：BE 兩條 path 序列化欄位名不同 — raw extractor dict 直接 `telephone`；`ParsedParty` entity 走 `telephone_number`。Mirror 既有 email 處理（`email` vs `email_address`）。

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

**測試結果**：

- 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 驗證：

```sql
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`：

```python
if not role_id or not party_uid:
    return    # ← role_id="" 直接 silent return
```

caller `write_parties:530` 傳 `role_id=parsed.role or ""`。`parsed.role` 來自 `_dict_to_parsed_parties:1104` 的 `role=p.get("role")`。docx party JSONB blob 由 `domain/oscal/parser/docx_section_extractors._parse_party_table` 抓出來，**只 set name / title / address / telephone / email / party_type，沒 set `role`** → `p.get("role")` 是 None → 整條 chain `None → "" → silent skip`。

**為何 Excel 沒踩同一坑 / docx 漏修**：

`app/oscal/service/ssp_excel_import_app_service.py:1956-1968` 註解清楚記錄 2026-05-21 已修同一 bug — Excel 02_單位 sheet 也沒 role 欄位，當時 fix 在 `_build_parties` adapter 補 `role=(o.get("role") or "responsible-organization")`。**但 docx adapter (`_dict_to_parsed_parties`) 從沒套這個 fix** — 大概是 Excel fix 時沒做 cross-source consistency 檢查（兩個 import path 各自獨立 adapt parser dict → ParsedParty，沒共用 helper）。

**為何 §11.22 fix 不夠 — Bug B 其實有兩段**：

| 段 | 位置 | 症狀 | 修法 | Commit |
|---|---|---|---|---|
| §11.22 | FE store `initDefaultDecisions` | added parties decision=null → payload skip → BE 收 skip → `_filter_parties_for_write` 過濾掉 → 0 parties to write | per-diff_status seed default action | `f31d0cf` |
| §11.24 | BE `_dict_to_parsed_parties` | decision=use_docx 通過 §11.22 但 `ParsedParty.role=None` → `_upsert_responsible_party` silent skip → oscal_parties 寫 5 但 link 0 | mirror Excel pattern 補 default role | 本 commit |

**Fix**：

```python
# 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）：

```sql
SELECT jsonb_array_length(parsed_result->'leveraged_services'),       -- 4
       jsonb_array_length(parsed_result->'leveraged_authorizations')  -- 1
  FROM oscal.ssp_docx_parse_jobs WHERE id=146;

SELECT count(*) FROM oscal.ssp_leveraged_authorizations WHERE ssp_id=266;
-- 1 (預期 4)
```

**Root cause**：`domain/oscal/adapter/cmmc_ssp_adapter.py:adapt_to_bundle` 把 docx 內兩種 leveraged 表分成不對稱的兩 path：

| Table | 內容 | 原 emit | 結果 |
|---|---|---|---|
| Table #6 (CSP/CSO 7-col) | FedRAMP service (provider / impact_level / fedramp_package_id) | `ParsedLeveragedAuthorization` + paired `ParsedComponent` | LA 寫到 DB |
| Table #7 (Category 5-col) | 一般外部服務 / 互連 / API (category / protocol / port_ranges / security_auth) | `ParsedComponent` only — **無 LA** | LA 完全沒寫 |

User docx 4 筆 services 分佈：1 筆 T6 (MDR) + 3 筆 T7 (Microsoft Windows Update / Fortinet / 印表機)。Adapter 把 T6 → 1 LA，T7 → 0 LA，導致 confirm path 只有 1 筆 LA 可寫。但 user-facing「外部利用服務」tab 讀 `oscal.ssp_leveraged_authorizations` 表（`module_frame_leveraged_service.list_items` → `SspLeveragedContextService._fetch_items`），且該表的手動新增 UI 接受 `category=external-service / interconnection / api` — **product 已採「廣義 leveraged_authorization」語意，包含 T6 + T7 兩種**。Adapter 分流的 OSCAL 嚴格語意（LA = FedRAMP ATO 限定）跟 product 語意不一致，導致 docx import 寫入只覆蓋一半。

**為何 Excel 沒踩同坑 / docx 漏修**：

Excel 端 `SHEET_LEVERAGED` 每行 = 1 LA（1:1），沒有 T6/T7 拆兩表。docx 拆兩 table 是 docx 特有版型，從 Phase 2 Task 8 cmmc_ssp_adapter.adapt_to_bundle 一刀切到現在（commit `ba0cdd7a`）。Phase 4 Bug B fix Cross-source consistency 教訓（§11.24）談的是 party adapter；leveraged adapter 同樣 cross-source 不一致但被忽略到 user 踩。

**Fix Options**：

| Option | 改動 | Pro | Con |
|---|---|---|---|
| A（採用）| T7 也 emit LA（mirror T6 pattern）+ `ParsedLeveragedAuthorization.props` 新欄位帶 category / protocol / port_ranges / security_auth | 一處修，user 視角一次解；非 OSCAL 標準欄位透過 props JSONB 走，跟 product 既有 manual add UI 對齊 | LA dataclass 多 props 欄位（小成本）；T7 LA 跟 T6 LA 語意混淆（但 product 已 accept 廣義語意）|
| B | FE 預覽「外部利用服務 (N)」改成讀 Component 集合 + 寫入時 component → LA 同時建 | OSCAL 語意純粹 | FE 動的多，跨 repo；跟 manual add UI 不對稱（manual add 寫 LA，import 寫 Component）|
| C | 只在 BE 把 Component / LA 兩個 source 在 GET 端 union 起來 | 寫入路徑不動 | 「外部利用服務」變成虛擬 view，刪除 / 編輯 UI 要走兩條 path，user-facing 體驗劣化 |

選 **Option A** — minimum surface area，跟 manual add UI 行為一致。Component pair 仍 emit（保留 OSCAL component 概念）+ Component 用 `leveraged_authorization_ref=la.title` 連回 LA（mirror T6 pattern），未來若要嚴格做 Component / LA 概念區分有 ref 可走。

**Fix 改動範圍**：

| 檔案 | 改動 |
|---|---|
| `domain/oscal/parser/ssp_intermediate.py` | `ParsedLeveragedAuthorization` 新增 `props: Optional[Dict[str, Any]] = None` |
| `domain/oscal/adapter/cmmc_ssp_adapter.py:adapt_to_bundle` | Table #7 loop 也 append `ParsedLeveragedAuthorization`（含 `props={"category": ..., "purpose": ..., "protocol": ..., "port_ranges": ..., "security_auth": ...}` 非空欄位），保留 Component pair 並補 `leveraged_authorization_ref=title` |
| `domain/oscal/import_pipeline/bundle_restore.py:_restore_la` | round-trip `props=d.get("props")` |
| `domain/oscal/service/write_strategy/leveraged_write_strategy.py:write` | merge `parsed.props` 進 entity props JSONB；OSCAL 標準欄位（`fedramp_package_id` / `impact_level` / `data_types` / `nature_of_agreement` / `authorized_users`）priority 高，於 props key collision 時不被覆蓋 |
| `tests/test_cmmc_ssp_adapter_v3_bundle.py` | 更新 `test_adapt_to_bundle_t7_category_creates_component_only` → `test_adapt_to_bundle_t7_category_creates_la_and_component`；新增 `test_adapt_to_bundle_t6_t7_mixed_yields_all_las` 覆蓋 user docx 4 筆 regression |
| `tests/test_ssp_write_strategy.py` | 新增 `test_leveraged_write_merges_parsed_props` 驗 OSCAL precedence；`test_leveraged_write_props_only_yields_props` 驗 T7 pure-props row 行為 |

**驗證 (2026-05-25 E2E)**：

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.json` — `parties_organization_section_title`「單位」→「負責單位」、`parties_person_section_title`「人員」→「參與人員」
- `src/config/locales/i18n/zh-tw/module-frame.json` — `tab_responsible_org`「責任單位」→「負責單位」、`tab_responsible_person`「責任人員」→「參與人員」
- `src/components/grc/ssp-docx-import-v2/sections/PartiesSection.vue` — `ROLE_LABEL.responsible-organization`「責任單位」→「負責單位」；org card + person card 各 append 2 個 `<div>`（電話 + 地址 InputText）
- `src/components/grc/ModuleFramePartiesPanel.vue` — DataTable column header「單位名稱」→「負責單位名稱」；DataTable append 2 `<Column>`（電話 + 地址）；Dialog append 2 input（電話 + 地址，org + person 都顯示）

**驗證** (2026-05-25 E2E)：
- 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 cause**：`domain/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 cause**：`jedi_common/session/database/db_mw.py:set_tenant_info_before_insert` 在 SQLAlchemy `before_flush` event hook 對所有 `session.new` 自動填：
```python
if hasattr(obj, "org_unit_id") and getattr(obj, "org_unit_id", None) is None:
    obj.org_unit_id = user_ctx.org_unit_id  # admin = 98
```

設計上是 tenant scope 用途，但 `oscal_parties` / `ssp_leveraged_authorizations` / `ssp_components` / `ssp_inventory_items` 的 `org_unit_id` 是**業務語意「鉤稽到哪個部門」**，**兩種語意衝突**。

**影響範圍** (fix 前 DB 證據)：
- 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.py` — `set_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_insert` 把 `org_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_las` → `test_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 cause**：`app/oscal/service/module_frame_template_ssp_app_service.py:73` 呼叫 `self._ssp.get_by_id(ssp_id)` — 但 `SspService` (jedi-oscal `app/services/ssp/ssp_service.py:54`) 的 method 名稱是 `get_ssp_by_id`，**不是** `get_by_id`。`try/except: pass` 默吞 `AttributeError` → `ssp_uid` 永遠 None → FE check `!templateSspExists || !templateSspUid` 走 hint 分支。**Phase 4 E3 ship 以來從沒人見過真正的 panel**。

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

**Bug J 教訓**：
1. **`try/except: pass` 默吞 caller 錯誤是 anti-pattern** — silent failure 讓 bug 隱形數週。即使要 graceful fallback，至少要 `log.warning` 或 `log.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_id` → `get_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/ap` 或 `module_frame` 對應 fields） |
| B — Permission 雙模式 | `common/middleware/permission/ssp_permission.py` — 新增 `require_read_access` / `require_write_access`，project SSP 沿用既有 participant gate；template SSP 走 mf-level permission（dev 階段 `is_admin`，未來考慮 mf owner role）。保留既有 `require_participant` / `require_manager` 供漸進 migrate |
| C — 3 endpoint 改用新 method | `app/oscal/service/ssp_components_app_service.py` / `ssp_inventory_items_app_service.py` / `ssp_leveraged_app_service.py` — sed-rename 改用 `require_read_access` / `require_write_access` |
| DI | `di_containers/oscal/oscal_containers.py` — wire `SspContextResolver` 為 SspPermissionChecker 新 dep |
| Tab rename | `i18n/{zh-tw,en}/ssp-components-leveraged-inventory.json` — tab header 加新 i18n key `tab_header`「系統元件與資產」(避開既有 `section_components` 跟 sub-panel 1 重名)；對應 view 改 t() 呼叫 |
| Tests fix | `tests/test_ssp_leveraged_app_service.py` — sed-rename mocks `require_participant`/`require_manager` → `require_read_access`/`require_write_access` (5 fails 修，168 pytest passed) |

**Edge case 發現**：`SystemSecurityPlanEntity.template_module_frame_id` 雖然 entity 有此 field 但 Phase 4 E3 ship 時沒 wire（DB column 100% null）— 改走「ssp.profile_id → module_frames.oscal_profile_uid」反向 query。Future fix 可一併補 entity field + 加 fallback。

**Pre-existing fails surface**：`tests/test_ssp_resources_app_service.py` 2 + `tests/test_ssp_system_characteristic_app_service.py` 2 — 我沒動這 2 service，是 pre-existing 不關 K1，列為 future cleanup item。

**驗證**：mf 372 (`2c4ba0b8-...`) template-edit「系統元件、外部授權與資產」tab 3 sub-panel 全 render、0 console errors、0 SSP endpoint 404

**Bug K 教訓**：
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-1**：`SspComponentsLeveragedInventoryTab.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_level` 進 `ParsedSystemCharacteristic`；缺 `system_identifier` + `description` 對應 |
| BE docx confirm path | `system_characteristic_write_strategy` **只被 Excel import 接** (`ssp_excel_import_app_service.py:1693-1708`)，docx import 從沒呼叫 — `ssp_docx_import_app_service` `__init__` 沒 `system_characteristic_write_strategy` param，confirm path `_run_v2_bundle_confirm` 沒 wire write call |
| BE write strategy filter | `_PARSED_TO_ENTITY_FIELDS` 排除 `description` + `system_identifier`（comment「Excel sheet 沒這兩個 column」），即便 parsed_sc 有也被丟掉 |

**Fix scope (跨 BE 3 phase + FE 2 phase)**：

| Phase | 改動 |
|---|---|
| M-A 1 — Extractor enhance | `domain/oscal/parser/docx_section_extractors.py` 新增 `_extract_introduction_fields()` paragraph walker + `_normalize_categorization()` + `_is_sc_placeholder()` filter + `_SC_LABEL_MAP`；`extract_system_characteristic_from_metadata` 合併 intro_fields 進 result。**設計選擇**：只接 inline value（label: value 同行），不做「label-only 段 + 下一段為 value」cross-paragraph lookup — 避免把下一個 section heading（如「Responsible Organization 專案負責單位」）誤抓為 value |
| M-A 2 — Dataclass field | `ParsedSystemCharacteristic` 加 `system_identifier` + `description` 兩 optional field |
| M-A 3 — Adapter wire | `cmmc_ssp_adapter.adapt_to_bundle` 把新 fields 進 `parsed_sc` |
| M-B — FE preview section | 新 `src/components/grc/ssp-docx-import-v2/sections/SystemCharacteristicSection.vue`（含 範圍名稱 / 識別碼 / 安全分類等級 Dropdown / 描述 Textarea 4 欄位）；`SspDocxImportPage.vue` 加 「受評標的」TabPanel + viewModel 加 `system_characteristic` + draft.system_characteristic + confirm payload `content_overrides.system_characteristic` (shallow merge) |
| M-C — docx confirm 接 SC write | `ssp_docx_import_app_service.__init__` 加 `system_characteristic_write_strategy` param + helper `_dict_to_parsed_system_characteristic` (mirror Excel)；`_run_v2_bundle_confirm` 結尾呼叫 sc write strategy；DI container wire |
| M-C fix — write strategy filter | `system_characteristic_write_strategy._PARSED_TO_ENTITY_FIELDS` 加 `description` + `system_identifier` mapping（原 comment「Excel sheet 沒這兩個 column」過時 — docx 有抽到） |

**順手做的兩件 polish**（user 反饋）：
- 廢棄「文件資訊」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 import** — `system_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`：<br>- `annotate_parse_result` 加 4 個新 annotation loop（mirror parties pattern：current vs parsed 比對、產 `diff_status` ∈ `unchanged/changed/added/gone` + `default_action` ∈ `use_docx/keep_current`）<br>- `build_diff_summary` 加對應 4 key counts<br>- `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`：<br>- `decisions` 加 `components` / `leveraged_authorizations` / `inventory_items` / `system_characteristic` 4 sub-map<br>- `initDefaultDecisions` 加對應 init logic（per-diff_status default action）<br>- `buildConfirmPayload` 加對應 decision list 進 payload<br>- `diffSummary` getter 加新 key surface |
| **O-D**: FE 4 個新 diff section 元件 | 新檔（mirror PartyDiffCard / ControlDiffCard pattern）：<br>- `SystemCharacteristicDiffSection.vue`（單筆 diff card，含 4 fields）<br>- `ComponentsDiffSection.vue`（list of ComponentDiffCard，含 component_type / title / ref / props）<br>- `LeveragedAuthorizationsDiffSection.vue`（list of LaDiffCard，含 FedRAMP fields）<br>- `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 永遠空 → `bulkApply` 的 `Object.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/_org`、`party_relink_button_user/_org`、`role_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: 5`、`parties_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 進版時順手檢修。

---

### 11.38 Bug S — ModuleFrameWriteStrategy.write_parties stale-link cleanup 誤砍 keep_current

**症狀**：mf 343 (b8065ad5 / 艾爾航空) docx import 走 mix-decision（部分 use_docx 部分 keep_current）的場景，confirm 後 user 看 template-edit 「參與人員」tab 發現「保留現值」的 party 鉤稽資料消失（連結 user_id / org_unit_id 像被刪）。實際 `oscal_parties` row 還在，只是 `oscal_responsible_parties` link 被砍。

**Root cause**：`ModuleFrameWriteStrategy.write_parties` L539-544 stale-link cleanup：

```python
# 對 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 shell**（`template_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 `SystemSecurityPlanQueryEntity` 加 `template_module_frame_id` field（`643d446`）
- BE `SspShellService.create_shell` 寫 `template_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_confirm` 用 `sc_dict.get("name")` 但 `ParsedSystemCharacteristic` key 是 `system_name` | 改 `sc_dict.get("system_name") or sc_dict.get("name")` |
| diff 現值不正確 | `_load_current_v3_lists` module_frame 路徑走 `resolve_existing_shell` → 找到 SSP 219/228（project SSP），載入 project 資料當「現值」 | 新增 `_load_mf_default_v3_lists()` 直接從 mf-default 表讀 |
| `la_uid_to_title` KeyError | `_load_mf_default_v3_lists` return dict 少 `la_uid_to_title` key | 補齊 return shape |
| SC not copied to new project | `copy()` Step 7 是純 UPDATE；SSP 沒 SC shell row（`_clone_system_characteristic_from_template` 讀舊 SSP 無 SC）→ 0 rows | Step 7 改 upsert：先查是否存在，不存在則 INSERT from mf-default |
| 責任人員只 clone 2/6 | `_clone_module_frame_parties_to_ssp` idempotency check 用 `(role_id, context_type, context_id)`；5 筆 role_id=NULL 的 parties → 第一筆 INSERT 後，後 4 筆誤 skip | 改 per `(party_uuid, role_id)` set 追蹤 |

**行為差異（before vs after）**：

- **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_controls` 設 `matched_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.provider` 非 `data.props.provider`）
- `ModuleFrameComponentsService._to_dict`：add/update 從 flat payload 打包 props

**匯入 Step 6c — MF defaults 寫入**：
Excel import superset/update flow 在 `_run_v2_bundle_confirm`（寫 SSP OSCAL tables）後，補 Step 6c `_write_mf_defaults` 同步寫 MF defaults，讓 template-edit 頁面能看到資料。

**啟動專案重複問題修正**：
- 根本原因：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_dict` 回 `props:{...}` 但 FE 讀 `data.provider`，debug 難度很高；設計時先確認 FE 讀哪一層

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

