# 技術設計文件(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 列 | 對 token(pairing/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-A):register 是純雲端 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 是全欄 upsert,register 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_test(testcontainers 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,並列 registerDeviceRoutes,UUID 識別。 - **Request body**:無。 - **行為**(與 register 對稱、但更寬鬆): 1. userID / `:id` / Get / owner / representative 檢查同 §3.1 步驟 1-5。 2. **不做「已註冊才可取消」的硬擋**:採冪等——若 `registered_at` 已是 NULL(未註冊),unregister 回 200(no-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 8601,nil=未註冊)。 - `Device`(extends Summary)自動繼承。 - `normalizeDevice` 加 `registeredAt: pick("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): 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-161,Light/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 新增 key(zh-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`(online→reconnecting→unknown→offline→error);同狀態內次比名稱 | | 名稱 | `displayName`(alias || name)localeCompare,A→Z | | 註冊時間 | `registeredAt` desc(新註冊在前);null(未註冊)排最後 | - 排序狀態存元件 local state(`useState`)即可;不需持久化(P0)。若要記住偏好可存 localStorage(可選、非必要)。 ### 6.3 Filter(依三態) 提供 filter(chips / 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 → 500(auth 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(前端拿不到其 UUID),handler 仍要自己擋(攻擊者可能猜 UUID 或從其他管道拿到)。repo 的 `SetRegistered` 的 WHERE 也帶 `is_representative = false`(§3.3),三層防護。 ### 7.4 其他 - register/unregister 是狀態變更操作,走既有 auth middleware(JWT/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 仍在 List、token 不變;unpair 行為完全不變(跑既有 unpair_test.go / unpair_db_test.go 確認全綠)。 ### 8.2 Repo 測試(SetRegistered,若採 §3.3) - in-memory:set→get 回值、set nil→清空、representative 拒絕(ErrNotFound)、已刪除拒絕。 - postgres db_test:testcontainers(本機無 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-first,single 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` omitempty,snake_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[契約定稿
api-device-mgmt.md
critical] --> BE[WS-BE: register/unregister
+ SetRegistered repo] C --> FE1[WS-FE: store 補 registeredAt
+ deriveTriState 純函式
對 mock 契約] FE1 --> FE2[WS-FE: 三態配色 + 排序 filter UI] C --> TS[WS-TEST: 測試設計
對契約先寫] BE --> INT[整合 join
真後端接前端] 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-BE:register/unregister handler + route + SetRegistered repo(in-mem+pg)+ handler test | backend | WS-FE, WS-TEST | 契約定稿 | 整合 join | | WS-FE:store 補 registeredAt + normalize + deriveTriState + 三態配色 + 排序/filter UI + i18n | frontend | WS-BE, WS-TEST | 契約定稿 | 整合 join | | WS-TEST:backend 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 §1/§3/§4/§7、`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 UI、register/unregister 呼叫。 - Output:`DeviceSummary.registeredAt` + normalize + `deriveTriState` 純函式 + DeviceCard 三態配色(warning token)+ device-list 排序/filter 控制 + store action `registerDevice`/`unregisterDevice` + i18n key(zh/en)。 - 來源指引:讀本 TDD §5/§6、`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.1–8.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-BE(backend)+ WS-FE(frontend)+ WS-TEST(testing)三線並行——契約已定、各自可對 mock/stub 開工。 3. 各線完成 → Reviewer 審 → 收斂到**整合 join**(真後端接前端)。 4. **E2E join** + unpair 回歸全綠 → P0 收尾。 5. effort scaling 判斷:本任務中等(2 個清楚分離模組 BE/FE + 測試),3 線平行是甜蜜點,不過度切分。