Bug R 真因 Handoff — diff_service annotate 同 party_uid duplicate entry + unlink 越權砍 8 row

項目 內容
緣由 Bug P/Q/R session 走完 fix + user 真實 reproduce 後,發現 Bug R 根本不是 wire 漏 1 行,是 diff_service.annotate_parse_result 對同 party_uid emit 兩個 entry (一個 changed、一個 gone),後續 unlink_parties 一次砍 8 個 link rows(含跨 context)— 嚴重 data corruption side effect
Branch feature/ssp-oscal-alignment(接續 H-N + Bug O + P/Q arc,這是 Bug R 第二輪 deeper dive)
接手前必讀 本文件自包含 + 上一份 P/Q/R handoff + Bug O FIXED-SUMMARY
預估時間 4-8 小時(深度跨 diff_service / write_strategy / unlink 邏輯 + 跨 context_type 影響面)

0. 接手讀序

  1. 本文件 — 真因 + 現有 evidence + revert 紀錄 + open questions
  2. 上一份 handoffdocs/features/FR-028-2605-ssp-oscal-alignment/handoff/2026-05-25-bug-p-q-r-parties-form-handoff.md — 了解 Bug P/Q/R session 的原始假設
  3. Bug O FIXED-SUMMARYdocs/features/FR-028-2605-ssp-oscal-alignment/handoff/2026-05-25-bug-o-FIXED-SUMMARY.md — Bug R 的 cache invalidate fix 是接續 Bug O annotate 改動
  4. 關鍵 BE files
    • app/oscal/service/ssp_docx_import_app_service.pyconfirm_import (line 540~) / _filter_parties_for_write (line 1281~) / _load_current_parties / _collect_parties_to_unlink
    • app/oscal/service/ssp_docx_diff_service.pyannotate_parse_result / _annotate_party (這條是真因區)
    • domain/oscal/service/write_strategy/module_frame_write_strategy.pywrite_parties / unlink_parties
    • app/oscal/service/party_context_service.pylist_parties / update_party
  5. FE 已 ship 的 fix (本 session commit 但沒 push):
    • 551c7dc Bug R cache invalidate
    • fa43b79 Bug Q role Dropdown + Bug P button wording
    • 還有 3 個 file uncommitted:PartiesSection.vue (Dropdown 改放 grid 內)、PartyLinkDialog.vue (拿掉 code filter)、SspDocxImportPage.vue (加 contentOverrides.parties collect)
  6. FE 跨 repo 必讀~/Projects/Billows/Audit-Manager/compliance-manager-fe/CLAUDE.md

讀完跑 §6 Pre-flight + §7 Verify → §3 開工順位。


1. 症狀

1.1 User-facing

  • mf 2c4ba0b8-52e2-4e90-a8fc-df8dcf481002 (AOO AOOA CMMC Level 1 導入計劃) → 批次維護 → 從文件匯入 → 上傳 ~/Desktop/亞航-CMMC-SSP-20260520-1會議討論版.docx
  • STEP 2 每個 tab 都選「全部採用新值」
  • STEP 3 「參與人員與單位」tab:對 1 org + 4 person 各選任意 role
  • STEP 3 「系統元件與外部授權服務」tab:3 系統依賴元件「狀態」都選「運作中」
  • 點右上「確認匯入」→ router push 回 template-edit page
  • 預期:parties 看到 role 已套用、components 「狀態」欄顯示「運作中」
  • 實際
    1. user image #5(/Users/chouraymond/.claude/image-cache/bfd49895-5cac-4f87-b6df-94379d7b0a8e/5.png)顯示參與人員 8 筆(原 4 + 新 4 同名但 role 不同的 row)
    2. user image #6 顯示系統依賴元件 4 筆「狀態」欄全空 (—)
    3. 整體像是 append + 部分舊資料保留 + STEP 3 inline edit 部分沒生效

1.2 BE log 證據(最新一次 18:41:51 confirm)

[ssp-confirm] _filter_parties_for_write parties_decisions count=5 map={
  'ecbe22dd-5ee8-4c43-aef3-6017f9eee9bb': 'use_docx',
  '5a1c53b0-97d0-42c3-9d8e-e424ad69c364': 'use_docx',
  '9c2c6408-3094-4dc4-b993-b5bb40800631': 'use_docx',
  'f1d242db-ce61-49d6-866e-14dc97f4c71b': 'use_docx',
  '9ef01730-631f-4c30-8e41-7bbdea8f362b': 'use_docx',
}

# 5 個 parsed parties annotated 第一輪 — diff_status 正常
[ssp-confirm] annotated party uid=ecbe22dd diff_status=unchanged decision=use_docx
[ssp-confirm] annotated party uid=5a1c53b0 diff_status=changed decision=use_docx
[ssp-confirm] annotated party uid=9c2c6408 diff_status=changed decision=use_docx
[ssp-confirm] annotated party uid=f1d242db diff_status=changed decision=use_docx
[ssp-confirm] annotated party uid=9ef01730 diff_status=changed decision=use_docx

# ⚠️ DUPLICATE!同 4 個 person uid 又出現第二次,這次標 diff_status=gone
[ssp-confirm] annotated party uid=5a1c53b0 diff_status=gone decision=use_docx
[ssp-confirm] annotated party uid=9c2c6408 diff_status=gone decision=use_docx
[ssp-confirm] annotated party uid=f1d242db diff_status=gone decision=use_docx
[ssp-confirm] annotated party uid=9ef01730 diff_status=gone decision=use_docx

# 接著 inject target_party_uid 對 5 個 matched parsed (含 1 org + 4 person)
[ssp-confirm] party uid=ecbe22dd: injecting target_party_uid=ecbe22dd for matched update
... (4 person 同樣 inject)

# 然後 4 個 gone person 走 unlink path
[ssp-confirm] party uid=5a1c53b0 gone+use_docx → unlink path (skipped from write)
[ssp-confirm] party uid=9c2c6408 gone+use_docx → unlink path (skipped from write)
[ssp-confirm] party uid=f1d242db gone+use_docx → unlink path (skipped from write)
[ssp-confirm] party uid=9ef01730 gone+use_docx → unlink path (skipped from write)

# resolved 5 parsed parties to write — 但其中 4 個也被排進 unlink
[ssp-confirm] _filter_parties_for_write resolved 5 parsed parties to write
[ssp-confirm] calling ModuleFrameWriteStrategy.write_parties with 5 parties...
[ssp-confirm] strategy.write_parties returned parties_written=5

# ⚠️ Unlink 一次砍 8 row(不是 4 — 為什麼?跨 context 越權砍?)
[ssp-confirm] calling ModuleFrameWriteStrategy.unlink_parties with 4 uids
[ssp-confirm] strategy.unlink_parties removed 8 link rows

1.3 DB 證據(confirm 後立刻 query)

-- mf 372 對應的 responsible_parties link:confirm 完只剩 1 個
SELECT rp.id, rp.role_id, rp.party_uuid, p.name, p.party_type
  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;
-- 結果:1 row (id=236, 屏東飛機維修廠, organization)
-- 預期:5 row (1 org + 4 person, 每個帶 user STEP 3 改的 role)

-- oscal_parties 最大 id 仍是 1275(confirm 沒新增 row)
SELECT MAX(id) FROM oscal.oscal_parties;
-- 結果:1275 (1271-1275 是 mf 370 亞航 E2E Excel 測試的,不是本次寫的)

→ confirm 對 mf 372 只留 1 個 link (屏東飛機維修廠),4 個 person link 全被 unlink。 → BE write_parties 寫了 5 個(但寫到別處或 update 既有 row)+ unlink 8 個 link rows,結果 mf 372 context 內 4 person 全失。

1.4 用戶提供完整 confirm POST payload(真實,請收)

POST /api/1.0/ssp-docx-import/120ab8a5-aad6-422d-8152-2b209d0ef105/confirm

原始 payload(未縮減,直接複製 user 提供內容)

{
  "decisions": [
    {
      "control_id": "AC.L1-3.1.1",
      "action": "use_docx",
      "objectives": [
        { "objective_key": "(a)", "action": "use_docx" },
        { "objective_key": "(b)", "action": "use_docx" },
        { "objective_key": "(c)", "action": "use_docx" },
        { "objective_key": "(d)", "action": "use_docx" },
        { "objective_key": "(e)", "action": "use_docx" },
        { "objective_key": "(f)", "action": "use_docx" }
      ]
    },
    {
      "control_id": "AC.L1-3.1.2",
      "action": "use_docx",
      "objectives": [
        { "objective_key": "(a)", "action": "use_docx" },
        { "objective_key": "(b)", "action": "use_docx" }
      ]
    },
    {
      "control_id": "AC.L1-3.1.20",
      "action": "use_docx",
      "objectives": [
        { "objective_key": "(a)", "action": "use_docx" },
        { "objective_key": "(b)", "action": "use_docx" },
        { "objective_key": "(c)", "action": "use_docx" },
        { "objective_key": "(d)", "action": "use_docx" },
        { "objective_key": "(e)", "action": "use_docx" },
        { "objective_key": "(f)", "action": "use_docx" }
      ]
    },
    {
      "control_id": "AC.L1-3.1.22",
      "action": "use_docx",
      "objectives": [
        { "objective_key": "(a)", "action": "use_docx" },
        { "objective_key": "(b)", "action": "use_docx" },
        { "objective_key": "(c)", "action": "use_docx" },
        { "objective_key": "(d)", "action": "use_docx" },
        { "objective_key": "(e)", "action": "use_docx" }
      ]
    },
    {
      "control_id": "IA.L1-3.5.1",
      "action": "use_docx",
      "objectives": [
        { "objective_key": "(a)", "action": "use_docx" },
        { "objective_key": "(b)", "action": "use_docx" },
        { "objective_key": "(c)", "action": "use_docx" }
      ]
    },
    {
      "control_id": "IA.L1-3.5.2",
      "action": "use_docx",
      "objectives": [
        { "objective_key": "(a)", "action": "use_docx" },
        { "objective_key": "(b)", "action": "use_docx" },
        { "objective_key": "(c)", "action": "use_docx" }
      ]
    },
    {
      "control_id": "MP.L1-3.8.3",
      "action": "use_docx",
      "objectives": [
        { "objective_key": "(a)", "action": "use_docx" },
        { "objective_key": "(b)", "action": "use_docx" }
      ]
    },
    {
      "control_id": "PE.L1-3.10.1",
      "action": "use_docx",
      "objectives": [
        { "objective_key": "(a)", "action": "use_docx" },
        { "objective_key": "(b)", "action": "use_docx" },
        { "objective_key": "(c)", "action": "use_docx" },
        { "objective_key": "(d)", "action": "use_docx" }
      ]
    },
    {
      "control_id": "PE.L1-3.10.5",
      "action": "use_docx",
      "objectives": [
        { "objective_key": "(a)", "action": "use_docx" },
        { "objective_key": "(b)", "action": "use_docx" },
        { "objective_key": "(c)", "action": "use_docx" }
      ]
    },
    {
      "control_id": "SC.L1-3.13.1",
      "action": "use_docx",
      "objectives": [
        { "objective_key": "(a)", "action": "use_docx" },
        { "objective_key": "(b)", "action": "use_docx" },
        { "objective_key": "(c)", "action": "use_docx" },
        { "objective_key": "(d)", "action": "use_docx" },
        { "objective_key": "(e)", "action": "use_docx" },
        { "objective_key": "(f)", "action": "use_docx" },
        { "objective_key": "(g)", "action": "use_docx" },
        { "objective_key": "(h)", "action": "use_docx" }
      ]
    },
    {
      "control_id": "SC.L1-3.13.5",
      "action": "use_docx",
      "objectives": [
        { "objective_key": "(a)", "action": "use_docx" }
      ]
    },
    {
      "control_id": "SI.L1-3.14.1",
      "action": "use_docx",
      "objectives": [
        { "objective_key": "(a)", "action": "use_docx" },
        { "objective_key": "(b)", "action": "use_docx" },
        { "objective_key": "(c)", "action": "use_docx" },
        { "objective_key": "(d)", "action": "use_docx" },
        { "objective_key": "(e)", "action": "use_docx" },
        { "objective_key": "(f)", "action": "use_docx" }
      ]
    },
    {
      "control_id": "SI.L1-3.14.2",
      "action": "use_docx",
      "objectives": [
        { "objective_key": "(a)", "action": "use_docx" },
        { "objective_key": "(b)", "action": "use_docx" }
      ]
    },
    {
      "control_id": "SI.L1-3.14.4",
      "action": "use_docx",
      "objectives": []
    },
    {
      "control_id": "SI.L1-3.14.5",
      "action": "use_docx",
      "objectives": [
        { "objective_key": "(a)", "action": "use_docx" },
        { "objective_key": "(b)", "action": "use_docx" },
        { "objective_key": "(c)", "action": "use_docx" }
      ]
    }
  ],
  "parties_decisions": [
    { "party_uid": "ecbe22dd-5ee8-4c43-aef3-6017f9eee9bb", "action": "use_docx" },
    { "party_uid": "5a1c53b0-97d0-42c3-9d8e-e424ad69c364", "action": "use_docx" },
    { "party_uid": "9c2c6408-3094-4dc4-b993-b5bb40800631", "action": "use_docx" },
    { "party_uid": "f1d242db-ce61-49d6-866e-14dc97f4c71b", "action": "use_docx" },
    { "party_uid": "9ef01730-631f-4c30-8e41-7bbdea8f362b", "action": "use_docx" }
  ],
  "manual_assignments": [],
  "skipped_paragraph_idxs": [],
  "predicted_controls_user_selection": [
    "PE.L1-3.10.5", "AC.L1-3.1.22", "SI.L1-3.14.1", "SC.L1-3.13.5",
    "AC.L1-3.1.2", "SI.L1-3.14.5", "PE.L1-3.10.1", "IA.L1-3.5.1",
    "AC.L1-3.1.20", "IA.L1-3.5.2", "MP.L1-3.8.3", "SI.L1-3.14.4",
    "SI.L1-3.14.2", "SC.L1-3.13.1", "AC.L1-3.1.1"
  ],
  "source_uid": "2c4ba0b8-52e2-4e90-a8fc-df8dcf481002",
  "content_overrides": {
    "components": {
      "1": { "status": "operational" },
      "2": { "status": "operational" },
      "3": { "status": "operational" }
    },
    "parties": {
      "0": {
        "address": "No. 330, Shengli Rd., Pingtung City, Pingtung County 900209, Taiwan (R.O.C.)",
        "email_address": null,
        "match_confidence": 0,
        "match_method": "unmatched",
        "matched_org_unit_id": null,
        "matched_org_unit_label": null,
        "matched_user_id": null,
        "matched_user_label": null,
        "name": "屏東飛機維修廠",
        "party_type": "organization",
        "remarks": null,
        "role": "responsible-organization",
        "short_name": null,
        "target_party_uid": null,
        "telephone_number": "08-7656111",
        "title": null,
        "party_uid": "ecbe22dd-5ee8-4c43-aef3-6017f9eee9bb"
      },
      "1": {
        "address": "444 Salomon Circle, Sparks, NV 89434, USA",
        "email_address": "info@sncorp.com",
        "match_confidence": 0,
        "match_method": "unmatched",
        "matched_org_unit_id": null,
        "matched_org_unit_label": null,
        "matched_user_id": null,
        "matched_user_label": null,
        "name": "Sierra Nevada Corporation",
        "party_type": "person",
        "remarks": null,
        "role": "system-owner",
        "short_name": null,
        "target_party_uid": null,
        "telephone_number": "+1 775-356-6888",
        "title": "NA",
        "party_uid": "5a1c53b0-97d0-42c3-9d8e-e424ad69c364"
      },
      "2": {
        "address": "No. 1050, Jichang Rd., Rende Dist., Tainan City 717206, Taiwan (R.O.C.)",
        "email_address": "hmtseng@airasia.com.tw",
        "match_confidence": 0,
        "match_method": "unmatched",
        "matched_org_unit_id": null,
        "matched_org_unit_label": null,
        "matched_user_id": null,
        "matched_user_label": null,
        "name": "曾漢明",
        "party_type": "person",
        "remarks": null,
        "role": "system-security-officer",
        "short_name": null,
        "target_party_uid": null,
        "telephone_number": "886-6-2681911 EXT 2563",
        "title": "Engineer",
        "party_uid": "9c2c6408-3094-4dc4-b993-b5bb40800631"
      },
      "3": {
        "address": "No. 1050, Jichang Rd., Rende Dist., Tainan City 717206, Taiwan (R.O.C.)",
        "email_address": "wywang@airasia.com.tw",
        "match_confidence": 0,
        "match_method": "unmatched",
        "matched_org_unit_id": null,
        "matched_org_unit_label": null,
        "matched_user_id": null,
        "matched_user_label": null,
        "name": "王文堯",
        "party_type": "person",
        "remarks": null,
        "role": "authorizing-official",
        "short_name": null,
        "target_party_uid": null,
        "telephone_number": "886-6-2681911 EXT 2600",
        "title": "Manager Information & Cyber Security",
        "party_uid": "f1d242db-ce61-49d6-866e-14dc97f4c71b"
      },
      "4": {
        "address": "No. 1050, Jichang Rd., Rende Dist., Tainan City 717206, Taiwan (R.O.C.)",
        "email_address": "edward@airasia.com.tw",
        "match_confidence": 0,
        "match_method": "unmatched",
        "matched_org_unit_id": null,
        "matched_org_unit_label": null,
        "matched_user_id": null,
        "matched_user_label": null,
        "name": "張才華",
        "party_type": "person",
        "remarks": null,
        "role": "information-owner",
        "short_name": null,
        "target_party_uid": null,
        "telephone_number": "886-6-2681911 EXT 2630",
        "title": "Manager Information & Cyber Security",
        "party_uid": "9ef01730-631f-4c30-8e41-7bbdea8f362b"
      }
    }
  }
}

Payload 觀察

  1. parties_decisions 5 個 uid 全 use_docx
  2. content_overrides.parties[0~4] 5 個 party 整份內容,含 role 欄位、含 party_uid、含 target_party_uid:null、含 match_confidence:0 match_method:"unmatched"
  3. FE 是把 draft.parties 整份 (含所有欄位) 塞進 content_overrides.parties(mirror components / leveraged 既有 idx-keyed pattern)— 這條 wire 是對的,payload 證明 BE 拿到了完整 user 編輯資料
  4. content_overrides.components 3 個 idx 都帶 status: operational
  5. system_characteristic_decisioncomponents_decisionsleveraged_authorizations_decisionsinventory_items_decisions 4 個 v3 decision keys — 這是 FE store buildConfirmPayload 漏帶?還是 user 沒選所以 FE 跳過?需查

1.5 user 上次 reproduce 也提供截圖

Screenshot 路徑 顯示
Image #5 (template-edit) /Users/chouraymond/.claude/image-cache/bfd49895-5cac-4f87-b6df-94379d7b0a8e/5.png 參與人員 (8) — 4 對重複 name 同 person,上半 4 個 role=系統使用者(舊),下半 4 個 role 是 user STEP 3 改的(系統所有者 / ISSO / AO / 資料所有者)
Image #6 (template-edit) /Users/chouraymond/.claude/image-cache/bfd49895-5cac-4f87-b6df-94379d7b0a8e/6.png 系統依賴元件 4 筆,「狀態」欄全部 (—)

但 user image 跟我 DB query 結果不符 — DB 查 mf 372 link 表只 1 row。可能 image 是某個瞬間 stale,或 FE 從別處抓資料。這部分待 next session 親手 reproduce 對齊


2. 真正 root cause 候選(推測,待 next session verify)

候選 A:diff_service.annotate_parse_result 把同 party_uid emit 兩次

從 log §1.2 確認 — annotated_parse_result.parties 列表內 person 5a1c / 9c2c / f1d2 / 9ef0 各出現兩次

  • 第一次:matched parsed party (從 docx 解出來的) → diff_status=changed
  • 第二次:unmatched current party (DB 既有但 parsed 沒給) → diff_status=gone

_filter_parties_for_write 對 use_docx + changed → write → 同個 uid 對 use_docx + gone → 走 _collect_parties_to_unlink 加進 unlink list → 同 uid 被同時 write + unlink → 結果 write 完又 unlink → 4 person 從 mf 372 context 消失

Verify 命令

# 找 ssp_docx_diff_service.annotate_parse_result 邏輯
grep -n "def annotate_parse_result\|def _annotate_party\|diff_status=.gone\|diff_status=.changed" \
  ~/Projects/Billows/Audit-Manager/compliance-manager-be/app/oscal/service/ssp_docx_diff_service.py

關鍵讀 _annotate_party 看「matched」跟「current orphan」是否該共用同 uid 或不該共用:

  • 若 docx parser 給的 5 個 party 的 uid 複用 DB 既有 uid(不該 — parser 不該知道 DB uid),那 annotate 看 current_parties.uid 跟 parsed.uid 重複 → bug
  • 若 docx parser uid 是自己 random gen 但剛好等於 DB 既有 uid,那是 deterministic uid scheme(依 name hash?)— 也是 bug

候選 B:_load_current_parties 對 module_frame source 拿錯 context

_load_current_parties(effective_source_uid, job.source_type='module_frame') 拿 mf 372 既有 parties。但實際 DB query mf 372 link 表只 1 row(屏東飛機維修廠)— 為什麼 annotate 看到 4 person 在 current?

可能:

  • _load_current_parties 不只讀 responsible_parties link,還讀 oscal_parties 全表
  • 或讀 mf metadata_parties 而非 responsible_parties
  • 或拿到別的 context(ssp 而不是 module_frame)

Verify 命令

grep -n "def _load_current_parties\|_load_current" \
  ~/Projects/Billows/Audit-Manager/compliance-manager-be/app/oscal/service/ssp_docx_import_app_service.py

Log: strategy.unlink_parties removed 8 link rows — caller 給 4 uids,但移除 8 個 link rows。可能:

  • unlink 對 party_uid IN (...) 砍整張表所有 context — 跨 module_frame + ssp + 其他 context 全砍
  • 或對 (context_type='module_frame') 全 mf 都砍(不只 mf 372)

→ 嚴重 data corruption,會把同個 party_uid 在其他 mf / ssp 的 link 也砍掉。

Verify 命令

grep -n "def unlink_parties\|delete.*party_uuid\|DELETE FROM.*responsible_part" \
  ~/Projects/Billows/Audit-Manager/compliance-manager-be/domain/oscal/service/write_strategy/module_frame_write_strategy.py \
  ~/Projects/Billows/Audit-Manager/compliance-manager-be/app/oscal/service/party_context_service.py
-- 看 unlink 前/後 link 表 row count 變化
-- (上面 log 顯示 confirm 之後 link 表只剩 53-8=45 row?看 53 是 mf 368-378 全部,砍 8 是哪 8 個)
PSQL ... -c "
SET app.is_super_admin='t';
SELECT context_type, context_id, count(*)
  FROM oscal.oscal_responsible_parties
 WHERE party_uuid IN ('5a1c53b0...', '9c2c6408...', 'f1d242db...', '9ef01730...')
 GROUP BY context_type, context_id ORDER BY count desc;
"

write_parties 寫了 5 個 oscal_parties row,但 _link_party (per party_context_service.py:add_party) 只在 add_party 才 call — write_parties 是 docx import path,是否漏 link 步驟?

Verify:grep ModuleFrameWriteStrategy.write_parties 內是否真有寫 responsible_parties 步驟。


3. 開工順位(建議按此順序)

  1. 跑 §6 Pre-flight + §7 Verify 確認 revert 乾淨 + Bug O / H-6 不退化(10 分鐘)
  2. 真實 reproduce 一次(per §1.1 步驟)→ 收 BE log + DB snapshot
  3. 驗候選 A:read ssp_docx_diff_service.annotate_parse_result + _annotate_party — 找出為什麼 same uid emit 兩次(30-60 分鐘)
  4. 驗候選 B:read _load_current_parties — 確認 module_frame source 真實讀哪張表(15 分鐘)
  5. 驗候選 C:read unlink_parties / delete by party_uuid — 看 scope 是否帶 context_type+context_id(15 分鐘)
  6. 驗候選 D:read ModuleFrameWriteStrategy.write_parties 看是否真寫 link(15 分鐘)
  7. 根據真因設計 fix plan(per CLAUDE.md feature-development-workflow Phase 3)
    • 若 A:修 annotate dedup 邏輯
    • 若 B:修 _load_current_parties scope
    • 若 C:修 unlink scope(這個最危險,可能已造成過去 confirm 的歷史 data corruption
    • 若 D:補 write_parties 內 link 寫入步驟
  8. 同時也要看 components inline edit (status: operational) 為什麼回 template-edit 後欄位空 — 可能 BE _apply_v2_bundle_overrides 對 components.{idx}.status 沒套,或 components write_strategy 漏存 status 欄位
  9. 進 plan mode 寫 plan 給 user approve 才開工(per CLAUDE.md「不切 branch / 不自動 push / plan 假設先 verify」)
  10. 收尾:design.md §11.38(Bug R deeper analysis)+ changelog + FIXED-SUMMARY + 等 user 下令再 commit 收尾文件(per memory feedback_wait_for_user_command_to_close

4. 該讀的檔案 / 預期改動範圍

4.1 BE 端

檔案 為何 read
app/oscal/service/ssp_docx_diff_service.py 候選 A 核心 — annotate_parse_result 為何 same uid emit 兩次
app/oscal/service/ssp_docx_import_app_service.py:_load_current_parties 候選 B — current_parties 從哪 load
app/oscal/service/ssp_docx_import_app_service.py:_collect_parties_to_unlink (line 1392+) 看 unlink 候選 list 怎麼產生
app/oscal/service/party_context_service.py:list_parties / remove_party GET parties 真實 query + 候選 C unlink scope
domain/oscal/service/write_strategy/module_frame_write_strategy.py write_parties / unlink_parties 邏輯
domain/oscal/service/write_strategy/ssp_write_strategy.py 對比 SSP 版本是否同樣問題
infra/oscal/repo/oscal_responsible_party_repo_impl.py (or 對應 path) 看 unlink/delete SQL 是否帶 context_type+context_id WHERE

4.2 FE 端

檔案 為何 read
compliance-manager-fe/src/components/grc/ssp-docx-import-v2/SspDocxImportPage.vue (uncommitted change) 看 contentOverrides.parties wire 是否該保留(payload 證明 FE 送對了 → 應該保留
compliance-manager-fe/src/stores/sspDocxImportStore.js buildConfirmPayload 為何 v3 decisions 沒送(components_decisions / leveraged_decisions 等都缺)
compliance-manager-fe/src/components/grc/ssp-docx-import-v2/sections/PartiesSection.vue (uncommitted change) Bug Q Dropdown grid 內 layout — 保留
compliance-manager-fe/src/components/grc/ssp-docx-import-v2/sections/PartyLinkDialog.vue (uncommitted change) Bug P 拿掉 code filter — 保留
compliance-manager-fe/src/composables/useModuleFrameParties.js cache 邏輯(這條沒問題)

4.3 跨 repo 必做

  • 改 FE 前必 read ~/Projects/Billows/Audit-Manager/compliance-manager-fe/CLAUDE.md
  • 改 BE service 層後必提醒 user 重啟 BE(per memory feedback_be_restart_after_service_change

5. Pre-flight Command(必跑)

# 1. 兩 repo branch + working tree
git -C ~/Projects/Billows/Audit-Manager/compliance-manager-be branch --show-current
git -C ~/Projects/Billows/Audit-Manager/compliance-manager-fe branch --show-current
# 預期:兩個都 feature/ssp-oscal-alignment

git -C ~/Projects/Billows/Audit-Manager/compliance-manager-be status --short
# 預期:M pyproject.toml + 多個 .png screenshots + .playwright-mcp/*.docx — 都是 dev artifact
# 重點:app/oscal/service/ssp_docx_import_app_service.py 應該 NOT 出現(revert 完)

git -C ~/Projects/Billows/Audit-Manager/compliance-manager-fe status --short
# 預期:3 個 M 檔
#   M src/components/grc/ssp-docx-import-v2/SspDocxImportPage.vue
#   M src/components/grc/ssp-docx-import-v2/sections/PartiesSection.vue
#   M src/components/grc/ssp-docx-import-v2/sections/PartyLinkDialog.vue
# 這 3 個是本 session 待 commit 的改動(layout + code filter fix + contentOverrides.parties wire),等 user 拍板再 commit / amend

# 2. local vs origin
git -C ~/Projects/Billows/Audit-Manager/compliance-manager-be log origin/feature/ssp-oscal-alignment..HEAD --oneline
# 預期:1 commit (0dac0d37 Bug O 收尾橫向文件) — 之前已 push 多個

git -C ~/Projects/Billows/Audit-Manager/compliance-manager-fe log origin/feature/ssp-oscal-alignment..HEAD --oneline
# 預期:2 commits (fa43b79 Bug Q+P, 551c7dc Bug R cache) — 上 session ship 沒 push

# 3. BE listener (上 session 砍掉了,user 應重啟過)
lsof -t -i:8000 && echo "BE up" || echo "BE down — restart needed"

# 4. BE pytest smoke
cd ~/Projects/Billows/Audit-Manager/compliance-manager-be
poetry run pytest tests/test_ssp_docx_import_app_service.py \
  tests/test_ssp_docx_diff_service.py \
  tests/test_ssp_write_strategy.py -q
# 預期:192 passed (跟上 session 一致)

6. Verify 上一輪 fix + Bug O 不退化(必跑)

# 6.1 H-6 cleanup verify — 4 OSCAL 表 org_unit_id 應全 NULL
PGPASSWORD='jedi@123!' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev -A -c "
SET app.is_super_admin='t';
SELECT 'parties' AS tbl, count(*) AS bad FROM oscal.oscal_parties WHERE org_unit_id IS NOT NULL
UNION ALL SELECT 'leveraged', count(*) FROM oscal.ssp_leveraged_authorizations WHERE org_unit_id IS NOT NULL
UNION ALL SELECT 'components', count(*) FROM oscal.ssp_components WHERE org_unit_id IS NOT NULL
UNION ALL SELECT 'inventory', count(*) FROM oscal.ssp_inventory_items WHERE org_unit_id IS NOT NULL;
"
# 預期 4 row all bad=0

# 6.2 確認 mf 372 既有 link 狀態
PGPASSWORD='jedi@123!' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev -A -c "
SET app.is_super_admin='t';
SELECT rp.id, rp.role_id, rp.party_uuid, p.name, p.party_type
  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;
"
# 預期:1 row only (id=236, 屏東飛機維修廠) — 證實上 session confirm 後 4 person link 全失

# 6.3 看 53 row 全部跨 context 分佈
PGPASSWORD='jedi@123!' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev -A -c "
SET app.is_super_admin='t';
SELECT context_type, context_id, count(*)
  FROM oscal.oscal_responsible_parties
 WHERE party_uuid IN (
   '5a1c53b0-97d0-42c3-9d8e-e424ad69c364',
   '9c2c6408-3094-4dc4-b993-b5bb40800631',
   'f1d242db-ce61-49d6-866e-14dc97f4c71b',
   '9ef01730-631f-4c30-8e41-7bbdea8f362b'
 )
 GROUP BY context_type, context_id ORDER BY context_id;
"
# 看 unlink 砍了哪些 context(候選 C verify)

7. 行為規範重要提醒(per CLAUDE.md + memory)

  • 永不切 branch(per memory feedback_no_branch_switch
  • 可自行 commit,不自動 push(per memory feedback_stage_commit_no_ask
  • 跨 repo 改 FE 前必 read FE CLAUDE.md
  • 改 BE service 層後必提醒 user 重啟 BE(per memory feedback_be_restart_after_service_change
  • DB 兩張表都 verify 才算 done — 任何 parties / link 改動必驗 oscal_parties + oscal_responsible_parties 兩張
  • 修 bug 前先驗證 DB / response 真實狀態(per memory feedback_verify_db_state_before_writing_fixes)— 不要靠直覺
  • plan 假設先 verify(per memory feedback_plan_vs_reality_verify_first)— 寫 plan 用的 method / table / signature 都先 grep 確認
  • 收尾必須等 user 下命令才做(per memory feedback_wait_for_user_command_to_close)— 寫 design §11.X / changelog / FIXED-SUMMARY 全部等 user 明確下令
  • 可人工驗證就切人工(per CLAUDE.md / 本 session 教訓)— 別再自己跑 playwright 一直改,用人手測
  • 不要晶晶體(per memory feedback_no_chinglish
  • 不要把 bug 標 caveat 甩給 user
  • PrimeVue 3.53 quirks — Dropdown 對 null value 有 bug 用 sentinel
  • 解 bug 前先確認方向再動(per 本 session 教訓 — 上次方向被 user 喊停 2 次)

8. 收尾流程(fix 完後,等 user 下令才做)

  1. design.md 加 §11.38 (Bug R deeper — diff_service duplicate entry + unlink scope corruption)
  2. design.md §11 index 加新段標
  3. changelog 寫 (type=fix,按性質分 BE / FE 拆 2 條可考慮)
  4. 寫 FIXED-SUMMARY → docs/features/FR-028-2605-ssp-oscal-alignment/handoff/2026-05-XX-bug-r-deeper-FIXED-SUMMARY.md
  5. 原 handoff (本檔) 標 ✓ FIXED block on top
  6. memory feedback — 候選教訓:
    • feedback_annotate_unique_entity_per_uid.md — annotate 同 uid 不該 emit 多次 entry
    • feedback_unlink_scope_must_match_context.md — delete by uid 必須帶 context_type+context_id WHERE
  7. 對話歷史歸檔 → docs/conversation-history/2026-05-XX/bug-r-deeper/
  8. commit + 問 user 確認 push 規模(不自動 push

9. 不在本期 scope

  • jedi-oscal docx revision normalize tmp file bug(之前 Phase 0.1 標的 follow-up — 跟本 bug 無關)
  • BE / FE 版號 bump(user 拍板才動)
  • Bug O / H-6 / H-N 回頭 revisit(已 closed)
  • Phase 5 OSCAL Export deferred plan(H-N 整段 user-facing complete 後才碰)

10. 上 session 已 ship 但未 push 的 commits

BE (0 ahead — 上 session 沒新 BE commit;本 handoff 寫完後可考慮 commit handoff)

無 — BE 改動已 revert 乾淨。

FE (2 ahead,沒 push)

Hash Type 內容 是否該保留
551c7dc fix Bug R cache invalidate(confirm 後 invalidateMfParties / invalidateSspParties) 保留 — payload 證明 wire 正確,cache 確實在 confirm 後被 invalidate
fa43b79 fix Bug Q (PartiesSection 補 role Dropdown) + Bug P (button wording 對 type 分流 + 5 i18n keys + icon 分流) 保留 — UI 改動 user 已確認 layout 1/2 OK

FE working tree (3 uncommitted files)

File 改動 是否該保留
SspDocxImportPage.vue confirm payload 加 if (draft.parties) contentOverrides.parties = {idx: {...p}} 保留 — payload 證明 wire 正確帶 5 個 party 的 role + 其他欄位到 BE。但 next session 可能要看是否該瘦身(只送 changed field 而非整份)
PartiesSection.vue 角色 Dropdown 從頂部搬進 grid 內地址右側 + read-only mode label 對齊 + Dropdown class 改 w-full 保留 — user image 確認 OK
PartyLinkDialog.vue 拿掉 ORG_UNITS filter 內 code: 欄位(Bug P 真實 400 root cause) 保留 — BE OrgUnitQueryRequest schema 沒 code 欄位,留會 400

Memory + CLAUDE.md changes (working tree)

File 改動
~/.claude/projects/-Users-...memory/feedback_wait_for_user_command_to_close.md 新檔,新規範「收尾等 user 下令」
~/.claude/projects/-Users-...memory/MEMORY.md index 加 1 行
compliance-manager-be/CLAUDE.md 「做 summary」段前面加新規範條文

→ 這 3 個改動上 session 未 commit(user 沒明示要 commit)。next session 進 working tree 會看到,可保留或一起 commit。

上 session 草寫但未 commit 的 BE docs(待 user 下令才 commit)

File 內容 動作建議
docs/features/FR-028-2605-ssp-oscal-alignment/design.md §11.35 / §11.36 / §11.37 + §11 index 加 3 條 可保留,但 §11.37 內容是「Bug R = cache invalidate fix」已經錯了(真因不是 cache)— next session 該改寫 §11.37 為「Bug R 第一輪 fix (cache invalidate) 部分正確,但真因是 diff_service duplicate entry,見 §11.38」
docs/changelog/2026-05-25-fix-bug-pqr-parties-form.md 涵蓋 Bug P/Q/R 3 個 fix 可保留,但 Bug R 段落該標 partial fix + 引 §11.38
docs/features/FR-028-2605-ssp-oscal-alignment/handoff/2026-05-25-bug-pqr-FIXED-SUMMARY.md 本期 SUMMARY 可保留,但 Bug R 段該改 partial + 引本 handoff
docs/features/FR-028-2605-ssp-oscal-alignment/implementation-plan-bug-pqr.md plan copy 保留 archive

11. 上 session commits 清單(git log 對照)

BE

0dac0d37 docs(ssp-oscal-alignment): Bug O 收尾橫向文件更新 + CLAUDE.md 收尾流程加「回頭更新文件」步驟  (origin 已有?或 ahead 1 — pre-flight verify)
c502ce08 docs(conversation-history): 2026-05-25 — H-N arc 4 sessions + Bug O 接手 1 session  (已 push)
058df1ab docs(ssp-oscal-alignment): Bug O Phase F 收尾 — design §11.34 closed + 補 5 教訓 + plan / changelog / SUMMARY / handoff P-Q-R
b0032e46 fix(ssp-oscal-alignment): Bug O regression — 4 v3 list annotated 用獨立 _diff key + GET 路徑漏 wire
bedbb91c feat(ssp-oscal-alignment): Bug O Phase B — BE confirm 接 4 個 v3 decisions filter(含 overwrite caveat)

FE

fa43b79 fix(ssp-oscal-alignment): Bug Q + Bug P — PartiesSection 補 role Dropdown + button wording 對 party_type 分流  (本 session, 未 push)
551c7dc fix(ssp-oscal-alignment): Bug R — confirm 後 invalidate parties cache  (本 session, 未 push)
5f6458b fix(ssp-oscal-alignment): Bug O regression + 收尾 UI 微調  (已 push)
061a742 feat(ssp-oscal-alignment): Bug O Phase E — DiffResolutionStep TabView refactor
7e34aeb feat(ssp-oscal-alignment): Bug O Phase D — FE 4 個新 DiffSection + 3 個 DiffCard 元件

12. 給 fresh session 的超短 prompt

請閱讀交接文件,了解現況,並接手 Bug R deeper dive。
路徑:docs/features/FR-028-2605-ssp-oscal-alignment/handoff/2026-05-25-bug-r-diff-service-duplicate-entry-handoff.md

按本檔 §0 接手讀序執行 — 必跑 §5 Pre-flight + §6 Verify mf 372 link 狀態 + 取 53 row 跨 context 分佈 → §3 開工順位。

簡述:Bug R 真因不是 cache,是 diff_service.annotate_parse_result 對同 party_uid emit 兩次 entry (一次 changed 一次 gone),加上 unlink_parties 一次砍 8 個 link 跨 context — 嚴重 data corruption。本檔 §1.2 有完整 BE log evidence,§1.4 有 user 真實 confirm payload,§2 有 4 個候選 root cause + verify 命令。

注意 §7 行為規範:不切 branch / 不自動 push / 跨 repo 改 FE 必 read CLAUDE.md / 改 BE service 必提醒重啟 / 收尾等 user 下令 / 人工驗證就切人工。