---
title: "線上更新 — 設計文件 (FR-167)"
brand: "Guidant AI · **FR-167** 線上更新"
eyebrow: "FR-167 · 設計文件 v1 · 2026-10-29（D1～D31 定案；35 張卡、8 批）"
h1: "設計文件：D1～D31 與各部件設計"
subtitle: "升級指令絕不出事：在資料庫複本上升、每步有時限、失敗自動退回；更新站在 Cloudflare、發版在決策者電腦蓋章；更新中心一頁通知、下載、按更新；Agent 先一台再其餘"
lede: "這份把討論稿 31 項定案展開到「照做就能交」。兩層：**第一層**改現有的 `guidantai`，升級在資料庫複本上做、每步有時限、失敗或斷電自動退回，現在那份資料庫從頭到尾沒動過；**第二層**加一個更新站（Cloudflare Worker＋R2）、一個發布 CLI（決策者電腦，一正一備印章）、主系統一頁更新中心（通知、下載、驗簽名、立即或排程、更新紀錄）、主機兩個 systemd 單元看更新單呼叫升級指令。版本只有一種、不自動裝、授權過期仍可升同主版號最新版。35 張卡 8 批，依賴與同批撞檔由 `handoff/cards.py` 程式驗過。"
chips: [
  {text: "D1～D31 定案", kind: ok},
  {text: "35 張卡、8 批", kind: accent},
  {text: "跨 7 repo：BE／FE／jedi-remote-agent／evidence-agent／License Center／update-center（新）／test", kind: accent},
  {text: "開發期無人跑；測試期首腦操作 190", kind: accent},
  {text: "陷阱：進度檔只有 guidantai 寫；主系統對更新單目錄只有寫權", kind: crit},
  {text: "陷阱：guidantai／install.sh 超標，只准一行呼叫，邏輯進 lib/", kind: crit}
]
footer: "FR-167 · 設計文件 v1 · 2026-10-29 · 討論稿：[discussion.html](discussion.html) · 雛形：[prototype/index.html](prototype/index.html) · 底表：handoff/cards.py · 前作：FR-065、FR-066、FR-062、FR-064 · 沿革見 LOG 與 git log"
---

> 狀態：**v1 定案，待開卡**｜日期：2026-10-29｜母卡：待建｜前作：FR-065（`install.sh --upgrade`、`guidantai`）、FR-066（Agent 安裝包）、FR-062（授權中心、Ed25519 簽章、kid 公鑰表）、FR-064（簽章驗證）、FR-134（弱點資料更新頁：更新中心的版面與 chunk 上傳的範本）、FR-166（Agent outbox：結果送不出先存著已做掉）
> 討論稿（選項推演、業界對照、排除方案）：[`discussion.html`](./discussion.html)｜雛形：[`prototype/index.html`](./prototype/index.html)（10 頁）
> 開發 branch：BE／FE／jedi-python-package／evidence-agent／license_center 2026-10-29 查皆在 `feature/FR-139`；`update-center` 是新 repo（已建、branch `main`，P2-1 從它長出）；runner 開工 `git branch --show-current` 再確認，不對就停下問，不切

> 🔴 **這份是設計決策，不是現況。** 內文的行號、檔名、數字停在 2026-10-29 定稿當時，之後實作改動的結果不回頭改寫這裡。上線後的畫面與 API 現況看 SPEC 頁與 FINAL-SPEC。

## 變更紀錄 {#changelog nav="變更紀錄"}

| 日期 | 變更 | 對應 |
|---|---|---|
| 2026-10-29 | 初版設計定案（D1～D31），35 張卡 8 批；拆開發期（小弟本機驗）與測試期（190）；套件走 beta 版；R2 既有 bucket 分主版號資料夾 | 母卡 CM-3241，子卡 CM-3242～CM-3276 |
| 2026-10-30 | jedi-remote-agent beta 版號改 `1.10.0b1`（1.9.0 已被占用，正式版為 1.10.0）；開發期 34 張卡驗收通過，落地與原文不同處（資料庫在 named volume、清單只讀 canonical）記在 `handoff/fr167-STATE.md` §3，本文不回改 | CM-3244、CM-3262、CM-3263 |

---

## 1. 需求背景與端到端 {#background nav="背景"}

現在每出一版，客戶要自己把 2.9GB 的出貨包搬上主機、照手冊敲 `install.sh --upgrade`；它直接在現在那份資料庫上套 migration，失敗靠事前備份還原，還原是寫入動作、本身會失敗。客戶在國外時這條路走不通。決策者的要求：按下確認之後全自動、看得到進度、不可以卡住、卡住也要能復原、資料絕對不掉、不靠人到現場、不自動裝、客戶主機上不多常駐程式不開新的埠。

### 做完之後

```{.mermaid cap="圖 1 — 端到端：四個地方、一條流程，私鑰只在決策者電腦"}
%%{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
    participant CI as 188 CI
    participant US as 更新站（Worker＋R2）
    participant DM as 決策者電腦
    participant BE as 客戶主系統
    participant H as 客戶主機 guidantai
    participant AG as Agent
    CI->>US: 出包→上傳 R2→登記草稿（CI token 只能到這）
    DM->>US: guidant-release publish：核內部先裝→蓋章→已發布
    BE->>US: 每天 03:00 出示授權檔問有沒有新版
    US-->>BE: 已發布清單（決策者簽章、90 天有效）
    BE->>BE: 驗章、版號>現行、主版號相同→通知系統管理員
    Note over BE: 管理員按「下載」→背景下載、驗雜湊→已就緒
    BE->>H: 有權限的人按立即／排程→寫 upgrade-order.json
    H->>H: systemd path 看到單→guidantai upgrade --from-order
    H->>H: 先換成新版的自己（D30）→預檢→停接新工作等做完→停服務
    H->>H: 複製資料庫→在複本上載映像、套 migration→健康檢查
    alt 任一步失敗／超時／斷電
        H->>H: 自動退回：丟複本、接回原份、起舊映像
    else 全過
        H->>H: 換接：複本轉正、原份留作升級前快照、前門最後換
    end
    H-->>BE: 進度檔 upgrade-progress.json（維護頁與更新中心都讀它）
    BE-->>AG: 心跳回 target_version（先 1 台，成功再其餘）
    AG->>BE: 下載 Agent 包→寫單→主機 systemd 觸發 install.sh upgrade→回報結果
```

**做完之後長這樣**（對照雛形 10 頁）：儀表板冒出「有新版」橫幅（⓪）；更新中心看版本說明、預檢、按下載（①）；下載中服務照常（①b）；就緒後選立即或排程（①c）；停機期間打開原網址是維護頁，每 3 秒更新步驟與時限（②）；失敗自動退回並給診斷包（③）；成功後 Agent 先一台再其餘（④，僅在確認視窗勾了「接著更新 Agent」時自動跟進）；跨版兩段（⑤）；例外狀態各一張卡（⑥）；更新站後台只給原廠看（⑦）。

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

| 期 | 做什麼 | 產出 | 完成怎麼判定（決策者檢查法） |
|---|---|---|---|
| **一 升級變安全**（P1，11 張） | `guidantai upgrade`／`recover`：先換自己、預檢、停接新工作等做完、停服務、複製資料庫、複本上升級、健康檢查、換接、自動退回、開機檢查、清理；維護頁；出包產 release.json；CI 跨版排練 | IT 現在就敲的那道指令變安全 | 在 190（現跑 1.26.0）灌 POC 資料庫備份，一道指令直升新版成功；放一支會失敗的 migration → 自動退回，資料筆數與升級前一致；升到一半 kill -9 主機重開 → 開機自動退回；停機期間打開原網址看到進度 |
| **二 更新站**（P2，6 張） | Cloudflare TS Worker＋D1＋R2、六張表、客戶端三端點、後台、發布 CLI（一正一備）、CI 登記草稿、印樣進產品、授權檔加主版號 | 我們發一版，客戶查得到 | 跑一次 tag pipeline，後台出現草稿；自己電腦跑 `guidant-release publish` 變已發布；用 DEV 授權檔打 check 回應裡有新版且簽章驗得過；用過期授權檔也回；拒絕名單的不回 |
| **三 更新中心**（P3，9 張） | 能力點、更新模組、每日檢查、通知、下載續傳、驗章、離線上傳、更新單、systemd 單元、排程、取消、重試、跨版兩段、更新中心頁、橫幅 | 管理員不必搬檔、不必登入主機 | DEV 指向本機更新站：收到通知→按下載→就緒→按立即，更新中心與維護頁都看到進度跑到完成；再試排程到點自己跑；沒有「系統更新」權限的帳號看不到按鈕；拔網路改離線上傳一樣就緒 |
| **四 Agent 跟進**（P4，5 張） | 心跳契約、rollouts 表、分批、Agent 下載、Agent 升級指令比照主系統、Agent 區面板 | Agent 不必逐台手動升 | 三台 Agent：主系統升完先一台變新版，其餘兩台接著升；故意讓第一台失敗，其餘兩台不會被推 |
| **五 收尾**（P5，4 張） | 手冊、SPEC、e2e、套件發版與釘版 | 文件齊 | 手冊有「什麼情況要敲 recover」；e2e 綠 |

依賴：一與二可以同時開（批 1 混排）；三要等一的骨架（P1-1）與二的更新站（P2-1）；四要等三的更新單；五最後。**決策者功課（都不卡開發期，測試期前備妥即可）**：①更新站網域（建議 `update.<公司網域>`，加進 Cloudflare 那個帳號的 zone）；R2 bucket 已有，Worker／D1／Access 由首腦用 wrangler 建；②在自己電腦跑 `guidant-release init-keys` 產一正一備，備用封存，PROD 印樣交給結案卡 P5-5；③R2 的 API token（只給該 bucket 物件讀寫）與更新站 CI token 填進 GitLab CI 變數（masked、不勾 Protect），不進 repo。CRA 兩題先不管，不列功課。

## 3. 決策定案（D1～D31） {#decisions nav="決策定案"}

定案一字內容以討論稿 callout 原文為準，本表為設計文件形狀的收斂。排序按主題不按編號。

| 編號 | 定案 | 落在哪 |
|---|---|---|
| **升級指令** | | |
| D13 | 客戶機上由 `guidantai upgrade` 換版，主系統容器不碰 docker | P1-1、§5.1 |
| D30 | 升級指令開跑第一步從已驗包取新版 `guidantai`＋`lib/` 換掉自己再跑；換自己失敗視同預檢不過；畫面不顯示指令版本 | P1-1 `self_replace.sh` |
| D18 | 在資料庫複本上升級，現在那份不動；證據檔不複製；設定與憑證一起複製 | P1-4、§5.2 |
| D19 | 跑到一半的工作等做完，永遠不強制停；時段結束還沒完就這次不更新 | P1-3c、P1-3b |
| D25 | 服務停掉之後沒有取消鈕；每步時限、總上限 60 分，到了自動判失敗退回 | P1-1 runner、§5.1 時限表 |
| D14 | 開放使用前一律退回；開放使用後不退回，發修正版 | P1-6、P1-7 |
| D17 | 必經版本只有三種情況才設；由 migration 必經標記自動算 | P1-9a |
| D20／D21 | 不做救援頁、不做常駐更新助手；執行者就是 `guidantai upgrade`，由 systemd 觸發 | P3-2c |
| **更新中心** | | |
| D22 | 先通知，管理員確認後才下載 | P3-2b、P3-3 |
| D23 | 離線更新：管理員向原廠索取單一離線檔自己上傳，從驗簽名起同一條路 | P3-2b |
| D24 | 記錄是誰按下載、誰按更新 | `product_update_jobs` |
| D26 | 沒有「敲指令」：有「系統更新」權限的人在畫面上按；主系統只寫更新單 | P3-1、P3-2c |
| D15／D28 | 下載要管理員確認、更新要有權限的人按、可立即可排程；**任何版本都不自動裝** | P3-2b、P3-2c |
| D10 | 每天檢查一次、管理員可關；關了更新中心一直提醒 | P3-2b |
| D16 | Agent 跟進可選：「更新到 X」確認視窗勾「主系統更新完成後，接著更新 Agent」（預設勾）就先推 1 台、正常再推其餘、任一台失敗其餘暫停；不勾就與主系統分開，之後在 Agent 區自己按，可排程、可稍後提醒 7 天。「必須更新」只在新版 `min_agent_version` 高於現行 Agent 時出現 | P4-2 |
| **版本與授權** | | |
| D27 | 版本只有一種；安全修正就是發下一版；只維護最新版；CRA 兩題待顧問 | §5.6、P5-1 |
| D7 | 授權過期仍可升同主版號最新版（唯讀、功能鎖）；換主版號要重新授權 | P2-1 判定、P2-5 欄位 |
| D29 | 每一版的更新包至少保留 10 年 | P2-1 `retain_until` |
| D8 | 不做差異更新；每版整包、不做只換單顆容器 | — |
| **更新站與發版** | | |
| D1 | 更新包放 S3 相容私有空間，用 Cloudflare R2 | P2-1、P2-4 |
| D2 | 更新站與授權中心分開，互不持有對方憑證；只複製公鑰表 | P2-1 |
| D2a | 更新站跑 Cloudflare Worker，先用免費方案；語言選 **TypeScript**（Python Worker 仍是測試版；決策者 2026-10-29 改判） | P2-1 |
| D3 | 發版最後一步在決策者電腦蓋章；印章一正一備、備用封存、跟授權中心印章分開；CI 只能登記草稿；換鑰用舊鑰背書、藏在一次對章通過的更新裡 | P2-3、P2-6 |
| D6 | 不做頻道；草稿可指定給內部機器（STG、POC）先裝 | P2-2 |
| D9 | 客戶只回報授權編號、機器指紋、現行版號、最近一次升級結果 | P2-1 `instance_version_reports` |
| D11 | 客戶出示本機授權檔、更新站用授權中心公鑰驗 | P2-1 |
| D12 | 拒絕名單第一版決策者在後台手動加 | P2-2 |
| D31 | 發布清單 90 天有效，過期重新蓋章；不做「回應 24 小時內」 | P2-3 簽、P3-2b 驗 |

## 4. 現況接入點盤點 {#inventory nav="現況盤點"}

| 零件 | 現況（2026-10-29 實查） | 本案動作 | 卡 |
|---|---|---|---|
| `scripts/installer/guidantai` | 1,562 行 bash，root，裝機時複製到 `/usr/local/bin/`；子命令 status／start／stop／restart／logs／uninstall／rotate-credentials／agent-check／diag／setup-token／bugsink-*；派送在 1480–1562 | **extend，超標只准加派送行**：加 `upgrade`、`recover`，邏輯全進新開的 `scripts/installer/lib/` | P1-1 |
| `scripts/installer/install.sh` | 3,021 行；`run_upgrade` 2853–2890 依序呼叫 `upgrade_check_preconditions`／`upgrade_backup_database`／`load_images`／`upgrade_apply_migrations`／`upgrade_switch_version`／`install_ops_cli`；備份失敗即中止、直接對現在那份套 migration | **extend**：`run_upgrade` 改呼叫 `lib/upgrade_runner.sh`，既有各步驟函式搬進對應 lib 檔；`install_ops_cli` 連 `lib/` 一起裝 | P1-1 |
| `scripts/init/migrate.sh` | 298 行；比對 `schema_migrations` 已套清單依序補套；只印 log 不回進度 | **extend**：每支印一行 `PROGRESS\t<n>/<total>\t<檔名>`，不寫進度檔（進度檔只有 runner 寫） | P1-5 |
| `scripts/build/build_bundle.sh` | 408–430 產 `bundle-info.txt`，「適用起始版號：1.0.0 以上」寫死；`bundle-manifest.sha256` 是 install.sh 驗 digest 的來源 | **extend**：多產 `release.json`（未簽）；可直升起始版由 `scripts/sql/manifest.tsv` 必經標記算；包內多放 Agent compose 交付包 | P1-9a |
| `.gitlab-ci.yml` `package` job | 193–232：sbom → build_bundle → push_harbor；包留在 188 不上傳 | **extend**：加 `publish_draft.sh`（R2 上傳＋登記草稿）；加 `rehearse-upgrade` job | P2-4、P1-9b |
| `docker/production/docker-compose.yml` | `guidant-db` pgdata 寫死路徑；`guidant-fe` 是唯一對外入口；`restart: unless-stopped` 靠 docker 開機起；**不另立 systemd unit**（FR-065 D10） | **extend**：pgdata 改變數（升級時指複本）；fe 多掛 `upgrade/` 唯讀；本案加三個 systemd 單元（更新單 path＋service、開機檢查 oneshot），由 install 裝 | P1-4、P3-2c、P1-7 |
| FE `nginx.onprem.conf` | 單一 location 反代 `/api/1.0`；後端掛掉回 502；沒有 error_page | **extend**：`error_page 502 503` 送 `maintenance.html`；`/upgrade-progress.json` alias 到唯讀掛載 | P1-8 |
| jedi-remote-agent 心跳 | `agent_enrollment_service.py` 371 收 `agent_version`、602 比 `allowed_targets_version`；`RemoteAgentAdapters` 已有 `heartbeat_extras`（vulndb、profile、tool_schemas、ldap_config 都掛這） | **extend**：加 `dispatch_gate`、`update_extra` 兩個 port；收 `upgrade_result` | P1-3a |
| `core/plugins/remote_agent.py`＋`_agent_vulndb.py` | 宿主接線形狀：心跳附 `vulndb {bundle_date, sha256, size}`、下載端點 `GET /agents/vulndb/bundle` 走資料面 | **reuse 形狀**：`_agent_update.py` 照抄，心跳附 `update {target_version, sha256, size}`、下載端點 `GET /agents/update/bundle` | P4-2 |
| evidence-agent `core/vulndb_sync.py`、`core/enroll.py` 355–372 | observer 形狀：心跳回應有新資料就背景下載、續傳、驗 sha；`enroll.py` 一行掛一個 observer；outbox.drain 已在（FR-166） | **reuse 形狀**：`update_sync.py` 照 `vulndb_sync.py`；Agent 送不出的結果已由 FR-166 outbox 處理，**本案不開卡** | P4-3 |
| evidence-agent `deploy/install.sh` `cmd_upgrade` 1153 | 已經會「改由新版交付包內的安裝程式執行升級」（`GUIDANT_AGENT_UPGRADE_DELEGATED`）——D30 的形狀 Agent 端早有 | **extend**：補每步時限、進度檔、自動退回；兩份 install.sh 只加一行，邏輯進 `agent/install-lib/` | P4-5 |
| `app/source_scan_vulndb/`、FE `views/vulndb/` | 弱點資料更新頁：status／check-update／auto-update／check-interval／chunk 上傳三端點／history；FE `VulndbManage.vue`＋`VulndbUpdateHistory.vue` 共 338 行 | **reuse 形狀**：更新中心的 BE service、chunk 上傳、FE 版面都照它 | P3-2a、P3-2b、P3-3 |
| `core/scheduler.py` 714、`core/scheduler_agent.py` | 排程已拆檔先例：`scheduler.py` 一行呼叫，job 在 `scheduler_agent.py`（`add_job(... "cron", hour=2 ...)`） | **reuse 形狀**：`scheduler_update.py` | P3-2b |
| `core/host_capabilities.py`、`2026-10-10-fr134-vulndb-settings-page.sql` | `system.vulndb.read／update` 兩顆＋選單路由綁定＋角色授權，一支 migration | **reuse 形狀**：`system.product-update.read／update` | P3-1 |
| jedi-license-runtime `common/public_keys.py` | `PUBLIC_KEYS` 以 kid 查 Ed25519 公鑰；支援新舊雙鑰過渡 | **reuse 機制**：發布印樣另一張表 `/etc/guidant/release-keys/<kid>.pub`，kid 規則同 | P2-6、P3-2b |
| License Center `modules/licensing` | payload 有 `modules`、`expires_at`、`expiry_policy`；沒有主版號 | **extend**：加 `product_major`（預設 1）；更新站把缺欄視為 1 | P2-5 |
| FR-064 `sign_manifest.sh`、LC `POST /api/internal/sign-manifest` | 出貨包 manifest 送授權中心簽 | **不用它簽發布清單**（D3：發布印章跟授權印章分開、在決策者電腦離線）；只 reuse Ed25519 封包格式與 kid 規則 | P2-3 |
| 通知 | `app/notification/service/notification_service.py`；公告模組 jedi-bulletin | **reuse**：有新版、已就緒、更新結果走通知；30 分與 5 分前全站提醒走公告或 toast（P3-2c 先 grep 定一個） | P3-2b、P3-2c、P3-4 |
| STG／POC | 1.26.0，跟主線同版；189 留有 1.23.1 映像 | 跨版排練在 190 e2e 目標做，**不碰 STG／POC** | P1-9b |

## 5. 詳細設計 {#design nav="詳細設計"}

### 5.1 升級指令：步驟、時限、進度檔（P1）

`guidantai upgrade <包 | --from-order>` 由 `lib/upgrade_runner.sh` 逐步呼叫，每步一個 lib 檔、一個時限、一個進度檔狀態。runner 是唯一寫進度檔的地方。

| 步 | lib 檔 | 做什麼 | 時限 | 失敗時 |
|---|---|---|---|---|
| 0 | `self_replace.sh` | 驗包雜湊與 `release.json` 簽章 → 把包內 `guidantai`＋`lib/` 裝到 `/usr/local/bin` → `exec` 新版帶同樣參數（環境變數 `GUIDANT_UPGRADE_REEXEC=1` 防迴圈） | 每 GB 60 秒、下限 300 秒（從大包抽維運指令要讀完整包；已解開目錄 60 秒） | 不動任何東西，頂層記 `skipped`（第 0～2 步失敗都不動系統） |
| 1 | `upgrade_preflight.sh` | 空間（包＋複本×1.2＋映像＋2GB 保留）、版本路徑對 `min_base_version`、資料庫連得上、停機預估（複製時間＝資料夾大小÷實測速率×3） | 2 分 | 列出不過項目，什麼都不動 |
| 2 | `upgrade_drain.sh` | 寫 `upgrade/draining` 旗標 → 主系統停接新工作（§5.3）→ 每 10 秒 psql 查進行中數 → 0 才過 | 30 分提醒；`--deadline` 到就放棄 | 刪旗標、恢復接單、進度檔 `skipped` |
| 3 | `upgrade_db_copy.sh` | `docker compose stop api worker socketio` → `stop db` → `cp -a pgdata pgdata-next` → 同時複製 `.env`／憑證目錄 | 預估×3 | 刪 `pgdata-next`、起舊版 |
| 4 | `upgrade_apply_on_copy.sh` | `docker load` 新映像並比對 `bundle-manifest.sha256`（5 分）→ 以 `GUIDANT_PGDATA_DIR=pgdata-next` 起 db → init image migrate 模式 → tail `PROGRESS` 行轉寫進度檔 | 載映像 5 分；**單支 migration 10 分無新進度** | 退回（§5.2） |
| 5 | `upgrade_health.sh` | 以複本起 api／worker（fe 不動）→ 180 秒 healthcheck → 實打登入、`/api/1.0/version`、三支主要 GET | 5 分 | 退回 |
| 6 | `upgrade_switch.sh` | `pgdata` → `pgdata-prev-<舊版>`，`pgdata-next` → `pgdata`；寫 `GUIDANT_VERSION`；**最後**重建 fe；進度檔 `succeeded` | 2 分 | 這一步失敗仍退回（換接前都可退） |
| 7 | `upgrade_cleanup.sh` | 刪更早的 `pgdata-prev-*` 與映像，只留上一版；更新包留到下次成功；刪更新單 | — | 只記 warning |

**總上限 60 分**（步 3～6）。進度檔 `<資料目錄>/upgrade/upgrade-progress.json`：

```json
{"job_id":"20261031-2200-a1b2","from":"1.24.1","to":"1.27.0","path":["1.27.0"],
 "triggered_by":{"kind":"order","user":"wang.dm","mode":"scheduled","at":"2026-10-29T09:25:00+08:00"},
 "started_at":"2026-10-31T22:00:04+08:00","status":"running",
 "steps":[{"n":1,"name":"preflight","status":"ok","started":"…","ended":"…","detail":"空間 42GB 需 8GB"},
          {"n":4,"name":"apply_on_copy","status":"running","started":"…","limit_s":600,"progress":"migration 31/47 2026-10-14-fr162-mapping-index.sql","last_progress_at":"…"}],
 "db":{"current":"pgdata","copy":"pgdata-next"},"prev_images":["guidant-ai-be:1.24.1","…"],
 "result":null,"error":null,"rolled_back_at":null}
```

欄位只增不改名；維護頁、更新中心、開機檢查、診斷包四邊都讀這一份。

**開機檢查**：`guidant-upgrade-boot-check.service`（oneshot，`After=docker.service`）跑 `guidantai upgrade --boot-check`：讀進度檔，`status=running` 且步 3～6 → 呼叫 rollback；其他狀態不動。`guidantai recover` 走同一支 rollback，給 docker 當掉自動退回沒跑的情況。

### 5.2 資料不掉：複本、退回、換接（P1-4／P1-6／P1-7）

- 服務停掉（步 3）到換接（步 6）之間沒有任何寫入進現在那份；退回＝刪 `pgdata-next`、以 `GUIDANT_PGDATA_DIR=pgdata` 起舊映像、健康檢查。沒有還原動作。
- 換接後 `pgdata-prev-<舊版>` 是升級前快照，**只給災難救援、不是回滾**（D14）；下次升級成功時清掉。
- 證據檔（SeaweedFS）不複製：升級過程禁止改寫既有檔，守衛是 migration 與 init image 不掛 SeaweedFS 卷；真要改檔的版本標成特殊版另案。
- 設定檔與憑證目錄步 3 一起 `cp -a`，退回時一起換回。
- 進行中的背景工作不會被中斷：步 2 等它做完才停服務（D19）。
- Agent 送不出的結果：FR-166 的 outbox 已在（`core/enroll.py` 每次心跳 `outbox.drain()`），本案不另做。

### 5.3 停接新工作（P1-3b／P1-3a）

旗標檔 `<資料目錄>/upgrade/draining`（主系統唯讀掛載看得到）。`core/maintenance_gate.py` 一個函式 `is_draining()`；三個消費者：worker 撿單前判一次、`core/plugins/remote_agent.py` 把它接進 jedi-remote-agent 的 `dispatch_gate` port（心跳不帶 pending 任務、結果照收）、更新中心 status 回 `draining: true` 給前端顯示「正在等 N 件做完」。不新開端點、不改任務狀態機。

### 5.4 更新站（P2）

**Repo** `update-center`（新；本機 `~/Projects/Billows/Audit-Manager/update-center`、遠端 `gitlab.com/auditmanager/update-center`、branch `main`；`.claude/workshop.toml` 登記 `update-center`）。**TypeScript** Worker（wrangler 4，Node 22；決策者 2026-10-29 選 TS，Python Worker 仍是測試版）、D1（SQLite）六張表、R2 一個 bucket。發布 CLI 同 repo 用 TS 寫（Node 內建 Ed25519 與 PKCS8 加密私鑰），一個 repo 一套工具鏈。

**Cloudflare 既有資源（決策者帳號，2026-10-29 確認）**：帳號 ID `6b1da966062f9093782aefa99c1e68d1`；R2 bucket **`guidant-ai-release`** 已建好（S3 端點 `https://6b1da966062f9093782aefa99c1e68d1.r2.cloudflarestorage.com/guidant-ai-release`）。Worker、D1、Access 由 P2-1／P2-2 的 `wrangler.toml` 宣告、首腦部署時建。開發期用 Cloudflare 自動給的 `*.raymond-jedicotech.workers.dev`，正式網域出貨前再定（P5-5 檢查項）。API 權杖（D1／Workers／R2／Access 編輯）與 R2 S3 金鑰已由決策者備妥、權限已驗，放 `update-center/.env`（不入版控）。後台 Access 政策只放行決策者信箱（值在 `wrangler.toml` 的 `ACCESS_ALLOWED_EMAILS` 變數，首腦部署時填；信箱不是祕密但不進文件）。

**bucket 內一個 bucket 不分、物件 key 分主版號資料夾**：`v<主版號>/<完整版號>/guidant-ai-<完整版號>.tar.gz`＋同目錄 `release.json`、`.sha256`，例如 `v1/1.27.0/guidant-ai-1.27.0.tar.gz`、`v2/2.0.0/…`。理由：主版號是授權邊界（D7），同一層資料夾就是「這把授權拿得到的全部」，後台與人工查檔都一眼分清；CRA 十年保留（D29）要整個 `v1/` 一起設保留規則。**key 只在更新站內部拼**（`download-url` 回預簽網址），客戶端與 CI 腳本都不寫死路徑：CI 把包交給 `register-draft` 時只給版號，上傳目標 key 由 Worker 算好回給它。換主版號不用改任何客戶端程式。

| 端點 | 誰打 | 驗什麼 | 回什麼 |
|---|---|---|---|
| `POST /v1/check` | 客戶主系統每 6 小時與啟動時 | body 帶本機授權檔（授權中心簽的，kid 查公鑰表 WebCrypto 驗）、機器指紋、現行版號 | 合法安裝 ∧ 不在拒絕名單 ∧ 主版號相同 ∧ 版號>現行 → 已發布清單（決策者簽過的 `release.json`＋簽章＋kid）與必經路徑 `path[]`；對該指紋開了草稿的也回；否則 `none`。順手寫 `instance_version_reports` |
| `POST /v1/download-url` | 客戶主系統按下載後 | 同上＋目標版號 | R2 預簽網址 15 分鐘；寫 `release_download_log` |
| `POST /v1/report` | 客戶主系統升級結束 | 同上＋結果 | 204 |
| `POST /admin/releases/register-draft` | CI（scoped token） | token 只能到這 | 建草稿列 |
| `POST /admin/releases/<v>/publish` | `guidant-release` CLI（決策者 Access 身分） | 帶簽好的清單、kid；內部先裝全成功 | 狀態 → published |
| `POST /admin/releases/<v>/{withdraw,internal-targets}`、`/admin/denylist`、`GET /admin/keys` | 後台頁（Cloudflare Access） | Access JWT | — |

表：`product_releases`（version、major、min_base_version、sha256、size、**r2_key**（`v<major>/<version>/…`，Worker 填）、status draft／published／withdrawn、internal_fingerprints[]、has_security_fix、signed_manifest、kid、retain_until）、`instance_version_reports`（license_id、fingerprint、current_version、last_seen、last_upgrade_result；**無客戶名稱**）、`release_download_log`、`license_denylist`（license_id、reason、added_by、at）、`signing_keys`（kid、pubkey、status primary／backup／revoked）、`ci_tokens`。

**客戶端信任**（P3-2b `package_verifier.py`）：只信 `/etc/guidant/release-keys/*.pub`；清單簽章驗過 → `issued_at+90d` 未過（D31）→ `version` > 現行 → `major` 相同 → 下載後 sha256 等於清單。更新站回什麼都不信任。

**發布 CLI**（P2-3，決策者電腦）：`guidant-release init-keys`（產 primary＋backup，私鑰檔 passphrase 加密 600，印備用封存說明）、`publish <版>`（取草稿、核 `internal_fingerprints` 全部回報成功、填 `issued_at`、蓋章、送回）、`withdraw <版>`、`rotate-key`（新鑰進包內 `release-keys/`、清單用舊鑰與新鑰各簽；舊鑰再簽一版後作廢）。passphrase 由決策者輸入，不進檔、不進 log、不給任何 session。

### 5.5 更新中心（P3）

**BE 模組** `product_update`（DDD 四層；照 `app/source_scan_vulndb` 的 settings service 形狀）。能力點 `system.product-update.read`（看）／`update`（下載、立即、排程、取消、重試、上傳、Agent 區）。

| 端點 | 做什麼 |
|---|---|
| `GET /system/product-update/status` | 目前版本、最近檢查、可用新版（含 path）、下載狀態、就緒、預檢結果、draining、進度檔內容、Agent rollout 摘要 |
| `POST …/check` | 立即檢查（打更新站） |
| `POST …/download`／`DELETE …/download` | 開始背景下載（續傳）／取消 |
| `POST …/uploads`、`PUT …/uploads/<id>/chunks/<n>`、`POST …/uploads/<id>/complete` | 離線上傳（向原廠索取單一離線檔，一次上傳），照 vulndb chunk 三端點 |
| `POST …/apply` `{mode: now|scheduled, at}` | 寫更新單（D26）；記錄誰按的 |
| `DELETE …/apply` | 服務停掉前取消：刪更新單 |
| `POST …/retry` | 退回後重試：從預檢重跑，不重下載 |
| `POST …/diag` | 寫 type=diag 更新單，主機產診斷包；`GET …/diag/<id>` 下載 |
| `GET …/history` | `product_update_jobs` 分頁 |
| `POST …/agents/{pause,resume,push}` | Agent 跟進（P4-2） |

**更新單**（P3-2c）：`<資料目錄>/upgrade/orders/<job_id>.json` `{job_id, version|path[], mode, not_before, requested_by, type: upgrade|diag}`。主系統容器對 `upgrade/orders/` **只有寫權**（compose 掛 `:rw` 但目錄 `chmod 730`、`chown root:guidant`），對 `upgrade/` 其餘唯讀。主機 `guidant-upgrade-order.path`（`PathChanged=`）觸發 `guidant-upgrade-order.service` → `guidantai upgrade --from-order <檔>`：立即就跑；排程則 `systemd-run --on-calendar` 排一次性 timer。執行前重驗包與簽章（過期或被刪就寫 `skipped` 並通知）。

**每日檢查**（P3-2b）：`core/scheduler_update.py` 每 6 小時一次（`CHECK_INTERVAL_HOURS`），主系統啟動時另跑一次，`update_site_client.check()`；連不到安靜略過、status 顯示「最近一次檢查失敗」；關閉開關存系統設定（唯一開關，D10）。

**畫面狀態**：status 回應把「單已寫、時間已到、主機還沒為這張單寫進度檔」換成 `submitted`（已送單等主機），只換回應、DB 仍是 `scheduled`，取消與重複按的擋法照舊認它。失敗終態三種：`skipped`（這次沒開始，系統沒動）、`rolled_back`（已自動退回）、進度檔 `result=rollback_failed`（退回也失敗，畫面紅色並顯示 `sudo guidantai recover`、停止輪詢）。

**資料表** `product_update_jobs`：from／to／path／status（checking／downloading／ready／scheduled／running／succeeded／rolled_back／skipped／cancelled）／downloaded_by（login＋nickname＋at）／applied_by／cancelled_by／source（online／offline）／not_before／started／ended／error／job_id。結束後從進度檔同步結果。審計欄位照規範回 `*_name`。

**FE**（P3-3、P3-4）：`views/product-update/UpdateCenter.vue`（照 `VulndbManage.vue` 版面）、`UpdateHistory.vue`、`ReadyDialog.vue`（立即／排程）、`NewVersionBanner.vue`；`AgentRolloutPanel.vue`（P4-4）。沒有 update 能力點只看不到按鈕。分段用 SegmentButtons、不自訂字級、無 border-left。

### 5.6 版本與授權（D7／D27）

- 版本只有一種；`release.json` 多一個 `has_security_fix` 讓更新說明多「影響範圍」「建議動作」兩段，其餘行為一樣。
- 授權檔 `product_major`（P2-5，缺欄視為 1）。更新站 `check` 只比主版號、不看 `expires_at`。產品內授權過期的行為（唯讀、模組鎖）不變，本案不碰 `common/authz/license.py`。
- CRA 文件（P5-1）照 D27 寫；CRA 兩題決策者裁示先不管，之後有答案只影響 P5-1 措辭。

### 5.7 Agent 跟進（P4）

- 是否自動跟進由管理員在「更新到 X」確認視窗決定（勾選「主系統更新完成後，接著更新 Agent」，預設勾）；不勾則 Agent 與主系統分開，管理員之後在 Agent 區自己按（立即、排程，或稍後提醒 7 天）。「必須更新」標示只在新版 `min_agent_version` 高於該台現行版時出現。
- 勾選時，主系統開放使用後 `agent_rollout_service` 建 `agent_update_rollouts`（agent_id、target_version、batch、status pending／dispatched／succeeded／failed／paused、source following／manual／agent-only）：batch 1 一台（最近心跳最新的那台）；成功 → 其餘 batch 2 全部 dispatched；失敗 → 其餘 paused。
- 心跳：`_agent_update.py` 的 `update_extra` 只對 `dispatched` 的 Agent 回 `{target_version, sha256, size}`；Agent 回 `upgrade_result` 推進狀態。
- 主動通知：下發列轉成 `dispatched` 且交易提交後，主系統背景呼叫 Agent 的 `POST /update/notify`（同一道 mTLS＋綁 op 短效 JWT，5 秒逾時）叫它立刻心跳；連不到只記 warning、退回等下一次心跳，不擋推送 API 與排程。版本約束：Agent 主線版號 ≤ 主系統現行版，否則不派。
- Agent 側 `core/update_sync.py`（照 `vulndb_sync.py`）：下載 `GET /agents/update/bundle`（資料面 JWT、續傳、驗 sha）→ 寫 `agent-upgrade-order.json` → 主機 `guidant-agent-upgrade-order.path` 觸發 `install.sh upgrade <包>`（P4-5 加時限、進度檔、自動退回）→ 結果隨心跳回。Agent 不連外網、不連更新站、不多常駐程式。
- 只升 Agent：主系統已升、某幾台落後 → 更新中心「推選定」建 `manual` rollout；Agent 單獨修正版（更新站 `release.json` 標 `agent_only`）→ 主系統下載存著不升自己，建 `agent-only` rollout。第一版先做前者，後者欄位先留。

### 5.8 安全規則對照

| 規則 | 怎麼守 |
|---|---|
| 對外服務無私鑰 | 更新站只有公鑰表與 R2 讀權；私鑰只在決策者電腦（P2-3） |
| 客戶端不信任更新站 | 清單簽章、kid、90 天、版號遞增、主版號、sha256 五道（P3-2b） |
| 主系統不碰 docker | 容器無 docker socket；只寫更新單；目錄只有寫權（P3-2c） |
| 主機不多門 | 不開新埠、不多常駐程式；systemd path／timer／oneshot 三個單元都是本機事件觸發（P3-2c、P1-7） |
| 上傳解析 | 離線上傳 chunk 大小上限、完成時先驗簽章再解包、驗不過即刪（P3-2b；照 `.claude/rules/security/upload.md`） |
| 祕密 | CI 的 R2／更新站 token 走 CI 變數不入 repo；passphrase 不進檔；診斷包遮罩（P2-4、P2-3、P1-10） |
| log | 進度檔與診斷包不含密碼與金鑰（grep 守衛） |
| 只回報必要資訊 | `check` 只送授權編號、指紋、版號、結果（P2-1） |

## 6. 拆分（35 張實作卡、8 批） {#breakdown nav="拆分"}

**批（Stage）＝工作坊同時開幾個小弟一起跑的那一組**：同一批不改同一個既有檔、不依賴對方產出、不搶同一外部資源。階段（P1～P5）只是分組標籤。底表在 [`handoff/cards.py`](handoff/cards.py)，兩個斷言已過：依賴都在更早的批；同批同 repo 無兩張卡改同一既有檔。

**登記型檔先佔位**：`scripts/installer/guidantai` 與 `install.sh` 由 P1-1 一次開好 `lib/` 全部步驟空殼與子命令派送，後面十張 P1 卡只填自己的 lib 檔；`config/app_modules.py` 與 `di_containers/` 由 P3-2a 一次登記；evidence-agent `core/enroll.py` 只有 P4-3 碰；`core/plugins/remote_agent.py` 兩張卡各加一行但在不同批（P1-3b 批 3、P4-2 批 6）。

**repo 分布**：BE 23、FE 4、update-center（新） 3、evidence-agent 2、jedi 套件 1、License Center 1、test 1。**環境卡 E-1**（批 2）：P1-3a 改完心跳契約直接發 beta 版（`1.10.0b1`，決策者授權小弟在工作坊可 push 自己的 ws branch、套件可發 beta），E-1 把主 repo 釘到 beta 並只 `poetry update jedi-remote-agent`；結案卡 P5-5 發正式版、改釘正式版。**開發期與測試期分開**：每張卡的「驗收」只寫小弟在本機／DEV 8003／本機 docker／`wrangler dev` 就驗得完的事，工作坊無人時不會卡；要 190、pipeline、真 Cloudflare、PROD 印樣的情境寫在「測試期（190）」欄，由首腦操作、決策者看畫面（§7）。**套件異動（發版前統一確認）**：jedi-remote-agent（心跳契約兩個 port）；License Center（授權檔加 `product_major`）。**新 repo** `update-center`（已建）：開卡時登記 `.claude/workshop.toml` 與 Notion「Repo」選項。

| 批 | 階段 | 卡 | repo | reuse／extend／new＋理由 | 改哪些既有檔 | 新開哪些檔 | 依賴 | 驗收（開發期，小弟本機就能驗） | 測試期（190，首腦操作） |
|---|---|---|---|---|---|---|---|---|---|
| 1 | P1 | **P1-1** 升級骨架：guidantai 加 upgrade／recover 子命令、先換成新版的自己（D30）、lib/ 步驟空殼、進度檔讀寫、每步時限框架 | BE | extend：`guidantai` 派送段加兩個子命令、`install.sh` 的 `run_upgrade` 改成逐步呼叫 `lib/upgrade_runner.sh`，兩支都超標只准一行呼叫。**登記型檔集中**：這張卡一次開好 `scripts/installer/lib/` 下所有步驟檔的空殼（preflight／drain／db_copy／apply_on_copy／health／switch／rollback／boot_check／cleanup／order／release_keys／systemd_units），後面的卡只填自己的檔 | `scripts/installer/guidantai 1562（派送兩行、source lib）`、`scripts/installer/install.sh 3021（run_upgrade 改呼叫 runner；install_ops_cli 連 lib/ 一起裝）` | `scripts/installer/lib/upgrade_runner.sh（step／timeout／寫進度）`、`scripts/installer/lib/progress.sh（upgrade-progress.json 讀寫，只有這裡寫）`、`scripts/installer/lib/self_replace.sh（D30：從已驗包取 guidantai＋lib 換掉 /usr/local/bin 後 exec）`、`scripts/installer/lib/upgrade_{preflight,drain,db_copy,apply_on_copy,health,switch,rollback,boot_check,cleanup,order}.sh（空殼）`、`scripts/installer/lib/{release_keys,systemd_units}.sh（空殼）` | — | `lib/` 每支 `bash -n`＋shellcheck 綠；bats：self_replace 對一個假包目錄能換掉 `--root` 指定的暫存 bin 目錄並 exec、簽章驗不過時暫存目錄一個位元組都沒變；runner 對一支 `sleep 5` 的假步驟給 2 秒時限 → 非零、進度檔 `status=timeout`；進度檔格式照 design §5.1（jq 驗欄位）；`install.sh` 與 `guidantai` 行數不增加 | 190：`sudo guidantai upgrade <包>` 先把自己換成包內版本（`guidantai --version` 是新版）再往下跑 |
| 1 | P1 | **P1-3a** jedi-remote-agent 心跳契約一次改完：dispatch_gate port（停派新任務）、下發 target_version 與 Agent 包資訊、收 Agent 升級結果欄 | jedi 套件 | extend：`RemoteAgentAdapters` 加 `dispatch_gate`（None＝照常）與 `update_extra`；心跳收 `upgrade_result` 照 `heartbeat_console.py` 白名單收法；不動任務邏輯 | `jedi_remote_agent/app/service/agent_enrollment_service.py（心跳回應組裝、收欄位）`、`jedi_remote_agent/plugin/adapters.py（兩個 port）` |  | — | `dispatch_gate` 回 False 時心跳不帶 pending 任務但照收結果（test）；回應含 `update: {target_version, bundle_sha256, size}` 或 null；收到 `upgrade_result` 寫 `remote_agents.last_upgrade_*`；既有 tests 綠；**版號 bump 到 `1.10.0b1` 並 `poetry publish -r nexus`（決策者授權發 beta），回寫寫明版號** | — |
| 1 | P1 | **P1-8** 前門維護頁：nginx 後端連不上改送 maintenance.html，頁面每 3 秒讀唯讀掛進來的進度檔畫步驟與時限（雛形 ②） | FE | extend：`nginx.onprem.conf` 加 `error_page 502 503 =503 /maintenance.html` 與 `/upgrade-progress.json` alias；頁面純 HTML＋少量 JS，不用框架、不接 API | `nginx.onprem.conf` | `public/maintenance.html` | — | 停掉 api 容器打首頁得 503 與維護頁；放一份假進度檔，頁面每 3 秒更新步驟、耗時、剩餘時限；沒有任何按鈕；`grep border-left` 0；e2e conf 不變 | — |
| 1 | P1 | **P1-9a** 出包：產未簽的 release.json（版號、主版號、雜湊、大小、可直升起始版、是否含安全修正）、可直升起始版由 migration 必經標記自動算、包內帶 Agent compose 交付包 | BE | extend：`build_bundle.sh` 的 bundle-info 段加 release.json；必經標記讀 `scripts/sql/manifest.tsv` 新欄（D17 三種情況由人標、腳本算） | `scripts/build/build_bundle.sh 408–430`、`scripts/sql/manifest.tsv（加 min_base 欄說明列）` | `scripts/build/release_json.sh` | — | 本機對一個假的 bundle 目錄跑 `release_json.sh`：欄位齊、sha256 與 `.sha256` 檔一致；manifest.tsv 標一支必經後 `min_base_version` 變那版、沒標時是 1.0.0；bundle-info.txt 的「1.0.0 以上」改成算出來的值；shellcheck 綠。**不在 188 出包** | 188 跑 build_bundle 後包內有 `release.json`，sha256 與 `.sha256` 檔一致，包內有 Agent compose 交付包 |
| 1 | P2 | **P2-1** 更新站 repo 骨架：TypeScript Worker（wrangler 4）、D1 六張表、客戶端三端點（check／download-url／report）、用授權中心公鑰表驗授權檔（WebCrypto）、主版號與拒絕名單判定、R2 物件 key 分主版號資料夾（v<major>/<version>/…）、真授權檔守衛測試 | update-center（新） | new（新 repo，TS）：模組分法照 License Center（routes／service／repo）；驗章用 WebCrypto 原生 Ed25519；授權檔格式與 kid 機制 reuse jedi-license-runtime `public_keys.py` 的定義（公鑰表複製成 TS 常數，守衛測試用授權中心真簽的檔對照）；型別用 `@cloudflare/workers-types`；測試用 vitest＋`@cloudflare/vitest-pool-workers` |  | `update-center/（package.json、tsconfig.json、wrangler.toml 綁既有 bucket `guidant-ai-release` 與 D1、src/index.ts、src/routes/client.ts、src/releases.ts（含 r2_key 拼法 `v<major>/<version>/<檔名>`，只在這裡拼）、src/licenses.ts、src/denylist.ts、migrations/0001_init.sql、test/、.env.example、README.md）` | — | 本機 `wrangler dev`：帶 DEV 真授權檔打 check 回已發布清單＋簽章；過期授權檔照回；主版號不同回 none；拒絕名單回 403；偽造授權檔 401；download-url 回 R2 簽名網址 15 分有效；report 寫 `instance_version_reports` 不含客戶名；守衛測試用授權中心真簽出來的檔，驗不過擋部署 | — |
| 1 | P2 | **P2-5** 授權檔 payload 加 product_major（預設 1）；簽發畫面與 CLI 帶欄位 | License Center | extend：`modules/licensing` 的 payload 組裝加一欄；既有照不補簽，更新站把缺欄視為 1 | `src/license_center/modules/licensing/service.py`、`src/license_center/modules/licensing/models.py`、`alembic migration` |  | — | 新簽的照 payload 有 `product_major: 1`；舊照驗章照過；LC 畫面顯示欄位 | — |
| 1 | P3 | **P3-1** 能力點 system.product-update.read／update、更新中心選單路由、預設角色授權（照 FR-134 vulndb 頁 migration） | BE | extend：`host_capabilities.py` 加兩顆；migration 照 `2026-10-10-fr134-vulndb-settings-page.sql` 形狀 | `core/host_capabilities.py 199 附近` | `scripts/sql/2026-xx-xx-fr167-product-update-capabilities.sql` | — | DEV 套後 `capabilities` 有兩顆、`ui_routes` 有 `product-update`、系統管理員角色帶 update；FR-132 守衛測試綠；回報「出貨基線待重產」 | — |
| 2 | P1 | **E-1** 環境卡：主 repo 改釘 jedi-remote-agent beta 版（P1-3a 發的 1.10.0b1），poetry update 只指定這支 | BE | reuse：`poetry update jedi-remote-agent` 指定套件名（不裸跑）；不再走 path 安裝與 assume-unchanged，因為小弟已可發 beta | `pyproject.toml（釘 `jedi-remote-agent==1.10.0b1`）`、`poetry.lock` |  | P1-3a | `pip show jedi-remote-agent` 版本 1.10.0b1、Location 在 `.venv`；`python -c 'from jedi_remote_agent.plugin.adapters import RemoteAgentAdapters;print(RemoteAgentAdapters.__dataclass_fields__.keys())'` 看得到 `dispatch_gate`；BE 8003 起得來；兩檔都 commit | — |
| 2 | P1 | **P1-2** 預檢：空間（包＋複本＋映像＋保留）、版本路徑對 release.json 的可直升起始版、資料庫連得上、驗 release.json 簽章與包雜湊、可清舊映像提示 | BE | extend：`upgrade_check_preconditions` 既有檢查搬進 `lib/upgrade_preflight.sh` 再加項；簽章驗證用 `openssl pkeyutl` Ed25519 對 `/etc/guidant/release-keys/<kid>.pub` | `scripts/installer/lib/upgrade_preflight.sh（P1-1 空殼）` |  | P1-1、P1-9a | bats 用假的 `df` 與假包：空間不足印需要多少與可清多少、回非零；`min_base_version` 高於現行版時印路徑並拒絕；改一位元組的 release.json 用 `openssl pkeyutl` 驗章失敗；全過時進度檔 `preflight=ok` 含停機預估；shellcheck 綠 | 190：空間不足印需要多少與可清多少、回非零不動；從 1.25.1 升需要必經 1.26.0 時印路徑並拒絕 |
| 2 | P1 | **P1-3c** 停接新工作、等做完：寫 draining 旗標檔、psql 輪詢進行中數、30 分提醒寫進度檔、時段結束放棄並清旗標（D19） | BE | extend：填 `lib/upgrade_drain.sh`；計數用 psql 直查 `background_jobs` 與 Agent 任務表，不另開端點 | `scripts/installer/lib/upgrade_drain.sh（P1-1 空殼）` |  | P1-1 | 對 DEV 庫（localhost:5432）：塞一筆 running 的 `background_jobs` 時不往下走；`--deadline` 到了回特定碼、旗標檔已刪；0 筆立刻過；30 分提醒用 `--notice-after 3` 秒縮時驗；shellcheck 綠 | — |
| 2 | P2 | **P2-2** 更新站後台：Cloudflare Access 守門的管理端點（發行版列表／撤回／指定內部先裝／拒絕名單／印章表）與簡單後台頁（雛形 ⑦）；CI 專用 token 只能登記草稿 | update-center（新） | extend：同 repo 加 admin 路由（TS）；後台頁純 HTML＋少量 JS，不用框架 | `update-center/src/index.ts（掛 admin 路由）` | `update-center/src/routes/admin.ts`、`update-center/public/admin.html` | P2-1 | 沒過 Access 的請求 403；CI token 打 register-draft 成功、打 publish 403；指定 STG 指紋先裝後，用 STG 授權檔打 check 看得到草稿、用別家看不到；撤回後 check 不再回 | — |
| 2 | P2 | **P2-3** 發布 CLI guidant-release（決策者電腦）：init-keys 一正一備（passphrase）、publish <版>（取草稿、核內部先裝結果、蓋章、送回）、withdraw、rotate-key（舊鑰背書新鑰） | update-center（新） | new（CLI，TS，同 repo `cli/`，Node 22 內建 `node:crypto`）：Ed25519 簽章封包格式與 kid 規則照授權中心同一套，不另創；私鑰檔用 PKCS8＋AES-256 passphrase 加密、600；passphrase 從 TTY 讀，不收參數不讀 env |  | `update-center/cli/guidant-release.ts（bin：guidant-release）`、`update-center/cli/README.md` | P2-1 | `init-keys` 產兩組、備用印出封存說明、私鑰檔 600；`publish 1.28.0` 在內部先裝未全成功時拒絕；成功後更新站狀態變已發布且簽章可用 `.pub` 驗過；`rotate-key` 產的新清單用舊鑰與新鑰各簽一份；passphrase 不進任何檔與 log | — |
| 2 | P3 | **P3-2a** 主系統更新模組骨架：product_update_jobs 表、domain／infra／app service、狀態與紀錄端點（讀進度檔＋表）、模組登記、DI | BE | new（新模組）照 `app/source_scan_vulndb` 的 settings service 形狀；讀進度檔用唯讀掛載；**登記型檔集中**：`config/app_modules.py` 與 `di_containers/` 這張卡一次登記完 | `config/app_modules.py`、`di_containers/__init__.py（或對應容器）` | `scripts/sql/2026-xx-xx-fr167-product-update-jobs.sql`、`domain/product_update/*`、`infra/product_update/*`、`app/product_update/service/product_update_app_service.py`、`api/product_update/routes/product_update_route.py`、`api/product_update/serializers/*` | P3-1 | DEV 套 migration 後表在、RLS 四指令；`GET /system/product-update/status` 回目前版本、最近檢查、進度檔內容；`GET .../history` 分頁；無能力點 403；守衛測試綠 | — |
| 3 | P1 | **P1-3b** 主系統接 draining：core/maintenance_gate 讀旗標檔、worker 不撿新單、心跳不派新任務（接 P1-3a 的 dispatch_gate） | BE | new（30 行）＋extend：worker 撿單處加一個判斷；`core/plugins/remote_agent.py` 接 port 一行 | `core/plugins/remote_agent.py（一行）`、`app/background_job/（撿單處一個判斷）` | `core/maintenance_gate.py` | E-1、P1-3a | 放旗標檔後：worker 不撿 queued 單（既有 running 照跑完）、Agent 心跳回應不帶 pending 任務但結果照收；拿掉旗標 10 秒內恢復；test 兩條 | — |
| 3 | P1 | **P1-4** 停服務、複製資料庫到複本、讓升級接複本跑：lib/upgrade_db_copy、compose 資料庫目錄改吃變數、fe 掛進度檔唯讀 | BE | extend：`docker-compose.yml` 的 pgdata 路徑改 `${GUIDANT_PGDATA_DIR:-…}`，升級時以環境變數指到複本；複製用 `cp -a`，時限＝預估三倍；證據檔不複製（D18） | `scripts/installer/lib/upgrade_db_copy.sh（P1-1 空殼）`、`docker/production/docker-compose.yml（pgdata 變數、fe 掛 upgrade/ 唯讀）` |  | P1-1 | 本機 docker：對一個假 pgdata 目錄跑 db_copy → `pgdata-next/` 存在、來源 mtime 不變；複製中 kill -9 重跑能清掉半套複本；時限 1 秒對大目錄 → 放棄、來源不動；`docker compose -f docker/production/docker-compose.yml config` 用 `GUIDANT_PGDATA_DIR` 指到複本目錄、fe 的 upgrade/ 掛載是 `:ro` | 190：跑完 `pgdata-next/` 存在且舊目錄 mtime 不變；複製中 kill -9 重跑能清半套複本；時限到自動放棄回舊版 |
| 3 | P1 | **P1-5** 在複本上升級：載映像驗雜湊、init image migrate 模式對複本、migrate.sh 每支印進度行、runner 轉寫進度檔、單支 10 分無進度自動判卡 | BE | extend：`migrate.sh` 每支 `printf 'PROGRESS\t%d/%d\t%s'`（它不寫進度檔，只印）；runner tail 轉寫 | `scripts/installer/lib/upgrade_apply_on_copy.sh（P1-1 空殼）`、`scripts/init/migrate.sh 298` |  | P1-1 | 本機 docker postgres：`migrate.sh` 每支印 `PROGRESS\t%d/%d\t%s`（既有輸出不變）；runner 把它轉成進度檔 `migration 3/5 <檔名>`；塞一支 `pg_sleep(30)` 的測試 migration、`--stall-timeout 10` 秒 → 判卡回非零；載映像用假 tar 驗雜湊不符就拒 | 190：真包真庫，進度檔逐支更新；塞 `pg_sleep(700)` 的 migration 10 分後判卡 |
| 3 | P2 | **P2-4** CI 出包後登記草稿：先打更新站 register-draft 拿到 R2 目標 key，再上傳包、release.json、.sha256（CI token；bucket guidant-ai-release） | BE | extend：`package` job 加一步；R2 上傳用 aws cli S3 相容端點（CI 變數 `R2_ACCESS_KEY_ID`／`R2_SECRET_ACCESS_KEY`／`UPDATE_SITE_CI_TOKEN`，不入 repo）；**上傳 key 由更新站回，腳本不自己拼主版號資料夾** | `.gitlab-ci.yml package job` | `scripts/build/publish_draft.sh` | P1-9a、P2-2 | `glab ci lint` 綠；`publish_draft.sh` 對本機 `wrangler dev` 與假 R2（`--r2-endpoint` 指向本機 MinIO 或 `wrangler dev` 的 r2 模擬）跑通；沒給 token 時立刻紅、訊息說缺哪個 CI 變數；repo 內 grep 不到任何 token。**不跑 pipeline** | 跑一次 tag pipeline：R2 有 `releases/<版>/` 兩檔、更新站後台出現草稿；token 缺時 job 紅且訊息清楚 |
| 3 | P2 | **P2-6** 發布印樣進產品：DEV 公鑰檔放 scripts/installer/release-keys/、install 與 upgrade 都裝到 /etc/guidant/release-keys/、換鑰時新印樣隨包進來（PROD 印樣與缺鑰守門留給 P5-5） | BE | extend：填 `lib/release_keys.sh`；有什麼 `.pub` 就裝什麼；「PROD 鑰缺就紅」的守門**不在這張做**（決策者尚未 init-keys），P5-5 補 | `scripts/installer/lib/release_keys.sh（P1-1 空殼）` | `scripts/installer/release-keys/<dev-kid>.pub（用 P2-3 的 CLI 產一組 DEV 鑰，私鑰留本機 600、不入 repo）` | P1-1、P2-3 | bats：對暫存 root 跑 release_keys.sh → 目錄有檔、權限 644；包內多一把新 kid 時裝完多一把、舊的保留；私鑰 grep 不到進 repo；shellcheck 綠 | 190：新裝後 `/etc/guidant/release-keys/` 有檔 644；升級包帶新 kid 時裝完多一把、舊的保留 |
| 3 | P3 | **P3-2b** 發現新版與下載：更新站 client、每日排程（抽 core/scheduler_update.py）、立即檢查端點、通知管理員、背景下載續傳、驗簽章與雜湊、取消下載、離線上傳（chunked 照 vulndb） | BE | extend：排程照 `scheduler_agent.py` 形狀新檔、`scheduler.py` 一行；上傳 reuse vulndb 的 chunk 三端點做法；通知 reuse `notification_service`；驗章 reuse jedi-license-runtime `public_keys` 的 kid 查表＋`/etc/guidant/release-keys` | `core/scheduler.py 714（一行）`、`app/product_update/service/product_update_app_service.py`、`api/product_update/routes/product_update_route.py` | `core/scheduler_update.py`、`app/product_update/service/update_site_client.py`、`app/product_update/service/package_downloader.py`、`app/product_update/service/package_verifier.py` | P3-2a | DEV 指向本機 wrangler dev：每日 03:00 與「立即檢查」都寫 `last_check`、有新版發通知；下載中斷重啟續傳；改一位元組驗章失敗並刪檔；離線上傳同一條驗證路；過期清單（issued_at+90d）拒收；版號 ≤ 現行拒收 | — |
| 3 | P3 | **P3-3** 更新中心頁：目前版本、最新版本、版本說明、預檢、下載、下載中、已就緒（立即／排程／取消）、離線上傳、更新紀錄、失敗原因（雛形 ①、①b、①c、③、⑥） | FE | reuse：頁面結構照 `views/vulndb/VulndbManage.vue`＋`VulndbUpdateHistory.vue`；上傳元件 reuse vulndb 的 chunk 上傳；api.js 加 PRODUCT_UPDATE_*；能力點進 `capabilities.js` | `src/config/api/api.js`、`src/config/capabilities.js`、`src/config/router/index.js`、`i18n` | `src/views/product-update/UpdateCenter.vue`、`src/views/product-update/UpdateHistory.vue`、`src/views/product-update/ReadyDialog.vue` | P3-2a | 雛形 ①～③、⑥ 逐頁對照實打；沒有 update 能力點只看不到按鈕；`grep border-left` 0；分段用 SegmentButtons；不自訂字級 | — |
| 4 | P1 | **P1-6** 啟動新版、健康檢查、開放使用：新版接複本起、180 秒＋登入與主要 API 實打、換接（複本轉正、舊份留作升級前快照）、前門最後才換 | BE | extend：health reuse 既有 180 秒 healthcheck 加實打；switch 把現有 `upgrade_switch_version` 改成「先換 api／worker、最後 fe」 | `scripts/installer/lib/upgrade_health.sh（P1-1 空殼）`、`scripts/installer/lib/upgrade_switch.sh（P1-1 空殼）` |  | P1-4 | 本機 docker：health 對一個回 500 的假 api 容器 → 非零不換接；對回 200 的假容器 → 過；switch 對假目錄：複本轉正、原份改名 `pgdata-prev-<版>` 且 mtime 不變；compose 啟動順序 api／worker 先、fe 最後（看 runner 寫的進度檔步驟時間）；shellcheck 綠 | 190：故意讓登入 API 壞掉 → 不換接；換接後 `pgdata/` 是原複本、`pgdata-prev-<舊版>/` 是原本那份且 mtime 不變；維護頁撐到 fe 重建前一刻 |
| 4 | P3 | **P3-2c** 更新單：立即／排程／取消／重試端點寫 upgrade-order.json；systemd path＋service 單元隨 install 裝；guidantai upgrade --from-order 讀單；排程用 systemd-run 到點；執行前重驗包；30 分前與 5 分前提醒寫給所有使用者 | BE | extend：端點進 P3-2a 的 service；主機側填 `lib/upgrade_order.sh` 與 `lib/systemd_units.sh`；提醒 reuse 既有公告或 toast 機制（先 grep 公告模組） | `app/product_update/service/product_update_app_service.py`、`api/product_update/routes/product_update_route.py`、`scripts/installer/lib/upgrade_order.sh（P1-1 空殼）`、`scripts/installer/lib/systemd_units.sh（P1-1 空殼）` | `scripts/installer/systemd/guidant-upgrade-order.path`、`scripts/installer/systemd/guidant-upgrade-order.service` | P3-2a、P1-1 | DEV 8003：四個端點寫／刪 `upgrade/orders/*.json`，schema 照 design §5.5；排程單帶 `not_before`；`upgrade_order.sh --dry-run <單>` 讀單、重驗包（假包改一位元組 → 拒）、對照序列；systemd 單元檔用 `systemd-analyze verify`（在本機 docker ubuntu 容器內跑）綠；提醒走既有公告機制（寫哪支、回寫列）；shellcheck 綠 | 190：按立即→5 秒內 path 單元觸發；選排程到點才跑；取消→單刪除；單上包驗不過→不跑並通知；容器對 `upgrade/orders/` 只有寫權 |
| 4 | P3 | **P3-4** 有新版橫幅（儀表板，只給系統管理員）、更新前 30 分與 5 分全站提醒、「這版先不提醒」（雛形 ⓪） | FE | reuse：全站橫幅用既有公告／toast 元件；狀態來自 P3-2a 的 status 端點 | `src/layout/（頂欄或橫幅掛點）`、`i18n` | `src/components/product-update/NewVersionBanner.vue` | P3-2a | 雛形 ⓪；按「這版先不提醒」後同版不再跳、新版再跳；排程前 30 分所有登入者看得到提醒 | — |
| 5 | P1 | **P1-10** 收尾與診斷：保留上一版映像、更新包保留到下次成功、清掉更早的；diag 併進度檔與更新單並遮罩；下載診斷包走更新單（type=diag） | BE | extend：填 `lib/upgrade_cleanup.sh`；`cmd_diag` 收集清單加兩個檔（guidantai 超標，只加路徑列） | `scripts/installer/lib/upgrade_cleanup.sh（P1-1 空殼）`、`scripts/installer/guidantai（cmd_diag 收集清單兩行）` |  | P1-6 | 本機 docker：三個版本的假映像跑 cleanup 只剩兩個；更新包保留規則（最新成功那包留、更早刪）用假檔驗；`cmd_diag` 收集清單含進度檔與更新單且 grep 守衛抓不到密碼字樣；shellcheck 綠 | 190：升級成功兩次後只剩新版與上一版映像；更新單 type=diag 觸發後 `upgrade/diag/` 出現 zip |
| 5 | P1 | **P1-7** 自動退回、開機檢查、guidantai recover：丟棄複本、改接原份、啟動保留的舊映像、健康檢查；systemd oneshot 開機讀進度檔；recover 走同一支 | BE | extend：填 `lib/upgrade_rollback.sh`、`lib/upgrade_boot_check.sh`；recover 子命令（P1-1 已派送）呼叫同一支 rollback | `scripts/installer/lib/upgrade_rollback.sh（P1-1 空殼）`、`scripts/installer/lib/upgrade_boot_check.sh（P1-1 空殼）` | `scripts/installer/systemd/guidant-upgrade-boot-check.service` | P1-4、P1-6 | 本機 docker：進度檔停在任一未完成步驟時跑 boot_check → 呼叫 rollback：複本刪、原份接回、假的舊版容器起、進度檔 `rolled_back_at` 有值；進度檔 `succeeded` 時 boot_check 什麼都不做；`guidantai recover` 走同一支（diff 兩條路徑呼叫同一函式）；shellcheck 綠 | 190：第 ⑦ 步 kill -9 整個流程 → 開機單元自動退回、舊版起來、筆數一致；`systemctl stop docker` 後 `sudo guidantai recover` 收尾 |
| 5 | P3 | **P3-2d** 跨版兩段：更新站回路徑（必經版本）、下載多包、更新單帶序列、CLI 依序跑每段各自複本與健康檢查、第二段失敗停在中間版（雛形 ⑤） | BE | extend：check 回應的 `path[]` 由更新站算；BE 下載迴圈；`lib/upgrade_order.sh` 讀序列逐段呼叫 runner | `app/product_update/service/product_update_app_service.py`、`app/product_update/service/package_downloader.py`、`scripts/installer/lib/upgrade_order.sh` |  | P3-2c、P2-1 | DEV 8003 對本機 wrangler dev：更新站標一版必經 → check 回 `path[]` 兩段、BE 兩包都下、更新單帶序列；`upgrade_order.sh --dry-run` 逐段呼叫 runner（看 log 兩次）；單段情境行為不變（既有 test 綠） | 190：1.25.1 裝機、1.26.0 標必經 → 兩包都下、兩段跑完；第二段 kill → 停在 1.26.0 可用 |
| 5 | P4 | **P4-5** Agent 升級指令比照主系統：每步時限、進度檔、失敗自動退回（兩份 install.sh 只加一行呼叫，邏輯進 install-lib/） | evidence-agent | extend：既有 `_upgrade_revert`、`upgrade_guard.sh` 收進 `install-lib/upgrade_runner.sh`；deploy/install.sh 的 `cmd_upgrade` 委派新包安裝程式照舊（D30 同形） | `scripts/install/install.sh 1387–1545（一行）`、`deploy/install.sh 1153（一行）` | `agent/install-lib/upgrade_runner.sh`、`agent/install-lib/progress.sh` | — | 本機 docker ubuntu 容器：對假包跑 `install-lib/upgrade_runner.sh` → 每步時限、進度檔與主系統同欄位、失敗步驟自動退回；兩份 install.sh 行數不增；shellcheck 綠 | lab 機（決策者指定一台）：升級中 kill → 重跑或開機自動退回 |
| 6 | P1 | **P1-9b** CI 跨版排練：e2e 目標先裝舊版＋灌真實舊庫，再用新包 guidantai upgrade；失敗擋 package | BE | extend：`deploy_e2e.sh` 既有 e2e 路徑加「先裝指定舊版」參數；舊庫用 smoke_tempdb 既有的 dump 來源 | `.gitlab-ci.yml`、`scripts/build/deploy_e2e.sh` |  | P1-7 | `glab ci lint` 綠；`deploy_e2e.sh --base-version 1.26.0 --dry-run` 印出會做的步驟（先裝舊版、灌 dump、再 upgrade）且參數解析有 test；job 的 `needs`／`rules` 讓它紅就擋 package（讀 yaml 驗）。**不跑 pipeline、不碰 190** | pipeline 多一個 `rehearse-upgrade` job：190 裝 1.26.0 → 新包升級成功、筆數一致；放壞 migration 時 job 紅、package 不跑 |
| 6 | P4 | **P4-2** Agent 跟進（主系統側）：agent_update_rollouts 表、開放後自動建第一批 1 台、心跳回 target_version＋包資訊（core/plugins/_agent_update.py）、Agent 下載端點、收升級結果推進或暫停、更新中心 Agent 區端點（暫停／繼續／推選定） | BE | extend：照 `_agent_vulndb.py` 形狀新檔；`core/plugins/remote_agent.py` 接 `update_extra` 一行；端點進 P3-2a 模組 | `core/plugins/remote_agent.py（一行）`、`app/product_update/service/product_update_app_service.py`、`api/product_update/routes/product_update_route.py` | `scripts/sql/2026-xx-xx-fr167-agent-update-rollouts.sql`、`core/plugins/_agent_update.py`、`app/product_update/service/agent_rollout_service.py`、`api/remote_agent/routes/agent_update_bundle_route.py` | P1-3a、P3-2c、P1-9a | DEV 8003＋pytest：升級完成事件 → rollouts 一筆 batch 1 一台；該台回成功 → 其餘進 batch 2；回失敗 → paused；心跳回 `update` 只給目標批（用 test client 模擬三台心跳）；下載端點走資料面 JWT、回包＋sha；無能力點 403；守衛測試綠 | 三台 Agent：主系統升完先一台成功再其餘；第一台失敗其餘 paused；推選定只對落後的台 |
| 7 | P4 | **P4-3** Agent 跟進（Agent 側）：心跳 observer 看 target_version、從主系統下載包驗雜湊、寫 agent-upgrade-order.json、主機 systemd path 觸發 install.sh upgrade、結果隨心跳回報 | evidence-agent | extend：observer 照 `vulndb_sync.py` 形狀新檔（下載、續傳、驗 sha 都 reuse 它的做法）；`enroll.py` 掛一行；systemd 單元由 P4-5 的 install-lib 裝 | `core/enroll.py 364（一行）`、`agent/install-lib/upgrade_runner.sh（裝 path 單元）` | `core/update_sync.py`、`agent/install-lib/systemd/guidant-agent-upgrade-order.{path,service}` | P4-2、P4-5 | Agent pytest：假心跳帶 update → 下載到暫存、sha 對、寫 `agent-upgrade-order.json`；sha 不對 → 刪檔回報；主系統 paused（無 update）時不動；observer 掛點一行；systemd 單元檔 `systemd-analyze verify` 綠（docker ubuntu） | lab 機：心跳帶 update → 下載、驗 sha、寫單、path 單元觸發 `install.sh upgrade`，成功後心跳 `agent_version` 變新；失敗自動退回回報 failed |
| 7 | P4 | **P4-4** 更新中心 Agent 跟進區：批次表、暫停／繼續、推選定、只升 Agent 說明（雛形 ④） | FE | reuse：表格照主站 DataTable 慣例；接 P4-2 端點 | `src/views/product-update/UpdateCenter.vue` | `src/views/product-update/AgentRolloutPanel.vue` | P4-2、P3-3 | 雛形 ④；第一台失敗後其餘顯示已暫停；推選定只能勾落後的 | — |
| 8 | P5 | **P5-1** 手冊與安裝文件：升級一章改寫（更新中心、離線上傳、排程、失敗怎麼看、recover、diag）、防火牆放行清單、資安例外登記（對外更新站）、回報出貨基線待重產 | BE | extend：手冊既有章節；例外登記進 `docs/security-report/requirements/` | `docs/user-manual/`、`docs/security-report/requirements/`、`scripts/installer/README.md` |  | P4-3、P4-4 | 章節齊；文件衛生 grep 0；例外登記可查；回寫明講出貨基線待重產 | — |
| 8 | P5 | **P5-2** SPEC：更新中心頁新頁（writing-feature-specs） | BE | new（spec 頁）照既有頁形狀 |  | `docs/spec-site/current/system-admin/product-update.md` | P4-4 | 頁面齊、變更紀錄一行、mkdocs build 綠 | — |
| 8 | P5 | **P5-3** e2e：更新中心頁（有新版→下載→就緒→排程→取消；離線上傳驗不過；無權限只看）對 stub 更新站 | test | reuse：site-regression 既有 page object；更新站用 wrangler dev stub |  | `site-regression features/steps（product-update）` | P3-3、P3-4 | headless 綠（BE 8003＋FE dev＋wrangler dev stub 由小弟自己起）；不實際執行升級；收工前把三個背景服務停掉 | — |
| 8 | P5 | **P5-5** 結案卡：jedi-remote-agent 發正式版（決策者確認套件清單後）、主 repo 釘正式版、PROD 印樣進 scripts/installer/release-keys/ 並補「PROD 鑰缺就紅」守門、poetry update 指定套件 | BE | reuse：照 jedi-package-dev skill 發版流程；守門照 build_bundle 既有 `--skip-prod-key-check` 精神 | `pyproject.toml`、`poetry.lock`、`scripts/build/build_bundle.sh（PROD 印樣缺就紅）` | `scripts/installer/release-keys/<prod-kid>.pub（決策者 init-keys 後交付）` | P4-3 | `pyproject.toml` 釘正式版且 `pip show` Location 在 `.venv`；`git status` 乾淨；沒 PROD 印樣 build_bundle 紅、有就綠；發版紀錄寫 commit | — |

## 7. 端到端驗收 {#acceptance nav="端到端驗收"}

全部在 190 e2e 目標或 DEV 做，**不碰 STG／POC**。這一節是**測試期**：開發期卡片全部驗收通過、決策者 push 並出一包之後，由首腦在 190 逐條操作、決策者看畫面；每條對應的卡在 §6 最後一欄。190 會被重灌與重開機多次，測試期間不拿它做別的。

| # | 情境 | 怎麼做 | 判定 |
|---|---|---|---|
| 1 | 一道指令直升 | 190 現跑 1.26.0、灌 POC 資料庫備份（唯讀 pg_dump），`sudo guidantai upgrade guidant-ai-<新>.tar.gz` | 成功；`guidantai --version` 是新版（D30）；資料筆數等於升級前；`pgdata-prev-1.26.0` 存在且 mtime 不變 |
| 2 | migration 卡住 | 新包塞一支 `SELECT pg_sleep(700)` | 10 分判卡、自動退回、舊版可登入、筆數一致 |
| 3 | 升到一半斷電 | 步 4 進行中 `kill -9` 全部進程、重開機 | 開機單元自動退回；進度檔 `rolled_back_at` 有值 |
| 4 | docker 當掉 | 步 4 進行中 `systemctl stop docker`、再起 | `sudo guidantai recover` 收尾、舊版起來 |
| 5 | 維護頁 | 停機期間打開原網址 | 503＋步驟清單每 3 秒更新；無按鈕 |
| 6 | 發版 | 跑 tag pipeline → 後台有草稿 → 指定 190 指紋先裝 → 190 `check` 看得到 → `guidant-release publish` | 已發布；DEV `check` 回應簽章驗過 |
| 7 | 更新中心整條 | DEV 指向本機 wrangler dev：通知 → 下載 → 就緒 → 立即 | 更新中心與維護頁都看到進度到完成；history 一列含下載人與按更新的人 |
| 8 | 排程與取消 | 選排程 → 到點前取消 → 再選排程 → 到點 | 取消後什麼都沒動；到點 path 單元觸發 |
| 9 | 離線上傳 | 拔網路、上傳同一包；再上傳改一位元組的包 | 前者就緒；後者驗不過即刪並顯示原因 |
| 10 | 權限 | 無 `update` 能力點帳號 | 看得到版本與紀錄、看不到任何按鈕；直接打 `POST …/apply` 403 |
| 11 | 授權過期 | DEV 換過期授權檔 | 仍收到新版、可升；升後唯讀、功能鎖 |
| 12 | 主版號 | 更新站放一筆 major=2 的已發布 | `check` 回 none |
| 13 | 跨版兩段 | 190 重灌 1.25.1、更新站標 1.26.0 必經 | 兩包都下、兩段跑完；第二段 kill → 停在 1.26.0 可用 |
| 14 | Agent 跟進 | 三台 Agent | 先一台成功再其餘；故意讓第一台失敗其餘 paused |
| 15 | 清單過期 | 更新站放 `issued_at` 100 天前的清單 | 客戶端拒收並顯示「請聯絡原廠重新發布」 |
| 16 | 守衛 | grep | 進度檔與診斷包無密碼；repo 無 token；`border-left` 0；兩支超標 shell 行數不增 |

## 8. 風險與陷阱 {#risks nav="風險"}

| 風險 | 怎麼擋 |
|---|---|
| 第一期出去那版的 `guidantai` 若「換自己」做錯，之後每版都卡 | P1-1 的 self_replace 要有「換失敗不動任何東西」的測試；1.27.0 出包前在 190 從 1.26.0 升一次（測試期 §7-1） |
| 複製資料庫的時限估錯（客戶庫 50GB） | 預檢用實測速率算、×3；時限寫進進度檔讓人看得到；超時放棄＝什麼都沒動 |
| fe 容器重建時維護頁消失 | 步 6 最後才重建 fe；fe 重建前進度檔已 `succeeded`，使用者只看到幾秒斷線 |
| 更新站 Worker 的 WebCrypto 驗章與授權中心的 cryptography 實作漂移 | P2-1 守衛測試用授權中心真簽的檔，驗不過擋部署 |
| 90 天沒發新版清單過期，客戶升不上 | 更新站後台列「最近一次簽發」；決策者 `publish` 同版重簽即可（D31） |
| 主系統容器對 `upgrade/orders/` 有寫權，被打穿的話能寫單觸發升級 | 單上的版本與包必須已驗過簽章且在本機；最壞是「被觸發一次合法升級」，仍走預檢與退回；不接受單上帶任意路徑 |
| 兩份 Agent install.sh 各自演化 | P4-5 邏輯全進 `install-lib/`，兩份只剩一行 |
| CRA 顧問說要分安全版 | §15 排除方案已寫反悔條件：更新站多一個種類欄，流程不變 |

## 9. 這份文件的定位 {#positioning nav="定位"}

| 你想知道 | 看哪份 |
|---|---|
| 為什麼這樣決定、排除了什麼 | [討論稿](discussion.html) §4、§15 |
| 做出來長什麼樣 | [雛形](prototype/index.html) 10 頁 |
| 每張卡改哪些檔、驗收什麼 | 本頁 §6 與 `handoff/cards.py` |
| 現在做到哪 | README 總進度表、Notion 母卡 |
| 上線後的畫面與 API | SPEC 頁（P5-2 產）與 FINAL-SPEC（收尾產） |
