visionA/docs/autoflow/04-architecture/feature-device-mgmt-tdd.md
jim800121chen f6d15b7b14 docs(arch): B 設備管理 + C 模型共享 + camera ADR-020 規劃文件
- feature-device-mgmt-tdd.md + api-device-mgmt.md(B 設備管理 TDD)
- feature-model-sharing-tdd.md + api-model-sharing.md + PRD feature + 設計規格(C 模型共享三方規劃)
- adr-020-ffmpeg-camera-indev.md(camera 三平台 indev)
- PRD.md / TDD.md 索引增補

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 16:31:38 +08:00

357 lines
25 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 技術設計文件TDD— 個人設備管理 / 設備與註冊P0
- **作者**Architect Agent
- **狀態**Draft待實作 + 三方交叉審閱)
- **最後更新**2026-08-02
- **上位文件**[`adr/adr-018-agent-device-model.md`](adr/adr-018-agent-device-model.md)(走向 A' 模型)、[`database.md`](database.md)、[`api/api-spec.md`](api/api-spec.md)
- **權威輸入**`.autoflow/05-implementation/device-mgmt-gap-analysis.md`(缺口盤點)
- **讀者**Backend / Frontend / Testing Agents
- **API 規格**[`api/api-device-mgmt.md`](api/api-device-mgmt.md)
---
## 0. 一句話與範圍
「取序號 → 上報 → 存 serial」地基ADR-018 WP-0/WP-B已完整落地`registered_at` 欄可讀寫、List/Get API 已回傳,但**整條「註冊」語意軸沒接上**:沒有任何路徑能把 `registered_at` 由 NULL 翻成有值,前端 store 也把後端回的 `registered_at` 丟掉。本 TDD 補完 P0 範圍:
| P0 細項 | 對應 work-stream | 一句話 |
|---------|-----------------|--------|
| 1. 註冊 API | WS-BE | `POST /api/devices/:id/register``registered_at` NULL→now() |
| 2. 已註冊檢查 | WS-BE | register 端點對「已註冊」回衝突(冪等取捨見 §3.2 |
| 3. 取消註冊(退回未註冊) | WS-BE | `POST /api/devices/:id/unregister` → 清 `registered_at`、**保留列**,與 unpair 分開 |
| 4. 三態分色 | WS-FE | 前端 store 補 `registeredAt` 欄 + 三態運算 + 配色 |
| 5. 排序 + filter | WS-FE | 排序切換(名稱/狀態/註冊時間)+ 依三態 filter + UI |
| 6. 資料模型 | WS-BE | 確認欄位現況、**判定不需 migration** |
| 7. 安全 | WS-BE | register/unregister 的 owner 檢查 + IDOR 防護 |
**非目標(本 TDD 不做)**
- 不改既有 `unpair`devices.go:256-346 軟刪 + cascade的任何行為。
- 不做批次註冊 / 自動註冊Q3 已定手動逐顆註冊)。
- 不動 serial 路由 / agents 表 / session_tokens。
- 不寫 production code、不寫 migration 實檔(本文件只給設計方向與契約)。
---
## 1. 關鍵決策:**取消註冊 ≠ unpair**(使用者已拍板,覆蓋 ADR-018 §1.1 Q5
> ⚠️ **這是本 TDD 最容易做壞的地方,工程師務必先讀懂再動手。**
系統裡有**兩個語意不同、絕不可合併**的「移除類」動作:
| 動作 | 端點 | 對 `registered_at` | 對 device 列 | 對 tokenpairing/session | 語意 |
|------|------|-------------------|-------------|---------------------------|------|
| **取消註冊**(本 TDD 新增) | `POST /api/devices/:id/unregister` | 清成 NULL | **保留**(列還在、仍在清單顯示為「未註冊」) | **不動** | 「這顆 USB 退回未註冊狀態,但我還看得到它」 |
| **unpair / 移除裝置**(既有,不動) | `POST /api/devices/:id/unpair` | 不特別處理(連列都軟刪了) | **軟刪**`deleted_at=now()`、從清單消失) | **cascade 撤銷**DeviceUnpairer | 「把整顆已配對裝置移除、斷開連線」 |
### 1.1 為什麼與 ADR-018 §1.1 不同(決策沿革)
ADR-018 §1.1 記載的 Q5 是「取消註冊 = 真實體刪除 + 清 session_tokens FK」。**使用者在本次2026-08-02重新拍板為「取消註冊 = 退回未註冊、保留裝置列、不硬刪」**,理由:實體刪除會讓使用者「取消註冊後就再也看不到那顆已插著的 USB」體驗不合理而「退回未註冊」讓已連接的 USB 仍留在清單、可再次註冊,符合三態模型(未註冊態就是要能看到的第三態)。
- ADR-018 為不可變決策紀錄、其 §1.1 原文不改;本 TDD 於此明載新決策為準。**實作以本 TDD 為準。**
- 「真實體刪除 + 清 session_tokens」的語意已由**既有 unpair** 覆蓋(雖是軟刪、但那是既有行為、本 TDD 不動)。取消註冊是全新的第三種較輕動作。
### 1.2 工程師落地時的三條紅線
1. **不要改 `devicesUnpairHandler`**devices.go:256-346。unregister 是**新 handler、新端點**,與 unpair 各走各的。
2. **unregister 不呼叫 `DeviceUnpairer`、不呼叫 `Delete/DeleteTx`、不碰 token。** 它只做一件事:把該列 `registered_at` set NULL。
3. **register / unregister 對 representative device 一律拒絕**(見 §7.3)——只有真實 USB`is_representative=false`)能被註冊 / 取消註冊。
---
## 2. 現況地基盤點(實作前必讀,避免重造輪子)
| 元件 | 現況 | 檔案:行 | 對本 TDD 的意義 |
|------|------|--------|----------------|
| `Device.RegisteredAt *time.Time` | 已存在domain | `device.go:83` | 直接用,不加欄 |
| DB 欄 `registered_at TIMESTAMPTZ`nullable | 已存在0005 | `migrations/0005_create_agents.up.sql:38` | 不需 migration |
| index `idx_devices_registered` | 已存在partial未刪除 | 同上:44 | filter「未註冊」可用本 P0 前端 client-side filter 為主index 供未來後端 filter |
| `scanDevice``registered_at` | 已接nullable *time.Time | `postgres_repository.go:391` | 讀取路徑完整 |
| `Save` upsert 寫 `registered_at` | 已接(`$15 = d.RegisteredAt` | `postgres_repository.go:268,286` | **register 可直接走 Save/SaveTx**、不必新寫 SQL但見 §3.3 建議加專用 UPDATE |
| List/Get API 回 `registered_at` | 已接 | `devices.go:65,129,220` | 後端→前端資料已備妥 |
| `is_representative=false` filter | List 已濾(只列真 USB | `device.go:190` / postgres List | register/unregister 端點另需在 handler 再擋一次 representative縱深 |
| owner 檢查範式 | 既有 handler 皆用 `UserContextFrom` + `d.OwnerUserID != userID → 403` | `devices.go:197-201,299-302` | register/unregister 照抄這個範式 |
| 前端 `DeviceSummary.registeredAt` | **不存在**(資料到前端被丟) | `device-store.ts:74-96` | WS-FE 第一件事:補欄 + normalize |
**結論**:後端 register/unregister 是「加 2 個 handler + 2 條 route + 1 個 repo 專用方法(可選)」;前端三態是「補 1 個欄位 + 三態運算 + 配色」。地基全在,只差翻轉開關與前端消費。
---
## 3. 註冊 API細項 1 + 2 + 6— WS-BE
### 3.1 端點:`POST /api/devices/:id/register`
- **路由註冊位置**`registerDeviceRoutes`devices.go:27-49與既有 unpair 並列(都是純雲端 DB 操作、用 UUID `:id`、不 proxy
- **識別值**UUID`:id`)。理由(對齊 ADR-018 FE-Aregister 是純雲端 DB 操作(只翻 `registered_at`、不路由到 local agent用主鍵 UUID 最自然;且序號可能為空的 device 也要能被查詢與擋掉。
- **Request body**:無(空 body
- **行為**
1. `UserContextFrom` 取 userID缺 → 500照既有範式 devices.go:95-100
2. `:id` 空 → 400 `VALIDATION_FAILED`
3. `DeviceRepo.Get(ctx, id)``ErrNotFound` → 404其他 DB error → `WriteDBError`
4. **owner 檢查**`d.OwnerUserID != userID` → 403 `FORBIDDEN`§7
5. **representative 檢查**`d.IsRepresentative == true` → 409 `CONFLICT`representative 不是真 USB、不可註冊§7.3)。
6. **已註冊檢查(細項 2**`d.RegisteredAt != nil` → 見 §3.2。
7. 翻轉set `registered_at = now()`、回 200 + 更新後的 DeviceListItem`registered_at`)。
### 3.2 已註冊檢查(細項 2— 取「409 衝突」,理由如下
兩個方案:
| 方案 | 行為 | 取捨 |
|------|------|------|
| **A. 409 CONFLICT採用** | 已註冊再 register → 回 409 `ALREADY_REGISTERED` | 語意明確、前端可提示「已註冊」;符合需求「避免重複註冊」的字面 |
| B. 冪等 200 | 已註冊再 register → 回 200不改 registered_at | 對 retry 友善,但掩蓋「使用者以為沒註冊卻已註冊」的狀態 |
**採 A**需求明確要「已註冊檢查避免重複註冊409 最貼合。前端收到 409 顯示 toast「此裝置已註冊」並 refetch。錯誤碼新增 `ALREADY_REGISTERED`(見 api 規格 §錯誤碼)。
> 註:`registered_at` 一旦設值,不因後續 exchange 覆寫——`upsertUSBDeviceTx` 復用分支pairing_exchange.go:329-341只更新 PairedAt/AgentID/type**不碰 RegisteredAt**,故重配對不會清掉註冊態(現況已正確、不需改)。
### 3.3 Repo 落地建議:新增專用 `SetRegisteredAtTx`(優於走 Save
雖然 `Save`/`SaveTx` 已能寫 `registered_at`upsert 全欄),但 register/unregister 建議**新增精準的單欄 UPDATE**,理由:
- Save 是全欄 upsertregister handler 得先 Get 整個 device 再 Save 回去,有「讀到寫之間被其他請求改動」的競態面(雖 P0 單裝置風險低,但語意不乾淨)。
- 專用 `UPDATE devices SET registered_at = $2, updated_at = now() WHERE id = $1 AND deleted_at IS NULL AND is_representative = false` 一句到位、天然只影響該欄、`RowsAffected()==0` 可轉 404/409 判定。
**建議簽名**domain `Repository` interface 加一法、in-memory + postgres 各實作):
```
// SetRegistered 設定/清除註冊時間。at=nil 表示取消註冊(清 NULL
// 只作用於未刪除、非 representative 的 device不符則回 ErrNotFound。
SetRegistered(ctx context.Context, id string, at *time.Time) error
```
- register`SetRegistered(ctx, id, &now)`handler 已先擋 already-registered故這裡不再判
- unregister`SetRegistered(ctx, id, nil)`
- 若不想動 interface退路是 handler 內 Get→改欄→Save可接受但較不乾淨。**推薦加 interface 方法**,並補 in-memory 對稱實作 + postgres db_testtestcontainers 130見 §8
### 3.4 資料模型(細項 6**不需 migration**
- `registered_at` 欄、`idx_devices_registered` index、domain 欄位、scan/save 讀寫路徑**全部已存在**§2 表。register/unregister 純粹是「翻轉既有欄」+「加 handler」**不新增欄、不改 schema、不寫 migration 檔**。
- 唯一的 code 面新增是可選的 `SetRegistered` repo 方法Go 層,非 schema
---
## 4. 取消註冊 API細項 3— WS-BE
### 4.1 端點:`POST /api/devices/:id/unregister`
- **路由**:同 register並列 registerDeviceRoutesUUID 識別。
- **Request body**:無。
- **行為**(與 register 對稱、但更寬鬆):
1. userID / `:id` / Get / owner / representative 檢查同 §3.1 步驟 1-5。
2. **不做「已註冊才可取消」的硬擋**:採冪等——若 `registered_at` 已是 NULL未註冊unregister 回 200no-op 成功),避免使用者連點兩次第二次報錯。(`SetRegistered(id, nil)` 對已 NULL 的列 UPDATE 到相同值、`RowsAffected` 仍為 1因 WHERE 命中;語意上「取消一個未註冊的 = 已達成目標」。)
3. `SetRegistered(ctx, id, nil)` → 清 `registered_at`
4. 回 200 + 更新後 DeviceListItem`registered_at` 為 null
- **絕不做**:不軟刪、不呼叫 DeviceUnpairer、不撤 token、不動 session§1.2 紅線)。
### 4.2 與 unpair 的並存驗證testing 重點)
- unregister 後device 仍 `GET /api/devices` 列得到、`registered_at=null`、pairing/session token 不變、tunnel 狀態不變。
- unpair 後device 從 List 消失軟刪、token 被撤。
- 兩端點互不影響:對同一 device 先 unregister 再 unpair 應正常unregister 只清欄、unpair 照樣軟刪)。
---
## 5. 三態分色(細項 4— WS-FE
### 5.1 前端資料斷點修復(第一步,最關鍵)
`device-store.ts``DeviceSummary`74-96**沒有 `registeredAt` 欄**`normalizeDevice`128-164也沒接後端回的 `registered_at` 在前端被丟棄。必須先補:
- `DeviceSummary``registeredAt?: string | null;`ISO 8601nil=未註冊)。
- `Device`extends Summary自動繼承。
- `normalizeDevice``registeredAt: pick<string>("registered_at", "registeredAt") ?? null,`(沿用既有 `pick` snake/camel 相容範式)。
### 5.2 三態運算規則(給前端明確定義)
三態 = **連線軸remoteStatus× 註冊軸registeredAt** 的組合:
| 態 | 條件 | 語意 | 配色 token |
|----|------|------|-----------|
| **已連接(已註冊在線)** | `remoteStatus === "online"` **且** `registeredAt != null` | 正常可用的個人設備 | `--status-online`(綠,既有) |
| **已連接未註冊** | `remoteStatus === "online"` **且** `registeredAt == null` | 插著、連線中但還沒註冊 → **第三態** | `--warning`(黃,既有 warning token 組) |
| **未連接** | `remoteStatus !== "online"`offline/reconnecting/error/unknown | 離線(不論註冊與否) | 沿用既有 RemoteDeviceBadge 各狀態色 |
**運算實作建議**:在 `device-store.ts` 或一支 `lib/device-state.ts` 加純函式:
```
type DeviceTriState = "online-registered" | "online-unregistered" | "offline";
function deriveTriState(d: Pick<DeviceSummary,"remoteStatus"|"registeredAt">): DeviteTriState
// online + registeredAt != null → "online-registered"
// online + registeredAt == null → "online-unregistered"
// 其餘 → "offline"
```
- 純函式便於 testing 單測(真值表 6 格)。
### 5.3 配色落地(沿用既有 design tokens不新增裸色
第三態「已連接未註冊」用**既有 warning token 組**`--warning` / `--warning-foreground` / `--warning-subtle`globals.css:159-161Light/Dark 都有定義),與 pairing 頁 / login 頁 / flash-dialog 的警示 UI 同色系。**禁止**寫 `bg-yellow-*` 裸色(對齊 flash-progress.tsx:11 的既有慣例)。
改動點:
- `remote-device-badge.tsx`:目前 badge 只吃 `remoteStatus`63-101。**方案:不改 badge 的內部語意**badge 仍表達連線狀態),而是在 `DeviceCard` 層疊加「未註冊」標記——online 且未註冊時,卡片額外顯示一個 warning 色的「未註冊」pill/badge新增小元件或用既有 warning classes。理由連線狀態與註冊狀態是正交兩軸硬塞進同一個 badge 會讓「online 但未註冊」的顏色語意打架。
- `device-card.tsx``isOnline`44旁加 `isRegistered = !!device.registeredAt`online && !registered → 卡片邊框 / 角落 pill 走 warning 色 + i18n 文案「未註冊」。
- **不只靠顏色**(沿用 design-review M2 無障礙原則):第三態要有文字「未註冊」+ icon不能只有黃色。
### 5.4 i18n
新增 keyzh-Hant + en 同步,對齊 `dictionaries/``devices.state.unregistered`(「未註冊」/ "Unregistered")、`devices.register.action`(「註冊」)、`devices.unregister.action`(「取消註冊」)、`devices.register.error.alreadyRegistered``devices.register.confirm` 等(實際 key 由 frontend agent 補全、testing 驗證存在)。
---
## 6. 排序 + filter細項 5— WS-FE
### 6.1 現況與範圍
- 現況:`device-list.tsx:31-37,77-79` 寫死一條「在線優先」排序,**無 filter、無切換 UI**。
- **不需後端改動、不需分頁**:個人設備數量級小(一個使用者的 USB 通常 < 20全部 client-side 排序 / 過濾即可。**不做 cursor/offset 分頁**資料量不成立徒增複雜度)。
### 6.2 排序(可自選鍵)
提供排序切換下拉或 segmented control
| 排序鍵 | 規則 |
|--------|------|
| 狀態預設保留既有行為 | 沿用 `STATUS_ORDER`onlinereconnectingunknownofflineerror同狀態內次比名稱 |
| 名稱 | `displayName`alias || namelocaleCompareAZ |
| 註冊時間 | `registeredAt` desc新註冊在前null未註冊排最後 |
- 排序狀態存元件 local state`useState`即可不需持久化P0)。若要記住偏好可存 localStorage可選非必要)。
### 6.3 Filter依三態
提供 filterchips / checkbox group選項
| filter | 條件 §5.2 deriveTriState |
|--------|------------------------------|
| 全部預設 | 不過濾 |
| 已連接 | triState === "online-registered" |
| 未連接 | triState === "offline" |
| 已連接未註冊 | triState === "online-unregistered" |
- 排序與 filter 組合 filter sort
- 空結果狀態filter 0 顯示無符合條件的裝置空狀態與既有 EmptyState 區隔——這不是完全沒裝置」)。
### 6.4 UI 落點
- 排序 + filter 控制列放 `devices/page.tsx` `device-list.tsx` 頂部
- `device-list.tsx` 目前接 `devices` prop 後自己排序改為接已排序 / 已過濾的 devices或把排序 filter state 提到 list frontend agent 決定放置層級保持既有 EmptyState / skeleton / pair CTA 行為不變)。
---
## 7. 安全(細項 7— WS-BE
### 7.1 Owner 權限檢查
register / unregister **都必須**照既有範式devices.go:197-201`UserContextFrom` userID `Get` device `d.OwnerUserID != userID` 403 `FORBIDDEN` UserContext 500auth middleware 沒配好不可 fallthrough)。
### 7.2 IDOR 防護
- **威脅**攻擊者用別人的 device UUID `POST /api/devices/{別人的id}/register` unregister若無 owner 檢查就能改別人裝置的註冊態
- **防護**:§7.1 owner 檢查即 IDOR 防線—— Get 拿到 device `OwnerUserID` caller userID 比對不符回 403。**不可**反正只是翻個 flag而省略
- **列舉防護取捨**owner 不符回 403既有 handler 慣例 devices.go:198)。這會洩漏 UUID 存在但不屬於你vs不存在404)」的差異既有 unpair/get 都用 403 TDD **沿用一致**不特別改成 404 混淆維持 codebase 一致性 security agent 要求統一改 404-混淆另案處理不在 P0 範圍
### 7.3 Representative device 防護
- register/unregister 前檢查 `d.IsRepresentative` true 409 `CONFLICT`representative agent 連線佔位非真 USB註冊語意不適用)。
- 縱深即使 List 已濾掉 representative前端拿不到其 UUIDhandler 仍要自己擋攻擊者可能猜 UUID 或從其他管道拿到)。repo `SetRegistered` WHERE 也帶 `is_representative = false`(§3.3三層防護
### 7.4 其他
- register/unregister 是狀態變更操作走既有 auth middlewareJWT/OIDC unpair 同一 route group)。
- 無新增 PII無新增對外資料揭露
- 建議整體register/unregister/IDOR納入 testing API 安全測試 Orchestrator 判定需深度威脅建模可送 security agent P0 owner+representative+IDOR 三檢查已覆蓋主要面
---
## 8. 測試策略(給 Testing Agent
### 8.1 Backend 單元 / handler 測試
- register未認證(500) / id(400) / device 不存在(404) / owner(403) / representative(409) / 已註冊(409) / 正常(200 registered_at null)。
- unregister owner(403) / representative(409) / 已註冊取消(200 registered_at=null) / 未註冊取消(200 冪等 no-op)。
- **並存回歸**unregister device 仍在 Listtoken 不變unpair 行為完全不變跑既有 unpair_test.go / unpair_db_test.go 確認全綠)。
### 8.2 Repo 測試SetRegistered若採 §3.3
- in-memorysetget 回值set nil清空representative 拒絕ErrNotFound)、已刪除拒絕
- postgres db_testtestcontainers本機無 docker `192.168.0.130` ADR-018 §4.3 dbtest 陷阱`go test -run xxx -list` 先確認 case 數再跑避免假綠UPDATE `RowsAffected` representative / deleted 列為 0 ErrNotFound
### 8.3 Frontend 測試
- `normalizeDevice``registered_at` 有值/缺值 `registeredAt` 正確 / null
- `deriveTriState` 真值表online×registered / online×null / offline×registered / offline×null / reconnecting / unknown(≥6 )。
- 三態配色online 未註冊卡片有 warning 標記 + 未註冊文字不只靠色)。
- 排序三種鍵各驗序filter四選項各驗集合 + 空結果狀態
### 8.4 E2E可選P0 視情況)
- 註冊一顆在線未註冊 device 卡片由黃轉綠 取消註冊 轉回黃 device 仍在清單
---
## 9. 並行化工作流計畫Parallelization Plan
### 9.1 契約contract-firstsingle source of truth
BE/FE 必須一致的契約全部定死於 [`api/api-device-mgmt.md`](api/api-device-mgmt.md)
- 端點`POST /api/devices/:id/register``POST /api/devices/:id/unregister`UUID 識別)。
- Response既有 `DeviceListItem` 形狀 `registered_at` omitemptysnake_case)。前端對這個形狀寫 normalize
- 錯誤碼`ALREADY_REGISTERED`(409)、`CONFLICT`(409, representative)、`FORBIDDEN`(403)、`NOT_FOUND`(404)、`VALIDATION_FAILED`(400)。
- 三態運算規則(§5.2 真值表= 前後端共識前端據此算色後端據此保證 `registered_at` 語意
### 9.2 依賴圖 + 關鍵路徑
```mermaid
graph LR
C[契約定稿<br/>api-device-mgmt.md<br/>critical] --> BE[WS-BE: register/unregister<br/>+ SetRegistered repo]
C --> FE1[WS-FE: store 補 registeredAt<br/>+ deriveTriState 純函式<br/>對 mock 契約]
FE1 --> FE2[WS-FE: 三態配色 + 排序 filter UI]
C --> TS[WS-TEST: 測試設計<br/>對契約先寫]
BE --> INT[整合 join<br/>真後端接前端]
FE2 --> INT
TS --> INT
INT --> E2E[E2E join]
```
- **關鍵路徑**`契約定稿 → WS-BE → 整合 join → E2E`契約是所有線的前置必須先鎖死
- WS-FE 可對著契約 mock 先開工store 補欄 + deriveTriState + UI不必等後端
### 9.3 Work-stream 清單 + 同步點
| Work-stream | 派給 | 可與誰平行 | 阻擋於 | 同步點 |
|-------------|------|-----------|--------|--------|
| WS-BEregister/unregister handler + route + SetRegistered repoin-mem+pg+ handler test | backend | WS-FE, WS-TEST | 契約定稿 | 整合 join |
| WS-FEstore registeredAt + normalize + deriveTriState + 三態配色 + 排序/filter UI + i18n | frontend | WS-BE, WS-TEST | 契約定稿 | 整合 join |
| WS-TESTbackend handler/repo 測試設計 + frontend 真值表/配色測試 + 並存回歸 + E2E 腳本 | testing | WS-BE, WS-FE | 契約定稿 | 整合 join / E2E join |
**同步點**
- **整合 join**真後端接上前端註冊卡片轉綠 / 取消轉黃device 保留端到端
- **E2E join** §8.4並確認 unpair 回歸全綠
### 9.4 任務卡Anthropic 四要素)
**WS-BE**
- Objective實作 register/unregister 兩端點 + 建議`SetRegistered` repo 方法符合 api-device-mgmt.md 契約
- Output可運行 handler + route 註冊 + repo 方法in-mem + pg+ 對應 Go 測試 pg db_test 130 testcontainers)。
- 來源指引讀本 TDD §1347`api/api-device-mgmt.md`既有 `devices.go` owner 檢查範式)、`unpair.go`**只讀不改**、理解語意差異)、`postgres_repository.go`Save/scan 範式)。
- 邊界**只加 register/unregister絕不改 unpair / Delete / DeviceUnpairer / token / session**。錯誤碼照契約不自創representative 一律擋不寫 migration欄已存在)。
**WS-FE**
- Objective補前端註冊軸消費——store `registeredAt`三態運算與配色排序/filter UIregister/unregister 呼叫
- Output`DeviceSummary.registeredAt` + normalize + `deriveTriState` 純函式 + DeviceCard 三態配色warning token+ device-list 排序/filter 控制 + store action `registerDevice`/`unregisterDevice` + i18n keyzh/en)。
- 來源指引讀本 TDD §56`api/api-device-mgmt.md`既有 `device-store.ts`pick 範式unpairDevice action 範式)、`device-card.tsx` / `remote-device-badge.tsx` / `device-list.tsx``globals.css`warning token)。
- 邊界識別值——register/unregister **UUID**DB 操作對齊 FE-A**不改** serial 路由類操作connect/camera/media)。配色只用既有 design token禁裸色第三態不只靠顏色加文字+icon)。不動 unpairDevice 行為
**WS-TEST**
- Objective對契約設計 backend + frontend 測試 + 並存回歸 + E2E 腳本
- Output:§8.18.4 測試清單落為可執行測試 / 腳本
- 來源指引讀本 TDD §8`api/api-device-mgmt.md`既有 unpair 測試
- 邊界只寫測試不改 production code特別覆蓋unregister vs unpair 語意不混unpair 回歸不破」。
---
## 10. 給 Orchestrator 的實作排程建議
1. **先鎖契約**確認 `api/api-device-mgmt.md` TDD 定稿
2. **同一輪平行 invoke**WS-BEbackend+ WS-FEfrontend+ WS-TESTtesting三線並行——契約已定各自可對 mock/stub 開工
3. 各線完成 Reviewer 收斂到**整合 join**真後端接前端)。
4. **E2E join** + unpair 回歸全綠 P0 收尾
5. effort scaling 判斷本任務中等2 個清楚分離模組 BE/FE + 測試3 線平行是甜蜜點不過度切分