| 項目 | 內容 |
|---|---|
| 日期 | 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 |
domain/oscal/strategy/module_frame_write_strategy.py:645-682 和 ssp_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 新 rowUser docx 改 role:
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。
| 時間 | 動作 | 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 全失) |
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 防將來。
_upsert_responsible_party cleanup-before-insertdomain/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).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)。
新增 / 擴充 tests:
tests/test_module_frame_write_strategy_v2_parties.py:
新 case test_upsert_responsible_party_replaces_role_when_changed:
_upsert_responsible_party(role='system-owner', uuid=A, mf, 372)_responsible_party.delete_by_id(...) 被叫一次(砍舊 row)_responsible_party.add(...) 被叫一次(INSERT 新 role)新 case test_upsert_responsible_party_noop_when_role_same:
tests/test_ssp_write_strategy_v2_parties.py:同樣 2 個 case
待 user 重啟 BE + reproduce 後:
GET /api/.../mf-parties response 真的回幾筆list_parties for module_frame view 看是否拉 dup(但 DB query 已驗只 1 row → 不太可能)目前 working assumption:FE cache 殘留,不需動 BE。等 reproduce verify。
待 reproduce 後驗:
_ssp_shell_service.ensure_shell(mf_uid=...) 是否成功(log search "shell ensure failed")ssp_components 表 status 欄位值_confirm_service.confirm(parsed_result, ssp_id, ...) 內 component write 流程是否寫 status可能 fix path:若 status 沒 wire 進 ComponentEntity,補 _apply_v2_bundle_overrides 後到 ComponentWriteStrategy.write 之間的傳遞。
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.md2026-05-25-bug-r-diff-service-duplicate-entry-handoff.md) 加 ✓ FIXED 標頭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/poetry run pytest \
tests/test_module_frame_write_strategy_v2_parties.py \
tests/test_ssp_write_strategy_v2_parties.py -v# 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-- 模擬 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_violationGrep 所有 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 沒同步:
_apply_v2_bundle_overrides。useModuleFrameParties cache key 多帶 invalidate trigger_upsert_responsible_party — 改 2 個 strategy + 加 unit tests