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-682ssp_write_strategy.py:423-456 兩處 _upsert_responsible_party

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 全表掃結果

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

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 段):

-        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

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

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

# 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

-- 模擬 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 下令)