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

**日付**: 2026-10-19 / **担当**: Cursor（または監督が指名する実装担当）/
**基点**: ★指示書AX の実装の完了後の develop の先端★（着手時に `git log --oneline -1` で記入: `_______`）
**前提**: ★D-179★（`docs/decisions.md:12934` 以降。★本書の作成と同時に監督がコミットする★）/ ★指示書AX の実装コミット★（担当ファイルが重なる。下記）/
★監督判断 第2ラウンド（2026-10-19。M1〜M11。§11 に反映済み。ブリーフより優先）★ /
設計書 `docs/handoff/設計_制御盤ログの取り出し_20261019.md`（★作成済み・本書と突き合わせ済み（2026-10-19）。制御盤の常駐プログラムの仕様はそちら★）/
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〜U5）と監督の設計判断（§2-2 の D1〜D25）は確定仕様。変えないこと**★（`CLAUDE.md` §0-1）。
★**期待値表（§3）は正。変えないこと**★（`CLAUDE.md` §0-2）。★§11 Q1〜Q16 はすべて監督判断（第2ラウンドと追加）で確定し、本文に反映済み★（★Q14・Q15 は本書の案 P1・P2 をそのまま採用。§2-3★）。

★**担当ファイルが指示書AX と重なる**★: `components/features/ds-logs/`（AX 段階7 が `DsLogScreen.module.css:83` のコメントを直す）・`d-watch-api.yaml`・`lib/i18n.ts`・`lib/types/index.ts`・`lib/fixtures/index.ts`。
★**AX の実装コミットが develop に入ってから着手する**★。

★**行番号は本書の作成時（2026-10-19）の作業ツリーで確かめたもの。develop の先端は AX のコミットで動いている（作成中にも動いた）**★。
★**着手時に develop の先端で、本書に書いた行番号をすべて確かめ直すこと**★（段階0-4）。ずれていたら関数名・文で探し直す。

★**`docs/decisions.md` に足すのは「D-179 の末尾への『実装の記録』」だけ**★（§13 の下書き）。★新しい D 番号は取らない★（★D-180 は指示書AX が予約済み★）。

---

## 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 ログの表の各行に「ログ」ボタン → ★モーダル★（種類・期間・並べ替え・一覧・選択・エクスポート・★制御盤トークン★） | 段階9 |
| 9 | ログのイベント（`lib/logger/events.ts`） | 段階10 |
| 10 | 画面確認（fixtures）と、★偽制御盤スクリプトによる非 fixtures の通し確認★ | 段階11 |

★**変えないもの**★: D-Sec ログの一覧（`GET /ds-devices`・`dsDevices()`）と備考（`PATCH /ds-devices/{serial_number}/note`）の挙動／
既存の cookie 認証・IP 制限の判定（`authenticate()` の今の分岐）／`lib/settings/json-store.ts` の中身／`prisma/`／メインDB（★本機能は DB に書かない。読むのは 404 の判定の `dsDeviceExists()` だけ★）。

---

## 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。出所: 構造（コード・`node_modules`））:
  - Next.js ★16.3.3★（`package.json:18`）。`middleware` は `proxy` に改名（`proxy.ts:1-21`）。★proxy は Node ランタイム固定★。
  - ★proxy が走るリクエストは、本体を clone してメモリにバッファする。既定の上限は 10MB。超えた分は切り捨てられ、★クライアントにはエラーにならず、サーバーに警告が1行出るだけ★★
    — `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:1197-1217`（`cloneBodyStream()` → `finalize()`）・`node_modules/next/dist/server/body-streams.js:52-118`（`bytesRead > bodySizeLimit` で `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:39`。`.tmp` → rename、壊れたら `.bak`。★`read()` は非同期★ `:45`）。前例 `lib/settings/ds-device-notes.ts`（`data/settings/…`。`/data` は `.gitignore:50`）。
  - ログは `logEvent(level, payload)`（`lib/logger/index.ts:112`）。イベントは `lib/logger/events.ts` の型付き union（`:456-471`）に足す。★キー名に `token` / `authorization` / `facility_name` / `note` 等を使うと本番で消される★（`lib/logger/redact.ts:12-46`）。
  - ★既存のストリーミング応答の例は無い★（CSV 出力は文字列を丸ごと返している）。
  - 画面: `components/features/ds-logs/DsLogScreen.tsx`（表の `<colgroup>` `:437-443`・見出し `:445-453`・日時の整形 `formatDateTime()` `:41`）。★モーダルの共通部品は無い★。前例は `components/features/detail-log/AreaModal.tsx:65`（`role="dialog" aria-modal="true"`・自前の overlay）。ページ送り `components/ui/Pagination.tsx`（`total` / `page` / `perPage` / `onPageChange`。★per_page の選択は持たない★）。日付は `<input type="date">` ＋ `openDatePicker()`（`components/ui/date-picker.ts:11`）。
  - API: `/ds-devices`（`d-watch-api.yaml:1376`）・`/ds-devices/{serial_number}/note`（`:1413`。path の `maxLength: 100`・文字種は絞らない）。パスの検証は `parsePathSerialNumber()`（`lib/api/path-params.ts:70-79`。★422 `validation_failed`★）。実在は `db.dsDeviceExists()`（`lib/db/index.ts:370`。fixtures `:680`・Prisma `:1944`）。
  - テスト: 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`）」を制御盤から受け取るだけ★で、種類の値を持たない・直書きしない |

### 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 ログの表の各行の「ログ」ボタン → モーダル★（`AreaModal.tsx` の作りに倣う）。★モックに無い画面なので、文言・配置は本書 段階9 を正とする★ | ユーザー要望（PUDU の画面） |
| 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`★（既存の流儀。M2。ブリーフの 400 `validation_error` は誤り）。★制御盤向けの形の誤りは 400 `result_invalid` / `chunk_invalid`★ | §11 Q1・Q10 |
| 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）★。D-Watch は zip の中身を見ない。★偽制御盤スクリプトと fixtures の zip は 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（`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:37`）で 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 | 検証した後に ★別のストアのインスタンスから★ 失効させる（ファイルの mtime が変わる） | 次の検証は `null`（★キャッシュは mtime で無効になる★） | D22 |
| 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 ④★ |

### 表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()` と同じ形）で `mkdtemp` のストアに差し替える。★`data/settings/` の実物に触らない★。KA1〜KA8 はルートのテスト（段階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 が切れたらエクスポートを取り消す）。★実際の切断で効くかは段階11 で確かめる★ |
| 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 で落ちる。`閉じる`・`キャンセル`・`読み込み中…`・`種類`・`日付` は既にある）。★色は 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）。★他のセッションの未コミットの変更を含めない★ |

---

## 段階0 — 着手前

1. ★`develop` で作業する★（`.claude/worktrees/` の中ではない。ワークツリーは main 基点で古い）。`git pull` で最新にし、★指示書AX の実装コミットが入っていること★を `git log` で確かめる。入っていなければ着手しない。基点のコミットを冒頭の欄に書く。
2. `git status` を控える。★自分が作っていない変更・未追跡ファイルは他のセッションの作業★。触らない・コミットに含めない。
3. ★`docs/decisions.md` に D-179 がコミット済みか★を確かめる。未コミットなら ★§13 の追記は D-179 のコミットを待つ★（`git add -p` で自分の塊だけを選ぶのでもよい）。
4. ★本書に書いた行番号を develop の先端ですべて確かめ直す★（特に `d-watch-api.yaml`・`lib/types/index.ts`・`lib/i18n.ts`・`lib/fixtures/index.ts`・`DsLogScreen.tsx` / `.module.css` — AX が触る）。ずれは作業報告に書く。
5. `npx prisma generate` → `npx tsc --noEmit -p .` → `npm test` が通ることを確かめ、★テストの件数を記録する★。通らなければ着手前に報告。
6. ★Next.js は版が新しい★。次を読む（`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-35`）・`…/03-file-conventions/route.md`（ストリーミング・`request.signal`）。

---

## 段階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
7. `lib/ds-logs/__tests__/validate.test.ts`（新規）: 段階3-3 の V1〜V7 と、段階7-2 の W1〜W9

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

---

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

### 2-1. 足すもの

1. `components.securitySchemes`（`:1734` 付近）に ★`agentBearer`★（`type: http`・`scheme: bearer`。description: 制御盤ごとのトークン。`/ds-agent/` 配下だけ。cookie では通らない。D-179）
2. タグ `ds-agent`（制御盤向け）
3. パス（`/ds-devices/{serial_number}/note` の後ろ・`/ds-alerts` の前に置く）。★検証エラーはすべて `ValidationError`（422 `validation_failed`。D17）★:

| パス | メソッド | security | 要点 |
|---|---|---|---|
| `/ds-devices/{serial_number}/logs` | GET | cookieAuth（既定） | クエリ `types`（カンマ区切り。省略で全種類）・`from` / `to`（ISO 8601 ★オフセット必須★。`from ≤ modified_at < to`）・`sort`（`date_desc` 既定 / `type_date_desc`）・`page`（1〜）・`per_page`（★50 / 100 / 200。既定 200★）。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`・422 |
| `/ds-devices/{serial_number}/logs/exports` | POST | cookieAuth | 本体 `{ file_ids: string[] }`（1〜`EXPORT_MAX_FILES` 件・重複なし・各 1〜1024 文字）。★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・422 |
| `/ds-devices/{serial_number}/logs/exports/{export_id}` | GET | cookieAuth | ★1回だけ使える★。200 は `application/zip`（`format: binary`）と ★5つのヘッダ（4-5）★。504 `agent_timeout`（30秒）・502 `agent_error`・404 `export_not_found`（無い・失効・使用済み・★パスのシリアルと違う★）。description に ★流し始めた後の失敗はダウンロードの失敗になる（ステータスは200のまま）★・★JSON のエラーはまれ（制御盤の切断など）で、ブラウザによってはファイルとして保存される（D21）★ |
| `/ds-devices/{serial_number}/agent-token` | GET | cookieAuth | ★P1（Q14 採用）★。200 `{ issued_at: string \| null }`。★トークン・ハッシュは返さない★。404・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` / 404 / 410 `command_expired` |
| `/ds-agent/commands/{command_id}/prepared` | POST | agentBearer | ★本体 `{ total_bytes, sha256, file_count }`★（export の指示だけ）。204 / 400 `result_invalid` / 404 / 410 `command_expired` |
| `/ds-agent/commands/{command_id}/chunks/{seq}` | POST | agentBearer | ★export の指示の command_id（D25）★。`application/octet-stream`・ヘッダ `X-Chunk-Sha256`（小文字の16進64桁）。204（★ブラウザ側に空きができてから。最大45秒★）/ 400 `chunk_invalid` / 409 `chunk_out_of_order` / 410 `export_cancelled` / 404 |
| `/ds-agent/commands/{command_id}/complete` | POST | agentBearer | ★export の指示の command_id（D25）★。`{ total_bytes, sha256 }` → 204 / 400 `result_invalid`（形の誤り・値の不一致）/ 410 `export_cancelled` / 404 |
| `/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` |

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`（`:1024` 付近）の近く）
- ★`ErrorCode`（`:31-45`）に足す★: `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:438`）と同じ 200 が上限★） |
| `DS_LOG_DEFAULT_PER_PAGE` | 200 | 既定 |

各定数の 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`★）

| # | 入力 | 期待 |
|---|---|---|
| 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 |
| V3 | `from` ＞ `to` | 422。★`from` ＝ `to` は通る（0件）★ |
| V4 | `types=`（空）／`types=app,,sys`（空の要素） | 422 |
| V5 | `sort=name` | 422 |
| V6 | `per_page=30` / `page=0` / `page=1.5` | 422 |
| V7 | POST の本体 `{ file_ids: [] }` / 501件 / 重複あり / 要素が文字列でない / 1025文字 | 422 |

コミット。

---

## 段階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>
export function __setAgentTokenStoreForTesting(store: JsonStore<AgentTokens> | null): void
```

- 保存先は ★呼び出し時に★ `path.join(process.cwd(), "data", "settings", "ds-agent-tokens.json")`（`ds-device-notes.ts:53-55` と同じ理由）。★再デプロイで消えないこと（Docker なら volume）は監督が `docs/deploy.md` に書く（§12-2）★
- ★`lib/logger` を import しない★（proxy から呼ぶため。`json-store.ts:15-17` と同じ考え方）。壊れたストアの警告は `onWarn` で `settings.store_corrupt` を出す（呼び出し側。前例 `ds-device-notes.ts:65-73`）
- ヘッダの形は ★`/^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`。★ヘッダの値は出さない★）。
   ★冒頭コメントの「実行順」と 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） | 同じ順で検証・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） | `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）

### 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()`） | 不正 |
| 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） |

prepared の本体（`total_bytes` は0以上の整数・`sha256` は小文字の16進64桁・`file_count` は1以上の整数）・complete の本体・fail の本体も同じファイルで検証し、★形が違えばすべて 400 `result_invalid`（D25 ④）★。

### 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` に関係なく同じ動き）。

### 7-4. テスト

| ファイル | 内容 |
|---|---|
| `app/api/ds-devices/[serial_number]/logs/__tests__/route.fixtures.test.ts` | fixtures で: `DS-002` の一覧が200（`agent.connected` true・`types` 3つ・`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 の絞り込みがルートを通って効くこと |
| `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`。★Bearer なし・違う → 401（`requireAgent()` の再検証）★・R7（どのルートでも `last_seen_at` が進む）・R35・R36・チャンクの 400 / 409 / 410・W1〜W9・prepared の検証 |
| `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` を読み、`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`: 種類3つ（★ダミーの名前★。例 `app` / `comm` / `sys`、ラベル「アプリ」「通信」「システム」）
   - `fixtureDeviceLogItems(now: Date)`: ★数百件（例 300）★を now から決まった間隔で作る。★now ちょうど・1時間前ちょうど・3時間前ちょうど・24時間前ちょうど・その1秒前★ の項目を必ず含める（画面の目視用。境界の正しさはテストが持つ）。大きさは 1KB〜50MB（★選択の合計が 100MB を超える組み合わせが作れること★）
   - `FIXTURE_EXPORT_ZIP_BASE64`: ★中身がダミーの文字だけの小さな正しい zip★。★中のパスは root の名前つき（例 `fixture-logs/app_20261019_1100.log`。D24）★（フォルダ `fixture-logs` を PowerShell の `Compress-Archive` で包み base64 にする。★実在の店名・ログを入れない★）
   - `FIXTURE_AGENT_OFFLINE_SERIALS = ["DS-001"]`（D10）
   - `lib/fixtures/index.ts` に `export * from "./ds-device-logs";`
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 を作らない）。

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

- 表の右端に ★「ログ」列★（`<colgroup>` に `.colLogs` を足す・幅は固定 ★例 80px★、`DsLogScreen.module.css` の `min-width` も同じだけ広げる（`:82` のコメントの規則））。各行に `ログ` ボタン → モーダルを開く（`serial_number` と `facility_name` を渡す）
- ★モーダルの開閉以外は今の画面を変えない★

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

| 部分 | 中身（★文言はこのとおり。JP/EN の辞書に足す★） |
|---|---|
| 見出し | 「制御盤のログ」＋シリアル・施設名。右に ★接続状態★: 「接続中」（ok の色）／「未接続」（ng の色）＋「最終接続 {日時}」（無ければ「最終接続 -」）。`閉じる` |
| 種類 | 制御盤から来た `types` のチェックボックス（★ラベルは生値。`t()` に通さない★）。★既定は全部★。★1つも選ばれていなければ取得せず「種類を1つ以上選んでください」★ |
| 期間 | ボタン「直近1時間」「直近3時間」「直近24時間」「全て」（`presets.ts`）＋ `<input type="date">` 2つ（`openDatePicker`。表2 T5〜T9）。★既定は「直近24時間」★（D23） |
| 並べ替え | 「日付」（`date_desc`）「種類＋日付」（`type_date_desc`） |
| 一覧 | チェックボックス・ファイル名・種類（ラベル）・大きさ（KB / MB）・更新日時。見出しに「このページをすべて選択」。下に `Pagination` ＋ ページあたり件数のセレクト（`DS_LOG_PER_PAGE_OPTIONS`）。★`truncated` のときは一覧の上に「件数が多いため、新しいものから一部だけを表示しています」（D18）★ |
| 選択中 | 「選択: {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 → ★トークンを読み取り専用の欄に1回だけ表示・「コピー」ボタン・注意「この値は二度と表示されません。制御盤の設定ファイルに入れてください」★。★モーダルを閉じたら state から消す★。<br>失効: ★確認「トークンを失効させます。この制御盤はログを送れなくなります。よろしいですか？」★ → DELETE。<br>★この部分は制御盤が未接続（409）でも使える★（初めて設定するときは未接続のため） |

- ★選択の保持（D23）★: `file_id` → `size_bytes` の Map で持つ。★ページ送り・並べ替え・ページあたり件数では保持★、★種類・期間を変えたら消す★
- 取得中は「読み込み中…」（`t()` に通す）。★一覧の取得は毎回制御盤に問い合わせる★（最大15秒）ので、★前の応答が後から返って上書きしないよう、要求ごとの番号で古い応答を捨てる★
- ★`role="dialog" aria-modal="true"`・`Esc` で閉じる・背景のスクロールを止める★（`AreaModal.tsx` の作りを確かめて揃える）。★準備中に閉じたら POST を `AbortController` で取り消す★（サーバー側はエクスポートを取り消す。4-9）
- ★色は CSS 変数・固定幅のボタン（JP/EN で位置が動かない）★
- 日時の表示は既存の `formatDateTime()`（`DsLogScreen.tsx:41`）と同じ形

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

★足す前に grep する★（`閉じる`・`キャンセル`・`読み込み中…`・`種類`・`日付` は既にある）。EN は ★暫定★。
例: 「ログ」"Logs"／「制御盤のログ」"Controller logs"／「接続中」"Connected"／「未接続」"Not connected"／「直近1時間」"Last 1 hour"／「直近24時間」"Last 24 hours"／「種類＋日付」"Type + date"／「このページをすべて選択」"Select all on this page"／「エクスポート」"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`・`reason`（`missing` / `invalid`） | proxy（段階5）。★ヘッダの値・トークンを出さない★ |
| ★`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`） | 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` | 失敗・取り消し・失効・タイムアウト |

- `LogEvent` の union（`:456-471`）に足す。★`reason` は固定の文字列の union★（制御盤から来た `message` を載せない。制御盤の `code` は `agent_code` として固定の長さで切って載せてよい）
- ★`file_name` / `file_ids` / `token` / `authorization` を載せない★。テストで「イベントの値に `file_id`・ファイル名・トークンが入っていない」ことを確かめる

コミット。

---

## 段階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`）

1. 本体で `USE_FIXTURES=true npm run build`（PowerShell: `$env:USE_FIXTURES='true'; npm run build`）
2. ★テスト用の `AUTH_TOKEN` をその場で生成★し、★環境変数で渡して★ `npx next start -p 3100`。★トークンを文書・コミット・報告に書かない★
3. `preview_start` の `url` で `http://localhost:3100` を開き、ログインする
4. 確かめること:
   - D-Sec ログの各行に「ログ」がある。`DS-002` で開く → 接続中・種類3つ・既定の期間（直近24時間）で一覧が出る
   - 種類を外す・期間のボタン・日付の範囲・並べ替え・ページ送り・ページあたり件数で一覧が変わる。種類・期間を変えると選択が消える
   - 「このページをすべて選択」→ 合計の表示。★100MB を超えると警告とボタンの無効★
   - ★エクスポート → 「制御盤で準備中…」→ zip が落ちる・開ける・中が `fixture-logs/…`★
   - ★`DS-001` で開くと「制御盤が接続されていません（最終接続: -）」★。★それでも制御盤トークンの部分は使える★
   - ★制御盤トークン★: 発行 → 確認 → 1回だけ表示・コピー → 閉じて開き直すと値は出ず、発行日時だけ → 作り直し → 失効 → 「未発行」（★ここで発行した値は確認後に失効させ、どこにも書かない★）
   - ★JP/EN 切替★でボタン・列の位置が動かない／★ダーク/ライト★で崩れない／`Esc` で閉じる
   - D-Sec ログの表で横スクロールが増えていない（`resize_window` 1440×900・JP/EN）。終わったら `desktop` に戻す
5. ★終わったら :3100 の listen プロセスを止める★（自分で起動したものだけ）

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

1. `npm run seed:local`（★ローカル以外を拒否する★）。`DS-002` が `controls` にあること
2. `USE_FIXTURES=false` で ★ホストが localhost・DB 名が `dwatch_local` であることを確かめてから★ `npm run build` → `next start -p 3100`。★`ALLOW_DIRECT_ACCESS=true` を環境変数で★（XFF が無いため）
3. ★画面のモーダル（`DS-002`）の「制御盤トークン」で発行★し、★その値を環境変数 `DS_AGENT_TOKEN` で偽制御盤に渡す★（★画面・ファイル・報告に残さない★）
4. ★偽制御盤 `scripts/ds-agent-fake.ts`（D23。コミットする）★を `tsx` で動かす:
   - `--server http://localhost:3100`・トークンは環境変数
   - ポーリング → `list` には ★固定の一覧（種類2つ・10件程度）★ → `export` には ★`--zip <パス>` のファイルの長さ・SHA-256・件数で `prepared`★ → `send` には ★そのファイルを 4MB ごとに送り `complete`★（★`file_ids` は見ない。中身の正しさは制御盤（Python）の仕様★）
   - zip は ★9〜10MB 程度★（★4MB を超えて3チャンクになる大きさ★）。★root の名前つき（D24）★: 乱数の中身のファイルを入れたフォルダ（例 `fake-root`）を `Compress-Archive` で包む（中のパスが `fake-root/…`）
   - ★チャンクの POST のタイムアウトは60秒★（D20。設計書と同じ）
5. 画面から一覧 → 選択 → エクスポート。★落ちたファイルの SHA-256 が `--zip` のファイルと一致★（`Get-FileHash` / `certutil -hashfile`）・★ブラウザに進捗（大きさ）が出る（`Content-Length`）★。サーバーのログに `ds_logs.export_prepared` と `ds_logs.export_completed`（`total_bytes` が一致）
6. ★準備の失敗★: 偽制御盤の `--fail-prepare too_large` で、画面に「選んだファイルが大きすぎます」（POST の 502）
7. ★切断★: エクスポートの途中でブラウザのダウンロードを取り消す → 偽制御盤に 410 が返り止まる・`ds_logs.export_failed`（`stage: send`）
8. ★偽制御盤を止める★ → 60秒後に画面で「未接続」・`ds_agent.disconnected` が1回
9. 応答のヘッダに `X-Accel-Buffering: no`・`Cache-Control: no-store`・`Content-Length` があり、★`Content-Encoding` が付いていない★こと（`read_network_requests`）
10. ★終わったら★ 画面で ★トークンを失効★、:3100 と偽制御盤を止める

★結果の報告には出所を書く★（「当方の 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〜V7・W1〜W9・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__/*` | — |

---

## 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`（足すだけ）・
`docs/decisions.md`（★D-179 の末尾に「実装の記録」を足すだけ★）・§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★）
- ★設計書 `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/**`（共通部品を作らない）
- `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）

---

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

★**Q1〜Q13 は監督判断（第2ラウンド。M1〜M11）で確定し、本文に反映済み。Q14〜Q16 は追加の監督判断で確定（Q14・Q15 は本書の案 P1・P2 をそのまま採用）。未確定は無い**★。
★判断が変わったら、「直す場所」を本文と同時に直すこと★（本文と末尾の決定が食い違ったまま渡さない）。

### 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 |

---

## 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）

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

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

---

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

```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）を監督判断で採用★
  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. 完了条件

- [ ] 着手前に指示書AX の実装コミットが develop に入っていた。基点のコミットを冒頭に記入し、★本書の行番号を先端で確かめ直した★
- [ ] 検証エラーは 422 `validation_failed`（D17）。§11 Q14・Q15 の採用（P1 `GET …/agent-token`・P2 未発行の DELETE も 204）を実装した
- [ ] `tsc` 0件・`lint` 0件・`npm run build` 成功（足したルートが `ƒ`）
- [ ] `npm test` 全件通過: 前 ____ 件 → 後 ____ 件
- [ ] 段階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）で: 一覧 → 絞り込み → 選択 → ★「制御盤で準備中…」→ zip が落ちる★／★`DS-001` で 409 の表示（トークンの部分は使える）★／トークンの発行・作り直し・失効／JP/EN・ダーク/ライト
- [ ] ★非 fixtures で偽制御盤スクリプトによる通し確認★: ★4MB を超えるファイルが複数チャンクで届き、SHA-256 が一致★／準備の失敗が画面に出る／途中の切断で 410／止めて60秒で未接続
- [ ] `docs/decisions.md` の D-179 の末尾に「実装の記録」を足した（D-179 の本文・`CLAUDE.md` には触っていない）
- [ ] 作業報告に: コミット一覧／テスト件数（前後）／確認の結果（★出所を書き分ける。`CLAUDE.md` §6-6★）／本書と違う判断をしたところ／行番号のずれ／§12 の申し送り
