# Bug R Deeper — Implementation Plan

| 項目 | 內容 |
|---|---|
| 日期 | 2026-05-25 |
| Branch | `feature/ssp-oscal-alignment` |
| Root cause | `_upsert_responsible_party` match key 含 `role_id` → docx 改 role 時舊 row 不砍，累積 dup；下次 confirm 觸發 only_current emit → unlink 砍 8 row → 4 person link 全失 |
| 業務語義拍板 | **1 party 1 context 1 role**（user 2026-05-25 拍板）|
| 影響範圍 | BE 4 個 caller（`module_frame_write_strategy` / `ssp_write_strategy` / `party_context_service.add_party` / `oscal_project_service._copy_template`）+ DB schema (UNIQUE constraint) |
| 預估時間 | 2-3 小時 BE fix + tests + SQL migration |
| 接手前必讀 | `handoff/2026-05-25-bug-r-diff-service-duplicate-entry-handoff.md` §1.2 BE log evidence |

---

## 1. Root cause（已驗證）

### 1.1 真因 mechanism

`domain/oscal/strategy/module_frame_write_strategy.py:645-682` 和 `ssp_write_strategy.py:423-456` 兩處 `_upsert_responsible_party`：

```python
existing = self._responsible_party.get_one(ResponsiblePartyQueryEntity(
    role_id=role_id, party_uuid=party_uid,           # ← role_id 含在 match key
    context_type=context_type, context_id=context_id,
))
if existing is not None: return    # 同 4 元組才 idempotent
# 否則 INSERT 新 row
```

User docx 改 role：
1. 新 (uuid, **new_role**, mf 372) → get_one None → INSERT 新 row
2. 舊 (uuid, **old_role**, mf 372) row 沒被砍 — `write_parties` stale cleanup 只看 uuid（line 539-544），uuid 還在 processed_uuids 內，不算 stale

→ mf 372 累積 2 row per person，下次 confirm 走 `_load_current_parties` 拿到 dup → match_parties 4 個進 only_current → emit `diff_status=gone` → unlink 砍 8 row。

### 1.2 Log evidence (handoff §1.2)

| 時間 | 動作 | mf 372 link 表 |
|---|---|---|
| baseline | — | 5 row（per mf 368 baseline 推算）|
| 18:37 confirm #1 | write 4 person 新 role → INSERT，舊 role 不砍 | **9 row**（4 person × 2 + 1 org）|
| 18:41 GET | `_load_current_parties loaded 9 parties` ← BE log 直接證實 | 9 row |
| 18:41 confirm #2 | match 1-to-1 → 4 個進 only_current → unlink | **1 row**（org，4 person 全失）|

### 1.3 全表掃結果

```sql
SELECT party_uuid, context_type, context_id, count(*) FROM oscal.oscal_responsible_parties
 GROUP BY 1, 2, 3 HAVING count(*) > 1;
-- → 0 筆
```

確認 DB 沒 dup 殘留（mf 372 砍過頭剩 1，其他 mf 沒被踩到）。**髒資料清理不需 DELETE，只需補 UNIQUE constraint 防將來**。

---

## 2. Fix Plan

### Phase 2: BE `_upsert_responsible_party` cleanup-before-insert

**`domain/oscal/strategy/module_frame_write_strategy.py:645-682`**：

```python
def _upsert_responsible_party(self, role_id, party_uid, context_type, context_id):
    """Find-or-create oscal_responsible_parties link.

    Match key: (party_uuid, context_type, context_id) — 1 party 1 context 1 role
    (business decision 2026-05-25, Bug R deeper §11.38).

    當同 (uuid, context) 已存在但 role_id 不同 → 砍舊 row + INSERT 新 row
    （avoid update() — ResponsiblePartyEntity 沒 uid，BaseRepositoryImpl.update()
    對它炸 AttributeError）。
    """
    if not role_id or not party_uid: return

    existing = self._responsible_party.get_one(ResponsiblePartyQueryEntity(
        party_uuid=party_uid,
        context_type=context_type, context_id=context_id,
        # 注意：不傳 role_id — 我們要找該 (uuid, context) 的所有 role
    ))
    if existing is not None:
        if existing.role_id == role_id:
            return  # 完全相同 → no-op
        # role 不同 → 砍舊 row（natural key 升級為 (uuid, context)，不允許 dup）
        link_id = getattr(existing, "id", None)
        if link_id is not None:
            self._responsible_party.delete_by_id(link_id)

    self._responsible_party.add(ResponsiblePartyEntity(
        role_id=role_id, party_uuid=party_uid,
        context_type=context_type, context_id=context_id,
    ))
```

**`domain/oscal/strategy/ssp_write_strategy.py:423-456`**：同改法，加同 docstring。

**`ssp_write_strategy.py:333-338` 註解更新**（unlink_parties 段）：
```diff
-        All role bindings for the same party in this context are removed (a
-        party can hold multiple roles on one entity, e.g. system-owner +
-        reviewer for the same person; the user-facing intent is "remove this
-        person", not "remove this person's system-owner role only").
+        Removes the single link row for the given party in this context
+        (business decision 2026-05-25 Bug R deeper §11.38: 1 party 1 context
+        1 role enforced by UNIQUE constraint uq_rp_party_context).
```

### Phase 3: DB UNIQUE constraint

`scripts/sql/2026-05-25-add-rp-uniq-constraint.sql`：

```sql
-- Date: 2026-05-25
-- Bug R deeper — 強制 1 party 1 context 1 role natural key

-- 1. (2026-05-25) Pre-check：確認全表已無 dup
SELECT party_uuid, context_type, context_id, count(*) AS dup
  FROM oscal.oscal_responsible_parties
 GROUP BY party_uuid, context_type, context_id
HAVING count(*) > 1;
-- 預期：0 筆。若有 dup 必須先 cleanup（不在本期 scope，當 hotfix 處理）

-- 2. (2026-05-25) 加 UNIQUE constraint 防將來累積
ALTER TABLE oscal.oscal_responsible_parties
  ADD CONSTRAINT uq_rp_party_context
  UNIQUE (party_uuid, context_type, context_id);
```

跑前用 cmmgr 帳號（per memory `feedback_sql_migration_use_cmmgr`）。

### Phase 4: Tests

新增 / 擴充 tests：

- `tests/test_module_frame_write_strategy_v2_parties.py`：
  - 新 case `test_upsert_responsible_party_replaces_role_when_changed`：
    - Setup mf 372 既有 link (uuid=A, role='system-user', context=(mf, 372))
    - call `_upsert_responsible_party(role='system-owner', uuid=A, mf, 372)`
    - assert `_responsible_party.delete_by_id(...)` 被叫一次（砍舊 row）
    - assert `_responsible_party.add(...)` 被叫一次（INSERT 新 role）
    - assert 最終 mf 372 對 uuid=A 只 1 row, role='system-owner'

  - 新 case `test_upsert_responsible_party_noop_when_role_same`：
    - existing role 跟 incoming role 一樣 → delete + add 都不該叫

- `tests/test_ssp_write_strategy_v2_parties.py`：同樣 2 個 case

### Phase 5: UI 8 筆 parties verify

待 user 重啟 BE + reproduce 後：
- mf 372 reload 看 `GET /api/.../mf-parties` response 真的回幾筆
- 若 BE response 1 筆 FE 顯示 8 筆 → FE cache / state issue（551c7dc 已 fix invalidate，可能還有 stale store）
- 若 BE response 8 筆 → grep `list_parties for module_frame view` 看是否拉 dup（但 DB query 已驗只 1 row → 不太可能）

**目前 working assumption**：FE cache 殘留，不需動 BE。等 reproduce verify。

### Phase 6: Components 「狀態」空 verify

待 reproduce 後驗：
- `_ssp_shell_service.ensure_shell(mf_uid=...)` 是否成功（log search "shell ensure failed"）
- 對應 SSP shell 的 `ssp_components` 表 status 欄位值
- `_confirm_service.confirm(parsed_result, ssp_id, ...)` 內 component write 流程是否寫 status

**可能 fix path**：若 status 沒 wire 進 ComponentEntity，補 `_apply_v2_bundle_overrides` 後到 `ComponentWriteStrategy.write` 之間的傳遞。

### Phase 7: 收尾文件（等 user 下令才動）

- `docs/features/FR-028-2605-ssp-oscal-alignment/design.md` §11.38（Bug R deeper RP natural key fix）
- `docs/features/FR-028-2605-ssp-oscal-alignment/design.md` §11 index 加新段標
- `docs/changelog/2026-05-25-fix-bug-r-rp-idempotent.md`（type=fix）
- `docs/changelog/2026-05-25-fix-bug-r-components-status.md`（如果 Phase 6 有改動 — type=fix）
- `docs/features/FR-028-2605-ssp-oscal-alignment/handoff/2026-05-25-bug-r-deeper-FIXED-SUMMARY.md`
- 原 handoff (`2026-05-25-bug-r-diff-service-duplicate-entry-handoff.md`) 加 ✓ FIXED 標頭
- memory feedback：
  - `feedback_rp_idempotent_full_natural_key.md` — 教訓「natural key 含 mutable field 等於沒 idempotent」
  - `feedback_business_semantic_review_when_pattern_smells.md` — 教訓「註解寫『多 role』跟實作 idempotent (含 role) 是 design coherence smell — 動 design 前先 surface 業務語義拍板」
- 對話歷史歸檔 `docs/conversation-history/2026-05-25/bug-r-deeper/`
- commit + 問 user 確認 push 規模

---

## 3. Verify steps（fix 後）

### 3.1 Unit tests

```bash
poetry run pytest \
  tests/test_module_frame_write_strategy_v2_parties.py \
  tests/test_ssp_write_strategy_v2_parties.py -v
```

### 3.2 DB state after fix + reproduce

```bash
# user 重啟 BE → 重做 docx import → confirm
# 然後 verify mf 372 link 表
PGPASSWORD='jedi@123!' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev -A << 'EOF'
SET app.is_super_admin='t';
SELECT rp.id, rp.role_id, rp.party_uuid, p.name
  FROM oscal.oscal_responsible_parties rp
  LEFT JOIN oscal.oscal_parties p ON p.uid::text = rp.party_uuid
 WHERE rp.context_type='module_frame' AND rp.context_id=372 ORDER BY rp.id;
-- 預期：5 row (1 org + 4 person 各 1 row，role 是 user 在 STEP 3 改的新 role)
EOF
```

### 3.3 UNIQUE constraint smoke

```sql
-- 模擬 dup INSERT 應該 fail
INSERT INTO oscal.oscal_responsible_parties (party_uuid, role_id, context_type, context_id)
VALUES ('test-uuid', 'system-user', 'module_frame', 999);
INSERT INTO oscal.oscal_responsible_parties (party_uuid, role_id, context_type, context_id)
VALUES ('test-uuid', 'system-owner', 'module_frame', 999);
-- 第二筆應該 23505 unique_violation
```

---

## 4. 風險 + Open question

### 4.1 風險（Pre-action verify 已執行 2026-05-25）

Grep 所有 `ResponsiblePartyEntity` INSERT call site → 4 個：

| Caller | 路徑 | 原行為 | 加 UNIQUE 後風險 |
|---|---|---|---|
| `module_frame_write_strategy._upsert_responsible_party` | docx mf import | 4 元組 dedupe（含 role）| 修為 cleanup-before-insert |
| `ssp_write_strategy._upsert_responsible_party` | docx ssp import | 4 元組 dedupe（含 role）| 修為 cleanup-before-insert |
| `party_context_service.add_party:201-216` | user 從 PartiesSection 點「新增」/「新增成員」 | 4 元組 dedupe（含 role）| 同 person 加第 2 role 會 UNIQUE violation |
| `oscal_project_service._copy_template_responsible_parties:843-865` | 啟動專案時複製 mf → ssp parties | 用 `role_id` 當 dedupe key（**含 role 但邏輯不同**：「同 ssp 同 role 已存在則 skip」）| mf 若同 ssp 同 role 兩個 party 會 skip 第二個（不致 UNIQUE violation 但行為改變需 user 確認）|

**業務語義拍板（user 2026-05-25）**：
- caller 3 (`add_party`)：**BE 自動換 role**（砍舊 + INSERT 新）— 跟 Phase 2 _upsert 同 pattern
- caller 4 (`_copy_template`)：**Skip 保留 ssp 原 role**（其實這條 path 幾乎不會遇到，因為 SSP 是新建立的）— dedupe key 改為 `(uuid, context)`

- **DB constraint 加上後若有 production 環境 dev DB 沒同步**：
  - 不在 dev 範圍，hotfix 文件留 follow-up

### 4.2 Open question

- **Phase 6 components 狀態**：依賴 user 重啟 BE + reproduce 才能 verify。是否成立 Phase 5 BE shell ensure 路徑、status wire 都對？預留可能要動 ComponentWriteStrategy / `_apply_v2_bundle_overrides`。
- **Phase 4 UI 8 筆**：working assumption 是 FE cache 殘留。若 reproduce 後仍 8 筆 → 可能要動 FE store / 拆 `useModuleFrameParties` cache key 多帶 invalidate trigger

---

## 5. 動工順序

1. ✅ Phase 0 verify（完成）
2. ✅ Phase 1 寫 plan（本文件）
3. **Phase 2 BE fix `_upsert_responsible_party`** — 改 2 個 strategy + 加 unit tests
4. **Phase 3 DB SQL migration** — 跑前 dry-run，user 拍板執行
5. **動 code 完提醒 user 重啟 BE** + reproduce
6. **Phase 4 / 5 verify** UI 8 筆 + components 狀態（依 reproduce 結果走）
7. **Phase 6 收尾**（等 user 下令）
