# 指示書AY — 制御盤（ラズパイ）のログを D-Sec ログ画面から取り出す（中継方式・サーバーに保存しない）

**日付**: 2026-10-19（★見直し 2026-11-05★）/ **担当**: Cursor（または監督が指名する実装担当）/
**基点**: ★`7492cc5`（2026-11-05）★（★着手時に `git log --oneline -1` で develop の先端を記入: `_______`。ずれていたら段階0 で行番号を確かめ直す★）。
★先端が `7492cc5` と違っても、その間のコミットが資料（`docs/`・`.claude/`）だけなら、コードは同じで本書の行番号はそのまま使える★（本書の見直し自体も資料のコミット `8d80dd1` で入っている）。確かめ方: `git diff --stat 7492cc5..HEAD -- . ':!docs' ':!.claude'` が★何も出さない★こと。何か出たら、そのファイルについて段階0-4 で行番号を確かめ直す。
旧: 作成時は `739de1f`、第1回の見直しは `99e5818`（2026-10-19。§11-3）。★`99e5818` → `7492cc5` で前提が4件崩れていた★（D-Sec ログ画面に編集モード・AreaModal に Esc・Next 16.4.2・json-store の書き込みの直列化）。★本文は `7492cc5` に合わせて直した（§11-4）★
**前提**: ★D-179★（`docs/decisions.md:12938` 以降。コミット済み）/ ★指示書AX の実装コミット★（`d282e07`。入った）/
★監督判断 第2ラウンド（2026-10-19。M1〜M11。§11 に反映済み。ブリーフより優先）★ / ★監督の見直し（2026-10-19。`99e5818`。§11-3）★ /
★監督の見直し（2026-11-05。`7492cc5`。ユーザー回答1件と監督の裁定 M1〜M20。§11-4。★§11-3 と食い違うところは §11-4 が正★）★ /
設計書 `docs/handoff/設計_制御盤ログの取り出し_20261019.md`（★作成済み・本書と突き合わせ済み。制御盤の常駐プログラムの仕様はそちら★。★本書の作成後に §1.6・§1.6.1・§5.2・§5.13・§5.13.1 が更新された（§11-3 B-9）。★2026-10-20 に一覧の `name` の形が `{root の name}/{root からの相対パス}` に変わった（設計書 §5.5 の表 `:884`。§11-4 M6）★）/
常駐プログラムの実物 `C:\Users\daich\dev\dwatch-log-agent`（★読むだけ。段階11-3-7b で本物どうしの通し確認に使う★）/
D-077（XFF の末尾）/ D-101（IP 制限・`ALLOW_DIRECT_ACCESS`）/ D-123（シリアルの文字種を絞らない）/ D-4（当たり外れは JS の `===`）/ D-054（`summary` と `meta.total`）/
D-143（DFA が作るのはビューアー。サーバーの構築保守は TeamWorks）/ D-154（セキュリティはリリース直前）/ `CLAUDE.md` §3・§6・§7・§9.3・§9.5・§9.6

★**ユーザー決定（§2-1 の U1〜U6）と監督の設計判断（§2-2 の D1〜D25）は確定仕様。変えないこと**★（`CLAUDE.md` §0-1）。
★**期待値表（§3 と、段階3-3 の V・段階7-2 の W）は正。変えないこと**★（`CLAUDE.md` §0-2）。★2026-11-05 に監督が V・W・表5 R43 を改めた（§11-4 M7）。改めた後の表が正★。★§11 Q1〜Q16 はすべて監督判断（第2ラウンドと追加）で確定し、本文に反映済み★（★Q14・Q15 は本書の案 P1・P2 をそのまま採用。§2-3★）。

★**担当ファイルが他の指示書と重なっていた**★: `components/features/ds-logs/`・`d-watch-api.yaml`・`lib/i18n.ts`・`lib/types/index.ts`・`lib/fixtures/index.ts`・`lib/logger/events.ts`。
★AX（`d282e07`）のほか、★指示書BB・BC・BE〜BI が `99e5818` の後に入った★。★特に BC 段階4（`bce6a93`）で D-Sec ログ画面の備考が編集モードになった★（§1・4-21・段階9-1）。
★`7492cc5` で監督が確かめ直して本文を直した★（§11-4）。

★**行番号は `7492cc5`（2026-11-05）で監督が確かめ直したもの**★（旧: `99e5818`・作成時は `739de1f`）。
★**着手時に develop の先端で、本書に書いた行番号をすべて確かめ直すこと**★（段階0-4）。ずれていたら関数名・文で探し直す。

★**`docs/decisions.md` に足すのは「D-179 の末尾への『実装の記録』」だけ**★（§13 の下書き）。★新しい D 番号は取らない★（★D-180（指示書AX）・D-181（指示書BA）は他の指示書の決定★）。
★**D-179 の末尾には 2026-10-19 の『4回目の追記』と 2026-11-05 の追記（本書の2回目の見直し）がある**★ので、★実装の記録はその後ろ（D-180 の見出しの前）に足す★。
★追記の前に `grep -n "^### D-1[78][0-9]" docs/decisions.md` で位置を確かめる★（`7492cc5` では D-179 `:12938`・D-180 `:13050`・D-181 `:13090`。★2026-11-05 の追記で D-180 以降は数行下がる★）。

---

## 0. 何をするか

| # | やること | 段階 |
|---|---|---|
| 1 | ★API 契約を先に書く★: ブラウザ向け ★6本★（一覧・エクスポートの準備・ダウンロード・トークンの状態／発行／失効）と制御盤向け ★6本★（指示の取得・一覧の結果・★準備完了（prepared）★・チャンク・完了・失敗）を `d-watch-api.yaml` に | 段階2 |
| 2 | 設定値（`lib/config/ds-agent.ts`）と純粋関数（`lib/ds-logs/query.ts`・`presets.ts`・`file-name.ts`・`validate.ts`） | 段階3 |
| 3 | ★制御盤トークン★（`lib/ds-agent/tokens.ts`）。★ハッシュだけを保存★。★発行・失効は D-Watch の API と画面から★（スクリプトは作らない。D16） | 段階4・7・9 |
| 4 | ★認証★: `/api/ds-agent/` 配下は ★Bearer だけ★、それ以外は ★cookie だけ★（`lib/proxy/authenticate.ts`・`proxy.ts`） | 段階5 |
| 5 | ★中継（relay）★: メモリ内の待ち行列・ロングポーリング・一覧の待ち合わせ・★エクスポートの「準備 → 送信」の2段★・ストリーム（`lib/ds-agent/relay.ts`）。★時刻とタイマーを注入できる形★ | 段階6 |
| 6 | ルート12本（`app/api/ds-devices/[serial_number]/logs/**`・`…/agent-token`・`app/api/ds-agent/**`） | 段階7 |
| 7 | fixtures の偽制御盤（`lib/fixtures/ds-device-logs.ts`・`lib/ds-agent/fixture-agent.ts`）。★relay の同じ経路を通す★ | 段階8 |
| 8 | 画面: D-Sec ログの表の各行に「ログ」ボタン（★備考の編集モード中は押せない。U6★）→ ★モーダル★（種類・期間・並べ替え・一覧・選択・エクスポート・★制御盤トークン★）。★モーダルの操作の部品（Tab の閉じ込め・背景のクリック）は `lib/ui/modal.ts`（新規）へ移して共有する（M2）★ | 段階9 |
| 9 | ログのイベント（`lib/logger/events.ts`） | 段階10 |
| 10 | 画面確認（fixtures）と、★偽制御盤スクリプトによる非 fixtures の通し確認★ | 段階11 |

★**変えないもの**★: D-Sec ログの一覧（`GET /ds-devices`・`dsDevices()`）と備考（`PATCH /ds-devices/{serial_number}/note`）の挙動／
★D-Sec ログ画面の備考の編集モード（編集／保存（N件）／キャンセル・下書き・`<fieldset disabled>`。BC 段階4・D-183）の動き★／
既存の cookie 認証・IP 制限の判定（`authenticate()` の今の分岐）／`lib/settings/json-store.ts` の中身／`prisma/`／メインDB（★本機能は DB に書かない。読むのは 404 の判定の `dsDeviceExists()` だけ★）／
★エラー一覧の `ErrorContentPicker.tsx` と `lib/errors/__tests__/`★（★再 export で import がそのまま通る。M2。`ErrorContentPicker.tsx` には `7492cc5` の時点で他のセッションの未コミットの変更がある★）。

---

## 1. 背景

- ★ユーザー要望（2026-10-19）★: 制御盤（ラズパイ）の中のログファイルを、ターミナルを使わずに、PUDU のログのエクスポート画面のように「種類（ファイル名の prefix）・期間で絞り、一覧から選んでダウンロード」したい。サーバーには上げたくない。
- ★いまの運用★: Raspberry Pi Connect（rpi-connect 2.7.0）の Remote shell から `cat` で見ている。★Pi Connect にはファイル転送・ポート転送・コマンド実行の公開 API が無い★（D-179 追記 項1。出典は D-179）。→ Pi Connect だけでは要望を満たせない。★Pi Connect は常駐プログラムの導入・更新に使う★。
- ★D-Watch の今★（コード実測 2026-10-19、★2026-11-05 に `7492cc5` で確かめ直した★。出所: 構造（コード・`node_modules`））:
  - Next.js ★16.4.2★（`package.json:21`。旧: 16.3.3）。`middleware` は `proxy` に改名（`proxy.ts:1-21`）。★proxy は Node ランタイム固定★。
  - ★proxy が走るリクエストは、本体を clone してメモリにバッファする。既定の上限は 10MB。超えた分は切り捨てられ、★クライアントにはエラーにならず、サーバーに警告が1行出るだけ★★（★16.4.2 でも同じ★）
    — `node_modules/next/dist/docs/01-app/03-api-reference/05-config/01-next-config-js/proxyClientMaxBodySize.md:7-9`・`:71-76`。
    本体を最後まで受け取ってからハンドラが動く — `node_modules/next/dist/server/next-server.js:1205-1233`（`cloneBodyStream()` `:1225` → `finalize()` `:1231`）・`node_modules/next/dist/server/body-streams.js:52-118`（`bytesRead > bodySizeLimit` `:98` で `console.warn` と `push(null)`）。
    → ★proxy を通るパスで 100MB を1本の POST で流すことはできない★。
  - `proxy.ts` の matcher は静的ファイル以外の全パス（`proxy.ts:112-114`。`/api` を含む）。★「matcher で除外しない。関数の中で分岐する」方針★（`proxy.ts:106-107`）。
  - 判定は `lib/proxy/authenticate.ts`（★判定するだけ・同期・`deps` 引数でテスト可能★。`:59-94`）。順序は ★IP 制限（403）→ 認証（401 / redirect）★。公開パスは `/login` と `/api/auth/login` だけ（`:25`）。
  - 認証は共有トークン（env `AUTH_TOKEN`）を cookie `dwatch_session` に入れる方式。比較は `verifyToken()`（`lib/auth/token.ts:22-41`。`timingSafeEqual`）。
  - IP 制限: `lib/config/allowed-ips.ts`（`ALLOWED_IPS` 空 `:24-26`・`ALLOW_ALL_WHEN_EMPTY = true` `:35`・`TRUSTED_PROXY_HOPS = 1` `:63`）。★このファイルは proxy のバンドルに judge 系を引き込まないため `lib/config/index.ts` から独立させてある★（`:4-8`）。
  - 動かし方は `npm run build` → `npm run start`（`docs/deploy.md` §2）。Docker の可能性あり（同 §1）。★PM2 / cluster の設定は無い → 1プロセス★。前段に nginx（XFF を追記。D-077。★既定の `proxy_read_timeout` は60秒★）。nginx の設定は TeamWorks 管理でリポジトリに無い。
  - DFA 独自ストアは `createJsonStore()`（`lib/settings/json-store.ts:46`。一時ファイル → rename、壊れたら `.bak`。★`read()` は非同期★ `:52`。★`lib/logger` を import しない★ `:22-24`）。★2026-10-19 以降、同じストアへの書き込みは待ち行列で直列化され、一時ファイルは書き込みごとに別の名前になった（BB A12）★ — ★本書の使い方（発行・失効のときだけ書く・読みは mtime でキャッシュ）は崩れない★。★`JsonStore` はファイルのパスを返さない★（`read` / `write` だけ。段階4 の mtime のキャッシュはパスを別に持つ。M13）。前例 `lib/settings/ds-device-notes.ts`（`data/settings/…`。既定パス `:53-55`・`__setDeviceNotesStoreForTesting()`。`/data` は `.gitignore:50`）。
  - ログは `logEvent(level, payload)`（`lib/logger/index.ts:113`）。イベントは `lib/logger/events.ts` の型付き union（`:486-501`）に足す。★キー名に `token` / `authorization` / `facility_name` / `note` 等を使うと本番で消される★（`lib/logger/redact.ts:12-46`）。
  - ★既存のストリーミング応答の例は無い★（CSV 出力は文字列を丸ごと返している）。
  - 画面: `components/features/ds-logs/DsLogScreen.tsx`（表の `<colgroup>` `:464-470`・見出し `:471-479`（`<th>` `:473-477`）・日時の整形 `formatDateTime()` `:47`）。
    ★**10-19 の BC 段階4（`bce6a93`。D-183）で、備考が他の3画面と同じ「編集モード」になった**★: `editing` / `drafts` / `rowErrors` / `saving`（`:140-143`）・下書きがある間の離脱確認（`:146-154`）・「編集」→「保存（N件）」「キャンセル」・まとめて保存（`saveNoteDrafts()` `:252`）・★編集中は絞り込みとページ送りを `<fieldset disabled>` で無効にする★（`filtersDisabled` `:221`・`:322-323`・`:540`）。
    ★列数の定数 `COLUMN_COUNT = 5`（`:66`）は保存に失敗した行の `colSpan`（`:524`）に使う★。0件の表示は `:536`（「該当するデバイスはありません」）。既存の「全て」ボタンは `:410`。
    ★一覧の取得は `useEffect` の片付けで `cancelled = true` にして古い応答を捨てる作法★（`:178-214`。BC 段階1・D-183。★`react-hooks/set-state-in-effect` の抑止コメントが `:182` にある★）。
    旧: 「備考は blur で1件ずつ確定・`DsLogScreen.tsx` は作成時から変わっていない」— ★誤り（`99e5818` の後に変わった）★。
    表の最小幅は ★800px★（`DsLogScreen.module.css:205`。★規則のコメントは `:199-201`: 「最小幅 ＝ 全列の幅の合計（施設名 200px ＋ 固定4列 600px）」・列を広げたら min-width も広げる・★余った幅は全列に幅の比で配られる（D-167 追記）★★）。
    列幅は `.colSerial` 140・`.colFacility` 200・`.colStatus` 112・`.colLastReported` 120・`.colNote` 228（`:222-250`。合計 800）。
    ★モーダルの共通部品は無い★。前例は2つ: ① `components/features/detail-log/AreaModal.tsx`（`role="dialog" aria-modal="true"` `:76`・自前の overlay・✕ボタンに `aria-label`。★Esc で閉じる処理は `:66-73`（BC 5-4 で入った）★。フォーカスの移動・背景のスクロールの停止は無い）／
    ② ★エラー一覧の `components/features/errors/ErrorContentPicker.tsx`（`5b2fdd0`。指示書BE）★: 開いたら入力欄へフォーカス・閉じたら呼び出したボタンへ戻す（`restoreFocusRef`）・Tab の閉じ込め（`trapFocusIndex()`）・背景を押して離したときだけ閉じる（`shouldCloseOnBackdrop()`）・`aria-labelledby` ＋ `useId()`。★判定の純粋関数は `lib/errors/error-content-picker.ts`（`shouldCloseOnBackdrop` `:112`・`trapFocusIndex` `:122`）★。★背景のスクロールを止める前例はどちらにも無い★（段階9-2）。
    ページ送り `components/ui/Pagination.tsx`（`total` / `page` / `perPage` / `onPageChange`。★per_page の選択は持たない★）。日付は `<input type="date">` ＋ `openDatePicker()`（`components/ui/date-picker.ts:11`）。
  - ★画面の API 呼び出し★: `apiFetch()`（`lib/api/client.ts:102`）は ★本文を必ず JSON で読む★（`:129`）ので、★204（本文なし）は `parsed = false` で `ApiFetchError` になる★（`:144-150`）。★ネットワークの失敗・取り消しは `status: 0` の `ApiFetchError`★（`:118`）。本文の無い応答の前例は素の `fetch`（`app/login/page.tsx:34`）。
  - API: `/ds-devices`（`d-watch-api.yaml:1726`）・`/ds-devices/{serial_number}/note`（`:1765`。path の `maxLength: 100`（`:1776`）・文字種は絞らない。★404 の description は「一覧に出ない制御盤(シリアルが無い・論理削除・controls.status が 0/1 以外。D-182)」★）・その次が区切りコメント（`:1812`）と `/ds-alerts`（`:1813`）。
    パスの検証は `parsePathSerialNumber()`（`lib/api/path-params.ts:71-80`。★422 `validation_failed`★）。★文字列の id の前例は `parsePathReportId()`（`:107-114`。空と上限超だけ 422・文字種は絞らない・`Number()` を通さない。上限の定数は `lib/config/index.ts:791`）★。
    実在は `db.dsDeviceExists()`（`lib/db/index.ts:421`。fixtures `:774`・Prisma `:2137`。★Prisma 版は論理削除と `controls.status` が 0/1 以外も false（BB A1・D-182）★）。
    ★本文の検証★: JSON でもオブジェクトでない本文（`null`・配列・数値）は `isJsonObject()`（`lib/api/json-body.ts:7`）で弾き 422「JSONの形式が不正です」（前例 `app/api/ds-devices/[serial_number]/note/route.ts:65-69`）。
    ★クエリの検証★: `page` / `per_page` は ★不正値でも 422 にせず、既定値・上限へ丸める★（`lib/ds-devices/query.ts:41`・`:56-60` ほか、page / per_page を持つ7つの query.ts が同じ。`toInt()` ＋ `Math.max(1, …)` ＋ `Math.min(PAGINATION.maxPerPage, …)`）。★空文字のパラメータは省略と同じ★（BH S2-2）。★存在しない日付は 422★（`lib/clean-logs/query.ts:63-71` の `parseDate()`。`toJstIso()` で戻して入力と比べる。BB A7・D-182）。
  - テスト: vitest（`vitest.config.ts`。★include は `lib/**` と `app/**` だけ★ — `components/` は走らない）。ルートのテストは Prisma 経路と fixtures 経路の2ファイルに分ける流儀（`app/api/ds-devices/[serial_number]/note/__tests__/route.test.ts` / `route.fixtures.test.ts`。ストアは `fs.mkdtemp` ＋ `__setDeviceNotesStoreForTesting()`）。

---

## 2. 決定（実装担当は従うこと）

### 2-1. ユーザー決定（2026-10-19。確定。D-179）

| # | 決定 | 帰結 |
|---|---|---|
| U1 | ★「アップロードしたくない」＝サーバーに保存しなければ、通過はよい★ | ★D-Watch はチャンクをメモリで受けて、待っているブラウザの応答へ流すだけ★。ディスク・DB・ストアに書かない（D3） |
| U2 | ★制御盤のソフトは DFA が作っている。言語は Python★ | 常駐プログラムは本書の範囲外（§10）。設計書が正 |
| U3 | ★ログを取り出すのは DFA の社内だけ★（顧客・現場には見せない） | 既存の cookie 認証・IP 制限の内側に置く。`facilities.contract` による出し分けはしない |
| U4 | ★A案（自前の中継）をすぐ進める★（D を先に作る案は採らない） | 本書 |
| U5 | ★ログのフォルダ・ファイル名の規則は後日★ | 制御盤の設定ファイルで与える。★D-Watch は「種類（`key` / `label`）」を制御盤から受け取るだけ★で、種類の値を持たない・直書きしない |
| U6 | ★「ログ」ボタンは備考の編集モード中は押せない★（ユーザー回答 2026-11-05「編集中は押せない」。§11-4 の裁定一覧では「U1」） | 絞り込み・ページ送りと同じ扱い（`filtersDisabled`）。★下書きを守る★（4-21・段階9-1・段階11-2） |

### 2-2. 監督の設計判断（確定。D-179 追記 項4・第2ラウンド M1〜M11・追加の判断 D25）

| # | 決定 | 理由 |
|---|---|---|
| D1 | ★ファイルは制御盤が zip にし、`CHUNK_MAX_BYTES`（4MB）以下のチャンクに分けて送る。チャンクごとに SHA-256（`X-Chunk-Sha256`）を付け、D-Watch は長さとハッシュを検証する。最後に `complete` で合計の長さと全体の SHA-256 を照合する★ | ★proxy の 10MB バッファ（超過分は切り捨て・クライアントにはエラーにならない）を避け、切り捨てを検知する★（§1） |
| D2 | ★接続はすべて制御盤から開く。ロングポーリング★（`GET /api/ds-agent/commands?wait=25`） | 店舗のネットワークに入口を作らない。★Next.js のルートハンドラは WebSocket のサーバーになれない★ |
| D3 | ★サーバーのディスクに書かない★（一時ファイル・`os.tmpdir()` も禁止）。チャンクはメモリで受けてブラウザの応答へ流す | U1 |
| D4 | ★relay はモジュールのシングルトン。`globalThis` に置く（開発時の HMR で二重にしない）★。★1プロセス前提★（`next start` を複数立てる・cluster にすると壊れる。再起動で進行中のエクスポートは失われる） | 前例 `lib/db/prisma.ts:10-22`・`:76-84`（`globalThis.__dwatchPrisma`） |
| D5 | ★`/api/ds-agent/` 配下は cookie では通さない。`Authorization: Bearer` を `lib/ds-agent/tokens.ts` で検証する★。★それ以外のパスで Bearer を出されても無視する（cookie が要る）★。★ルートでも `requireAgent(request)` で再検証して serial を得る★（★ヘッダで serial を渡さない★） | D-179 追記 項4-4・項5 |
| D6 | ★IP 制限は今のまま全パスにかける（agent パスも）★。★リリース時に許可リストを入れると制御盤が弾かれる★ → 店舗の IP を載せるか、agent パスだけ IP 制限を外すかは ★リリース時に決める（D-154）★。★本書では決めない★ | D-179 追記 項4-5 |
| D7 | ★制御盤トークンは制御盤ごと。32バイトの乱数を base64url（43文字）★。保存は DFA 独自ストア `data/settings/ds-agent-tokens.json`（`createJsonStore`）に `{ [serial_number]: { token_sha256, issued_at } }`。★生の値は保存しない・ログに出さない★。★メインDBには書かない★ | D-179 追記 項4-4 |
| D8 | ★一覧は制御盤からもらい、絞り込み（種類・期間）・並べ替え・ページ分けは D-Watch 側の純粋関数で行う★（`lib/ds-logs/query.ts`） | 期待値表でテストできる場所に置く |
| D9 | ★設定値は `lib/config/ds-agent.ts`（新規・独立ファイル）★。値は 段階3-1 の表。★ハードコード禁止★（`CLAUDE.md` §7） | `lib/config/index.ts` に足すと proxy のバンドルに judge 系を引き込む（`allowed-ips.ts:4-8` と同じ理由） |
| D10 | ★fixtures の偽制御盤は relay の同じ経路（待ち行列 → 結果 → 準備完了 → チャンク → 完了）を通す★。fixtures の制御盤のうち1台（★`DS-001`★）は未接続にして 409 を出す | `CLAUDE.md` §9.5 |
| D11 | ★画面は D-Sec ログの表の各行の「ログ」ボタン → モーダル★。★見た目と Esc は `AreaModal.tsx`（Esc `:66-73`）、フォーカスの移動・閉じたら呼び出したボタンへ戻す・Tab の閉じ込め・背景を押して離したときだけ閉じるは `ErrorContentPicker.tsx` に倣う★。★判定の純粋関数（`trapFocusIndex`・`shouldCloseOnBackdrop`）は `lib/ui/modal.ts`（新規）へ移して共有する★（M2。`lib/errors/error-content-picker.ts` は再 export）。★背景のスクロールの止め方は前例が無いので `DeviceLogsModal.tsx` の中に書く★（段階9-2）。★モックに無い画面なので、文言・配置は本書 段階9 を正とする★ | ユーザー要望（PUDU の画面）。M2（§11-4） |
| D12 | ★ログに出すのはファイル数とバイト数だけ★。★ファイルの中身・ファイル名の一覧全体・トークンは出さない★ | 段階10 |
| D13 | ★却下★: 1本の POST で 100MB を流しそのパスだけ matcher から外す（`proxy.ts:106-107` に反する）／`experimental.proxyClientMaxBodySize` を上げる（全量をメモリに溜めてからハンドラが動く）／別プロセスの中継（nginx の振り分けを TeamWorks に頼む・認証が二重）／WebSocket（ルートハンドラはなれない） | D-179 追記 項4-2 |
| D14 | ★エクスポートは「準備 → 送信」の2段★（M1）。<br>① `POST …/logs/exports` は 404 / 409 / 429 / 422 を判定 → 制御盤に `kind: "export"`（params `{ file_ids, max_bytes, chunk_bytes }`）→ ★制御盤が zip を作り終えて `POST /api/ds-agent/commands/{command_id}/prepared` `{ total_bytes, sha256, file_count }` を送るまで待つ（`EXPORT_PREPARE_TIMEOUT_SEC` = 50）★ → ★201★ `{ export_id, download_url, total_bytes, file_count, expires_in_sec }`。★準備中の失敗（`too_large` / `file_not_allowed` / `file_not_found` / `io_error` 等）はこの POST の 502 `agent_error`（`details.agent_code`）★、50秒で来なければ 504 `agent_timeout`。<br>② `GET …/logs/exports/{export_id}`（★1回だけ★）は制御盤に `kind: "send"`（params `{ export_command_id }`）→ 最初のチャンクを `EXPORT_FIRST_CHUNK_TIMEOUT_SEC`（30）待つ（来なければ 504 JSON）→ ★200 で流す。`Content-Length: total_bytes` を付ける★。流し始めた後の失敗は `controller.error()` | ★準備の失敗を画面の JSON で返せる★。★長い待ちを nginx の60秒より短くする★。ブラウザに進捗が出る |
| D15 | ★上限の判定は zip のバイト数に一本化★（M1）。D-Watch は ★prepared の `total_bytes ≤ EXPORT_MAX_BYTES`★ を確かめ（超えたら 502 `agent_error`・`agent_code` `too_large`）、★流れたバイト数が `total_bytes` と一致し、SHA-256 が prepared の `sha256` と一致すること★を complete で確かめる | 制御盤は元ファイルの合計で早く断り、zip の大きさでも断る（設計書） |
| D16 | ★トークンの発行・失効は D-Watch の API と画面から★（M8）: `POST /api/ds-devices/{serial_number}/agent-token` → 201 `{ token, issued_at }`（★生の値はこの応答で1回だけ。既にあれば作り直し、古いものは無効★）／`DELETE …/agent-token` → 204（★未発行でも 204。§2-3 P2★）／★発行日時の読み取りは `GET …/agent-token` → 200 `{ issued_at }`（§2-3 P1）★。★既存の cookie 認証★。★`scripts/ds-agent-token.ts` は作らない★ | ★本番のサーバーは TeamWorks が構築保守する（D-143）ので、サーバー上でスクリプトを動かす前提を置かない★ |
| D17 | ★ブラウザ向けの検証エラーは 422 `validation_failed`★（第2ラウンド M2。ブリーフの 400 `validation_error` は誤り）。★ただし `page` / `per_page` は 422 にせず丸める・空文字のパラメータは省略と同じ（既存の query.ts と同じ。§11-4 M7）★。★制御盤向けの形の誤りは 400 `result_invalid` / `chunk_invalid`★ | §11 Q1・Q10・§11-4 M7 |
| D18 | ★一覧の上限と `truncated`★（M3）: 制御盤は ★更新日時の新しい順に並べてから★ 2万件または JSON 4MB のどちらかに達したら打ち切り `truncated: true`。`LIST_RESULT_MAX_BYTES` = 4MB。★ブラウザ向けの一覧の応答に `truncated` を足し、画面は「件数が多いため、新しいものから一部だけを表示しています」★ | §11 Q3 |
| D19 | ★認証の通った制御盤の ★すべての★ リクエスト（commands・result・prepared・chunks・complete・fail）で `last_seen_at` を更新する★（M4） | エクスポートの最中（ポーリングが途切れても）未接続にしない |
| D20 | ★`EXPORT_IDLE_TIMEOUT_SEC` = 45★（M5）。★チャンクの POST に 204 を返すまでの待ち（流量制御）も最大45秒。超えたら 410 `export_cancelled` にしてエクスポートを失敗にする★ | nginx の60秒より短く。制御盤のチャンクの POST のタイムアウトは60秒（設計書） |
| D21 | ★ダウンロードは `<a href download>` を作って `click()`★（M1）。★`location.href` は使わない★。★GET が JSON のエラーになるのは制御盤の切断などまれな場合だけで、そのときは JSON がファイルとして保存されうる（既知の制約）★ | 準備の失敗は POST で返る（D14） |
| D22 | ★proxy を `async` にし、agent パスの判定を `authenticateAgent()`（新規・async）に分ける★。`authenticate()` は agent パスなら cookie があっても `unauthenticated`。★トークンのストアは mtime を見てメモリにキャッシュしてよい★ | §11 Q8 |
| D23 | ★本書の暫定の採用（M10）★: 期間ボタンの `to` は省略（Q4）／未知の種類キーは0件（Q5）／zip のファイル名のシリアルは `[A-Za-z0-9._-]` 以外を `_`・`filename*` も付ける（Q6）／順番違いのチャンクはエクスポートも失敗（Q9）／待ち行列が満杯なら 429 `agent_busy`・410 `command_expired`・400 `result_invalid`（Q10）／偽制御盤スクリプト `scripts/ds-agent-fake.ts` をコミット（Q11）／同じ制御盤の2本目のポーリングは古い方を 204（Q12）／期間の既定は直近24時間・種類や期間を変えたら選択を消す（Q13）／404 の判定は `db.dsDeviceExists()`（DB は読むだけ） | §11 |
| D24 | ★zip の中のパスは `{root の name}/{root からの相対パス}`（制御盤側。M6）★。★一覧の `name` も同じ形（2026-10-20 に設計書が変更。§11-4 M6）★。D-Watch は zip の中身も `name` の中身も解析しない（表示と name 昇順の並べ替えにだけ使う）。★偽制御盤スクリプトと fixtures の zip・一覧の `name` は root の名前つきで作る★ | 本物と同じ形で画面確認する |
| D25 | ★追加の監督判断（2026-10-19）★:<br>① ★chunks / complete / 送信中の fail の URL の command_id は ★export の指示の id★★。`send` の指示は自分の command_id を持ち、`params.export_command_id` で export を指す（★`send` の id には何も送られてこない★）<br>② ★`send` が来たのに制御盤に zip が無い → 制御盤は `fail { code: "export_not_found" }`★。D-Watch は最初のチャンクの前なら GET を 502 `agent_error`、流し始めた後ならストリームを error<br>③ ★list は export と並行して処理される（制御盤側）★。★D-Watch はエクスポートの進行中でも list の指示を待ち行列に入れてよい★（エクスポートの進行中を理由に list を断らない）<br>④ ★制御盤から来た JSON（result / prepared / complete / fail）の形の誤りは、すべて 400 `result_invalid`★。チャンクは 400 `chunk_invalid`<br>⑤ ★prepared から send・complete・fail・失効までを「進行中」に数える★（1台1本の上限＝429 `export_busy` の判定に入る。準備中も数える）。★prepared の後に GET が `EXPORT_CLAIM_TIMEOUT_SEC`（60秒）来なければ D-Watch はエクスポートを失効させ、進行中から外す★（制御盤は120秒で zip を消す） | §11 Q16。設計書も同じ内容で直す |

### 2-3. 本書の案で採用されたもの（★確定。追加の監督判断 2026-10-19 で §11 Q14・Q15 を採用★）

| # | 決定 | §11 |
|---|---|---|
| P1 | ★トークンの状態を読む `GET /api/ds-devices/{serial_number}/agent-token` を足す★（`{ issued_at: string \| null }`。★トークン・ハッシュは返さない★） | Q14 |
| P2 | ★未発行の `DELETE …/agent-token` も 204★（冪等） | Q15 |

---

## 3. ★期待値表（正）★

★表1〜表6 はブリーフ §5 の柱と第2ラウンドの監督判断から本書で具体化したもの★（★表1・表2・表4・表6 と表3 の関数の行は第1ラウンド、表5 と表3 の API の行は第2ラウンドで作り直した★）。
★表3 KA4・KA7 は本書の案（P1・P2）が §11 Q14・Q15 で採用されて確定した行★。
★**表1 は純粋関数の入力なので架空の名前のまま**★（★2026-10-20 の `name` の形の変更（`{root の name}/{相対パス}`）にも合わせない。§11-4 M6★）。★実機の種類（`api_client` 等）と `name` の形（`<種類>/<種類>.log` / `<種類>/<種類>.log.YYYY-MM-DD`）は、fixtures（段階8-1）と偽制御盤スクリプト（段階11-3）でだけ使う★（監督の見直し 2026-10-19・2026-11-05。§11-3 B-9・§11-4 M6）。

### 表1（`lib/ds-logs/query.ts` の `queryDeviceLogs()`。入力の8件）

`types`（制御盤から来た順）= `[{ key: "app", label: "アプリ" }, { key: "comm", label: "通信" }, { key: "sys", label: "システム" }]`（★ダミー。本物の値はユーザーが後日教える★）

| file_id | name | type | size_bytes | modified_at（制御盤から来た値） | 備考 |
|---|---|---|---|---|---|
| `f1` | `app_20261019_1100.log` | app | 1000 | `2026-10-19T11:00:00+09:00` | `f3` と同時刻 |
| `f2` | `app_20261019_0900.log` | app | 2000 | `2026-10-19T09:00:00+09:00` | |
| `f3` | `comm_20261019_1100.log` | comm | 3000 | `2026-10-19T11:00:00+09:00` | `f1` と同時刻 |
| `f4` | `comm_20261018_1200.log` | comm | 4000 | `2026-10-18T12:00:00+09:00` | ★ちょうど 24時間前★ |
| `f5` | `sys_20261019_1130.log` | sys | 5000 | `2026-10-19T11:30:00+09:00` | |
| `f6` | `sys_20261018_1159.log` | sys | 6000 | `2026-10-18T11:59:59+09:00` | 24時間前の1秒前 |
| `f7` | `app_20261019_0200.log` | app | 7000 | ★`2026-10-18T17:00:00Z`★ | ★= 10/19 02:00 JST。オフセット違い★ |
| `f8` | `app_20261019_1200.log` | app | 8000 | `2026-10-19T12:00:00+09:00` | ★ちょうど now★ |

| # | 入力 | 期待（file_id の並び） | total | 根拠 |
|---|---|---|---|---|
| Q1 | 指定なし（`sort=date_desc`） | f8, f5, f1, f3, f2, f7, f4, f6 | 8 | 新しい順・★同時刻は name 昇順★（`app_…` < `comm_…`） |
| Q2 | `types=app` | f8, f1, f2, f7 | 4 | |
| Q3 | `types=app,sys` | f8, f5, f1, f2, f7, f6 | 6 | |
| Q4 | `types=xyz` | （空） | 0 | D23（200・0件） |
| Q5 | `types=app,xyz` | f8, f1, f2, f7 | 4 | D23 |
| Q6 | `from=2026-10-19T11:00:00+09:00` | f8, f5, f1, f3 | 4 | ★ちょうど from は入る★ |
| Q7 | `to=2026-10-19T11:00:00+09:00` | f2, f7, f4, f6 | 4 | ★ちょうど to は入らない★ |
| Q8 | `from=2026-10-18T12:00:00+09:00&to=2026-10-19T12:00:00+09:00` | f5, f1, f3, f2, f7, f4 | 6 | f4 は入る・f8 は入らない・f6 は前 |
| Q9 | `from=2026-10-19T02:00:00+09:00&to=2026-10-19T02:00:01+09:00` | f7 | 1 | ★比較は瞬間（`Date.parse`）。文字列で比べない★ |
| Q10 | `from=2026-10-19T02:30:00Z` | f8, f5 | 2 | from 側の Z も瞬間で |
| Q11 | `sort=type_date_desc` | f8, f1, f2, f7, f3, f4, f5, f6 | 8 | ★`types` 配列の順 → 新しい順 → name 昇順★ |
| Q12 | `sort=type_date_desc&types=comm,app` | f8, f1, f2, f7, f3, f4 | 6 | ★クエリの順ではなく制御盤の `types` の順★ |
| Q13 | `from=2026-10-18T12:00:00+09:00`（表2 の「直近24時間」） | f8, f5, f1, f3, f2, f7, f4 | 7 | f6 は入らない |
| Q14 | Q1 を `perPage=3, page=1` | f8, f5, f1 | 8 | ★純粋関数には任意の perPage を渡せる★（ルートの 50/100/200 は検証側） |
| Q15 | Q1 を `perPage=3, page=3` | f4, f6 | 8 | 最終ページの端数 |
| Q16 | Q1 を `perPage=3, page=4` | （空） | ★8★ | ★範囲外のページは空配列、total は正しい★ |
| Q17 | 出力の `modified_at` | f7 は ★`2026-10-19T02:00:00+09:00`★、他は入力どおり | — | ★出力は `toJstIso()`（`lib/format/date.ts:42`）で JST に揃える★（`CLAUDE.md` §9.3） |

### 表2（`lib/ds-logs/presets.ts`。now = `2026-10-19T12:00:00+09:00`）

| # | 入力 | from | to | 根拠 |
|---|---|---|---|---|
| T1 | `1h`（直近1時間） | `2026-10-19T11:00:00+09:00` | ★なし★ | D23（制御盤の時計が進んでいても最新を外さない） |
| T2 | `3h`（直近3時間） | `2026-10-19T09:00:00+09:00` | なし | D23 |
| T3 | `24h`（直近24時間） | `2026-10-18T12:00:00+09:00` | なし | D23 |
| T4 | `all`（全て） | なし | なし | |
| T5 | 日付の範囲 `2026-10-18`〜`2026-10-19` | `2026-10-18T00:00:00+09:00` | ★`2026-10-20T00:00:00+09:00`★ | ★終了日を含む★ |
| T6 | `2026-10-31`〜`2026-10-31` | `2026-10-31T00:00:00+09:00` | `2026-11-01T00:00:00+09:00` | 月またぎ |
| T7 | 開始だけ `2026-10-19` | `2026-10-19T00:00:00+09:00` | なし | |
| T8 | 終了だけ `2026-10-18` | なし | `2026-10-19T00:00:00+09:00` | |
| T9 | `2026-10-19`〜`2026-10-18`（逆） | ★`null`（不正）★ | — | 画面が「開始日は終了日以前にしてください」を出し、取得しない |
| T10 | now を UTC で渡す（`2026-10-19T03:00:00Z`）・`1h` | `2026-10-19T11:00:00+09:00` | なし | ★実行環境のタイムゾーンに依存しない★ |

### 表3（制御盤トークン。関数は `lib/ds-agent/tokens.ts`、API は `app/api/ds-devices/[serial_number]/agent-token/route.ts`。ストアは `mkdtemp`）

| # | 入力 | 期待 | 根拠 |
|---|---|---|---|
| K1 | `issueAgentToken("DS-002")` | 戻り値の `token` が `/^[A-Za-z0-9_-]{43}$/`・`issued_at` が `+09:00` の ISO。ストアに `DS-002: { token_sha256: 64桁の16進, issued_at }`。★ストアのファイルの中身に生のトークンの文字列が無い★ | D7 |
| K2 | `verifyAgentAuthorization("Bearer " + K1 の値)` | `"DS-002"` | |
| K3 | `"Bearer "` ＋ 形は正しいが違う43文字 | `null` | |
| K4 | `null` / `""` | `null` | |
| K5 | K1 の値だけ（`Bearer ` なし） | `null` | |
| K6 | `"bearer " + 値`（小文字）／`"Bearer  " + 値`（空白2つ）／末尾に空白 | `null`（★厳密に `Bearer` ＋空白1つ★） | D22 |
| K7 | ストアが空（未登録） | `null` | |
| K8 | `revokeAgentToken("DS-002")` の後に K2 | `null` | |
| K9 | `DS-002` を再発行 | ★古い値は `null`、新しい値は `"DS-002"`★ | D16 |
| K10 | `DS-002` と `DS-003` を発行 | それぞれの値がそれぞれのシリアルを返す | |
| K11 | ストアの1件の `token_sha256` が壊れている（64桁の16進でない） | ★その1件だけ無視し例外にしない★。他の制御盤は通る | |
| K12 | 検証した後に ★別のストアのインスタンスから★（★同じパスに作った2つ目のストア★）失効させる（ファイルの mtime が変わる） | 次の検証は `null`（★キャッシュは mtime で無効になる★） | D22・★§11-4 M13（ストアとパスを一緒に差し替える）★ |
| KA1 | `POST /api/ds-devices/DS-002/agent-token`（cookie） | ★201★ `{ token, issued_at }`。`token` は K1 の形。★ストアには SHA-256 だけ★ | D16 |
| KA2 | もう一度 POST | 201・新しい値。★1回目の値で `verifyAgentAuthorization` が `null`★ | D16 |
| KA3 | `DELETE /api/ds-devices/DS-002/agent-token` | ★204★。以後 K2 は `null` | D16 |
| KA4 | 未発行の `DELETE` | ★204★（冪等） | ★P2（Q15 採用）★ |
| KA5 | `POST /api/ds-devices/DS-999/agent-token`（制御盤に無い） | ★404 `not_found`★。ストアは変わらない | D23 |
| KA6 | パスのシリアルが101文字 | ★422 `validation_failed`★ | D17 |
| KA7 | `GET /api/ds-devices/DS-002/agent-token` | 発行後 `{ issued_at: "…+09:00" }`／未発行 `{ issued_at: null }`。★応答に `token` / `token_sha256` が無い★ | ★P1（Q14 採用）★ |
| KA8 | KA1〜KA3 のログ | `ds_agent.token_issued` / `ds_agent.token_revoked` が ★`serial_number` だけ★ で1行ずつ。★値に K1 の文字列が無い★ | 段階10 |

### 表4（`lib/proxy/authenticate.ts`。`deps` で IP・`AUTH_TOKEN`・トークン検証を差し替える）

| # | パス | IP | cookie | Authorization | 期待 |
|---|---|---|---|---|---|
| A1 | `/api/ds-agent/commands` | 許可 | なし | 正しい Bearer | ★allow★ |
| A2 | 同上 | 許可 | ★正しい cookie★ | なし | ★unauthenticated（401）★ |
| A3 | 同上 | 許可 | なし | 違う Bearer | unauthenticated |
| A4 | 同上 | 許可 | 正しい cookie | 違う Bearer | unauthenticated |
| A5 | `/api/ds-devices` | 許可 | なし | ★正しい Bearer★ | ★unauthenticated（Bearer を無視）★ |
| A6 | `/api/ds-devices` | 許可 | 正しい cookie | 正しい Bearer | allow（Bearer は無視） |
| A7 | `/api/ds-agent/commands` | ★拒否★ | なし | 正しい Bearer | ★deny_ip（403）★。★トークン検証を呼ばない★（スパイで0回） |
| A8 | `/api/ds-agents`（似た名前） | 許可 | 正しい cookie | なし | allow（★agent パスではない★。前方一致の誤り） |
| A9 | `/ds-logs`（画面） | 許可 | なし | 正しい Bearer | unauthenticated（redirect） |
| A10 | `/api/ds-agent` / `/api/ds-agent/` | 許可 | なし | 正しい Bearer | allow（agent パス。ルートが無いので後段で 404） |
| A11 | `/api/ds-devices/DS-002/agent-token` | 許可 | なし | ★正しい Bearer★ | ★unauthenticated★（★制御盤のトークンでトークンを作り直せない★） |

### 表5（`lib/ds-agent/relay.ts`。★時刻とタイマーを注入★。t は秒。制限値はテストで小さく差し替えてよい）

★エクスポートの状態★: 準備中（POST が待っている）→ 準備完了（201 を返した。受け取り待ち）→ 送信待ち（GET。`send` を入れた）→ 流れ中 → 完了 / 失敗 / 取り消し / 失効。★準備中〜流れ中を「進行中」と数える★。

| # | 操作 | 期待（relay の結果 → ルートの HTTP） | 根拠 |
|---|---|---|---|
| R1 | t=0 制御盤が `poll(DS-002, wait=25)`。t=1 ブラウザが `requestList(DS-002)` | poll が ★即座に★ `{ command_id, kind: "list", params: {} }`（→ 200）。t=2 `submitListResult` → ブラウザに一覧（→ 200） | |
| R2 | R1 で結果が来ない | ★t=16（依頼から15秒）で `timeout`（→ 504 `agent_timeout`）★。t=15.999 ではまだ待っている | |
| R3 | R2 の後 t=17 に `submitListResult` | `gone`（→ 410 `command_expired`） | D23 |
| R4 | poll が t=0 に始まり t=25 に指示なしで返る（→ 204）。以後リクエストなし | ★t=85 は接続中（ちょうど60秒）★、★t=85.001 は未接続（→ 409 `agent_offline`）★。`last_seen_at` は t=25 | |
| R5 | 一度もリクエストしていない | 未接続。`last_seen_at: null`・`agent_version: null` | |
| R6 | poll 中（待っている間） | 接続中（`last_seen_at` が60秒より前でも） | |
| R7 | ★poll が t=25 に終わり、以後ポーリングは無いが t=80 にチャンクの POST が来た★ | ★`last_seen_at` は t=80★。t=140 は接続中、t=140.001 は未接続。★result・prepared・chunks・complete・fail のどれでも同じ★ | ★D19（M4）★ |
| R8 | `poll` の `wait` が 0 / 26 / `abc` | ★422 `validation_failed`★ | D17 |
| R9 | 同じ制御盤の2本目の poll（1本目が待っている） | ★1本目が即座に `null`（204）★、2本目が待つ | D23 |
| R10 | ★準備★: t=0 `createExport(DS-002, [f1, f2])` | 制御盤の poll に `{ kind: "export", params: { file_ids: [f1, f2], max_bytes: EXPORT_MAX_BYTES, chunk_bytes: CHUNK_MAX_BYTES } }`。t=10 `prepared { total_bytes: 9437184, sha256, file_count: 2 }` → ★createExport が `prepared`（→ 201 `{ export_id, download_url, total_bytes: 9437184, file_count: 2, expires_in_sec: 60 }`）★ | D14 |
| R11 | 準備で prepared が来ない | ★t=50 はまだ待っている、t=50.001 で `timeout`（→ 504 `agent_timeout`）★。その後の prepared は `gone`（→ 410） | D14 |
| R12 | 準備中に制御盤が `fail { code: "too_large" }`（`file_not_allowed` / `file_not_found` / `io_error` も同じ） | ★createExport が `agent_error`（→ 502。`details.agent_code = ["too_large"]`）★ | D14 |
| R13 | prepared の `total_bytes` が ★ちょうど `EXPORT_MAX_BYTES`★ / ★`EXPORT_MAX_BYTES + 1`★ | 201 / ★502 `agent_error`（`agent_code` `too_large`）★。後者はエクスポートを失敗にする（prepared の応答は 204 のまま。後の `send` は来ない） | D15 |
| R14 | prepared（201）から ★t=+60 に claim★ / ★t=+60.001 に claim★ | 受理 / `not_found`（→ 404 `export_not_found`。失効） | |
| R15 | ★同じ export_id で2回目の claim★ | `not_found`（→ 404） | |
| R16 | パスのシリアルが違う claim（DS-003 の URL で DS-002 の export_id） | `not_found`（→ 404） | |
| R17 | ★送信★: claim → 制御盤の poll に `{ kind: "send", params: { export_command_id } }` → seq 0（4MB）→ seq 1（4MB）→ seq 2（1MB）→ `complete { total_bytes: 9437184, sha256 }` | claim が seq 0 の到着で ★ストリーム（→ 200・`Content-Length: 9437184`）★。読んだバイト列が seq 0〜2 と一致。ストリームが閉じる | D14・★D25 ①（チャンク・complete は export の指示の command_id へ。send の command_id へ送ると 404）★ |
| R18 | claim から ★t=+30 に seq 0★ / ★来ないまま t=+30.001★ | 受理 / ★`timeout`（→ 504 `agent_timeout`。まだ JSON で返せる）★。その後のチャンクは 410 | D14 |
| R19 | `send` の後・最初のチャンクの前に制御盤が `fail`（★`export_not_found` ＝ 制御盤に zip が無い★ を含む） | claim が `agent_error`（→ 502。`agent_code` は制御盤の code） | D14・★D25 ②★ |
| R20 | ★prepared の後・`send` の前★にチャンク | ★409 `chunk_out_of_order`★ | |
| R21 | seq 0 の後に ★seq 2★ | 409 `chunk_out_of_order`。★ストリームを error で閉じる★ | D23 |
| R22 | ★ハッシュ不一致★（流し始めた後） | 400 `chunk_invalid`。ストリームを error | D1 |
| R23 | ★最初のチャンクのハッシュ不一致★（まだ応答していない） | チャンクは 400。★claim は `agent_error`（→ 502。`agent_code` `chunk_invalid`）★ | D1 |
| R24 | チャンクの長さ ★ちょうど `CHUNK_MAX_BYTES`★ / ★`CHUNK_MAX_BYTES + 1`★ / ★0バイト★ | 受理 / 400 / 400 | D1 |
| R25 | ★受け取った合計が prepared の `total_bytes` を超えた★ | そのチャンクは 400 `chunk_invalid`。ストリームを error | ★D15★ |
| R26 | `complete` の `total_bytes` が prepared と違う／★受け取った合計が `total_bytes` に足りない★ | 400。ストリームを error | D15 |
| R27 | ★`complete` の `sha256` が prepared の `sha256` と違う★／★受け取ったバイト列の SHA-256 が prepared の `sha256` と違う★ | 400。ストリームを error | ★D15★ |
| R28 | 流し始めた後に制御盤が `fail`（`export_not_found` 等。★URL は export の指示の command_id★） | ストリームを error。`fail` は 204 | ★D25 ①②★ |
| R29 | 流し始めた後にブラウザが切断（`cancel` / `request.signal` の abort） | ★次のチャンクは `cancelled`（→ 410 `export_cancelled`）★ | |
| R30 | ★流量制御★: seq 0 を受けたがブラウザが読んでいない | ★seq 0 の `pushChunk` は保留（204 を返さない）★。`reader.read()` で解決する。★relay が持つチャンクは常に2つ以下★ | |
| R31 | ★R30 の保留が ★t=+45★ / ★t=+45.001★ まで続く | まだ保留 / ★410 `export_cancelled`・エクスポートは失敗（ストリームを error）★ | ★D20（M5）★ |
| R32 | 流し始めた後、★次のチャンクが来ないまま t=+45 / t=+45.001★ | 流れたまま / ★ストリームを error★。その後のチャンクは 410 | ★D20★ |
| R33 | 同じ制御盤で進行中（準備中・★prepared の後（受け取り待ち）★・送信待ち・流れ中のどれか）に2本目の `createExport` | `busy`（→ 429 `export_busy`）。1本目が完了・失敗・失効した後は受理 | ★D25 ⑤★ |
| R34 | 3台で進行中に4台目の `createExport` | `busy`（→ 429） | |
| R35 | DS-003 のトークンで DS-002 の command_id に result・prepared・チャンクを送る | `not_found`（→ 404）。★403 にしない★ | |
| R36 | ★`list` の command_id に prepared★ を送る | `not_found`（→ 404） | |
| R37 | 待ち行列に `PENDING_COMMANDS_PER_DEVICE`（5）件あるときの `requestList` / `createExport` | `busy`（→ 429 `agent_busy`） | D23 |
| R38 | 一覧の結果に `truncated: true` | そのままブラウザへ | ★D18★ |
| R39 | 状態の変化 | 未接続 → 接続中で `ds_agent.connected` を1回、接続中 → 未接続で `ds_agent.disconnected` を1回。★リクエストのたびには出さない★ | |
| R40 | ★t=0 prepared（201）。GET が来ない★ → t=60 / ★t=60.001★ | 進行中のまま（t=60 に `createExport` すると 429） / ★失効★: 進行中から外れ、★同じ制御盤の `createExport` が受理される★。claim は 404 `export_not_found`。その後に制御盤が送る `fail` / チャンクは `gone`（→ 410） | ★D25 ⑤★（制御盤は120秒で zip を消す） |
| R41 | ★エクスポートの流れ中（または準備中）に `requestList`★ | ★待ち行列に入れ、制御盤の poll に `list` が渡る★（断らない）。一覧の結果が返る | ★D25 ③★ |
| R42 | ★`send` の指示の command_id★ にチャンク・`complete` を送る | `not_found`（→ 404） | ★D25 ①★ |
| R43 | result / prepared / complete / fail の本体の ★形の誤り★（JSON でない・必須の項目が無い・型が違う） | ★400 `result_invalid`（4つとも同じ）★。状態は変えない | ★D25 ④★ |
| R44 | result / prepared / complete / fail の本体が ★JSON だがオブジェクトでない★（`null`・`[]`・`0`・`"x"`） | ★400 `result_invalid`（4つとも同じ。`isJsonObject()` で判定）★。★500 にしない★。状態は変えない | ★D25 ④・§11-4 M7 (d)★ |

### 表6（`lib/ds-logs/file-name.ts` の `exportFileName(serial, now)`）

| # | 入力 | 期待 |
|---|---|---|
| N1 | `DS-002`・`2026-10-19T12:34:56+09:00` | `DS-002_logs_20261019-123456.zip` |
| N2 | 同じ瞬間を `2026-10-19T03:34:56Z` で | 同上（★JST★） |
| N3 | `2026-10-19T15:00:00Z` | `…_20261020-000000.zip`（日付が変わる） |
| N4 | シリアル `DS 002/"x"` | `DS_002__x__logs_….zip`（D23） |

★**テストの書き方の注意**★:
- ★日時は `+09:00` 付きの ISO 文字列で書く★。`new Date(2026, 9, 19)` のような実行環境のタイムゾーンに依存する書き方をしない（表2 T10）
- 表5 は ★本物のタイマーで待たない★。`createRelay({ now, setTimer, clearTimer, limits })` に偽の時計を渡し、`advance(秒)` で進める（`vi.useFakeTimers()` でもよいが、`now` と `setTimer` を両方とも同じ偽の時計から取ること）
- 表5 の 4MB・9MB は ★`limits.chunkMaxBytes` を小さく（例 16 バイト）差し替えて★ 同じ規則を確かめてよい。★ただし R13・R24 の境界（ちょうど・+1）と R11・R14・R18・R31・R32 の秒の境界は、差し替えた値で必ず書く★
- ★期待値は設定値から作る★（`CHUNK_MAX_BYTES + 1`・`EXPORT_PREPARE_TIMEOUT_SEC` 等）。★R21〜R23・R27 の「わざと違う値」だけは直書きし、コメントで理由を書く★
- 表4 の「正しい Bearer」は `deps.verifyAgentToken` のスタブで作る（ストアに触らない）。★A7 はスタブが呼ばれていないこと（`toHaveBeenCalledTimes(0)`）を見る★
- 表3 は `__setAgentTokenStoreForTesting()`（新規。`ds-device-notes.ts` の `__setDeviceNotesStoreForTesting()` に倣うが、★ストアとパスを一緒に渡す★。段階4・§11-4 M13）で `mkdtemp` のストアに差し替える。★`data/settings/` の実物に触らない★。KA1〜KA8 はルートのテスト（段階7-4）
- ★表5 R44 は `null`・`[]`・`0`・`"x"` の4つを `it.each` で、result / prepared / complete / fail の4ルートすべてに当てる★（ルートのテスト。段階7-4）
- ★ディスクに書かないことのテスト★（D3）は 段階7-4 のとおり

---

## 4. ★落とし穴（全段階で読むこと）★

| # | 罠 | どう避けるか |
|---|---|---|
| 4-1 | ★proxy は本体を 10MB までメモリにバッファし、超えた分を切り捨てる。クライアントにはエラーにならず、サーバーに警告が1行出るだけ★（§1 の出所） | ★チャンクは `CHUNK_MAX_BYTES`（4MB）以下★。★長さとハッシュを必ず検証する★。★`experimental.proxyClientMaxBodySize` を上げて解決しようとしない★。★agent パスを matcher から外さない★（`proxy.ts:106-107`） |
| 4-2 | ★relay がモジュールのシングルトンであること★ | ★`globalThis.__dwatchDsAgentRelay` に置く★（`lib/db/prisma.ts` と同じ形。`declare global`）。★ルートごとにモジュールが別に読み込まれても同じ relay を見る★（推測。段階11-3 の通し確認で、依頼と結果が別ルートで噛み合うことを確かめる）。★1プロセス前提を壊す変更（cluster・PM2 の複数インスタンス・`next start` の複数起動）をしない★ |
| 4-3 | ★全チャンクを配列に溜める★ | ★流量制御★: `new ReadableStream({ pull, cancel }, { highWaterMark: 1 })`。`pushChunk` は `enqueue` した後、★`desiredSize > 0` になる（`pull` が呼ばれる）まで 204 を返さない★（★最大 45秒。超えたら 410。D20★） |
| 4-4 | ★応答を流し始めた後にステータスを変えようとする★ | ★最初のチャンクが届くまで `Response` を返さない★（それまでの失敗は JSON の 504 / 502）。★流し始めた後の失敗は `controller.error()`★。ログに残す |
| 4-5 | ★ダウンロードの応答ヘッダの付け忘れ★ | ★`Content-Type: application/zip`・`Content-Disposition: attachment; filename="…"; filename*=UTF-8''…`・`Cache-Control: no-store`・`X-Accel-Buffering: no`・`Content-Length: total_bytes`★ を必ず付ける（nginx に応答をバッファさせない＝一時ファイルに書かせない）。ルートのテストで5つとも見る |
| 4-6 | ★サーバーのディスクに書く★（一時ファイル・`os.tmpdir()`・`fs.mkdtemp`・`createWriteStream`） | ★禁止★（D3）。`lib/ds-agent/relay.ts`・`lib/ds-logs/**`・`app/api/ds-agent/**`・`app/api/ds-devices/[serial_number]/logs/**` は ★`node:fs` / `node:os` を import しない★（段階7-4 で確かめる）。★トークンのストア（`tokens.ts`）だけは書いてよい★（発行・失効のときだけ） |
| 4-7 | ★認証の取り違え★ | ★agent パスは cookie で通さない。通常パスは Bearer で通さない★（表4）。★ヘッダで serial を受け取らない★。ルートは `requireAgent()` で再検証し、★トークンから serial を得る★。★command_id が別の制御盤のものなら 404★（表5 R35）。★トークンの発行・失効は cookie のパス★（表4 A11） |
| 4-8 | ★トークン・中身をログやストアに残す★ | ★生のトークンは保存しない（SHA-256 だけ）★。★出すのは発行の POST の応答の1回だけ★（画面の state にも閉じたら残さない・`localStorage` に入れない）。★ファイルの中身・ファイル名の一覧全体をログに出さない★。イベントのキー名に `token` / `authorization` / `note` / `detail` を使わない（`lib/logger/redact.ts` が本番で消す） |
| 4-9 | ★ブラウザ・制御盤の切断に気づかない★ | ★`ReadableStream` の `cancel()` と `request.signal` の `abort` の両方★でエクスポートを取り消す（次のチャンクは 410）。★ポーリングと準備の POST の `request.signal` も見る★（ポーリングが切れたら待ちを外す。準備の POST が切れたらエクスポートを取り消す）。<br>★`request.signal` は `Request.signal`（Web 標準の AbortSignal。★Next.js の docs には記述が無い★。`03-file-conventions/route.md` にも signal・abort の記述は無い）★。★ブラウザの切断で abort が来ることは推測★なので、★段階11-3 の非 fixtures の通し確認（ダウンロードを途中で取り消す）で必ず確かめる★。★来ないなら、書き込みの失敗（`controller.enqueue` 後の書き込みエラー）や `cancel()` コールバックで検知する★ |
| 4-10 | ★長い待ちが nginx の60秒を超える★ | ★準備の POST は最大 50秒（`EXPORT_PREPARE_TIMEOUT_SEC`）・チャンクの保留とチャンクの間隔は最大 45秒・ダウンロードの最初のチャンクは最大 30秒・ポーリングは最大 25秒・一覧は最大 15秒★。★設定値を上げて 60秒以上にしない★（D14・D20） |
| 4-11 | ★`Content-Length` と実際のバイト数が食い違う★ | `Content-Length` は ★prepared の `total_bytes`★。★超えるチャンクは受けない（R25）★。足りないまま終わるのは失敗のときだけ（ブラウザ側はダウンロード失敗になる。それでよい） |
| 4-12 | ★「実測」と書くときに出所を書かない★ | `CLAUDE.md` §6-6。★「当方の fixtures 由来」「当方の偽制御盤スクリプト由来」「構造（コード・`node_modules`）」★を書き分ける |
| 4-13 | ★JP/EN・ダーク/ライト★ | 辞書のキーは ★日本語の原文★（`lib/i18n.ts`）。★既にあるキーは足さない★（重複は lint で落ちる。`閉じる`・`キャンセル`・`読み込み中…`・`種類`・`日付` は既にある）。★`全て` は `7492cc5` の辞書に無い（EN でも「全て」のまま）ので `全て: "All"` を足す★ — ★既存の D-Sec ログの「全て」ボタン（`DsLogScreen.tsx:410`）も EN で "All" になる。これは段階9-1 の「今の画面を変えない」の例外として認める（M3）★。★色は CSS 変数★。★`components/` はテスト対象外なので画面は目視で確かめる★ |
| 4-14 | ★`t()` に生値を通す★ | ファイル名・種類のラベル（制御盤から来る）・シリアル・施設名・★トークン★は ★`t()` に通さない★ |
| 4-15 | ★シリアルの照合を緩める★ | ★relay の鍵・トークンのシリアル・パスのシリアルは JS の `===`★（D-4）。大小文字を揃えない・trim しない |
| 4-16 | ★設定値の直書き★ | ★`lib/config/ds-agent.ts` から読む★。★このファイルは `node:` の import を持たない★（画面からも import する） |
| 4-17 | ★`modified_at` を文字列で比べる・並べる★ | ★`Date.parse()` の瞬間で比べる★（表1 Q9・Q10）。★オフセットの無い値は制御盤の結果ごと不正★（段階7-2） |
| 4-18 | ★制御盤の結果を検証せずに返す★ | ★制御盤の応答は外から来たデータ★。形・長さ・型を検証し、違えば 502 `agent_error`（段階7-2）。★ファイル名は React の通常の描画だけ★（`dangerouslySetInnerHTML` を使わない） |
| 4-19 | ★`git add -A`・`eslint-disable` の追加★ | 禁止（`CLAUDE.md` §7）。★他のセッションの未コミットの変更を含めない★（★`7492cc5` の時点で `components/features/errors/ErrorContentPicker.tsx`・`lib/errors/error-content-label.ts`・`lib/errors/__tests__/error-content-label.test.ts` に他のセッションの未コミットの変更がある★）。<br>★例外は1つだけ★: 段階9-2 の一覧の取得を M4 の作法（`useEffect` の中で `setLoading(true)`）で書くと `react-hooks/set-state-in-effect` が出るので、★`DsLogScreen.tsx:182` と同じ形の1行（`// eslint-disable-next-line react-hooks/set-state-in-effect -- 理由`）は足してよい★（BC 段階1 で全画面がそろえた形。他の規則の抑止は足さない） |
| 4-20 | ★「ログ」列を足して D-Sec ログの表に横スクロールが出る・ボタンの位置が JP/EN で動く★ | ★D-167（全列に幅があり、余りは幅の比で全列に配られる。ユーザーが決めた作り）を崩さない★ — 「ログ」列にも `<col>` で幅（例 72px）を付け、★余りの配分で列が少し広がるのは認める★。★ボタンを固定幅にして（列の中で寄せる位置も固定）、JP/EN で幅と位置が変わらないようにする★。<br>★表の最小幅は列幅の合計に合わせる★: `DsLogScreen.module.css:205` の `min-width` を ★800px → 872px★（72px の列のとき。★実際に付けた列幅で計算する★）、★`:199` のコメントの式も「施設名 200px ＋ 固定5列 672px」のように直す★。段階9-1・11-2（M1） |
| 4-21 | ★備考の編集モード中に「ログ」ボタンを押す★ | ★押せない（U6。ユーザー回答 2026-11-05「編集中は押せない」）★。★絞り込み・ページ送りと同じ `filtersDisabled`（`DsLogScreen.tsx:221`）で無効にする★（`disabled={filtersDisabled}`。表の行は `<fieldset>` の外なので ★ボタンに直接付ける★）。★下書きを守るため★。保存かキャンセルで編集モードを抜けた後に押せる。段階9-1・11-2 で確かめる |
| 4-22 | ★`COLUMN_COUNT` を直し忘れる★ | ★`DsLogScreen.tsx:66` の `COLUMN_COUNT = 5` を ★6★ にする★（保存に失敗した行の `colSpan`（`:524`）に使う）。★直し忘れると、保存に失敗した行の文言が「ログ」列の手前で切れる★（M1） |
| 4-23 | ★`apiFetch()` で本文の無い応答を読む・取り消しをエラーと出す★ | ★トークンの失効（`DELETE …/agent-token` → 204）は `apiFetch()` を使わず素の `fetch()`★（`apiFetch` は 204 を `ApiFetchError` にする。`lib/api/client.ts:129-150`。前例 `app/login/page.tsx:34`）。★モーダルを閉じて準備の POST を `AbortController` で取り消したときの `ApiFetchError`（`status: 0`）はエラー表示しない★（M5） |
| 4-24 | ★存在しない日付を通す★ | `Date.parse("2026-02-30T00:00:00+09:00")` は ★NaN にならず、3/2 00:00 JST（UTC で `2026-03-01T15:00:00Z`）の瞬間を返す★（2026-11-05 監督が node で実測。出所: 構造）。★`toJstIso()` で戻して入力の文字列全体と比べる方法は、`Z` など `+09:00` 以外のオフセットの正しい入力（表1 Q10）まで弾くので使わない★。★日付の部分（先頭10文字）を `parseDate()`（`lib/clean-logs/query.ts:63`。export 済み）に通して確かめる★（BB の規則。段階3-3 V2・段階7-2 W4。M7 (c)） |

---

## 段階0 — 着手前

1. ★`develop` で作業する★（`.claude/worktrees/` の中ではない。ワークツリーは main 基点で古い）。`git pull` で最新にし、基点のコミットを冒頭の欄に書く。★`7492cc5` からずれていたら、4 の確かめ直しを省かない★。
2. `git status` を控える。★自分が作っていない変更・未追跡ファイルは他のセッションの作業★。触らない・コミットに含めない（★`7492cc5` の時点では `ErrorContentPicker.tsx` ほか3件。4-19★）。
3. ★`grep -n "^### D-1[78][0-9]" docs/decisions.md` で D-179 の末尾（2026-11-05 の追記の後ろ・D-180 の前）の位置を控える★。★`docs/decisions.md` に他のセッションの未コミットの差分があれば、§13 の追記は `git add -p` で自分の塊だけを選ぶ★。
4. ★本書に書いた行番号を develop の先端ですべて確かめ直す★（特に `d-watch-api.yaml`・`lib/types/index.ts`・`lib/i18n.ts`・`lib/fixtures/index.ts`・`lib/logger/events.ts`・`DsLogScreen.tsx` / `.module.css`・`lib/errors/error-content-picker.ts` — 他の指示書が触る）。ずれは作業報告に書く。
5. `npx prisma generate` → `npx tsc --noEmit -p .` → `npm test` が通ることを確かめ、★テストの件数を記録する★（`7492cc5` の作業ツリーで ★157ファイル・3,855件★。§15）。通らなければ着手前に報告。
6. ★Next.js は版が新しい（16.4.2）★。次を読む（`CLAUDE.md` 末尾）: `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md`（`async` にできること `:31`）・`…/05-config/01-next-config-js/proxyClientMaxBodySize.md`・`…/01-getting-started/15-route-handlers.md`（★GET はキャッシュされない★。`app/api/settings/display-limits/route.ts:27-36`）・`…/03-file-conventions/route.md`（ストリーミング。Streaming の節 `:367`）。★`request.signal` は route.md に記述が無い★（Web 標準の `Request.signal`。4-9）。

---

## 段階1 — 期待値表をテストにする（実装より先）

★この時点ではテストが落ちる（関数・型が無い）のが正しい★。★コミットはテストだけで1つ（「テストだけ。この時点では落ちる」と明記）★。
各ファイルの冒頭コメントに対応する表を写し、「指示書AY §3 表N。ブリーフ §5 と監督判断 第2ラウンドから本書で具体化。★P1・P2 の行は §11 Q14・Q15 の採用で確定★」と書く。

1. `lib/ds-logs/__tests__/query.test.ts`（新規）: 表1 Q1〜Q17（`it.each`）。★Q14〜Q16 は純粋関数を直接呼ぶ★
2. `lib/ds-logs/__tests__/presets.test.ts`（新規）: 表2 T1〜T10
3. `lib/ds-logs/__tests__/file-name.test.ts`（新規）: 表6 N1〜N4
4. `lib/ds-agent/__tests__/tokens.test.ts`（新規）: 表3 K1〜K12（`mkdtemp`。`afterEach` で消す）
5. `lib/proxy/__tests__/authenticate.test.ts`（★足す★）: 表4 A1〜A11。★既存のテストは無修正で通ること★
6. `lib/ds-agent/__tests__/relay.test.ts`（新規）: 表5 R1〜R43（★R44 はルートで判定するので段階7-4 のルートのテスト★）
7. `lib/ds-logs/__tests__/validate.test.ts`（新規）: 段階3-3 の V1〜V10 と X1〜X6（パスパラメータ）、段階7-2 の W1〜W11

（表3 KA1〜KA8 のルートのテストは、ルートの形が段階7 で決まるので段階7 で書く）

---

## 段階2 — API 契約（`d-watch-api.yaml`。★実装より先。`CLAUDE.md` §6-1★）

### 2-1. 足すもの

1. `components.securitySchemes`（`:2104`。`cookieAuth` `:2105`）に ★`agentBearer`★（`type: http`・`scheme: bearer`。description: 制御盤ごとのトークン。`/ds-agent/` 配下だけ。cookie では通らない。D-179）
2. タグ `ds-agent`（制御盤向け）
3. パス（`/ds-devices/{serial_number}/note`（`:1765`）の後ろ・`/ds-alerts` の区切りコメント（`:1812`。`/ds-alerts` は `:1813`）の前に置く）。★ブラウザ向けの検証エラーは 422 `validation_failed`（D17）★。`components.responses` の既存の応答（`Unauthorized` ほか `:2234` から）を使えるものは使う:

| パス | メソッド | security | 要点 |
|---|---|---|---|
| `/ds-devices/{serial_number}/logs` | GET | cookieAuth（既定） | クエリ `types`（カンマ区切り。省略・空で全種類）・`from` / `to`（ISO 8601 ★オフセット必須・存在する日付★。`from ≤ modified_at < to`。空は省略と同じ）・`sort`（`date_desc` 既定 / `type_date_desc`。★description に「この API の `sort` は向きを持たない1引数。清掃詳細ログ等の `sort` / `order` とは別」と書く（M9）★）・`page`（1〜）・`per_page`（★1〜200。不正値は既定値・上限へ丸める（既存の一覧と同じ）。既定 200。画面の選択肢は 50 / 100 / 200（M7）★）。200 は `DsLogListResponse`（★`truncated` を含む。D18★）。409 `agent_offline`（`details.last_seen_at` に最終接続。無ければ空配列）・504 `agent_timeout`・502 `agent_error`（`details.agent_code`）・429 `agent_busy`・404 `not_found`（★description は note と同じ文「一覧に出ない制御盤(シリアルが無い・論理削除・controls.status が 0/1 以外。D-182)」。M11★）・422 |
| `/ds-devices/{serial_number}/logs/exports` | POST | cookieAuth | 本体 `{ file_ids: string[] }`（1〜`EXPORT_MAX_FILES` 件・重複なし・各 1〜1024 文字。★本体が JSON でもオブジェクトでなければ 422「JSONの形式が不正です」（note と同じ。M7 (d)）★）。★201★ `{ export_id, download_url, total_bytes, file_count, expires_in_sec }`。★最大 50秒待つ（制御盤が zip を作る）★。502 `agent_error`（`too_large` / `file_not_allowed` / `file_not_found` / `io_error` 等）・504・409・429 `export_busy` / `agent_busy`・404（★note と同じ文。M11★）・422 |
| `/ds-devices/{serial_number}/logs/exports/{export_id}` | GET | cookieAuth | ★1回だけ使える★。`export_id` は ★1〜`PATH_EXPORT_ID_MAX_LENGTH` 文字（文字種は絞らない。範囲外は 422。M8）★。200 は `application/zip`（`format: binary`）と ★5つのヘッダ（4-5）★。504 `agent_timeout`（30秒）・502 `agent_error`・404 `export_not_found`（無い・失効・使用済み・★パスのシリアルと違う★）・422。description に ★流し始めた後の失敗はダウンロードの失敗になる（ステータスは200のまま）★・★JSON のエラーはまれ（制御盤の切断など）で、ブラウザによってはファイルとして保存される（D21）★ |
| `/ds-devices/{serial_number}/agent-token` | GET | cookieAuth | ★P1（Q14 採用）★。200 `{ issued_at: string \| null }`。★トークン・ハッシュは返さない★。404（★description は note と同じ文。`dsDeviceExists()` の中身が D-182 で変わった。M11★）・422 |
| 同上 | POST | cookieAuth | 本体なし。★201★ `{ token, issued_at }`。description: ★生の値はこの応答で1回だけ・既にあれば作り直して古いものは無効・保存は SHA-256 だけ（D16）★。404（同上）・422 |
| 同上 | DELETE | cookieAuth | ★204★（★未発行でも 204（冪等）。P2（Q15 採用）★）。404（同上）・422 |
| `/ds-agent/commands` | GET | ★agentBearer★ | `wait`（1〜25。既定 25）・ヘッダ `X-Agent-Version`。200 `DsAgentCommand`（★`kind` は `list` / `export` / `send`★）/ 204（指示なし）/ 401 / 422 |
| `/ds-agent/commands/{command_id}/result` | POST | agentBearer | 本体 `DsAgentListResult`（★4MB 以下★）。204 / 400 `result_invalid`（★オブジェクトでない本文も。R44★）/ 404 / 410 `command_expired`。★`command_id` は1〜`PATH_COMMAND_ID_MAX_LENGTH` 文字（範囲外は 422。M8。以下の4本も同じ）★ |
| `/ds-agent/commands/{command_id}/prepared` | POST | agentBearer | ★本体 `{ total_bytes, sha256, file_count }`★（export の指示だけ）。204 / 400 `result_invalid` / 404 / 410 `command_expired` / 422 |
| `/ds-agent/commands/{command_id}/chunks/{seq}` | POST | agentBearer | ★export の指示の command_id（D25）★。★`seq` は0以上の整数（範囲外は 422。M8）★。`application/octet-stream`・ヘッダ `X-Chunk-Sha256`（小文字の16進64桁）。204（★ブラウザ側に空きができてから。最大45秒★）/ 400 `chunk_invalid` / 409 `chunk_out_of_order` / 410 `export_cancelled` / 404 / 422 |
| `/ds-agent/commands/{command_id}/complete` | POST | agentBearer | ★export の指示の command_id（D25）★。`{ total_bytes, sha256 }` → 204 / 400 `result_invalid`（形の誤り・値の不一致）/ 410 `export_cancelled` / 404 / 422 |
| `/ds-agent/commands/{command_id}/fail` | POST | agentBearer | `{ code, message }`（code は 1〜64 文字・message は 1〜500 文字。★送信中の失敗は export の指示の command_id。制御盤に zip が無いときは `export_not_found`（D25）★）→ 204 / 400 `result_invalid` / 404 / 410 `export_cancelled` / 422 |

4. スキーマ: `DsLogType { key, label }`・`DsLogFile { file_id, name, type, size_bytes, modified_at }`・`DsAgentStatus { connected, last_seen_at, agent_version }`・
   `DsLogListResponse { agent, types, items, truncated, meta }`・`DsLogExportRequest`・`DsLogExportResponse { export_id, download_url, total_bytes, file_count, expires_in_sec }`・
   `DsAgentTokenStatus { issued_at }`・`DsAgentTokenIssued { token, issued_at }`・
   `DsAgentCommand { command_id, kind: [list, export, send], params }`・`DsAgentListResult`（`{ types, items, truncated }` または `{ error: { code, message } }`）・`DsAgentPrepared`・`DsAgentComplete`・`DsAgentFail`
5. ★各パスの description に★: ★サーバーには保存しない（D-179）★／一覧は制御盤からもらい D-Watch が絞る（D8）／★1プロセス前提・再起動で進行中は失われる★／★チャンクが 4MB 以下である理由（proxy の 10MB バッファ）★／★エクスポートは準備 → 送信の2段（D14）★

### 2-2. 型とエラーコード — `lib/types/index.ts`

- 上のスキーマと同名の型を足す（`CLAUDE.md` §9.2。`DsDevice`（`:1132`）の近く）
- ★`ErrorCode`（`:32-46`）に足す★: `agent_offline` / `agent_timeout` / `agent_error` / `agent_busy` / `export_busy` / `export_not_found` / `chunk_invalid` / `chunk_out_of_order` / `export_cancelled` / `command_expired` / `result_invalid`。★`validation_error` は足さない（D17）★

コミット（YAML と型で1つ）。

---

## 段階3 — 設定値と純粋関数

### 3-1. `lib/config/ds-agent.ts`（新規。★`node:` を import しない・`lib/config/index.ts` を import しない★）

| 名前 | 値 | 意味 |
|---|---|---|
| `AGENT_POLL_WAIT_SEC` | 25 | ポーリングの待ちの上限（`wait` の最大・既定） |
| `AGENT_OFFLINE_AFTER_SEC` | 60 | 最後のリクエストからこれを ★超えたら★ 未接続（D19） |
| `LIST_TIMEOUT_SEC` | 15 | 一覧の結果を待つ上限 |
| `EXPORT_PREPARE_TIMEOUT_SEC` | ★50★ | ★制御盤が zip を作り終えるまで（POST が待つ上限。nginx の60秒より短く）★ |
| `EXPORT_CLAIM_TIMEOUT_SEC` | 60 | 201 から ダウンロード URL を開くまでの上限（`expires_in_sec`） |
| `EXPORT_FIRST_CHUNK_TIMEOUT_SEC` | 30 | 開いてから最初のチャンクまで |
| `EXPORT_IDLE_TIMEOUT_SEC` | ★45★ | ★流し始めた後のチャンクの間隔の上限、かつチャンクの POST を保留する上限（D20）★ |
| `CHUNK_MAX_BYTES` | `4 * 1024 * 1024` | 1チャンクの上限（proxy の 10MB より十分小さく） |
| `EXPORT_MAX_BYTES` | `100 * 1024 * 1024` | ★1回の zip の上限（仮。D-179 追記 項5）★ |
| `EXPORT_MAX_FILES` | 500 | 1回に選べるファイル数 |
| `EXPORT_CONCURRENCY_PER_DEVICE` | 1 | 制御盤ごとの同時エクスポート |
| `EXPORT_CONCURRENCY_TOTAL` | 3 | 全体の同時エクスポート |
| `LIST_RESULT_MAX_BYTES` | ★`4 * 1024 * 1024`★ | 一覧の結果の本体の上限（D18） |
| `LIST_RESULT_MAX_ITEMS` | 20000 | 一覧の件数の上限（制御盤はここで打ち切り `truncated`） |
| `PENDING_COMMANDS_PER_DEVICE` | 5 | 制御盤ごとの待ち行列の上限 |
| `DS_LOG_PER_PAGE_OPTIONS` | `[50, 100, 200]` | ★画面の★ページあたり件数の選択肢（★`PAGINATION.maxPerPage`（`lib/config/index.ts:470`）と同じ 200 が上限★）。★API の検証には使わない（API は 1〜200 に丸める。M7）★ |
| `DS_LOG_DEFAULT_PER_PAGE` | 200 | 既定（★`per_page` が省略・空・数でないときの値★） |
| `PATH_EXPORT_ID_MAX_LENGTH` | 64 | ★パスの `export_id` の長さの上限（M8）★。`crypto.randomUUID()` の36文字を受けられる長さ。★文字種は絞らない★ |
| `PATH_COMMAND_ID_MAX_LENGTH` | 64 | ★パスの `command_id` の長さの上限（M8）★。同上 |

各定数の docstring に ★「何の上限か・なぜその値か・出所（D-179・監督判断 第2ラウンド M1〜M5）」★ を書く。★リクエストを待たせる5つ（`LIST_TIMEOUT_SEC` 15・`AGENT_POLL_WAIT_SEC` 25・`EXPORT_FIRST_CHUNK_TIMEOUT_SEC` 30・`EXPORT_IDLE_TIMEOUT_SEC` 45・`EXPORT_PREPARE_TIMEOUT_SEC` 50）には「nginx の `proxy_read_timeout`（既定60秒）より短くすること」★ と書く。

### 3-2. `lib/ds-logs/query.ts`（新規。純粋関数）

```ts
export interface DeviceLogQuery {
  types: readonly string[] | null;   // null = 全種類
  from: number | null;               // 瞬間（ms）。from ≤ t
  to: number | null;                 // t < to
  sort: "date_desc" | "type_date_desc";
  page: number;                      // 1〜
  perPage: number;
}
/** 表1。★入力の配列を書き換えない★。並べ替えの同値は name の `<` / `>`（`localeCompare` を使わない） */
export function queryDeviceLogs(
  types: readonly DsLogType[], items: readonly DsLogFile[], query: DeviceLogQuery
): { items: DsLogFile[]; total: number }
```

- ★`modified_at` は `Date.parse()` で比べる★。出力の `modified_at` は ★`toJstIso(new Date(ms))`★（表1 Q17。秒まで）
- `type_date_desc` の種類の順は ★`types` 配列の添字★。★`types` に無い種類の項目は来ない★（段階7-2 の検証で弾く）

### 3-3. クエリ・本体・パスの検証（`lib/ds-logs/validate.ts`。新規。`lib/api/query-result.ts` の `QueryParseResult` を返す。★エラーは 422 `validation_failed`★）

★2026-11-05 に監督が改めた（§11-4 M7・M8）★。旧: 空の `types=`・`per_page=30`・`page=0`・`page=1.5` を 422 にしていた — ★既存の一覧 API の作法（空は省略と同じ・page / per_page は丸める）と食い違うので改めた★。

★作法の手本★: ★`page` / `per_page`★ は `lib/ds-devices/query.ts:56-60`（`toInt()` ＋ `Math.max(1, …)` ＋ `Math.min(PAGINATION.maxPerPage, …)`。★page / per_page を持つ既存の7つの query.ts（clean-logs・ds-alerts・ds-devices・error-reports・facilities・prechecks・schedules）が同じ書き方★。`reports` の `limit` も同じ考え方）。既定は `DS_LOG_DEFAULT_PER_PAGE`（200）。
★空文字★ は BH の作法（`sort=` / `sort=%20` は省略と同じ。指示書BH S2-2）。★`from` / `to` の存在しない日付★ は BB の規則（`parseDate()`。4-24）。

| # | 入力 | 期待 |
|---|---|---|
| V1 | パラメータなし | `{ types: null, from: null, to: null, sort: "date_desc", page: 1, perPage: 200 }` |
| V2 | `from=2026-10-19`（日付だけ）／`from=2026-10-19T11:00:00`（★オフセットなし★）／`from=abc` | 422 |
| V2b | ★`from=2026-02-30T00:00:00+09:00`／`to=2026-02-29T00:00:00Z`（★2026年は平年★）／`from=2026-13-01T00:00:00+09:00`（★存在しない日付★）★ | ★422★（`Date.parse` は NaN にならないので、日付の部分を `parseDate()` で確かめる。4-24） |
| V2c | `from=2026-10-19T02:30:00Z`／`from=2026-10-19T02:30:00+00:00` | ★通る★（`+09:00` 以外のオフセットも正しい入力。表1 Q10。★V2b の確かめ方がこれを弾かないこと★） |
| V3 | `from` ＞ `to` | 422。★`from` ＝ `to` は通る（0件）★ |
| V4 | ★`types=`・`types=%20`・`from=`・`to=`・`sort=`（空・空白だけ）★ | ★省略と同じ★（V1 と同じ値。★422 にしない★） |
| V4b | `types=app,,sys`／`types=app,`（★空でない並びの中の空の要素★） | 422（★V4 の「全体が空」とは別。打ち間違いを黙って通さない★） |
| V5 | `sort=name` | 422 |
| V6 | ★`per_page=30` → 30・`per_page=500` → 200・`per_page=0` → 1・`per_page=abc` → 200・`page=0` → 1・`page=1.5` → 1・`page=abc` → 1★ | ★422 にせず丸める★（手本の書き方のとおり。`toInt()` は `Number.parseInt` なので `1.5` は 1） |
| V7 | POST の本体 `{ file_ids: [] }` / 501件 / 重複あり / 要素が文字列でない / 1025文字 | 422 |
| V8 | ★POST の本体が JSON だがオブジェクトでない★（`null`・`[]`・`1`・`"x"`） | ★422「JSONの形式が不正です」★（`isJsonObject()`。note と同じ文。M7 (d)） |
| V9 | POST の本体が JSON でない（`{`） | 422「JSONの形式が不正です」（note と同じ） |
| V10 | `wait=0`・`wait=26`・`wait=abc`（制御盤向け `GET /ds-agent/commands`） | 422（表5 R8。★これは丸めない★ — 制御盤のプログラムの誤りを早く見つけるため） |

★パスパラメータ（M8）★: `export_id`・`command_id` は ★`parsePathReportId()`（`lib/api/path-params.ts:107-114`）と同じ作法★ — ★空と上限超だけ 422・文字種は絞らない・`Number()` を通さない・入力値をエコーしない★。上限は `lib/config/ds-agent.ts` の `PATH_EXPORT_ID_MAX_LENGTH` / `PATH_COMMAND_ID_MAX_LENGTH`（D9。`lib/config/index.ts` に足さない）。
★`seq` は 0 を許す整数★なので ★`parsePathId()`（1以上）は使わない★。専用の検証（`/^(0|[1-9][0-9]{0,8})$/`。先頭の 0・符号・小数・空白を通さない）を `validate.ts` に書く。

| # | 入力 | 期待 |
|---|---|---|
| X1 | `export_id` / `command_id` が `crypto.randomUUID()` の形（36文字） | 通る |
| X2 | 空文字・`PATH_*_MAX_LENGTH + 1` 文字 | 422（★入力値を `message` / `details` に入れない★） |
| X3 | `export_id=abc`（形は違うが長さは範囲内） | ★通る★（文字種は絞らない。relay で見つからず 404 `export_not_found`） |
| X4 | `seq=0`・`seq=1`・`seq=10` | 通る（0・1・10） |
| X5 | `seq=-1`・`seq=01`・`seq=1.0`・`seq=abc`・`seq=1e3`・空 | 422 |
| X6 | `seq=1234567890`（10桁） | 422（★上限は9桁。4MB × 10億 は 100MB の上限より十分大きい★） |

コミット。

---

## 段階4 — 制御盤トークン（`lib/ds-agent/tokens.ts`）

```ts
/** 32バイトの乱数を base64url。ストアには SHA-256 の16進だけを書く。★生の値は戻り値で1回だけ★ */
export async function issueAgentToken(serialNumber: string): Promise<{ token: string; issued_at: string }>
export async function revokeAgentToken(serialNumber: string): Promise<boolean>   // 無ければ false
export async function agentTokenIssuedAt(serialNumber: string): Promise<string | null>   // P1（Q14 採用）
/** `Authorization` ヘッダの生値 → serial または null。★全件と timingSafeEqual で比べ、途中で抜けない★ */
export async function verifyAgentAuthorization(header: string | null): Promise<string | null>
/** ★ストアとパスを組で持つ★（`JsonStore` はパスを返さないので、mtime を見るためにパスを別に持つ。M13） */
export interface AgentTokenStoreTarget { store: JsonStore<AgentTokens>; filePath: string }
export function createAgentTokenStore(filePath?: string, onWarn?: (info: JsonStoreWarning) => void): AgentTokenStoreTarget
/** ★テスト専用★。★ストアとパスを一緒に差し替える★（`null` で既定へ戻す。キャッシュも捨てる） */
export function __setAgentTokenStoreForTesting(target: AgentTokenStoreTarget | null): void
```

- 保存先は ★呼び出し時に★ `path.join(process.cwd(), "data", "settings", "ds-agent-tokens.json")`（`ds-device-notes.ts:53-55` と同じ理由）。★再デプロイで消えないこと（Docker なら volume）は監督が扱う（§12-2・§12-3）★
- ★`createAgentTokenStore(filePath)` はパスを受け取ってストアを作り、`{ store, filePath }` を返す★（M13）。★mtime のキャッシュ（下）はこの `filePath` を `fs.stat` する★。★テストはストアとパスを一緒に差し替える★ — ★ストアだけ差し替えてパスが既定のままだと、mtime を `data/settings/` の実物で見てしまい、K12 が偶然通ったり実物を読んだりする★
- ★`lib/logger` を import しない★（proxy から呼ぶため。`json-store.ts:22-24` と同じ考え方）。壊れたストアの警告は `onWarn` で `settings.store_corrupt` を出す（呼び出し側。前例 `ds-device-notes.ts:65-73`）
- ★書き込みは `json-store` の待ち行列で直列化される★（2026-10-19 以降。発行と失効が重なってもファイルは壊れない）。★ただし読み → 書きの後勝ちは防がない★（`json-store.ts` の冒頭コメント）— 同じ制御盤の発行と失効を同時に押すと、結果はどちらか一方になる（P1 は社内の少人数なので許容）
- ヘッダの形は ★`/^Bearer ([A-Za-z0-9_-]{43})$/`★（表3 K5・K6）。形が違えば ★ストアを読まずに `null`★
- 比較は ★入力の SHA-256 とストアの各 `token_sha256` を `timingSafeEqual`★。★見つかっても残りを比べ終えてから返す★
- ★キャッシュ（D22）★: 読んだ内容をメモリに持ち、★`fs.stat` の `mtimeMs` が変わったら読み直す★（表3 K12）。★発行・失効の後は自分のキャッシュを捨てる★
- `issued_at` は `toJstIso(new Date())`（`+09:00`）
- ★シリアルの実在（DB）はここでは見ない★（ルートが `dsDeviceExists()` で見る。表3 KA5）

コミット（表3 K1〜K12 が通ること）。

---

## 段階5 — 認証（`lib/proxy/authenticate.ts` と `proxy.ts`。D22）

1. `authenticate.ts` に ★`isAgentPath(pathname)`★（`pathname === "/api/ds-agent" || pathname.startsWith("/api/ds-agent/")`。表4 A8・A10）
2. ★`authenticate()`（同期）★: IP 判定の後、★agent パスなら cookie を見ずに `unauthenticated`★（表4 A2 の保険。proxy からは呼ばれない）。★それ以外の分岐は変えない★
3. ★`authenticateAgent(pathname, forwardedFor, authorization, deps)`（新規・async）★:
   1. ★IP 判定（`authenticate()` と同じ関数を共有。複製しない）★ → 拒否なら `deny_ip`（★トークン検証を呼ばない★。表4 A7）
   2. `deps.verifyAgentToken ?? verifyAgentAuthorization` → `null` なら `unauthenticated`、そうでなければ `allow`
   3. ★戻り値に serial を入れない★（後段へヘッダで渡さない。D5）
4. `proxy.ts`: ★`export async function proxy`★。`isAgentPath(pathname)` なら `await authenticateAgent(…, request.headers.get("authorization"))`、そうでなければ今の `authenticate()`。
   ★401 の応答は今の API の 401 と同じ形★。agent パスの 401 では ★`ds_agent.auth_failed`（warn）を出す★（`reason: "missing" | "invalid"`・`route`・★`method`（`AuthLogEvent`（`lib/logger/events.ts:457-471`）に合わせる。M10）★。★ヘッダの値は出さない★）。
   ★冒頭コメントの「実行順」と matcher のコメントは変えず、agent パスの分岐を1行足す★
5. ★`lib/ds-agent/require-agent.ts`（新規）★: `requireAgent(request): Promise<{ ok: true; serial: string } | { ok: false; response: Response }>`。★制御盤向けのルートの先頭で必ず呼ぶ★（D5）。★通ったら relay の `touch(serial)` で `last_seen_at` を更新する（D19。表5 R7）★

★既存の `lib/proxy/__tests__/authenticate.test.ts` が無修正で通ること★。コミット。

---

## 段階6 — relay（`lib/ds-agent/relay.ts`）

### 6-1. 形

```ts
export interface RelayDeps {
  now: () => number;                                   // ms
  setTimer: (fn: () => void, ms: number) => unknown;   // 既定は setTimeout（★unref() する★）
  clearTimer: (handle: unknown) => void;
  limits: RelayLimits;                                 // 既定は lib/config/ds-agent.ts
  onEvent?: (e: RelayEvent) => void;                   // ログの受け口（段階10）。relay は logger を import しない
}
export function createRelay(deps?: Partial<RelayDeps>): Relay
export function getRelay(): Relay            // globalThis.__dwatchDsAgentRelay（無ければ作る）
export function __setRelayForTesting(relay: Relay | null): void
```

| 側 | メソッド | 戻り値 |
|---|---|---|
| 制御盤 | `touch(serial)` | `void`（★認証の通ったすべてのリクエストで呼ぶ。D19★） |
| 制御盤 | `poll(serial, agentVersion, waitSec, signal)` | `Promise<AgentCommand \| null>` |
| 制御盤 | `submitListResult(serial, commandId, result)` | `"ok" \| "not_found" \| "gone"` |
| 制御盤 | `submitPrepared(serial, commandId, { total_bytes, sha256, file_count })` | `"ok" \| "not_found" \| "gone"`（★`total_bytes` が上限を超えても `"ok"`。エクスポート側を失敗にする。R13★） |
| 制御盤 | `failCommand(serial, commandId, code, message)` | `"ok" \| "not_found" \| "gone"` |
| 制御盤 | `pushChunk(serial, exportCommandId, seq, bytes, sha256Hex)` | `Promise<"ok" \| "invalid" \| "out_of_order" \| "cancelled" \| "not_found">` |
| 制御盤 | `completeExport(serial, exportCommandId, totalBytes, sha256Hex)` | `"ok" \| "invalid" \| "cancelled" \| "not_found"` |
| ブラウザ | `agentStatus(serial)` | `{ connected, last_seen_at, agent_version }` |
| ブラウザ | `requestList(serial)` | `Promise<{ kind: "ok"; result } \| { kind: "offline" \| "timeout" \| "busy" } \| { kind: "agent_error"; code }>` |
| ブラウザ | `createExport(serial, fileIds, signal)` | `Promise<{ kind: "prepared"; exportId; totalBytes; fileCount; expiresInSec } \| { kind: "offline" \| "busy" \| "timeout" } \| { kind: "agent_error"; code }>` |
| ブラウザ | `claimExport(serial, exportId, signal)` | `Promise<{ kind: "stream"; body: ReadableStream<Uint8Array>; totalBytes } \| { kind: "not_found" \| "timeout" } \| { kind: "agent_error"; code }>` |

### 6-2. 規則

- ★id（`command_id` / `export_id`）は `crypto.randomUUID()`★（推測できない値。パスに出る）
- ★エクスポートの流れ（D14）★: `createExport` で `export` の指示を入れ ★prepared を最大 50秒待つ★ → prepared の `total_bytes` を ★`EXPORT_MAX_BYTES` と比べる（D15）★ → 201 → `claimExport` で ★`send` の指示（`{ export_command_id }`）を入れる★ → 最初のチャンクを最大30秒 → 流す。★チャンク・`complete`・送信中の `fail` は export の指示の command_id に来る。`send` の指示は自分の id を持ち、`params.export_command_id` で export を指す（D25 ①）★。★prepared から送信・完了・失敗・失効までを進行中に数え、prepared から60秒 GET が無ければ失効させて進行中から外す（D25 ⑤）★。★list はエクスポートの進行中でも待ち行列に入れる（D25 ③）★
- ★`complete` で確かめること（D15）★: 受け取った合計 ＝ prepared の `total_bytes` ＝ `complete` の `total_bytes`、かつ 受け取ったバイト列の SHA-256 ＝ prepared の `sha256` ＝ `complete` の `sha256`
- ★`last_seen_at` は `touch()`（すべての agent リクエスト）と poll の開始・終了で更新★。poll が待っている間は接続中（表5 R4〜R7）。★状態が変わったときだけ `onEvent`★（表5 R39。未接続への変化はタイマーで見つける）
- ★SHA-256 は `node:crypto` の `createHash` を逐次★（全体のハッシュのためにチャンクを溜めない）
- ★流量制御の保留とアイドルは 45秒（D20）★。超えたら 410 とエクスポートの失敗
- ★終わった・失効したエクスポートと指示は、タイマーで消す★（メモリに残し続けない）。消した後の id は `not_found`／失効の直後は `gone`（410）を返せるよう、★短い間（例 `EXPORT_IDLE_TIMEOUT_SEC`）だけ墓標を残す★
- ★タイマーは `unref()`★（テストとプロセスの終了を止めない）
- docstring に ★1プロセス前提・再起動で失われる・ディスクに書かない・待ちは nginx の60秒より短く★ を書く

コミット（段階1 の表5 が通ること）。

---

## 段階7 — ルート

### 7-1. ブラウザ向け（`app/api/ds-devices/[serial_number]/`）

| ファイル | 流れ |
|---|---|
| `logs/route.ts`（GET） | `parsePathSerialNumber()` → クエリの検証（3-3）→ ★`db.dsDeviceExists()`（404）★ → `requestList()` → 結果の検証（7-2）→ `queryDeviceLogs()` → 200（`agent`・`types`・`items`・★`truncated`★・`meta`） |
| `logs/exports/route.ts`（POST） | 同じ順で検証（★本体は `request.json()` が失敗したら 422、`isJsonObject()` が false でも 422「JSONの形式が不正です」（V8・V9。note の `route.ts:65-69` と同じ）★）・404 → ★`createExport(serial, fileIds, request.signal)`（最大50秒）★ → ★201★（`download_url` は `/api/ds-devices/${encodeURIComponent(serial)}/logs/exports/${exportId}`） |
| `logs/exports/[export_id]/route.ts`（GET） | `parsePathSerialNumber()` → ★`export_id` の検証（X1〜X3。M8）★ → `claimExport(serial, exportId, request.signal)` → `stream` なら `new Response(body, { headers })`（★4-5 の5つ★。ファイル名は `exportFileName(serial, now)`）→ それ以外は JSON のエラー |
| `agent-token/route.ts`（GET / POST / DELETE） | `parsePathSerialNumber()`（422）→ `db.dsDeviceExists()`（404）→ `agentTokenIssuedAt` / `issueAgentToken` / `revokeAgentToken` → 200 / ★201★ / ★204★。★POST の応答に `Cache-Control: no-store`★。ログ `ds_agent.token_issued` / `ds_agent.token_revoked`（段階10） |

- ★`withRequestId(request, …)` で包む★（既存のルートと同じ）。500 は `logApiError()`
- ★fixtures のときは relay を呼ぶ前に `ensureFixtureAgent()`（段階8）★。★それ以外の分岐をルートに書かない★（`CLAUDE.md` §9.5）
- ★404 の判定は `db.dsDeviceExists()`。`7492cc5` では論理削除と `controls.status` が 0/1 以外の制御盤も false（D-182）★ — ★D-Sec ログの一覧に出ない制御盤のログは取れない・トークンも作れない★（一覧に出ないので画面から押せない。整合している）

### 7-2. 制御盤の一覧の結果の検証（`lib/ds-logs/validate.ts`）

★1件でも形が違えば、結果全体を不正とする★（黙って落とさない）→ ブラウザには 502 `agent_error`（`details.agent_code = ["result_invalid"]`）、制御盤には 400 `result_invalid`。

| # | 結果 | 期待 |
|---|---|---|
| W1 | 正しい（types 3・items 8・`truncated: false`） | 受理 |
| W2 | `types` の `key` が重複 | 不正 |
| W3 | `items[].type` が `types` に無い | 不正 |
| W4 | `modified_at` に ★オフセットが無い★（Python の naive な `isoformat()`） | 不正 |
| W4b | ★`modified_at` が存在しない日付★（`2026-02-30T00:00:00+09:00`） | ★不正★（V2b と同じ確かめ方。4-24。M7 (c)） |
| W5 | `size_bytes` が負・小数・文字列 | 不正 |
| W6 | `file_id` が重複 | 不正 |
| W7 | `items` が `LIST_RESULT_MAX_ITEMS + 1` 件 | 不正（制御盤はそこで打ち切る約束。D18） |
| W8 | 本体が `LIST_RESULT_MAX_BYTES`（4MB）を超える | 不正（★`Content-Length` が無くても読んだ長さで判定★） |
| W9 | `{ error: { code, message } }` | `failCommand` と同じ扱い（ブラウザに 502・`agent_code` は制御盤の code） |
| W10 | ★本体が JSON だがオブジェクトでない★（`null`・`[]`・`1`・`"x"`） | ★不正（制御盤に 400 `result_invalid`。`isJsonObject()`。表5 R44・M7 (d)）★ |
| W11 | `items[].name` が `api_client/api_client.log.2026-10-18` の形（`/` を含む） | ★受理★（`name` は `{root の name}/{相対パス}`。★`/` を理由に弾かない・中身を解析しない★。M6） |

prepared の本体（`total_bytes` は0以上の整数・`sha256` は小文字の16進64桁・`file_count` は1以上の整数）・complete の本体・fail の本体も同じファイルで検証し、★形が違えば（オブジェクトでない本文を含め）すべて 400 `result_invalid`（D25 ④・R44）★。
★`total_bytes` の下限の差★: 常駐プログラムの試験用の偽 D-Watch（`dwatch-log-agent/fake_dwatch.py:631`）は `total_bytes` を ★1以上★ で検証しているが、本書は ★0以上★。★実害は無い★（空の zip でも22バイトあるので、本物の制御盤が 0 を送ることはない）。★本書の 0以上 のままにする★（M16）。

### 7-3. 制御盤向け（`app/api/ds-agent/`）

| ファイル | 要点 |
|---|---|
| `commands/route.ts`（GET） | ★`requireAgent()`★ → `wait` の検証（422）→ `X-Agent-Version` は `/^[0-9A-Za-z.+-]{1,32}$/` に合えば採り、合わなければ `null`（拒否しない）→ `poll(serial, version, wait, request.signal)` → 200 / 204 |
| `commands/[command_id]/result/route.ts` | `requireAgent()` → 本体（`request.text()` の長さで W8）→ 検証 → `submitListResult` → 204 / 400 / 404 / 410 |
| `commands/[command_id]/prepared/route.ts` | `requireAgent()` → 本体の検証 → `submitPrepared` → 204 / 400 / 404 / 410 |
| `commands/[command_id]/chunks/[seq]/route.ts` | `requireAgent()` → `seq` は0以上の整数 → `X-Chunk-Sha256` は `/^[0-9a-f]{64}$/` → `new Uint8Array(await request.arrayBuffer())` → `pushChunk` → 204 / 400 / 409 / 410 / 404 |
| `commands/[command_id]/complete/route.ts` | `requireAgent()` → `{ total_bytes, sha256 }` の検証（★形の誤りは 400 `result_invalid`★）→ `completeExport` |
| `commands/[command_id]/fail/route.ts` | `requireAgent()` → `{ code, message }` の検証（★形の誤りは 400 `result_invalid`★）→ `failCommand` |

★agent のルートは DB を読まない★（`USE_FIXTURES` に関係なく同じ動き）。
★`[command_id]` を持つ5本は、`requireAgent()` の後に ★`command_id` の検証（X1〜X3。M8）★、chunks は ★`seq` の検証（X4〜X6）★ をしてから本体を読む。★本体は `request.text()` → `JSON.parse` の失敗も `isJsonObject()` の false も 400 `result_invalid`（R43・R44）★。
★任意（M12）★: 6本に共通の前処理（`requireAgent()` → `command_id` の検証 → 本体の読み取りと `isJsonObject()`）は、BI の `lib/clean-logs/advice-response.ts` のように ★`lib/ds-agent/` の関数に切り出してよい★（例 `lib/ds-agent/agent-request.ts`。★ルートは薄く保つ★）。切り出すなら ★`node:fs` / `node:os` を import しない★（7-4 の静的検査の対象に足す）。

### 7-4. テスト

| ファイル | 内容 |
|---|---|
| `app/api/ds-devices/[serial_number]/logs/__tests__/route.fixtures.test.ts` | fixtures で: `DS-002` の一覧が200（`agent.connected` true・`types` ★4つ（段階8。キーで比べ、仮の表示名を期待値に埋め込まない）★・`truncated` false）・絞り込みが効く／★`DS-001` は 409★／`DS-999` は 404／★POST → 201（`total_bytes` が fixtures の zip の長さ）→ GET で zip のバイト列が fixtures の zip と一致・ヘッダ5つ★／★2回目の GET は 404★ |
| `app/api/ds-devices/[serial_number]/logs/__tests__/route.test.ts` | ★非 fixtures★（`vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }))`・`db.dsDeviceExists` を `vi.spyOn`）。relay は `__setRelayForTesting(createRelay(偽の時計))`。表5 の HTTP の対応（409 / 504 / 502 / 429 / 404 / 422）と、表1 の絞り込みがルートを通って効くこと。★V4（空は省略と同じ）・V6（丸め。`meta.per_page` に丸めた値）・V2b（存在しない日付は 422）・V8（POST exports の本体が `null` / `[]` で 422）・X2（`export_id` が長すぎて 422）★ |
| `app/api/ds-devices/[serial_number]/agent-token/__tests__/route.test.ts`（新規） | ★表3 KA1〜KA8★（ストアは `mkdtemp`・`db.dsDeviceExists` を `vi.spyOn`・ログは `logEvent` をスパイ） |
| `app/api/ds-agent/__tests__/routes.test.ts` | トークンストアは `mkdtemp`（★ストアとパスを一緒に差し替える。M13★）。★Bearer なし・違う → 401（`requireAgent()` の再検証）★・R7（どのルートでも `last_seen_at` が進む）・R35・R36・チャンクの 400 / 409 / 410・W1〜W11・prepared の検証・★R44（オブジェクトでない本文は4ルートとも 400 `result_invalid`。500 にならない）★・★`command_id` / `seq` の 422（X2・X5・X6）★ |
| `app/api/ds-agent/__tests__/end-to-end.test.ts` | ★ルートのハンドラを直接呼んで一連★: ブラウザ GET logs ↔ 制御盤 GET commands → POST result ／ ★ブラウザ POST exports ↔ 制御盤 GET commands（export）→ POST prepared → 201 → ブラウザ GET exports ↔ 制御盤 GET commands（send）→ chunks 0..2 → complete★。★読んだ zip のバイト列と SHA-256 が送ったものと一致・`Content-Length` が一致★ |
| ★ディスクに書かないこと★（同じ end-to-end のファイル） | (a) ★`vi.spyOn(fs, "writeFileSync")`・`vi.spyOn(fs.promises, "writeFile")`・`vi.spyOn(fs.promises, "appendFile")`・`vi.spyOn(fs, "createWriteStream")`・`vi.spyOn(fs.promises, "mkdtemp")`・`vi.spyOn(os, "tmpdir")`★ を一連の間に仕掛け、★0回★であること（`import fs from "node:fs"` / `import os from "node:os"` の既定の import に仕掛ける）。★トークンの発行（ストアの書き込み）はスパイを仕掛ける前に済ませる★ (b) ★ソースの静的な検査★: `lib/ds-agent/relay.ts`・`lib/ds-logs/*.ts`・`app/api/ds-agent/**/route.ts`・`app/api/ds-devices/[serial_number]/logs/**/route.ts`（★M12 で前処理を `lib/ds-agent/` に切り出したらそのファイルも★）を読み、`from "node:fs"` / `from "fs"` / `from "node:os"` / `tmpdir` / `mkdtemp` の文字列が無いこと |

★ルート本体は静的 import★（`it()` の中で `await import()` しない。`note/__tests__/route.test.ts` の冒頭コメント）。コミット。

---

## 段階8 — fixtures の偽制御盤

1. `lib/fixtures/ds-device-logs.ts`（新規）:
   - `fixtureDeviceLogTypes`: ★種類4つ。実機に合わせる（設計書 §1.6・§5.2。§11-3 B-9）★: `api_client` / `postcondition_security` / `postcondition_kick_action` / `send_start_sig`（この順）。
     ラベルは設計書 §5.2 の仮の名前（「API 通信（報告間隔の取得・通信の有効/無効）（仮）」「セキュリティ状態の報告（仮）」「セコム状態の監視・清掃の起動（仮）」「清掃開始信号（仮）」）。
     ★表示名は仮★なので、★テストの期待値に表示名を埋め込みすぎない★（キーで比べる。表示名は fixtures の定数から取る）
   - `fixtureDeviceLogItems(now: Date)`: ★数百件（例 300）★を now から決まった間隔で作る。★`name` は実機の形★: ★`{root の name}/{相対パス}`★ — `<種類>/<種類>.log`（各種類の最新）と `<種類>/<種類>.log.YYYY-MM-DD`（設計書 §1.6・§5.5 の表 `:884`。★2026-10-20 に変わった形。zip の中のパスと同じ★。M6。旧: `<種類>.log` だけ）。
     ★now ちょうど・1時間前ちょうど・3時間前ちょうど・24時間前ちょうど・その1秒前★ の項目を必ず含める（画面の目視用。境界の正しさはテストが持つ）。大きさは 1KB〜50MB（★選択の合計が 100MB を超える組み合わせが作れること★。★実機は全部で 1.2MB 程度だが、画面の上限の確認のため大きくしてよい★）
   - `FIXTURE_EXPORT_ZIP_BASE64`: ★中身がダミーの文字だけの小さな正しい zip★。★中のパスは `<種類>/<ファイル名>`★（★root はフォルダごと。設計書 §5.2・D24★。例 `postcondition_security/postcondition_security.log.2026-10-18`）（種類のフォルダを入れた作業用のフォルダの中身を PowerShell の `Compress-Archive` で包み base64 にする。★実在の店名・ログを入れない★）
   - `FIXTURE_AGENT_OFFLINE_SERIALS = ["DS-001"]`（D10）
   - `lib/fixtures/index.ts` に `export * from "./ds-device-logs";`（`./ds-devices` の export は `:18`。★AX で `ds-alerts.ts` を消したので `./ds-alerts` の export は無い。1行足す作業には影響しない★）
2. `lib/ds-agent/fixture-agent.ts`（新規）: `ensureFixtureAgent(relay, serial)`:
   - ★`FIXTURE_AGENT_OFFLINE_SERIALS` と fixtures に無いシリアルは何もしない★（→ 409 / 404 が本物と同じ経路で出る）
   - それ以外は ★`relay.poll(serial, "fixture", AGENT_POLL_WAIT_SEC, …)` を1本だけ待たせておき★、指示が来たら ★本物と同じメソッド★ で返す: `list` → `submitListResult`／★`export` → `submitPrepared`（fixtures の zip の長さ・SHA-256・選んだ件数）★／★`send` → 小さく（例 1KB）分けて `pushChunk` を複数回 → `completeExport`★（流量制御の経路を通す）
   - ★1シリアル1本★（`globalThis` の Set で二重起動を防ぐ）
3. ★`docs/fixtures.md` は監督が直す★（ブリーフ §7）。足すべきことは §12-2

コミット。

---

## 段階9 — 画面（モーダル）

★見た目は `AreaModal.tsx` / `.module.css` から借りる。新しい共通の画面部品は作らない★（`components/ui/` に Modal を作らない）。★判定の純粋関数だけを `lib/ui/modal.ts` に移して共有する（9-2。M2）★。

### 9-1. `components/features/ds-logs/DsLogScreen.tsx`

- 表の右端に ★「ログ」列★: `<colgroup>`（`:464-470`）に `<col className={styles.colLogs} />` を、見出し（`:471-479`）に `<th>` を、各行（`<tr>` の最後の `<td>` の後）に `<td>` を足す（★`<colgroup>` の上のコメント（`:455-463`）のとおり、`<col>` と `<th>` と `<td>` を同じ順に★）。
  - ★列幅（M1）★: `.colLogs` に幅（例 72px）を付ける。★D-167 の作り（全列に幅があり、余りは幅の比で全列に配られる）を崩さない★ — 余りの配分で「ログ」列も少し広がるのは認める。★ボタンは固定幅（例 `width: 56px`）で列の中の決まった位置（例 中央）に置き、JP/EN で幅と位置が変わらないようにする★
  - ★`DsLogScreen.module.css:205` の `min-width: 800px` を列幅の合計に合わせる★（72px なら ★872px★）。★`:199` のコメントの式も直す★（例「最小幅 872px ＝ 全列の幅の合計（施設名 200px ＋ 固定5列 672px）」。`:222` の `.colFacility` のコメントの「min-width 800 - 固定4列 600」は旧来の由来なのでそのままでよい）。★「余った幅（約350px）」の数字も合計に合わせて直す★（1150 − 872 ＝ 約278px）
  - ★`COLUMN_COUNT`（`:66`）を 5 → ★6★ にする（4-22）★
- 各行に `ログ` ボタン → モーダルを開く（`serial_number` と `facility_name` を渡す。★押したボタンの要素も渡し、閉じたらそこへフォーカスを戻す★（9-2））
- ★「ログ」ボタンは編集モード中は押せない（U6。4-21）★: `disabled={filtersDisabled}`（`:221`）。★表は `<fieldset disabled>` の外なのでボタンに直接付ける★
- ★並べ替えに BH の `SortableTh`（清掃詳細ログ・エラー一覧の列見出しで並べ替える部品）は使わない★（★D-Sec ログの表は並べ替えを持たず、本書のモーダルの並べ替えは向きを持たない2択（9-2）で `sort` / `order` の切り替えと API が合わない★。M20）
- ★モーダルの開閉と「ログ」列以外は今の画面を変えない★（★例外: 辞書に `全て` を足すので、既存の「全て」ボタン（`:410`）が EN で "All" になる（4-13・M3）★）

### 9-2. `components/features/ds-logs/DeviceLogsModal.tsx`（新規）と `.module.css`

| 部分 | 中身（★文言はこのとおり。JP/EN の辞書に足す★） |
|---|---|
| 見出し | 「制御盤のログ」＋シリアル・施設名。右に ★接続状態★: 「接続中」（ok の色）／「未接続」（ng の色）＋「最終接続 {日時}」（無ければ「最終接続 -」）。`閉じる` |
| 種類 | 制御盤から来た `types` のチェックボックス（★ラベルは生値。`t()` に通さない★）。★既定は全部★。★1つも選ばれていなければ取得せず「種類を1つ以上選んでください」★ |
| 期間 | ボタン「直近1時間」「直近3時間」「直近24時間」「全て」（`presets.ts`。★「全て」は辞書に `全て: "All"` を足す。M3★）＋ `<input type="date">` 2つ（`openDatePicker`。表2 T5〜T9）。★既定は「直近24時間」★（D23） |
| 並べ替え | ★ボタン2つ★「日付」（`date_desc`）「種類＋日付」（`type_date_desc`）（★向きの切り替えは持たない。M9・M20★） |
| 一覧 | チェックボックス・ファイル名・種類（ラベル）・大きさ（KB / MB）・更新日時。見出しに「このページをすべて選択」。下に `Pagination` ＋ ページあたり件数のセレクト（`DS_LOG_PER_PAGE_OPTIONS`）。★`truncated` のときは一覧の上に「件数が多いため、新しいものから一部だけを表示しています」（D18）★。<br>★ファイル名の列（M6）★: `name` は `{root の name}/{相対パス}`（例 `postcondition_kick_action/postcondition_kick_action.log.2026-10-18`）で長いので、★1行で省略表示（`text-overflow: ellipsis`）し、`title` に全文★（★`t()` に通さない。4-14★）。<br>★0件★: 取得に成功して0件なら「該当するファイルはありません」（★取得の失敗（エラーの文言）と分けて出す。BC 段階2 の作法★。M3） |
| 選択中 | 「選択: {n}件・合計 {size}」。★合計が `EXPORT_MAX_BYTES` を超えたら警告「1回に取り出せるのは {max} までです」＋エクスポートを押せなくする★（★元ファイルの合計での目安。最終の判定は制御盤の zip の大きさ（D15）★）。★`EXPORT_MAX_FILES` を超えても同じ★（「1回に選べるのは {n} 件までです」） |
| エクスポート | 押す → ★「制御盤で準備中…」（最大50秒。ボタンを押せなくする）★ → POST が 201 → `download_url` を ★`<a href download>` を作って `click()`（D21）★ → 「ダウンロードを開始しました（{size}）」 |
| エラー | 409「制御盤が接続されていません（最終接続: {日時}）」／504「制御盤から応答がありません。しばらくしてから再度お試しください」／429 `export_busy`「この制御盤は別のエクスポートを処理中です」／429 `agent_busy`「制御盤が混み合っています。しばらくしてから再度お試しください」／502「制御盤でエラーが発生しました（{code}）」（★`too_large` は「選んだファイルが大きすぎます（1回 {max} まで）」★） |
| ★制御盤トークン★（下部。D16） | 「制御盤トークン」＋「発行日時 {日時}」／「未発行」（`GET …/agent-token`。P1（Q14 採用））。ボタン「発行（作り直し）」「失効」。<br>発行: ★確認「トークンを発行します。発行済みのトークンは使えなくなります。よろしいですか？」★ → POST（`apiFetch`）→ ★トークンを読み取り専用の欄に1回だけ表示・「コピー」ボタン・注意「この値は二度と表示されません。制御盤の設定ファイルに入れてください」★。★モーダルを閉じたら state から消す★。<br>失効: ★確認「トークンを失効させます。この制御盤はログを送れなくなります。よろしいですか？」★ → DELETE（★素の `fetch`。204 は本文が無いので `apiFetch` を使わない。4-23・M5★。`response.ok` でなければ本文の `error.message` を出す）。<br>★この部分は制御盤が未接続（409）でも使える★（初めて設定するときは未接続のため） |

- ★選択の保持（D23）★: `file_id` → `size_bytes` の Map で持つ。★ページ送り・並べ替え・ページあたり件数では保持★、★種類・期間を変えたら消す★
- 取得中は「読み込み中…」（`t()` に通す）。★一覧の取得は毎回制御盤に問い合わせる★（最大15秒）ので、★前の応答が後から返って上書きしないよう、古い応答を捨てる★ — ★BC 段階1（D-183）で全画面がそろえた作法: `useEffect` の中で取得し、片付けで `cancelled = true` にする★（手本 `DsLogScreen.tsx:178-214`。★番号で数える方式は使わない（M4）★。`setLoading(true)` の抑止コメントは 4-19 の例外のとおり）
- ★モーダルの作り（M2・D11）★:
  - ★`role="dialog"` `aria-modal="true"`・自前の overlay・✕ボタン（`aria-label={t("閉じる")}`）の見た目は `AreaModal.tsx` に揃える★。★見出しは `aria-labelledby` ＋ `useId()`（`ErrorContentPicker.tsx` と同じ）★
  - ★Esc で閉じる★: ★`AreaModal.tsx:66-73` と同じ書き方★（`window` の `keydown` で `e.key === "Escape"` → `onClose()`。片付けで外す）
  - ★開いたら最初の操作要素へフォーカス・閉じたら呼び出した「ログ」ボタンへ戻す・Tab の閉じ込め・背景を押して離したときだけ閉じる★: ★`ErrorContentPicker.tsx` と同じ作り★（`restoreFocusRef` 相当・`trapFocusIndex()`・`shouldCloseOnBackdrop()` を `onMouseDown` と `onClick` で）
  - ★判定の純粋関数を共有にする★: `trapFocusIndex`・`shouldCloseOnBackdrop` を `lib/errors/error-content-picker.ts`（`:112`・`:122`）から ★`lib/ui/modal.ts`（新規。React も DOM も import しない純粋関数）★へ移し、★`lib/errors/error-content-picker.ts` からは `export { trapFocusIndex, shouldCloseOnBackdrop } from "@/lib/ui/modal";` で再 export する★。★`ErrorContentPicker.tsx` と `lib/errors/__tests__/error-content-picker.test.ts` は変えずに通ること★（`ErrorContentPicker.tsx` には他のセッションの未コミットの変更がある。4-19）。`DeviceLogsModal.tsx` は `@/lib/ui/modal` から import する
  - ★背景のスクロールを止める★: ★前例が無い★ので `DeviceLogsModal.tsx` の中に書く（開いたら `document.body.style.overflow = "hidden"`、片付けで ★開く前の値に戻す★）
  - ★AreaModal・ErrorContentPicker は触らない・`components/ui/` に共通部品は作らない★
- ★準備中に閉じたら POST を `AbortController` で取り消す★（サーバー側はエクスポートを取り消す。4-9）。★取り消したときの `ApiFetchError`（`status: 0`）はエラー表示しない（閉じた後に state を触らない）★（4-23・M5）
- ★色は CSS 変数・固定幅のボタン（JP/EN で位置が動かない）★
- 日時の表示は既存の `formatDateTime()`（`DsLogScreen.tsx:47`）と同じ形

### 9-3. `lib/i18n.ts`

★足す前に grep する★（`閉じる`・`キャンセル`・`読み込み中…`・`種類`・`日付` は既にある。★`全て` は無い（`7492cc5`）★）。EN は ★暫定★。
例: 「ログ」"Logs"／「制御盤のログ」"Controller logs"／「接続中」"Connected"／「未接続」"Not connected"／「直近1時間」"Last 1 hour"／「直近24時間」"Last 24 hours"／★「全て」"All"（★既存の D-Sec ログの「全て」ボタンも EN で "All" になる。M3★）★／「種類＋日付」"Type + date"／「このページをすべて選択」"Select all on this page"／★「該当するファイルはありません」"No matching files"（0件。M3）★／「エクスポート」"Export"／「制御盤で準備中…」"Preparing on the controller…"／「制御盤トークン」"Controller token"／「発行（作り直し）」"Issue (regenerate)"／「失効」"Revoke"／「コピー」"Copy"／ほか 9-2 の文言すべて。

コミット（画面は vitest で走らないので段階11 で見る）。

---

## 段階10 — ログのイベント（`lib/logger/events.ts`）

| event | level | フィールド（★これ以外を載せない★） | どこで |
|---|---|---|---|
| `ds_agent.connected` / `ds_agent.disconnected` | info | `serial_number`・`agent_version` | relay の `onEvent`（状態が変わったときだけ。表5 R39） |
| `ds_agent.auth_failed` | warn | `route`・★`method`★・`reason`（`missing` / `invalid`） | proxy（段階5）。★`AuthLogEvent`（`:457-471`）に合わせて `method` を持つ（M10）★。★ヘッダの値・トークンを出さない★ |
| ★`ds_agent.token_issued` / `ds_agent.token_revoked`★ | info | ★`serial_number` だけ★ | `agent-token` のルート（表3 KA8。D16） |
| `ds_logs.list` | info | `serial_number`・`item_count`・`truncated`・`duration_ms`・`result`（`ok` / `offline` / `timeout` / `busy` / `agent_error`）・★`agent_code`（`result` が `agent_error` のときだけ。M10）★ | GET logs |
| `ds_logs.export_prepared` | info | `serial_number`・`file_count`・`total_bytes`・`duration_ms` | POST exports の 201 |
| `ds_logs.export_started` | info | `serial_number`・`file_count` | claim で流し始めたとき |
| `ds_logs.export_completed` | info | `serial_number`・`file_count`・`total_bytes`・`duration_ms` | complete |
| `ds_logs.export_failed` | warn | `serial_number`・`stage`（`prepare` / `send`）・`reason`・`bytes_sent`・★`agent_code`（制御盤の code があるときだけ。M10）★ | 失敗・取り消し・失効・タイムアウト |

- `LogEvent` の union（`:486-501`）に足す。★`reason` は固定の文字列の union★（制御盤から来た `message` を載せない）。★制御盤の `code` は `agent_code` として固定の長さ（64文字）で切って載せてよい★ — ★載せるのは表で `agent_code` を書いた2つ（`ds_logs.list` と `ds_logs.export_failed`）だけ★（旧: 箇条書きにだけ書いてあり表と食い違っていた。M10）
- ★`file_name` / `file_ids` / `token` / `authorization` を載せない★。テストで「イベントの値に `file_id`・ファイル名・トークンが入っていない」ことを確かめる
- ★`docs/logging-design.md` に足す（Cursor の担当。M10。BI と同じ）★: §3「ログイベントと共通フィールド」（`:103`）のイベントの表（`:108-117`）に上の10個（`ds_agent.*` 5つ・`ds_logs.*` 5つ。名前空間でまとめて2行にしてよい）を、§10 の「既存の全APIルート」（`:477`）の一覧に足したルートのファイル（ブラウザ向け4ファイル・制御盤向け6ファイル）を。★それ以外の節は変えない★

コミット。

---

## 段階11 — 画面確認と通し確認

★**アプリ内ブラウザ（`mcp__Claude_Browser__*`）を使う。Edge（ヘッドレス・CDP を含む）は使わない**★（ユーザー指示 2026-10-16）。
★**ユーザーの `:3000`（`next dev`。ステージング向け）は止めない**★。★`next dev` ではなく `next build` → `next start` で確かめる★（1プロセス・本番と同じ形。4-2）。

### 11-1. 全体

1. `npx tsc --noEmit -p .` 0件／`npm run lint` 0件／`npm test` 全件通過（★段階0 の件数から増えている★）
2. ★`npm run build` の出力で、足したルートがすべて `ƒ`（Dynamic）★であること
3. grep（★0件★）:
   ```
   grep -rn "node:fs\|from \"fs\"\|node:os\|tmpdir\|mkdtemp\|createWriteStream" lib/ds-agent/relay.ts lib/ds-logs app/api/ds-agent "app/api/ds-devices/[serial_number]/logs"
   grep -rn "proxyClientMaxBodySize" next.config.ts
   grep -rn "console.log" lib/ds-agent lib/ds-logs app/api/ds-agent
   grep -rn "validation_error" lib app
   ```
4. ★`scripts/ds-agent-token.ts` が無いこと★（D16）

### 11-2. fixtures（`:3100`）

★`.claude/launch.json` の `d-watch-fixtures` は `next dev` なので使わない★（M14）。

1. ★`:3100` が空いていることを確かめる★（`Get-NetTCPConnection -LocalPort 3100 -State Listen`。何か居たら自分で起動したものか分からないので止めずに報告する）
2. 本体で `$env:USE_FIXTURES='true'; npm run build`
3. ★起動時にも `USE_FIXTURES=true` を渡す★（M14）: ★`.env.local` の2行目は `USE_FIXTURES=false`、`lib/db/use-fixtures.ts` は ★実行時に★ `process.env.USE_FIXTURES !== "false"` を読む★ — ★起動時に渡さないと `.env.local` の値でステージングDB（実データ）につながる★。
   ★テスト用の `AUTH_TOKEN` をその場で生成★し、★環境変数で渡して★ 起動する（例 `$env:USE_FIXTURES='true'; $env:AUTH_TOKEN=<生成した値>; npx next start -p 3100`）。★トークンを文書・コミット・報告に書かない★。`ALLOW_DIRECT_ACCESS=true` は `.env.local`（36行目）にあるので渡さなくてよい
4. `preview_start` の `url` で `http://localhost:3100` を開き、ログインする
5. 確かめること:
   - D-Sec ログの各行に「ログ」がある。`DS-002` で開く → 接続中・★種類4つ（`api_client` ほか。段階8）★・既定の期間（直近24時間）で一覧が出る。★ファイル名が `api_client/api_client.log…` の形で、長いものは省略され、`title` で全文が出る（M6）★
   - ★「編集」を押して編集モードにすると「ログ」が押せない／「保存」か「キャンセル」で抜けた後に押せる（U6・4-21）★。★編集モードの動き（下書き・保存（N件）・離脱確認）が今までどおり★
   - ★モーダルの操作（M2）★: 開くと最初の操作要素にフォーカス／Tab・Shift+Tab がモーダルの中で回る／`Esc` で閉じる／★モーダルの中で押して背景で離しても閉じない・背景で押して離すと閉じる★／開いている間は背景がスクロールしない・★閉じたら元に戻る★／★閉じたら押した「ログ」ボタンにフォーカスが戻る★
   - 種類を外す・期間のボタン・日付の範囲・並べ替え（ボタン2つ）・ページ送り・ページあたり件数で一覧が変わる。種類・期間を変えると選択が消える。★0件のとき「該当するファイルはありません」★（取得の失敗とは別の表示。M3）
   - ★取得中に種類を続けて変えても、最後の条件の一覧が残る（古い応答で上書きされない。M4）★
   - 「このページをすべて選択」→ 合計の表示。★100MB を超えると警告とボタンの無効★
   - ★エクスポート（M17）★: ★ファイルの保存はユーザーの許可が要るので、通常は `javascript_tool` で確かめる★ — ページの中で `fetch` で `POST …/logs/exports`（201）→ `fetch(download_url)` → ★ヘッダ5つ（4-5）があり `Content-Encoding` が無いこと★・★`crypto.subtle.digest("SHA-256", await r.arrayBuffer())` が fixtures の zip（`FIXTURE_EXPORT_ZIP_BASE64` を node で SHA-256 にした値）と一致★・★同じ `download_url` の2回目が 404★。★zip の中のパスが `<種類>/<ファイル名>` であることは段階7-4 のテストで見る★。
     ★画面の「エクスポート」を押して「制御盤で準備中…」→ ファイルが保存されるところは、ユーザーに許可を取ってから1回だけ★（取れなければ「制御盤で準備中…」の表示までを見て、保存は未確認と報告する）
   - ★`DS-001` で開くと「制御盤が接続されていません（最終接続: -）」★。★それでも制御盤トークンの部分は使える★
   - ★制御盤トークン★: 発行 → 確認 → 1回だけ表示・コピー → 閉じて開き直すと値は出ず、発行日時だけ → 作り直し → 失効 → 「未発行」（★ここで発行した値は確認後に失効させ、どこにも書かない★）。★確認の `window.confirm` は `javascript_tool` で `window.confirm = () => true` に差し替えてよい（M17）★
   - ★JP/EN 切替★で「ログ」ボタンの幅と位置・モーダルのボタンの位置が動かない（★既存の「全て」ボタンが EN で "All" になるのは意図どおり。M3★）／★ダーク/ライト★で崩れない
   - D-Sec ログの表で横スクロールが増えていない（`resize_window` 1440×900・JP/EN）。★「ログ」ボタンの幅と位置が JP/EN で変わらない★（★列が余りの配分で少し広がるのは認める。D-167・4-20・M1★）。★保存に失敗した行の文言が「ログ」列まで届いている（`COLUMN_COUNT` 6。4-22）★ — 失敗を起こすのが難しければ、コードで `COLUMN_COUNT` が `<th>` の数と一致することを見る。終わったら `desktop` に戻す
6. ★終わったら :3100 の listen プロセスを止める★（M14）: ★`Get-NetTCPConnection -LocalPort 3100 -State Listen` の `OwningProcess` のコマンド行（`Get-CimInstance Win32_Process -Filter "ProcessId=<pid>"` の `CommandLine`）が ★自分が起動した `next start -p 3100` であることを確かめてから★ 止める。★`:3000`（ユーザーの `next dev`）は止めない★

### 11-3. 非 fixtures の通し確認（★偽制御盤スクリプト★。ローカルDB `dwatch_local`）

★ローカルDB へ向ける（M15）★: 接続先は2系統ある — ★シード（`scripts/seed-local-db.ts`）は `DATABASE_URL` だけ★を読み（`:92`。`dotenv` は既にある環境変数を上書きしない）、★アプリは `DATABASE_HOST` / `DATABASE_PORT` / `DATABASE_USER` / `DATABASE_PASSWORD` / `DATABASE_NAME` があればそちらを優先する★（`lib/db/prisma.ts:42-65`）。★`.env.local` の8〜12行目と14行目はステージングの値★なので、★両方の系統を環境変数で上書きしないとステージングにつながる★。
ローカル用の値は ★`.env.local` の5行目のコメント（ローカル用の `DATABASE_URL`）★ にある。★その行から PowerShell で URL を読み、`DATABASE_URL` と `DATABASE_HOST`〜`DATABASE_NAME` の環境変数に入れる★（`[System.Uri]` で分解。★パスワードを画面・報告に出さない★）。★起動前に host が `localhost`（または `127.0.0.1`）・DB 名が `dwatch_local` であることだけを表示して確かめる★。`ALLOW_DIRECT_ACCESS=true` は `.env.local` にある（XFF が無いため要る）。
★MySQL のサービス `MySQL84` は手動起動★。`Get-Service MySQL84` が止まっていたら、★管理者権限が要るのでユーザーに起動してもらう★（Claude からは起動できない）。

1. `npm run seed:local`（★`lib/db/local-guard.ts` がローカル以外を拒否する★）。`DS-002`・`DS-003` が `controls` にあること（★`controls.status` が 0/1 で、D-Sec ログの一覧に出ること。D-182★）
2. 上の環境変数と `$env:USE_FIXTURES='false'` で `npm run build` → 同じ環境変数で `npx next start -p 3100`（11-2 の 1・6 と同じく、空きを確かめてから起動し、コマンド行を確かめてから止める）
3. ★画面のモーダル（`DS-002`）の「制御盤トークン」で発行★し、★その値を環境変数 `DS_AGENT_TOKEN` で偽制御盤に渡す★（★画面・ファイル・報告に残さない★）
4. ★偽制御盤 `scripts/ds-agent-fake.ts`（D23。コミットする）★を `tsx` で動かす:
   - `--server http://localhost:3100`・トークンは環境変数
   - ポーリング → `list` には ★固定の一覧（10件程度）★ → `export` には ★`--zip <パス>` のファイルの長さ・SHA-256・件数で `prepared`★ → `send` には ★そのファイルを 4MB ごとに送り `complete`★（★`file_ids` は見ない。中身の正しさは制御盤（Python）の仕様★）
   - ★固定の一覧の種類と `name` は実機に合わせる（§11-3 B-9・§11-4 M6）★: 種類は `api_client` / `postcondition_security` / `postcondition_kick_action` / `send_start_sig`（ラベルは設計書 §5.2 の仮の名前）、`name` は ★`<種類>/<種類>.log` と `<種類>/<種類>.log.YYYY-MM-DD`★（`{root の name}/{相対パス}`。zip の中のパスと同じ）
   - zip は ★9〜10MB 程度★（★4MB を超えて3チャンクになる大きさ★）。★中のパスは `<種類>/<ファイル名>`（root はフォルダごと。設計書 §5.2・D24）★: 種類のフォルダ（例 `postcondition_security/`）に乱数の中身のファイル（例 `postcondition_security.log.2026-10-18`）を入れ、それらのフォルダを `Compress-Archive` で包む
   - ★チャンクの POST のタイムアウトは60秒★（D20。設計書と同じ）
5. 画面から一覧 → 選択（★選んだ `file_id` を控える★）。★エクスポートは 11-2 と同じく `javascript_tool` で確かめる（M17）★: ページの中で `fetch` で `POST …/logs/exports`（本体は控えた `file_ids`）→ 201 → `fetch(download_url)` → ★`crypto.subtle.digest("SHA-256", …)` が `--zip` のファイルの SHA-256（`Get-FileHash`）と一致★・★`Content-Length` が `--zip` のファイルの長さ★。サーバーのログに `ds_logs.export_prepared` と `ds_logs.export_completed`（`total_bytes` が一致）。
   ★画面の「エクスポート」を押してファイルを保存し、ブラウザに進捗（大きさ）が出ることを見る確認は、ユーザーに許可を取ってから1回だけ★（11-2 と合わせて1回でよい）
6. ★準備の失敗★: 偽制御盤の `--fail-prepare too_large` で、画面に「選んだファイルが大きすぎます」（POST の 502。★これは画面の「エクスポート」を押してよい — ファイルは保存されない★）
7. ★切断★: `javascript_tool` で `fetch(download_url)` の `body.getReader()` から1回 `read()` した後に ★`reader.cancel()`★（M17。ブラウザのダウンロードの取り消しの代わり）→ 偽制御盤に 410 が返り止まる・`ds_logs.export_failed`（`stage: send`）。
   ★`request.signal` の abort が実際に来たか（または `cancel()`・書き込みの失敗のどれで検知したか）を記録する★（★abort が来ることは推測。4-9★）。★どれでも検知できなければ報告して止まる★
7b. ★本物の常駐プログラム（`C:\Users\daich\dev\dwatch-log-agent\agent.py`）での通し確認（M16）★ — 偽制御盤スクリプトは D-Watch 側の確認用なので、★最後に本物どうしをつなぐ★:
   - ★制御盤は `DS-003` を使う★（`DS-002` は偽制御盤スクリプトのトークンと取り合わない）。画面のモーダル（`DS-003`）でトークンを発行する
   - ★試験用のログフォルダと設定ファイルはスクラッチパッドに作る★（`dwatch-log-agent` のフォルダには何も書かない。★`agent.py` は読むだけ★）: 4つの種類のフォルダ（`api_client/` ほか。`config.example.json` の `roots` と同じ名前）に、`<種類>.log` と `<種類>.log.YYYY-MM-DD` の試験用ファイル（乱数の中身。★1つは 4MB を超える大きさ★）
   - 設定ファイル（例 `config.local.json`）は `config.example.json` を写し、`server_url` は `http://localhost:3100`・`serial_number` は `DS-003`・`roots[].path` は試験用フォルダ・`temp_dir` はスクラッチパッドの中。★トークンは環境変数から Python で設定ファイルに書き込み、画面・報告に表示しない★
   - 動かす: `python agent.py --config <設定ファイル> --server http://localhost:3100`（★`http://` は `localhost` / `127.0.0.1` のときだけ通る★。Ctrl+C か `TaskStop` で止める）
   - 確かめること: ★画面で `DS-003` が「接続中」・種類4つ・`name` が `<種類>/<ファイル名>` の形★／★選んだファイルのエクスポート（`javascript_tool`）で zip が届き、`complete` が通る（サーバーのログに `ds_logs.export_completed`）★／★zip の中のパスが `<種類>/<ファイル名>`★（受け取ったバイト列をスクラッチパッドに書くのはユーザーの許可を取ってから。取れなければサーバーのログの `total_bytes` と `file_count` で代える）／★止めて60秒で「未接続」★
   - ★片付け★: 常駐プログラムを止める → 画面で `DS-003` のトークンを失効 → スクラッチパッドの試験用フォルダと設定ファイル（トークンが入っている）を消す
   - ★常駐プログラムの API の形（パス・ヘッダ・`send` の `export_not_found`・一覧の失敗の本文・各タイムアウトが D-Watch の待ちより長いこと・`modified_at` がオフセット付き・一覧の打ち切り）は、`7492cc5` の時点で監督の点検役が本書と一致することを読んで確かめた★（推測ではなくコードを読んだ結果。ただし動かしてはいない）。★`total_bytes` の下限の差（`fake_dwatch.py:631` は1以上・本書は0以上）は実害なし★（段階7-2）
8. ★偽制御盤を止める★ → 60秒後に画面で「未接続」・`ds_agent.disconnected` が1回
9. 応答のヘッダに `X-Accel-Buffering: no`・`Cache-Control: no-store`・`Content-Length` があり、★`Content-Encoding` が付いていない★こと（5 の `fetch` の `response.headers` で見る。`read_network_requests` でもよい）
10. ★終わったら★ 画面で ★トークンを失効★（`DS-002`・`DS-003`）、:3100（11-2 の 6 の手順で）と偽制御盤を止める

★結果の報告には出所を書く★（「当方の fixtures 由来」「当方の偽制御盤スクリプト由来」「当方のシード由来」。`CLAUDE.md` §6-6）。

---

## 8. テスト一覧（本書で足す・直すもの）

| ファイル | 内容 | 段階 |
|---|---|---|
| `lib/ds-logs/__tests__/query.test.ts`（新規） | 表1 | 1 |
| `lib/ds-logs/__tests__/presets.test.ts`（新規） | 表2 | 1 |
| `lib/ds-logs/__tests__/file-name.test.ts`（新規） | 表6 | 1 |
| `lib/ds-logs/__tests__/validate.test.ts`（新規） | V1〜V10・X1〜X6・W1〜W11・prepared の本体 | 1 |
| `lib/ds-agent/__tests__/tokens.test.ts`（新規） | 表3 K1〜K12 | 1 |
| `lib/proxy/__tests__/authenticate.test.ts`（足す） | 表4。★既存は無修正で通る★ | 1 |
| `lib/ds-agent/__tests__/relay.test.ts`（新規） | 表5（★準備 → 送信の2段★） | 1 |
| `app/api/ds-devices/[serial_number]/logs/__tests__/route.fixtures.test.ts`（新規） | fixtures の経路 | 7 |
| `app/api/ds-devices/[serial_number]/logs/__tests__/route.test.ts`（新規） | 非 fixtures の経路 | 7 |
| ★`app/api/ds-devices/[serial_number]/agent-token/__tests__/route.test.ts`（新規）★ | ★表3 KA1〜KA8★ | 7 |
| `app/api/ds-agent/__tests__/routes.test.ts`（新規） | 制御盤向けの6本 | 7 |
| `app/api/ds-agent/__tests__/end-to-end.test.ts`（新規） | 一連・★ディスクに書かない★ | 7 |
| `lib/logger/__tests__/…`（既存の流儀に合わせる） | 足したイベントに禁止のキーが無い | 10 |
| ★無修正で通ること★ | `app/api/ds-devices/**`（既存の2本）・`lib/settings/__tests__/*`・`lib/proxy/__tests__/request-id.test.ts`・`lib/auth/__tests__/*`・★`lib/errors/__tests__/error-content-picker.test.ts`（`trapFocusIndex` / `shouldCloseOnBackdrop` を `lib/ui/modal.ts` へ移しても再 export で通る。M2）★ | — |

---

## 9. 触ってよいもの・触ってはいけないもの

### 触ってよい

`d-watch-api.yaml`・`lib/types/index.ts`（足すだけ）・`lib/config/ds-agent.ts`（新規）・`lib/ds-logs/**`（新規）・`lib/ds-agent/**`（新規）・
`lib/proxy/authenticate.ts`・`proxy.ts`（段階5 の範囲だけ）・`scripts/ds-agent-fake.ts`（新規。開発用）・
`app/api/ds-devices/[serial_number]/logs/**`（新規）・★`app/api/ds-devices/[serial_number]/agent-token/**`（新規）★・`app/api/ds-agent/**`（新規）・`lib/fixtures/ds-device-logs.ts`（新規）・`lib/fixtures/index.ts`（1行）・
`components/features/ds-logs/**`・`lib/i18n.ts`（足すだけ）・`lib/logger/events.ts`（足すだけ）・
★`lib/ui/modal.ts`（新規。`trapFocusIndex`・`shouldCloseOnBackdrop` の移し先。M2）★・★`lib/errors/error-content-picker.ts`（★2つの関数を移して再 export に置き換えるだけ。他の関数は変えない★）★・
★`docs/logging-design.md`（★§3 のイベントの表と §10 の「既存の全APIルート」の一覧に足すだけ。M10★）★・
`docs/decisions.md`（★D-179 の末尾（2026-11-05 の追記の後ろ）に「実装の記録」を足すだけ★）・§8 のテスト

### 触ってはいけない

- ★`CLAUDE.md`★（監督が直す）・★`docs/decisions.md` の D-179 の本文とそれ以前★・`docs/spec.md`・`docs/handoff.md`・`docs/code-map.md`・★`docs/fixtures.md`★・`docs/deploy.md`（★監督。§12-2・§12-3★）・★`docs/logging-design.md` の §3 の表と §10 の一覧以外★
- ★設計書 `docs/handoff/設計_制御盤ログの取り出し_20261019.md`★ と `docs/handoff/` の他の文書（他の担当の作業）
- ★`scripts/ds-agent-token.ts` を作ること★（D16）・`package.json`（★script を足さない★）
- ★`prisma/schema.prisma` / `lib/generated/prisma/**`★・★メインDBへの書き込み★（★本機能は DB に書かない★。4カラムの UPDATE も使わない）・生SQL
- `lib/settings/json-store.ts`・`lib/settings/ds-device-notes.ts`・`lib/auth/**`（使うだけ）・`lib/config/allowed-ips.ts`（★IP 制限の扱いはリリース時。D6★）・`lib/config/index.ts`（★ds-agent の値を足さない。D9★）
- `next.config.ts`（★`proxyClientMaxBodySize` を足さない。4-1★）・`proxy.ts` の matcher
- `d-watch-mock.jsx`（モックに無い画面。書き換えない）・`components/ui/**`（共通部品を作らない）
- ★`components/features/errors/ErrorContentPicker.tsx`・`components/features/detail-log/AreaModal.tsx`・`lib/errors/__tests__/**`・`lib/errors/error-content-label.ts`★（★`ErrorContentPicker.tsx` ほかには他のセッションの未コミットの変更がある。4-19★）
- `.claude/launch.json`（★`d-watch-fixtures` は `next dev` なので段階11 では使わない。書き換えもしない★）
- ★`C:\Users\daich\dev\dwatch-log-agent` の中のファイル★（★読むだけ。段階11-3-7b の試験用フォルダ・設定はスクラッチパッドに作る★）
- `GET /ds-devices`・`PATCH /ds-devices/{serial_number}/note` の挙動・`lib/fixtures/ds-devices.ts`
- ★認証情報（`AUTH_TOKEN`・発行した制御盤トークン・DB の接続情報）を文書・コード・テスト・コミット・作業報告に書くこと★
- ★`git add -A`★・`eslint-disable` の追加

---

## 10. 範囲外（本書ではやらない）

- ★制御盤の Python の常駐プログラム★（設計書。DFA の制御盤ソフトの担当が作る）。★zip の中のパス（`{root の name}/…`。D24）・指示の値と設定ファイルの値の小さいほうを採ること・別スレッドでの処理・配布の方法も制御盤側★。本書の偽制御盤スクリプトは ★D-Watch 側の確認用★ で、その代わりにはならない
- ★TeamWorks の nginx の設定★（第13回 S-5: 制御盤から 443 で届くこと・`/api/ds-agent/` の本体 5MB と一時ファイルに書かないこと・`X-Accel-Buffering` を効かせること・`proxy_read_timeout` を60秒より短くしないこと）。監督が照会する
- ★リリース時の IP 制限の扱い★（D6・D-154）と、★トークンの発行・失効の権限の絞り込み★（P1 は DFA 社内の cookie 認証の全員が使える）
- ★顧客への公開★（U3）・`facilities.contract` による出し分け
- 一覧の結果のキャッシュ（毎回制御盤に問い合わせる）・「前回のエクスポートをもう一度」
- 認証の失敗の回数制限（リリース時のセキュリティ強化。§12-2）
- ★制御盤の再起動への対応★: 本番機は ★更新のある日の 12:00 にラズパイごと再起動する★（設計書 §5.13.1 の改訂案）。★D-Watch 側では一時的に「未接続」になるだけで、特別な処理は要らない★（その瞬間に進行中のエクスポートは失敗する。既存の失敗の扱いのまま）

---

## 11. 質問と回答（2026-10-19）

★**Q1〜Q13 は監督判断（第2ラウンド。M1〜M11）で確定し、本文に反映済み。Q14〜Q16 は追加の監督判断で確定（Q14・Q15 は本書の案 P1・P2 をそのまま採用）。未確定は無い**★。
★**11-3 は `99e5818` での監督の見直し（B-1〜B-9）。11-4 は `7492cc5` での監督の見直し（ユーザー回答 U1 と裁定 M1〜M20）。どちらも本文に反映済み。★11-3 と 11-4 が食い違うところは 11-4 が正★（11-3 の該当する行には「旧:」と 11-4 への参照を書いた）**★。
★判断が変わったら、「直す場所」を本文と同時に直すこと★（本文と末尾の決定が食い違ったまま渡さない）。

### 11-1. 監督判断（第2ラウンド・2026-10-19）で確定したもの

| # | 論点 | 回答 | 反映した場所 |
|---|---|---|---|
| Q1 | ブラウザ向けの検証エラーのステータスとコード（ブリーフは 400 `validation_error`。既存は 422 `validation_failed`） | ★422 `validation_failed`★（M2。ブリーフの 400 は誤り）。制御盤向けの形の誤りは 400 `result_invalid` / `chunk_invalid` | §2-2 D17 / 表3 KA6 / 表5 R8 / 段階2 / 3-3 / 7 / 11-1 |
| Q2 | エクスポートの指示を制御盤に渡す時点 | ★「準備 → 送信」の2段★（M1）。POST で `export` を渡し prepared を最大50秒待って 201、GET で `send` を渡す。★暫定（GET の時点で渡す）は置き換え★ | §0 / §2-2 D14・D15 / 表5 R10〜R34 / 4-10・4-11 / 段階2・3-1・6・7・8・9・11 |
| Q3 | `truncated` を画面に伝えるか | ★伝える★（M3）。制御盤は新しい順に並べてから 2万件 / 4MB で打ち切る。`LIST_RESULT_MAX_BYTES` は 4MB | §2-2 D18 / 表5 R38 / 段階2・3-1・7-2・9-2・10 |
| Q4 | 期間ボタンの `to` | ★省略（暫定どおり）★ | D23 / 表2 T1〜T3 |
| Q5 | `types` の未知のキー | ★0件（暫定どおり）★ | D23 / 表1 Q4・Q5 |
| Q6 | zip のファイル名 | ★`[A-Za-z0-9._-]` 以外を `_`・`filename*` も（暫定どおり）★ | D23 / 表6 N4 / 4-5 |
| Q7 | ダウンロードの開き方 | ★`<a href download>` の `click()`（暫定どおり。M1）★。GET の JSON のエラーはまれで、ファイルとして保存されうる（既知の制約） | D21 / 段階2・9-2 |
| Q8 | proxy の非同期化 | ★async にし `authenticateAgent()` を分ける（暫定どおり）★。★トークンのストアは mtime を見てメモリにキャッシュしてよい★ | D22 / 表3 K12 / 表4 / 段階4・5 |
| Q9 | 順番違いのチャンク | ★エクスポートも失敗（暫定どおり）★ | D23 / 表5 R21 |
| Q10 | ブリーフに名前の無いエラー | ★429 `agent_busy`・410 `command_expired`・400 `result_invalid`（暫定どおり。M2）★ | D17・D23 / 段階2 / 表5 R3・R37 |
| Q11 | 偽制御盤スクリプトをコミットするか | ★`scripts/ds-agent-fake.ts` としてコミット（暫定どおり）★。★zip は root の名前つき（M6）★ | D23・D24 / 段階11-3 / §9 |
| Q12 | 同じ制御盤からの2本目のポーリング | ★古い方を 204（暫定どおり）★ | D23 / 表5 R9 |
| Q13 | 画面の既定と選択の保持 | ★期間の既定は直近24時間・種類／期間を変えたら選択を消す（暫定どおり）★ | D23 / 段階9-2 |
| — | （M4）未接続の判定 | ★認証の通った制御盤のすべてのリクエストで `last_seen_at` を更新★ | D19 / 表5 R7 / 段階5-5・6 |
| — | （M5）タイムアウト | ★`EXPORT_IDLE_TIMEOUT_SEC` = 45。流量制御の保留も最大45秒で 410★ | D20 / 表5 R31・R32 / 段階3-1 |
| Q16 | チャンク・`complete` を送る command_id | ★export の指示の id★（追加の監督判断 2026-10-19）。`send` は自分の id を持ち `params.export_command_id` で指す。あわせて: `send` で zip が無ければ `fail` `export_not_found`／list はエクスポートと並行（待ち行列に入れてよい）／制御盤の JSON の形の誤りはすべて 400 `result_invalid`／prepared から失効までを進行中に数え、60秒で失効 | §2-2 D25 / 表5 R17・R19・R28・R33・R40〜R43 / 段階2 / 6-2 / 7-2・7-3 |
| — | （M8）トークンの発行 | ★D-Watch の API（POST / DELETE `…/agent-token`）と画面から。スクリプトは作らない★。ログ `ds_agent.token_issued` / `token_revoked` | D16 / 表3 KA1〜KA8 / 表4 A11 / 段階2・4・7・9・10・11 / §9 |

### 11-2. 追加の監督判断（2026-10-19）で本書の案を採用したもの

| # | 論点 | 具体例 | 回答 | 反映した場所 |
|---|---|---|---|---|
| Q14 | ★トークンの発行日時を画面に出すための読み取り★ | M8 の API は POST と DELETE だけ。画面は「発行日時」を出す（M8）ので読む手段が要る。一覧の GET（`…/logs`）に載せると、未接続（409）のときに出せない — ★初めて設定するときは必ず未接続★ | ★採用（P1）★: `GET …/agent-token` → 200 `{ issued_at: string \| null }`（★トークンの値・ハッシュは返さない★）。★ブラウザ向け API は6本★ | 段階2 / 表3 KA7 / 段階4・7・9-2 |
| Q15 | ★未発行の DELETE★ | 二重に押した・別の人が先に失効させた | ★採用（P2）★: ★204（冪等）★ | 表3 KA4 / 段階2・7 |

### 11-3 見直し（2026-10-19）

★develop が `739de1f`（本書の作成時）から `99e5818`（指示書AX・AZ・BA の実装の後）に進んだので、監督が先端で参照を突き合わせ、次のとおり判断した★。
★期待値表の値（入力・期待される並び・状態コード）は変えていない★（表1 の name も元のまま。B-9）。

| # | 論点 | 判断 | 反映した場所 |
|---|---|---|---|
| B-1 | ★基点と行番号★ | 旧: ★基点は `99e5818`★（★2026-11-05 に `7492cc5` へ改めた。行番号も `7492cc5` で確かめ直した（§11-4 の「行番号」）★）。AX の実装（`d282e07`。記録 `af26086`）が入り、前提は満たされた。当時直した行番号: D-179 `12934 → 12938`・`logEvent` `112 → 113`・colgroup・`/ds-devices`・note・securitySchemes・`dsDeviceExists`・`/ds-alerts`（★値はどれも `7492cc5` でさらに動いた。本文の値が正★）。★着手時に先端がずれていたら段階0-4 で確かめ直す★ | 冒頭 / §1 / 段階0-1 / 段階2-1 / 段階9-1 |
| B-2 | ★決定番号★ | ★D-180（指示書AX）・D-181（指示書BA）は他の指示書の決定★。★AY は新しい D 番号を取らず、D-179 の末尾に「実装の記録」を足すだけ★。★D-179 の末尾には 2026-10-19 の「4回目の追記」がある★ので、その後ろ（D-180 の前）に足す。追記の前に `grep -n "^### D-1[78][0-9]" docs/decisions.md` で位置を確かめる | 冒頭 / 段階0-3 / §9 / §13 |
| B-3 | ★`request.signal` の参照先★ | ★`route.md` には signal・abort の記述が無い★。★Web 標準の `Request.signal`（Next.js の docs に記述なし）★。★ブラウザの切断で abort が来ることは推測★ → ★段階11-3 で必ず確かめる★。来ないなら書き込みの失敗や `cancel()` で検知する。ストリーミングの参照は route.md の Streaming の節（`:367`）のまま | 4-9 / 段階0-6 / 段階11-3 の 7 |
| B-4 | ★モーダルの Esc・フォーカス・背景のスクロール★ | 旧（10-19）: 「AreaModal に Esc が無いので `DeviceLogsModal.tsx` の中に全部書く・共通部品は作らない」。★2026-11-05 に改めた（§11-4 M2）★: ★AreaModal には BC 5-4 で Esc が入った（`:66-73`）★ので Esc はその書き方に倣う。フォーカスの移動・Tab の閉じ込め・背景のクリックは ★エラー一覧の `ErrorContentPicker.tsx`（指示書BE）に倣い、判定の純粋関数を `lib/ui/modal.ts` に移して共有する★。背景のスクロールの停止だけは前例が無いので `DeviceLogsModal.tsx` の中に書く | §1 / D11 / 段階9-2 / 段階11-2 |
| B-5 | ★D-Sec ログ画面は変わったか★ | 旧（10-19）: 「`DsLogScreen.tsx` は作成時から変わっていない」— ★誤りになった（§11-4）★。★10-19 の BC 段階4（`bce6a93`）で備考が編集モードになった★（ほかに BB・BC の `370ac96` / `52e23e5` / `6c2848c` / `50a3082` も触った） | §1 |
| B-6 | ★「ログ」列の幅★ | 旧（10-19）: 「固定幅にし、余り幅の配分で広がらないようにする」。★2026-11-05 に改めた（§11-4 M1）★: ★D-167（全列に幅があり余りは比で配られる）を崩さない。列が少し広がるのは認め、ボタンを固定幅にする★。最小幅は 800 → 872px（72px の列のとき） | 4-20 / 段階9-1 / 段階11-2 |
| B-7 | ★備考の編集中に「ログ」を押したとき★ | 旧（10-19）: 備考は1件ずつ確定する作りなので特別な処理はしない、としていた。★2026-11-05 に改めた（§11-4 U1）★: ★編集モード中は「ログ」を押せない★（ユーザー回答「編集中は押せない」） | §1 / 4-21 / 段階9-1 / 段階11-2 |
| B-8 | ★`lib/fixtures/index.ts` の変化★ | ★AX で `ds-alerts.ts` を消したので `./ds-alerts` の export は無くなった★。★1行足す作業には影響しない★ | 段階8-1 |
| B-9 | ★設計書の更新（§1.6・§1.6.1・§5.2・§5.13・§5.13.1）への追従★ | ① ★fixtures の偽の一覧と偽制御盤スクリプトの種類と root 名を実機に合わせる★: 種類は `api_client` / `postcondition_security` / `postcondition_kick_action` / `send_start_sig`（表示名は設計書の仮の名前。★テストの期待値に表示名を埋め込みすぎない★）。ファイル名は `<種類>.log` と `<種類>.log.YYYY-MM-DD`（★一覧の `name` は 2026-10-20 から `<種類>/<ファイル名>`。§11-4 M6★）。★zip の中のパスは `<種類>/<ファイル名>`（root はフォルダごと）★。② ★表1 は純粋関数の入力なので架空の名前のまま★（★実機の種類とファイル名の形は fixtures（段階8-1）と偽制御盤スクリプト（段階11-3）でだけ使う★。一度実機の形に置き換えたが、名前の日付と `modified_at` が食い違って実装者を迷わせるため監督判断で取り消した）。③ ★制御盤は更新のある日の 12:00 に再起動する★。★D-Watch 側は一時的に「未接続」になるだけで、特別な処理は要らない★ | §3 冒頭 / 段階7-4 / 段階8-1 / 段階11-2・11-3 / §10 / §12-2 |

### 11-4 監督の見直し（2026-11-05。基点 `7492cc5`）

★develop が `99e5818` から `7492cc5` に進んだ（指示書BB・BC・BE〜BI ほか）。監督が点検役2人（前半＝冒頭〜§4・段階0〜7、後半＝段階8〜11・§10〜§12）に先端で突き合わせさせ、次のとおり裁定した★。★本文と §11-3 を同時に直した★（§11-3 の B-1・B-4〜B-7 は「旧:」と書いてここを指す）。

★崩れていた前提（4件）★:
1. ★D-Sec ログ画面に編集モードが入った★（10-19 の BC 段階4 `bce6a93`。`editing` / `drafts` / `saveNoteDrafts`・編集／保存（N件）／キャンセル・`<fieldset disabled>`。旧の「備考は1件ずつ確定」の前提は消えた。表の列数の定数 `COLUMN_COUNT = 5`（`:66`）がある）
2. ★AreaModal に Esc が入った★（`:66-73`。✕に `aria-label`。フォーカス・スクロールの停止は無い。`role="dialog"` `:76`）
3. ★Next.js が 16.3.3 → 16.4.2★（`package.json:21`。`next-server.js` の参照は `:1205-1233`。10MB で切り捨てる挙動は同じ）
4. ★`json-store` の書き込みが直列化された★（`createJsonStore` `:46`・`read` `:52`・logger を import しない `:22-24`。★本書の使い方は崩れない★）

★裁定★:

| # | 何を | なぜ | どこを直したか |
|---|---|---|---|
| U1 | ★「ログ」ボタンは編集モード中は押せない★（ユーザー回答 2026-11-05「編集中は押せない」。本文では §2-1 の U6） | 絞り込み・ページ送りと同じ扱い。★下書きを守る★ | §0 / §2-1 U6 / 4-21 / 段階9-1 / 段階11-2 / §11-3 B-7 |
| M1 | ★「ログ」列は D-167 を崩さない★。列が少し広がるのは認め、★ボタンを固定幅に★。最小幅は列幅の合計（800 → 872px。72px の列のとき）・コメントの式も直す。★`COLUMN_COUNT` を 6 に★ | D-167 はユーザーが決めた作り。`COLUMN_COUNT` を直し忘れると保存に失敗した行の文言が切れる | 4-20 / 4-22（新）/ 段階9-1 / 段階11-2 / §11-3 B-6 |
| M2 | ★`trapFocusIndex`・`shouldCloseOnBackdrop` を `lib/ui/modal.ts`（新規）へ移し、`lib/errors/error-content-picker.ts` から再 export★。Esc は AreaModal と同じ書き方、フォーカス・Tab・背景のクリックは ErrorContentPicker と同じ。背景のスクロールの停止は自前 | 前例（BE）があるのに二重に書かない。既存のテストと `ErrorContentPicker.tsx`（他のセッションの未コミットあり）を変えずに済む | §0 / §1 / D11 / 段階9-2 / §8 / §9 / §11-3 B-4 |
| M3 | ★`全て: "All"` を辞書に足す★（既存の D-Sec ログの「全て」ボタンも EN で "All" になる。「今の画面を変えない」の例外）。★0件の文言「該当するファイルはありません」を足す★ | `全て` は辞書に無く EN でも日本語のまま。失敗と該当なしを分ける（BC 段階2） | 4-13 / 段階9-1・9-2・9-3 / 段階11-2 |
| M4 | ★古い応答は `useEffect` の片付けで `cancelled = true` にして捨てる★（番号方式はやめる） | BC 段階1（D-183）で全画面がそろえた作法 | 段階9-2 / 4-19（抑止コメントの例外） |
| M5 | ★失効（DELETE。204）は素の `fetch`★。★準備の POST を取り消したときの `ApiFetchError`（status 0）はエラー表示しない★ | `apiFetch` は 204 を例外にする（`lib/api/client.ts:129-150`。前例 `app/login/page.tsx:34`） | 4-23（新）/ 段階9-2 |
| M6 | ★一覧の `name` は `{root の name}/{相対パス}`（zip の中のパスと同じ）★。fixtures・偽制御盤スクリプトをこの形に。表1 は架空の名前のまま。ファイル名の列は省略表示＋`title` | 2026-10-20 に設計書（§5.5 の表）と常駐プログラムが変わった | 冒頭 / §3 冒頭 / D24 / 段階7-2 W11 / 段階8 / 段階9-2 / 段階11-2・11-3 / §11-3 B-9 |
| M7 | ★クエリと本文の検証を改めた★: (a) 空は省略と同じ（V4）(b) `page` / `per_page` は丸める（V6。手本 `lib/ds-devices/query.ts:56-60`。page / per_page を持つ7つの query.ts が同じ）(c) 存在しない日付は 422（V2b・W4b。`parseDate()` で日付の部分を確かめる）(d) オブジェクトでない JSON は POST exports が 422「JSONの形式が不正です」（V8）・制御盤向けは 400 `result_invalid`（R44・W10）。旧 D17 の「既存の流儀」という書き方は消した | 既存の一覧 API と食い違っていた（旧 V4・V6 は 422）。`Date.parse` は存在しない日付で NaN にならない。`null` の本文で 500 にしない（BB） | 冒頭 / §1 / D17 / 表5 R44（新）/ 4-24（新）/ 段階2 / 段階3-1・3-3 / 段階7 / §8 |
| M8 | ★`export_id`・`command_id` は `parsePathReportId()` と同じ作法★（空と上限超だけ 422・文字種は絞らない・`Number` を通さない。上限は `lib/config/ds-agent.ts`）。★`seq` は 0 を許す整数なので `parsePathId()` を使わず専用の検証★ | パスの検証が書かれていなかった | 段階2 / 段階3-1 / 段階3-3 X1〜X6（新）/ 段階7 |
| M9 | ★並べ替えはボタン2つのまま★。YAML の description に「この API の `sort` は向きを持たない1引数」と書く | 清掃詳細ログ等の `sort` / `order` と取り違えない | 段階2 / 段階9-2 |
| M10 | ★`ds_logs.list` と `ds_logs.export_failed` の表に `agent_code`★。union は `:486-501`。`ds_agent.auth_failed` は `method` を持つ。★`docs/logging-design.md` の §3 の表と §10 の一覧への追記は Cursor の担当★ | 箇条書きと表が食い違っていた。`AuthLogEvent` に合わせる。BI と同じ分担 | 段階5 / 段階10 / §9 |
| M11 | ★404 の description は note と同じ文★（一覧に出ない制御盤。D-182） | `dsDeviceExists()` の中身が変わった（論理削除・`controls.status`） | 段階2 / 段階7-1 |
| M12 | ★agent の6本の前処理は `lib/ds-agent/` に切り出してよい（任意）★ | BI の `lib/clean-logs/advice-response.ts` と同じ考え方 | 段階7-3・7-4 |
| M13 | ★トークンのストアはパスを受け取って作り、テストはストアとパスを一緒に差し替える★ | `JsonStore` はパスを返さないので、mtime のキャッシュ（K12）が見るパスが決まらなかった | 段階4 / 表3 K12 / §3 テストの注意 / 段階7-4 |
| M14 | ★段階11-2 は起動時にも `USE_FIXTURES=true`★。止めるときは 3100 のプロセスのコマンド行を確かめる。`launch.json` の `d-watch-fixtures`（`next dev`）は使わない | `.env.local` は `false` で、`use-fixtures.ts` は実行時に読む。渡さないとステージングDB | 段階11-2 |
| M15 | ★段階11-3 のローカルDB への向け方★: シードは `DATABASE_URL`、アプリは `DATABASE_HOST` 系を優先（`prisma.ts:42-65`）。どちらも `.env.local` 5行目のコメント（ローカル用の値）から環境変数で渡し、host と DB 名を確かめる。`MySQL84` は手動起動 | `.env.local` の有効な値はステージング。片方だけ上書きするとステージングにつながる | 段階11-3 |
| M16 | ★本物の常駐プログラムでの通し確認（11-3-7b）を足した★（`DS-003`・トークンは表示しない・`agent.py` は読むだけ・試験用のフォルダと設定はスクラッチパッド・片付けまで）。`fake_dwatch.py:631` の `total_bytes ≥ 1` と本書の `≥ 0` の差は実害なし | 偽制御盤スクリプトだけでは本物との食い違いを見つけられない | 段階7-2 / 段階11-3 |
| M17 | ★ダウンロードの確認は `javascript_tool` の `fetch` で★（ヘッダ5つと `Content-Encoding` 無し・`crypto.subtle.digest` の SHA-256・取り消しは `reader.cancel()`）。★画面で保存する確認はユーザーの許可を取って1回だけ★。`window.confirm` は差し替えてよい | アプリ内ブラウザでのファイルの保存にはユーザーの許可が要る | 段階11-2・11-3 |
| M18 | ★§12 に申し送りを足した★: 設計書 §5.11 と常駐プログラムの README の `:3000` を `:3100`（`next start`）に／★ステージングのコンテナを作り直すと `data/settings` が消える恐れ★（`docs/deploy.md` に記述なし。TeamWorks への確認） | 段階11 の手順と食い違う。トークンが反映のたびに消えると制御盤が全部止まる | §12-1・§12-3（新） |
| M19 | ★Next.js は 16.4.2★。`next-server.js` は `:1205-1233`。10MB で切り捨てる挙動は同じ。`json-store` の直列化で本書の使い方は崩れない | 前提の確かめ直し | §1 / 段階0-6 / 段階4 |
| M20 | ★段階9 の並べ替えに BH の `SortableTh` は使わない★ | 向きの切り替えと API が合わない（M9） | 段階9-1 |

★行番号（`99e5818` → `7492cc5`。監督が実物で確かめた）★: D-180 `13041 → 13050`・D-181 `13081 → 13090`（D-179 `:12938` はそのまま）／events の union `456-471 → 486-501`／`DsLogScreen.tsx` の colgroup `438-444 → 464-470`・見出し `445-452 → 471-479`・`formatDateTime` `41 → 47`／`DsLogScreen.module.css` の最小幅 `82-88 → 199-205`／YAML の `/ds-devices` `1381 → 1726`・note `1418 → 1765`（`maxLength` `1429 → 1776`）・`/ds-alerts` `1457 → 1813`（区切り `:1812`）・securitySchemes `1718 → 2104`／`parsePathSerialNumber` `70-79 → 71-80`／`dsDeviceExists` `369 → 421`（fixtures `689 → 774`・Prisma `1957 → 2137`）／`DsDevice` `1024 → 1132`／`ErrorCode` `31-45 → 32-46`／`PAGINATION.maxPerPage` `438 → 470`／`toJstIso` `37 → 42`／`json-store` の `createJsonStore` `39 → 46`・`read` `45 → 52`／`next-server.js` `1197-1217 → 1205-1233`／`package.json` の next `:18 → :21`。★`logEvent` `:113` は変わらない★。
★テストの件数（`7492cc5` の作業ツリー。監督の点検役の実測）: 157ファイル・3,855件★（§15 の「前」）。

---

## 12. 申し送り

### 12-1. 設計書（制御盤の常駐プログラム）の担当へ（★監督経由。本書の実装では設計書を直さない★）

1. ★D25（追加の監督判断）★は設計書側も同じ内容で直している（監督から連絡あり）。★実装の前に設計書と表5 R17・R19・R28・R33・R40〜R43 が一致しているかを確かめる★
2. ★チャンクの POST は、ブラウザ側が読むまで 204 が返らない（最大45秒、超えたら 410）★。制御盤の HTTP のタイムアウトは60秒
3. ★`modified_at` は必ずオフセット付き★（naive な `isoformat()` は結果ごと不正になる。W4）
4. ★`X-Chunk-Sha256` は小文字の16進64桁★。`seq` は0から連番。409 / 410 が返ったら送信をやめる
5. ★prepared の本体★: `total_bytes`（0以上の整数）・`sha256`（小文字の16進64桁）・`file_count`（1以上）。★`send` が来たら同じ zip を送る（作り直さない）★ — `complete` の `sha256` は prepared と同じでなければ 400
6. 一覧の結果は ★4MB 以下・2万件以下★・★`items[].type` は必ず `types` のどれか★・`file_id` は重複させない（W2〜W8）
7. ★（2026-11-05 追加。M18）設計書 §5.11「開発時の動かし方」（`:991`。`:993` と `:999`）と常駐プログラムの `README.md` の「開発 PC での動かし方」（`:106`・`:108`・`:115`）を、★`:3100` の `next start`★（`--server http://localhost:3100`）に直す★。★`:3000` はユーザーの `next dev`（ステージング向け）で、試験では触らない（段階11）★。
   ★あわせて README の「D-Watch 側（指示書AY）ができるまでは…1・2 はできない」（`:137`）を AY の実装の後に直す★。★設計書の担当の作業（本書の実装では直さない）★

### 12-2. 監督へ（本書の実装では直さないもの）

1. `CLAUDE.md` §3 の「リリース直前のセキュリティ強化」の対象に ★制御盤トークンの失敗の回数制限★・★agent パスの IP 制限の扱い（D6）★・★トークンの発行・失効を誰ができるか★ を足す
2. `docs/fixtures.md` に fixtures の制御盤のログ（★種類4つ（実機に合わせた `api_client` ほか。表示名は仮）★・約300件・境界の項目・未接続 `DS-001`・ダミーの zip（★`<種類>/<ファイル名>`★））を足す（ブリーフ §7）
3. `docs/deploy.md` に ★1プロセス前提★（PM2 / cluster にしない・再起動で進行中のエクスポートは失われる）と ★`data/settings/ds-agent-tokens.json` が再デプロイで消えないこと（Docker なら volume。M8）★（★§12-3 の確認の結果を待って書く★）
4. `docs/spec.md` / `docs/code-map.md` に機能とファイルの対応

### 12-3. TeamWorks への確認・依頼（★監督が扱う。2026-11-05 追加。M18 (b)★）

1. ★ステージングのアプリはコンテナで、反映のたびにイメージを作り直している★（`fetch` ＋ `reset --hard` の後に作り直す運用）。★`data/settings/` がボリュームに載っていなければ、反映のたびに中身が消える★ — ★本書のトークン（`ds-agent-tokens.json`）だけでなく、既存の `advice.json`・`display-limits.json`・`ds-device-notes.json` も★。
   ★帰結の例★: トークンが消えると、★次の反映の直後からすべての制御盤が 401 になりログを送れない★（画面では「未発行」に戻る）。備考・アドバイスの設定・表示の上限も初期値に戻る。
2. ★`docs/deploy.md` にはこの記述が無い★（`7492cc5`）。★ボリュームの有無を TeamWorks に確かめ、無ければ `data/settings` をボリュームに載せるよう依頼する★。結果を §12-2 の 3 と `docs/deploy.md` に書く

---

## 13. 決定の記録（★D-179 の末尾に足す「実装の記録」の下書き★）

★足す場所は D-179 の末尾の「2026-11-05 の追記」（本書の2回目の見直し）の後ろ、D-180 の見出しの前★（`grep -n "^### D-1[78][0-9]" docs/decisions.md` で確かめる）。★新しい D 番号は取らない★。

```markdown
- ★実装の記録（20XX-XX-XX。指示書AY）★:
  1. ★コミット★: （段階ごとのハッシュを列挙）
  2. ★API★: ブラウザ向け6本（`GET /ds-devices/{serial_number}/logs`・`POST …/logs/exports`（準備。最大50秒・201）・`GET …/logs/exports/{export_id}`（送信。`Content-Length` 付き）・`GET / POST / DELETE …/agent-token`）と制御盤向け6本（`/ds-agent/commands`・`…/result`・`…/prepared`・`…/chunks/{seq}`・`…/complete`・`…/fail`。kind は list / export / send）。`d-watch-api.yaml` が正
  3. ★部品★: `lib/ds-agent/relay.ts`（メモリ内の中継。`globalThis`。1プロセス前提）・`lib/ds-agent/tokens.ts`（`data/settings/ds-agent-tokens.json` に SHA-256 だけ。mtime でキャッシュ）・`lib/ds-logs/{query,presets,file-name,validate}.ts`（純粋関数）・`lib/config/ds-agent.ts`（設定値。待ちは 15 / 25 / 30 / 45 / 50秒で nginx の60秒より短い）・`scripts/ds-agent-fake.ts`（開発用の偽制御盤）
  4. ★認証★: proxy を async にし、`/api/ds-agent/` 配下は `authenticateAgent()`（IP → Bearer）。cookie では通らない。ルートでも `requireAgent()` で再検証し、すべての agent リクエストで `last_seen_at` を更新。トークンの発行・失効は cookie のパス（API と画面）
  5. ★監督判断 第2ラウンド（M1〜M11）を反映★。★§11 Q14・Q15 は本書の案（P1 `GET …/agent-token`・P2 未発行の DELETE も 204）を監督判断で採用★。★2026-11-05 の見直し（§11-4。編集モード中は「ログ」を押せない・モーダルの部品を `lib/ui/modal.ts` で共有・空は省略と同じ／page と per_page は丸める／存在しない日付は 422 ほか）を反映★
  6. ★確認★: テスト件数（前 → 後）／fixtures の画面確認（当方の fixtures 由来）／非 fixtures の通し確認（当方の偽制御盤スクリプト由来: zip の大きさ・チャンク数・SHA-256 の一致・準備の失敗・切断で 410・未接続）
  7. ★本書と違う判断をしたところ★: （あれば）
```

---

## 14. コミット

段階ごとに分ける（段階1 / 段階2 / 段階3 / 段階4 / 段階5 / 段階6 / 段階7 / 段階8 / 段階9 / 段階10 / D-179 の追記）。★`git add` はファイルパスを明示する★（`git add -A` 禁止）。
メッセージには ★何を変えたか★ と ★なぜ（D-179。制御盤のログを中継で取り出す・サーバーに保存しない）★ を書く。★段階1 は「テストだけ（この時点では落ちる）」と明記★。
★`docs/decisions.md` は自分の塊だけをコミットする★（他のセッションの未コミットの差分を巻き込まない）。★PR の作成とマージは daichi がレビューしてから★（D-154）。

---

## 15. 完了条件

- [ ] 基点のコミットを冒頭に記入し（`7492cc5` 以降）、★本書の行番号を先端で確かめ直した★
- [ ] 検証エラーは 422 `validation_failed`（D17）。§11 Q14・Q15 の採用（P1 `GET …/agent-token`・P2 未発行の DELETE も 204）を実装した
- [ ] `tsc` 0件・`lint` 0件・`npm run build` 成功（足したルートが `ƒ`）
- [ ] `npm test` 全件通過: 前 ★157ファイル・3,855件（`7492cc5`）★ → 後 ____ ファイル・____ 件（★着手時の先端で数え直して前が違えば、その値も書く★）
- [ ] ★`lib/ui/modal.ts` に2つの関数を移し、`lib/errors/error-content-picker.ts` は再 export。`lib/errors/__tests__/error-content-picker.test.ts` と `ErrorContentPicker.tsx` は無修正★（M2）
- [ ] ★編集モード中は「ログ」が押せない（U6）・`COLUMN_COUNT` が 6・最小幅が列幅の合計★（M1）
- [ ] 段階11-1 の grep がすべて0件・`scripts/ds-agent-token.ts` が無い
- [ ] ★ディスクに書かないことをテストで確かめた★（スパイ0回・ソースの静的な検査）
- [ ] ★proxy の matcher・`next.config.ts` を変えていない★。`/api/ds-agent/` は cookie で通らず、他のパスは Bearer で通らない（表4）
- [ ] ★エクスポートは準備 → 送信の2段★: 準備の失敗は POST の 502、prepared の `total_bytes` の上限・`complete` の合計と SHA-256 の照合、GET に `Content-Length`（表5）
- [ ] ★待ちは 15 / 25 / 30 / 45 / 50秒（すべて nginx の60秒より短い）★
- [ ] ★トークンの生の値がストア・ログ・文書・コミットに無い★。発行・失効は API と画面から（`ds_agent.token_issued` / `token_revoked` に `serial_number` だけ）
- [ ] fixtures（:3100。★起動時にも `USE_FIXTURES=true`★）で: 一覧 → 絞り込み → 選択 → ★エクスポート（`javascript_tool` の `fetch` でヘッダ5つ・SHA-256 の一致・2回目は 404。画面での保存はユーザーの許可を取って1回だけ）★／★`DS-001` で 409 の表示（トークンの部分は使える）★／トークンの発行・作り直し・失効／★モーダルの Esc・フォーカス・Tab・背景のクリック・スクロールの停止★／JP/EN・ダーク/ライト
- [ ] ★非 fixtures で偽制御盤スクリプトによる通し確認★: ★4MB を超えるファイルが複数チャンクで届き、SHA-256 が一致★／準備の失敗が画面に出る／途中の切断で 410／止めて60秒で未接続
- [ ] ★本物の常駐プログラム（`agent.py`。`DS-003`）での通し確認（11-3-7b）★と片付け（トークンの失効・試験用フォルダと設定の削除）
- [ ] ★`docs/logging-design.md` の §3 の表と §10 の一覧に足した★（M10）
- [ ] `docs/decisions.md` の D-179 の末尾に「実装の記録」を足した（D-179 の本文・`CLAUDE.md` には触っていない）
- [ ] 作業報告に: コミット一覧／テスト件数（前後）／確認の結果（★出所を書き分ける。`CLAUDE.md` §6-6★）／本書と違う判断をしたところ／行番号のずれ／§12 の申し送り
