---
title: "網址型 Profile 存成檔案並同步給 Agent — 設計文件 (FR-174)"
brand: "Guidant AI · **FR-174** 網址型 Profile 存檔"
eyebrow: "FR-174 · 設計文件 · 2026-11-01 定稿"
h1: "網址只是來源，檔案才是真相"
subtitle: "外部連結型 Profile 解析成功當下存成檔案，從此與上傳檔案同一條路同步給 Agent；拆卡表由 handoff/cards.py 產，9 張 6 批"
lede: "🔴 **這份是設計決策，不是現況。** 前提：**Agent 不連外網**（FR-172 已裁），所以「主系統把網址丟給 Agent 自己抓」這條路要整條拔掉；**出貨前無客戶**，不做舊版相容、不寫回填，既有兩筆按一次「重新解析」就轉型。本案推翻 jedi-detection 一條明文不變式（網址型下載內容不落儲存），理由見 D1。"
chips: [
  {text: "D1～D7 已裁", kind: ok},
  {text: "9 張卡 6 批、4 repo", kind: accent},
  {text: "套件基底 1.7.3b1（FR-171 分支）", kind: warn},
  {text: "Agent 不連外網", kind: crit},
]
footer: "FR-174 設計文件 · 2026-11-01 · 需求紀錄 requirement-notes.md · 拆卡表 handoff/cards.py"
---

> 📑 **本頁是 v1.0（定稿）。** 實作中有設計異動，見 **[設計 v1.1](design-v1.1.md)**（補開 E-1、A-2，H-1 挪批 5、Z-1 挪批 6，工具代號更正）。本頁內容保留定稿原樣，不回頭改。

## 0. 分工概述（30 秒版） {#overview nav="概述"}

**整案一句話**：主系統登記「外部連結」型的檢測 Profile 時，背景下載解析完**順手把那份壓縮檔存起來**，這一版從此是檔案型；既有的「只同步檔案型給 Agent」機制不用改，Agent 主控台下拉自然就有東西；網址留著當「來源網址」做新鮮度檢查。

| 階段 | 做什麼 | 卡 | 完成怎麼判定（決策者檢查法） |
|---|---|---|---|
| E 環境 | 主系統開發期改裝本機 jedi-detection | E-0 | 小弟回寫貼出 import 路徑在工作區底下 |
| P 套件 | 解析成功就存檔轉型、約束放寬、派工只認檔案型、刪網址分支；公版兩支範例改成隨套件帶檔 | P-1、P-2 | Nexus 上有 `jedi-detection 1.8.0b1`／`b2`；P-1 回寫貼出新測試清單與約束驗證結果 |
| H 主系統 | DEV 套 014、兩支 dev-sec 按「重新解析」轉型、本機 Agent 下拉看到兩支 | H-1 | **你親眼看**：本機 Agent 主控台 CINC 掃描表單的 Profile 下拉出現「dev-sec Linux 基準」「dev-sec Windows 基準」，名稱與主系統 Profile 頁相同 |
| F 前端 | 版本明細多「來源網址」列、提示文案改 | F-1 | 主系統 Profile 頁 dev-sec 那版同時看到檔名與網址 |
| A Agent | 拔掉「網址原樣交給引擎」分支 | A-1 | 小弟回寫貼出 `grep SOURCE_TYPE_URL` 為 0 |
| Z 結案 | 發正式版、改回釘版、收口、回報基線重產 | Z-1 | `git status` 乾淨；回寫列出基線要補的兩列 |

**依賴一句**：E-0 → P-1 → （P-2、F-1、A-1）→ （E-1 環境對齊 FR-171、A-2 Agent 快取路徑）→ H-1 → Z-1。**你第一個看得到畫面的是批 5 的 H-1**。

---

## 1. 決策定案表 {#decisions nav="決策"}

| # | 題目 | 定案 | 被排除的 | 理由 |
|---|---|---|---|---|
| D1 | 網址型 Profile 怎麼同步給 Agent | **A 案**：解析成功當下把下載到的壓縮檔存進 `upload_files`，該版 `source_type` 改 `file`、`file_id` 有值；掃描、同步、對帳全走檔案型既有路徑 | **B 案**：保留 url 型、放寬約束讓它同時帶 `file_id`，同步條件也放寬 | A 讓「平台解析的」與「Agent 掃的」是同一份檔，正好解掉套件不變式擔心的「兩邊對不上」；B 是同一版兩個來源，Agent 該用哪個要另訂規則，日後兩個真相。且 FR-172 已裁 Agent 不連外網，「Agent 自己拉網址」本來就走不通，不變式的前提已不存在 |
| D2 | 來源網址存哪 | **沿用既有 `url` 欄**；約束 `chk_dpv_source_exclusive` 改成「file 型 `file_id` 必填、`url` 可有可無；url 型 `url` 必填、`file_id` 必空」 | 另加 `source_url` 欄 | 兩欄存同一串網址只差生命週期階段，是多餘複製；沿用一欄少一次搬移、少一組讀寫。這不是 B 案：B 案的問題是「網址型同時帶檔案」會有兩個掃描來源，這裡是「檔案型附帶出處」，掃描來源永遠只有檔案一個 |
| D3 | 轉型時機 | **解析成功落庫那一刻**（`_persist` 同一個 transaction）：寫控制項、寫 `file_id`、改 `source_type`、寫 `sha256` 與 ETag 一起 commit | 下載完就先存檔再解析 | 下載成功但解析失敗（不是合法 inspec 包）時不該留一個檔案型的壞版本；同一 transaction 保證「有檔必有控制項、有控制項必有檔」 |
| D4 | 下載或解析失敗的版本 | 維持 url 型、`extraction_status=failed`、`file_id` 空；既有派工守門（failed 拒派）與「修正來源」流程照舊 | 半成品也轉型 | 失敗版本沒有檔可同步，轉型只會讓 Agent 拉到空 |
| D5 | 來源更新偵測與重抽 | **保留**：file 型且 `url` 非空的版本開控制項頁時照舊 HEAD 比 ETag，變了就重抽；重抽成功**同一版換檔**（新 `upload_files` 列、`file_id` 指過去、`sha256` 更新；舊檔由 `delete_file` 清掉）。Agent 下次同步比 sha256 不同就重拉 | 偵測到變更就長新版 | 套件文件已排除「自動長 v2」（任務綁的版本 uid 會指不到實際掃的內容）；同版換檔讓「版本」仍是使用者的語意、「檔」是內容的語意 |
| D6 | 「Agent 自己拉網址」掃描路徑 | **整條拔掉**：套件 `build_payload` 只回 file 形狀、派工對 url 型版本 raise；Agent `resolve_profile_source` 刪 url 分支；相關 docstring 與不變式註解改寫 | 留著當備援 | 出貨前無客戶不做相容；留著是死碼，且與「Agent 不連外網」矛盾，六個月後沒人知道哪條是主路徑 |
| D7 | 安裝包的 dev-sec 兩支公版範例 | **改成檔案型隨套件帶**：兩支 tar.gz 進 jedi-detection `assets/`＋`storage-seed.map`，seed 004 兩列改 file 型反查 `upload_files` uid、`url` 留著；主專案基線 `upload_files` 補兩列（基線重產屬決策者裁示，卡只回報） | 從種子拿掉由客戶自己匯入 | 新裝客戶內網到不了 GitHub，網址型種子裝完下拉一樣空；TWGCB 八支已走這條路，不新開機制 |
| D8 | 既有資料（DEV、189 各兩筆） | 不寫回填 migration；升級後到控制項頁按一次「重新解析」即轉型 | 回填腳本 | 出貨前無客戶；189 兩筆是 Demo 資料，按一下就好 |
| D9 | 套件版本基底 | 從 FR-171 CM-3382 發的 **1.7.3b1** 往上長 **1.8.0b1**（P-1）、**1.8.0b2**（P-2）、正式 **1.8.0**（Z-1）；套件分支基底是 jedi-python-package 的 `ws-CM-3379`（FR-171 批 3 合回後以 `feature/FR-172` 為準） | 從 Nexus 1.7.2 長 | 主系統在 FR-171 分支已釘 1.7.3b1，從 1.7.2 長會把 FR-171 的改動蓋掉 |
| D10 | 與 FR-171 B 主題（設定通道）的接點 | **不碰**。本案主系統端不改 `_agent_profile_sync.py`、不改心跳回應；Profile 清單的查法 `list_current_files_visible_to` 不變，轉型後的版本自然納入。FR-171 B-1 之後把清單搬去 `GET /agents/config` 時查法照用 | 等 B-1 合回再做 | 主系統端零程式改動，與 FR-171 批 4 不撞檔；不必等 |

---

## 2. 一版 Profile 的一生（定案後） {#flow nav="流程"}

```{.mermaid cap="圖 1 — 外部連結型 Profile 從登記到 Agent 可用。虛線框是本案新增的一步；下方灰線是被拔掉的舊路。"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
  autonumber
  participant U as 管理者（主系統 Profile 頁）
  participant S as 主系統（jedi-detection）
  participant F as upload_files／儲存
  participant A as Agent
  U->>S: 新增 Profile，來源＝外部連結 https://…
  S->>S: 建版 v1：source_type=url、file_id=NULL、status=pending
  S->>S: 背景 worker：下載（60 秒內、50 MB 內、SSRF 五道防護）
  S->>S: cinc-auditor 解析（28～117 秒）
  rect rgb(226,240,241)
    Note over S,F: 本案新增：解析成功 → 存檔 → 轉型（同一 transaction）
    S->>F: 壓縮檔 bytes 存成 upload_files（save_dir=detection-profiles，sha256 回填）
    S->>S: 寫控制項；source_type=file、file_id=新列、url 保留、sha256＝bytes 雜湊、ETag
  end
  A->>S: 心跳（FR-171 B 之後改 GET /agents/config）
  S-->>A: 現行 file 型清單（含這一版：uid、version、sha256、file_uid、tool、name）
  A->>S: GET /agents/files/<file_uid>
  S-->>A: 壓縮檔
  A->>A: 對帳 sha256 → 解壓進 cache → 主控台下拉出現
  U->>S: 開控制項頁
  S->>S: HEAD 來源網址比 ETag；變了 → 同版重抽、換檔（D5）
```

**被拔掉的舊路**：派工 `_profile` payload 帶 `{source_type:url, url}` → Agent 把網址原樣丟給 `cinc-auditor exec`。本案後派工只會有 file 形狀；Agent 收到 url 形狀一律報「派工資料錯誤」。

---

## 3. 套件改動細節（P-1） {#package nav="套件"}

### 3.1 存檔與轉型

- 新檔 `jedi_detection/common/profile_snapshot.py`：`store_snapshot(file_upload_service, upload_file_domain_service, raw: bytes, file_name: str, scope: str, login_name: str) -> UploadFileEntity`。內部把 bytes 包成 werkzeug `FileStorage`，走 **與 `DetectionProfileService._store_file` 完全相同的分流**（SYSTEM 走 `upload_system_file`、TENANT 走 `upload_files`，`save_dir="detection-profiles"`），再 `_backfill_sha256`。`_store_file` 本身改成呼叫這支（1,146 行的檔只准一行呼叫，不複製）。
- `DetectionProfileExtractionService._extract_in_thread`：下載成功、解析成功後，在 `_persist` 的 session 內呼叫 `store_snapshot`，把回來的 `file_id` 交給 `_persist`。**順序**：先 `store_snapshot`（檔進儲存）→ `replace_for_version`（控制項）→ 改版本列。存檔在 session 內是可以的：它是本機／物件儲存寫入，不是外部網站。
- `_persist(…, file_id=None)`：`file_id` 有值時 `version.source_type = "file"`、`version.file_id = file_id`；`url`、`source_etag`、`source_last_modified`、`sha256` 照舊寫。
- **重抽換檔（D5）**：`_persist` 發現版本已有 `file_id` 且本次又存了新檔 → 舊 `file_id` 收進一個清單，commit 後 `file_upload_service.delete_file(old_uid)`（失敗只 log，不回滾——孤兒檔比壞版本便宜）。
- 檔名：沿用 `_download()` 從網址推得的 `name`；推不出時用 `<profile uid>-v<version>.tar.gz`。

### 3.2 守門與派工

- `common/detection_profile_ref.build_payload`：刪 url 分支；`source_type != "file"` 直接 `raise ValueError`（派工層包成 `DETECTION_PROFILE_NOT_DISPATCHABLE`）。
- `detection_orchestration_service._load_profile` 之後**一律** `_load_profile_file`，不再依 source_type 分流。url 型且 failed 的版本本來就被 `assert_profile_dispatchable` 擋；url 型且 pending／running 的版本（還在抽）由 `build_payload` 擋，錯誤訊息「此版本尚未解析完成，無法派工」。
- `_check_source_freshness`：條件從「source_type == url」改成「`url` 非空 且 status == succeeded 且有驗證器」。`_read_controls` 已把 `_source_url` 帶出來，不用改。
- `list_current_files_visible_to`、`current_profiles_for_heartbeat`、`record_agent_states`：**不動**。

### 3.3 資料庫（migration 014）

```sql
-- jedi-detection 014：外部連結型 Profile 解析成功後存成檔案（FR-174 D1／D2）
-- Date: 2026-11-0X
-- 🔴 套件只提供、不執行；冪等。
ALTER TABLE config.detection_profile_versions DROP CONSTRAINT IF EXISTS chk_dpv_source_exclusive;
ALTER TABLE config.detection_profile_versions ADD CONSTRAINT chk_dpv_source_exclusive CHECK (
    (source_type = 'file' AND file_id IS NOT NULL)
 OR (source_type = 'url'  AND url IS NOT NULL AND file_id IS NULL)
);
COMMENT ON COLUMN config.detection_profile_versions.source_type IS 'file＝有檔（上傳或由來源網址解析後存下）／url＝只登記網址、尚未解析成功';
COMMENT ON COLUMN config.detection_profile_versions.url IS '來源網址；file 型有值代表這份檔是從該網址解析存下的，開頁時拿它比 ETag';
```

主專案側由 `flatten_pkg_migrations.sh` 攤平到 `scripts/sql/packages/jedi_detection/014-…`，DEV 手套並登記 `schema_migrations`（登記名 `packages/jedi_detection/014-fr174-url-profile-snapshot.sql`）。

### 3.4 要改寫的註解與文件

`detection_profile_extraction_service.py` 檔頭「url 型：平台代為下載，但只在記憶體」整段、`_download()` docstring 紅字、`detection_profile_version.py` 兩個欄位 `comment=`、`detection_profile_ref.build_payload` docstring 的兩形狀表。改成描述新規則與陷阱（「解析失敗不轉型，否則 Agent 會拉到空」「重抽換檔要清舊檔」），不留「原本／改判」字樣。

---

## 4. 主系統、前端、Agent {#host nav="主系統與 Agent"}

| 端 | 改什麼 | 不改什麼 |
|---|---|---|
| 主系統（H-1） | DEV 套 014；兩支 dev-sec 按「重新解析」；一支守衛測試（`core/plugins/`、`app/` 下不得出現 `source_type == "url"` 分支） | `_agent_profile_sync.py`、心跳回應、`retry` 端點、`agents/files` 端點 |
| 前端（F-1） | 版本列與明細：有 `url` 就多顯示「來源網址」，圖示改依「有無網址」；快照提示改「內容取自來源網址的當下快照，Agent 掃描用的是同一份檔案」；過期提示刪「與實際掃描的內容不一致」 | 新增 Profile 表單（仍選上傳或外部連結）、修正來源對話框 |
| Agent（A-1） | `profile_cache.resolve_profile_source` 刪 url 分支與 `SOURCE_TYPE_URL`；`inspec.py` docstring 三路改兩路；兩支測試改預期 raise | 心跳、同步、cache、主控台下拉（都已是 file 型路徑） |

---

## 5. 拆卡表 {#cards nav="拆卡表"}

底表 `handoff/cards.py`（兩個斷言已過：依賴都在更早的批、同批同 repo 不改同一既有檔）。

| 批 | 卡 | 階段 | repo | 做什麼 | reuse／extend／new | 改既有檔 | 新開檔 | 依賴 | 驗收（小弟本機） | model／effort |
|---|---|---|---|---|---|---|---|---|---|---|
| 1 | **E-0** | 環境 | BE | 環境卡：主系統開發期改裝 jedi-detection 本機路徑（工作坊工作區 develop 模式），驗 import 路徑在工作區底下；改動不 commit | reuse：FR-171 E-0（CM-3385）的做法原封照抄，只是套件換 jedi-detection | `pyproject.toml（path dependency，assume-unchanged 不 commit）` | `docs/features/FR-174-2611-url-profile-snapshot-sync/handoff/dev-env.md` | — | `python -c "import jedi_detection,inspect;print(inspect.getfile(jedi_detection))"` 印出 `jedi-python-package/.worktrees/<母卡號>/jedi-detection/` 底下；`PORT=8003 python main.py` 起得來、`GET /api/1.0/detection-profiles` 200；`git status` 看不到 pyproject.toml（assume-unchanged）；dev-env.md 寫清楚怎麼還原 | sonnet／low |
| 2 | **P-1** | 套件 | jedi 套件 | jedi-detection 1.8.0b1：網址型解析成功就存檔轉 file 型（url 留作來源網址）、約束放寬 file 可帶 url、新鮮度檢查改看 file 型的 url、派工只認 file 型、刪「網址丟給 Agent」分支與不變式註解 | extend：`_extract_in_thread` 下載成功後多一步 `_store_snapshot()`（組 `FileStorage` 走既有 `DetectionProfileService._store_file` 同一條，抽成兩支共用的 module 函式）；`_persist` 多收 `file_id` 並把 `source_type` 寫成 file；`_check_source_freshness` 條件改「有 url 且 succeeded」不看 source_type；`build_payload` 刪 url 分支；`list_current_files_visible_to` 不動（轉型後自然撈到）；`migrations/014` 改約束。new：`common/profile_snapshot.py`（bytes→upload_files 的共用小函式，因為 `detection_profile_service.py` 1,146 行已超標只准一行呼叫） | `jedi_detection/app/service/detection_profile_extraction_service.py`<br>`jedi_detection/app/service/detection_profile_service.py（一行呼叫）`<br>`jedi_detection/common/detection_profile_ref.py`<br>`jedi_detection/app/service/detection_orchestration_service.py（_load_profile 之後一律走 _load_profile_file）`<br>`jedi_detection/infra/detection_tools/model/detection_profile_version.py（欄位註解）`<br>`jedi_detection/api/serializers/detection_profile.py（controls 回 source_url）`<br>`jedi_detection/app/dto/detection_profile_dto.py`<br>`tests/test_detection_source_upload.py`<br>`tests/test_agent_profile_sync_service.py`<br>`pyproject.toml（版號 1.8.0b1）` | `jedi_detection/migrations/014-fr174-url-profile-snapshot.sql`<br>`jedi_detection/common/profile_snapshot.py`<br>`tests/test_profile_snapshot.py` | E-0 | pytest 全綠含新測：①網址型抽取成功後該版 `source_type=file`、`file_id` 有值、`url` 仍是原網址、`sha256`＝下載 bytes 雜湊、`upload_files.sha256` 同值（突變：不存檔→紅）；②下載失敗仍是 url 型 failed、`file_id` NULL；③`build_payload` 對任何版本只回 file 形狀，餵一個 source_type=url 的版本會 raise；④`_check_source_freshness` 對 file 型且有 url 的版本會探測、對沒 url 的不探測；⑤約束：`INSERT source_type='file', file_id=1, url='https://x'` 過、`source_type='url', file_id=1` 被擋（對本機 DEV 套 014 後 psql 驗）。`poetry publish -r nexus` 1.8.0b1，回寫版號 | opus／high |
| 3 | **P-2** | 套件 | jedi 套件 | jedi-detection 公版範例改檔案型：dev-sec linux／windows 兩支 tar.gz 進 `assets/`＋`storage-seed.map` 兩列、seed 004 兩列 versions 改 file 型（file_id 反查 upload_files uid、url 留著）、回報主專案基線 `upload_files` 要補兩列 | extend：照 TWGCB 八支同一條路（assets＋storage-seed.map＋004 反查 uid）；不新開機制 | `jedi_detection/migrations/004-detection-profiles-seed.sql`<br>`jedi_detection/assets/storage-seed.map`<br>`pyproject.toml（版號 1.8.0b2；include 已含 assets）` | `jedi_detection/assets/dev-sec-linux-baseline.tar.gz`<br>`jedi_detection/assets/dev-sec-windows-baseline.tar.gz` | P-1 | 兩支 tar.gz 的 sha256 與 004 寫的 `sha256` 一致、檔名在 map 內；本機乾淨庫跑 001～014 後 `select source_type,file_id is not null,url from config.detection_profile_versions where uid in (dev-sec 兩個 uid)` 回 `file|t|https://…`（file_id 反查的 upload_files 兩列要先用卡上附的 INSERT 樣板塞進本機庫，樣板同 TWGCB 列的形狀）；`poetry publish -r nexus` 1.8.0b2；回寫**明講「主專案基線 upload_files 要補兩列、出貨基線待重產」並附那兩列 INSERT** | sonnet／medium |
| 3 | **F-1** | 前端 | FE | FE 版本明細與控制項頁：file 型帶 url 時多一列「來源網址」；清單圖示依「有無來源網址」而非 source_type；快照提示文案改成「內容取自來源網址的當下快照，Agent 掃描用的是同一份檔案」；過期提示拿掉「與實際掃描的內容不一致」 | extend：`ProfileVersionTable.vue`／`ProfileDetailDialog.vue`／`DetectionProfileControlsView.vue` 既有條件改判 `url` 有無；i18n 兩個 key 改文案、加一個 `field_source_url` | `src/views/detection-profile/components/ProfileVersionTable.vue`<br>`src/views/detection-profile/components/ProfileDetailDialog.vue`<br>`src/views/detection-profile/DetectionProfileControlsView.vue`<br>`src/config/locales/i18n/zh-tw/detection-profile-manage.json`<br>`src/config/locales/i18n/en/detection-profile-manage.json` | — | P-1 | vitest 既有綠；本機對 8003 開 Profile 頁：dev-sec 版本列顯示檔名與來源網址兩者、明細有「來源網址」列、控制項頁提示新文案；`grep -rn border-left src/views/detection-profile` 為 0 | sonnet／low |
| 3 | **A-1** | Agent | evidence-agent | Agent 拔掉「url 型原樣交給引擎」分支：`resolve_profile_source` 只認 file 型，收到 url 型明確報派工資料錯誤；模組與 connector docstring 三路改兩路；對應測試改成預期 raise | extend：刪分支與常數 `SOURCE_TYPE_URL`，錯誤走既有 `ProfilePayloadError` | `guidant_agent/sync/app/profile_cache.py`<br>`guidant_agent/scan/adapters/connectors/inspec.py（docstring）`<br>`test/test_profile_cache.py`<br>`test/test_inspec_connector.py` | — | P-1 | `poetry run pytest test/test_profile_cache.py test/test_inspec_connector.py` 綠；突變：把 raise 改回 `return url` 對應測試要紅；`grep -rn SOURCE_TYPE_URL guidant_agent` 為 0 | sonnet／low |
| 4 | **E-1** | 環境 | jedi 套件 | 套件環境對齊 FR-171：把 feature/FR-172（已含 171 正式版）合進 174 工作坊分支，解 jedi-detection/pyproject.toml 版號衝突（版號取 174 的 1.8.0b2、相依下限取 171 的 remote-agent>=1.12.0）；主系統 `.venv-fr174` 的 jedi-remote-agent／jedi-agent-protocol 換成正式版 | reuse：純合併與環境對齊，不改套件程式碼 | `jedi-detection/pyproject.toml` | — | P-2 | `git merge-tree` 確認只有 jedi-detection/pyproject.toml 一處衝突並解掉；174 工作坊分支 jedi-detection 版號 1.8.0b2、相依 `jedi-remote-agent>=1.12.0`；`.venv-fr174/bin/python -c` 印三支版本：protocol 0.2.0、remote-agent 1.12.0、detection 1.8.0b2（工作區原始碼）；jedi-detection 全部 pytest 綠 | sonnet／low |
| 4 | **A-2** | Agent | evidence-agent | Agent Profile 快取路徑改可設定：`profile_cache.CACHE_ROOT` 寫死 `/data/content/cache`，Mac 本機跑會 Read-only file system；改讀環境變數 `AGENT_CONTENT_CACHE_DIR`（預設不變），本機 `start-agent.sh` 補一行指到 `$DATA/content-cache` | extend：`profile_cache.CACHE_ROOT` 改成函式讀 env、預設值不變；不新增模組 | `guidant_agent/sync/app/profile_cache.py`<br>`test/test_profile_cache.py` | — | — | `poetry run pytest test/test_profile_cache.py` 綠，新增一條：設 env 後 `build_cache_dir` 落在該目錄（突變：拿掉讀 env → 紅）；本機 start-agent.sh 補 `export AGENT_CONTENT_CACHE_DIR="$DATA/content-cache"` 後起 Agent，log 不再出現 `Read-only file system: '/data'`、`ls ~/guidant-agent-local/data/content-cache` 看得到已同步的 Profile 目錄 | sonnet／low |
| 5 | **H-1** | 主系統 | BE | 主系統接線：釘 jedi-detection 1.8.0b1（path 模式下先不改 pin）、`scripts/sql/packages/` 攤平 014、DEV 套 014、DEV 兩支 dev-sec 按「重新解析」轉成 file 型、本機 Agent 下拉出現兩支 CINC Profile；守衛測試 | reuse：全部走既有機制（flatten_pkg_migrations、`_agent_profile_sync` 不動、`retry` 端點不動）；只加一支守衛測試 | `docs/features/FR-174-2611-url-profile-snapshot-sync/handoff/dev-env.md（補 DEV 已套 014）` | `test/test_fr174_profile_snapshot_guard.py` | P-1, E-1, A-2 | DEV（localhost:5432）`psql` 套 `packages/jedi_detection/014` 並登記 schema_migrations；對 dev-sec 兩版打 `POST .../versions/<uid>/extraction/retry`，輪詢到 succeeded 後 DB 查到 `source_type=file`、`file_id` 有值、`url` 保留；`GET /api/1.0/agents/heartbeat` 回應（或 Agent log）清單含這兩支；本機 Agent 主控台 `GET /console/scan/profiles?tool=cinc_auditor` 回兩筆、label 與主系統名稱相同；守衛測試：主系統 `core/plugins/` 與 `app/` 下 grep 不到 `source_type == "url"` 分支（突變：加一行→紅） | sonnet／medium |
| 6 | **Z-1** | 結案 | BE | 結案：jedi-detection 發正式 1.8.0、主系統改回釘版並取消 assume-unchanged、`poetry update jedi-detection`、守衛全綠、FR-174 STATE／README 收口、CM-3410 卡 append 指向本案完成；回報基線重產清單 | reuse：FR-171 Z-2 結案卡同形 | `pyproject.toml`<br>`poetry.lock`<br>`docs/features/FR-174-2611-url-profile-snapshot-sync/README.md`<br>`docs/features/FR-174-2611-url-profile-snapshot-sync/handoff/FR-174-STATE.md` | — | P-2, H-1, F-1, A-1, E-1, A-2 | `pip show jedi-detection` 1.8.0 且路徑在 site-packages；`git status` 乾淨；`pytest test/test_module_boundaries.py test/test_fr174_profile_snapshot_guard.py` 綠；README 總進度全 ✅；回寫列「出貨基線待重產：upload_files 兩列＋packages manifest 014」 | sonnet／medium |

共 9 張、6 批；BE 3、jedi 套件 3、evidence-agent 2、FE 1。

---

## 6. 端到端驗收 {#e2e nav="驗收"}

1. DEV 主系統 Profile 頁：dev-sec Linux 版本列顯示來源「上傳檔案」圖示、檔名 `linux-baseline-master.tar.gz`（或推得的檔名）與來源網址；明細有「來源網址」列。
2. 本機 Agent 主控台 → 掃描 → CINC Auditor → Profile 下拉有「dev-sec Linux 基準」「dev-sec Windows 基準」，選一支對本機目標跑完有報告。
3. 主系統檢測任務綁 dev-sec Linux 派給本機 Agent，Agent log 顯示走 cache 路徑（不是網址）。
4. 控制項頁對 dev-sec 版本：改 DB 的 `source_etag` 成假值再開頁 → 出現「有更新版本，正在重新解析」→ 抽完 `file_id` 換成新列、舊 upload_files 列消失、Agent 下次同步重拉（sha256 變了）。
5. 新增一支壞網址的外部連結 Profile → failed、仍是 url 型、`file_id` 空、派工被擋 412。
6. 乾淨庫跑 001～014＋主專案 seed（補兩列 upload_files）→ dev-sec 兩版是 file 型。
7. `grep -rn "source_type == \"url\"\|SOURCE_TYPE_URL"` 在主系統 `core/`／`app/` 與 Agent `guidant_agent/` 為 0。

---

## 7. 不做什麼 {#out nav="不做"}

- 不加新欄、不加新表、不加新端點。
- 不做回填 migration、不做舊 Agent 相容。
- 不改 FR-171 設定通道、不改心跳。
- 不處理 189 POC（屬上版動作；升級後由決策者按兩次「重新解析」）。
- 不重產出貨基線（Z-1 回報清單，決策者裁示後另做）。
