# 指示書AT — アラートの対応状況を画面から更新する（`robot_error_reports` の4カラム UPDATE）

**日付**: 2026-10-02 / **担当**: Cursor /
**前提**: D-042 / D-104 / D-105 / D-152 / D-153 項3 / D-155 / `CLAUDE.md` §3・§6-1・§7・§9.3 /
TeamWorks 第10回回答 N-5・N-6・N-14（`docs/handoff/TeamWorks回答_第10回_20261002.md`）

★**渡す順番: 指示書AR → 指示書AS → 本書AT を一括で渡す**★（D-153 項3 / D-155 項6）。
★分割 PR の作成とマージは、AR と本書の実装が終わってから daichi がレビューして行う★（D-154 項4）。

> ★★**監督の確認は済み（2026-10-02。D-156）**★★ — §10 の8項目はすべて推奨どおりで承認。本文のまま実施すること。
> （以下は作成時の注記）本書の §3 で ★【要確認】★ と付けた決定は、
> 推奨案を本文に書き込んである。★承認されればそのまま渡せる★。却下された項目だけ本文を差し替えること。
> 一覧は §10。★決まったら `docs/decisions.md` に D 番号で記録する（本書の作業者は書かない）★。

---

## 0. 何をするか（1行）

`/alerts` の★事前チェック由来のアラート★について、★対応状況（未解消→対応中→解消）と備考★を画面から更新できるようにする。
書き込み先は ★`robot_error_reports` の `status` / `comment` / `responded_at` / `resolved_at` の4カラムだけ★。

---

## 1. 前提 — いまどうなっているか（2026-10-02 実測。出所はすべて「構造（リポジトリのコード）」）

| # | 事実 | 場所 |
|---|---|---|
| 1 | ★API 契約はある★。`PATCH /prechecks/{id}/status`（`id` は string、`status` は `unresolved`/`in_progress`/`resolved`、`memo` は最大400文字）。200 は更新後の `PrecheckItem`、ほかは 404 / 422 | `d-watch-api.yaml:659-690` |
| 2 | ★実装は無い★。`app/api/prechecks/` には `route.ts`（GET）しか無く、その冒頭に「PATCH は今回作らない」と書いてある | `app/api/prechecks/route.ts:7` |
| 3 | `PrecheckItem.id` は ★事前チェック由来＝`robot_error_reports.error_id`★、★出発失敗＝本清掃行の `clean_timetables.id` の文字列★ | `lib/db/index.ts:1306` / `:1371`、`d-watch-api.yaml:1771-1773` |
| 4 | ★出発失敗（`launch_failed`）には `robot_error_reports` の行が無い★ → ★書き込み先が無い★。`resolution_status` は `unresolved` 固定・日時と備考は null 固定 | D-104 / D-105 申し送り、`lib/db/index.ts:1386-1395` |
| 5 | `robot_error_reports` に ★主キーが無い★。一意なのは ★`error_id`（varchar(50)）だけ★ | `prisma/schema.prisma:307-312` |
| 6 | ★罠★: `RobotErrorReport.updated_at` に ★`@updatedAt`★ が付いている | `prisma/schema.prisma:336` |
| 7 | `status` の 0/1/2 ↔ enum の変換は両方向とも既にある（逆変換は seed 用として作ったもの） | `lib/db/precheck-mapping.ts:32`（DB→API）/ `:48`（API→DB） |
| 8 | ★日時を書くときは `toDbJst()` を通す★（アダプタは `getUTC*` で文字列を作るため）。docstring に「4カラムを実装するときは必ず通せ」とある | `lib/format/date.ts:70-83` |
| 9 | 書き込み監査ログの型 `DbWriteLogEvent`（`event: "db.write"`）は ★定義だけあって一度も出していない★。★`record_id: number`★ | `lib/logger/events.ts:205-222`、設計は `docs/logging-design.md:311-339` |
| 10 | ★生SQL は ESLint で禁止★。どうしても要るときはタグ付きテンプレートだけを個別に disable し、理由を decisions に書く、というルール | `eslint.config.mjs:41-70`（D-122） |
| 11 | 画面は ★バッジを表示するだけ★（クリックできない） | `components/features/alerts/AlertsScreen.tsx:295-312` |
| 12 | モックでは ★D-Sec アラートだけ★ がクリックで状態を回す（未対応→対応中→対応済み→未対応）。★事前チェックのアラートには操作が無い★ | `d-watch-mock.jsx:191-201` / `:961`（D-Sec）、`:640-645`（事前チェック。バッジのみ） |
| 13 | 書き込みの見本（PATCH ルート・画面の備考編集）は既にある。★保存先はメインDBではなく DFA の JSON ストア★ | `app/api/ds-devices/[serial_number]/note/route.ts`、`components/features/ds-logs/DsLogScreen.tsx:245-293` |
| 14 | DB 接続は1アカウント分の環境変数しか無い（`DATABASE_URL` または `DATABASE_HOST`/`PORT`/`USER`/`PASSWORD`/`NAME`） | `lib/db/prisma.ts:38-70`、`.env.example` |
| 15 | ★ステージングへの4カラム UPDATE はユーザーが許可済み★（実データの対応状況が書き換わることを承知のうえ）。★それ以外の列への書き込みは禁止★ | `CLAUDE.md` §3 の接続先注記 / D-152 |
| 16 | ★ステージングのデータは TeamWorks が入れ替えることがある（時期未定）★。★0行のテーブル（`clean_timetables` など）には 10/5 中に何らかのデータが入る★ | 第10回 N-6 / D-155 項5 |
| 17 | ★`robot_error_reports` はエラー全般の表になる予定★（`robot_id` / `serial_number` を NULL 可にする DDL 変更待ち。両方 NULL＝セキュリティ、`robot_id` だけ NULL＝D-S制御盤） | 第10回 N-5 / D-155 項5 |
| 18 | DB の既定タイムゾーンは 'Asia/Tokyo' に変更済み（先方申告。★当方の再実測は未了★） | 第10回 N-14 / D-155 項4 |

---

## 2. 範囲

| やる | やらない |
|---|---|
| ★`robot_error_reports` の4カラムの UPDATE★（`/alerts` の事前チェック由来の行だけ） | ★`control_error_histories`（D-Sec アラート）への書き込み★ — N-5 で D-Sec アラートの出どころが `robot_error_reports` に移る可能性があるため、★いまは作らない★（`PATCH /ds-alerts/{id}/status` は契約のまま残す） |
| `PATCH /prechecks/{id}/status` の実装（fixtures / Prisma の両方） | 出発失敗（`launch_failed`）の対応状況の記録（書き込み先が無い。D-105 申し送りのまま） |
| `/alerts` の画面に「状態の切り替え」と「備考の編集」を足す | 完了率・清掃結果・予定外タスクのコード（★指示書AS の範囲★） |
| 書き込み監査ログ（`db.write`）を初めて出す | 4カラム以外の列への書き込み（★権限も無い★。`CLAUDE.md` §7） |
| 書き込み用の接続を別アカウントにできる器 | スキーマの変更（★§3-1 の `@updatedAt` 1箇所だけ例外★） |

---

## 3. 決定

### 3-1. ★`@updatedAt` の罠 — `RobotErrorReport.updated_at` から `@updatedAt` を外す★【要確認】

**何が起きるか（★実測★。出所: 構造 — 当方が Prisma 7.10.0 の生成クライアントに★偽のドライバアダプタ★をつないで、送られる SQL を記録した。★DB には一切接続していない★）**

いまのスキーマのまま `updateMany` で `status` / `comment` / `responded_at` だけを渡すと、Prisma は次の SQL を送る:

```sql
UPDATE `robot_error_reports` SET `status` = ?, `comment` = ?, `responded_at` = ?, `updated_at` = ? WHERE `robot_error_reports`.`error_id` = ?
-- updated_at の引数は "2026-10-02T11:35:11.700Z"（＝アプリの時計の UTC）
```

`update`（1件更新）でも同じく `updated_at = ?` が入る（さらに前後に SELECT とトランザクションが付く）。

| 問題 | 中身 |
|---|---|
| ★5列目の書き込み★ | `CLAUDE.md` §7 違反。★更新用アカウントは列単位の GRANT★（D-042）なので ★権限エラーで落ちる★ |
| ★値の時刻もずれる★ | アダプタは `getUTC*` で文字列を作る（`node_modules/@prisma/adapter-mariadb/dist/index.js` の `formatDateTime()`）ので、★JST の DB に UTC の壁時計値（9時間前）が入る★ |
| ★そもそも不要★ | DDL に `ON UPDATE current_timestamp()` がある（第10回 N-14 c の回答でも確認）ので、★DB が自分で更新する★ |

**検討した案**

| 案 | 内容 | 判断 |
|---|---|---|
| ★(a)★ | ★`schema.prisma:336` から `@updatedAt` だけを外す★ | ★採用★ |
| (b) | `$executeRaw` のタグ付きテンプレートで4列だけの UPDATE を書く | 不採用。ESLint のルール上は個別 disable が可能（`eslint.config.mjs:41-50`）で、SQL も MySQL 互換の素の UPDATE なので TeamWorks への通知も要らない。だが ★「アプリに生SQLは1件も無い」状態（`CLAUDE.md` §3）が崩れる★、★列名・型の検査が効かなくなる★、★日時の書式を自前で扱う★ことになる。(a) で足りるので選ばない |
| (c) | `updateMany` を使えば `@updatedAt` は付かないのでは | ★不採用（実測で否定）★。上のとおり `updateMany` でも `updated_at = ?` が入る |
| (d) | 書き込み専用に別のスキーマ／生成クライアントを持つ | 不採用。生成が2系統になり CI の `prisma generate` も2本要る。スキーマが2つに割れて食い違う入口が増える |
| (e) | `$extends` のクエリ拡張で `updated_at` を引数から抜く | 不採用。`updated_at` は呼び出し側の引数ではなく、★クエリコンパイラ（wasm）が後から足している★ので抜けない |

**(a) を採る理由**

1. ★外した版で同じ実験をして、`SET` に渡した列しか出ないことを確かめた★（出所: 構造 — スキーマの複製から生成したクライアント＋偽アダプタ。リポジトリのファイルは変更していない）:
   ```sql
   UPDATE `robot_error_reports` SET `status` = ?, `comment` = ?, `responded_at` = ? WHERE `robot_error_reports`.`error_id` = ?
   ```
   （★`control_error_histories` は `updated_at` 列そのものが無い★ので、もともと `SET status = ?` だけになる。将来 D-Sec 側を作るときにこの罠は無い）
2. ★D-056 に反しない★ — `@updatedAt` は ★DB 由来の事実ではなく Prisma 固有の属性★で、★`db pull` は出力しない★。
   ★このプロジェクトは db pull のたびに `@updatedAt` を手で付け外ししている★（D-059 運用 `docs/decisions.md:399`、指示書Q 2-3 — 実測で HEAD 24箇所 → pull 後 20箇所）。
   つまり ★「db pull 後に手で戻す」作業は既に毎回ある★。今回はそこに ★「`robot_error_reports.updated_at` だけは付けない」という例外を1つ足す★だけ
3. ★壊れたら必ずテストが落ちるようにする★（段階2 のガードテスト）。誰かが「DDL に ON UPDATE があるから」と機械的に付け直しても、★`npx prisma generate` → `npm test` で赤くなる★

**弱点（承知のうえで採る）**: db pull の手順書・D-059 の運用ルールに「例外1件」が増える。★記録は監督が D 番号で書く★（本書の作業者は `docs/decisions.md` に触らない）。

### 3-2. ★書き込みは `updateMany`（1文の UPDATE）で、書き込み用の接続から行う★【要確認】

- ★読み取り（存在確認・更新前の値・更新後の値）は既存の `getPrisma()`（参照用）で行う★
- ★書き込みは `getWritePrisma()`（新設）で、`robotErrorReport.updateMany({ where: { error_id }, data })` の1文だけ★
- ★`update`（1件更新）を使わない理由★: Prisma の `update` は ★SELECT → UPDATE → 全列の SELECT をトランザクションで包む★（実測）。
  ★更新用アカウントに SELECT 権限があるかは分かっていない★（`CLAUDE.md` §3 は「4カラムのみ UPDATE 可」とだけ書いている）。
  ★`updateMany` なら必要な権限は「4列の UPDATE」＋「WHERE で読む `error_id` の SELECT」だけ★（MySQL は WHERE で読む列に SELECT 権限を要求する）
- ★`updateMany` の戻り値（件数）で存在を判定しない★ — 存在確認は書く前に参照用で行う。件数が 0 なら「確認と書き込みの間に行が消えた」（N-6 の入れ替え）とみなして 404 にする
  （mariadb ドライバの既定 `foundRows: true` で「一致した行数」が返る。値が変わらなくても 1。ただしこの既定に依存した分岐は書かない）

**環境変数（★値は絶対に書かない・コミットしない★）**

| 変数 | 意味 |
|---|---|
| `DATABASE_WRITE_USER` | 書き込み用アカウントのユーザー名 |
| `DATABASE_WRITE_PASSWORD` | 同パスワード |

- ★接続先（ホスト・ポート・DB名）は参照用と同じ DB なので `DATABASE_HOST` / `DATABASE_PORT` / `DATABASE_NAME` を使う★（`DATABASE_WRITE_*` を使うときはこの3つが必須。URL 形式は設けない — `%40` が壊れる問題（`lib/db/prisma.ts:29-30`）を持ち込まないため）
- ★2つとも未設定なら、参照用の接続（`getPrisma()` と同じクライアント）で書く★ — ★いまはこれで動く★（ユーザー: 今のアカウントに書き込み権限がある見込み）
- ★片方だけ設定されていたら、最初の書き込みで例外★（黙って参照用に倒さない）。メッセージに値を出さない
- `lib/db/prisma.ts` に `getWritePrisma()` を足す。★`globalThis.__dwatchPrismaWrite` にキャッシュ★（`getPrisma()` と同じ理由）。★`lib/db/index.ts` の先頭で静的 import しない★（`lib/db/prisma.ts:3-8` の理由）
- `scripts/check-env.ts` が使う `lib/config/env-check.ts` に検査を足す: ★未設定＝warn「書き込みは参照用アカウントで行います」★ / ★片方だけ＝error★。★値は出さない★
- `.env.example` に2つのキーを★コメントアウト・値なし★で足す

### 3-3. ★書き込んでよい行 — 「`/alerts` に事前チェック異常として出る行」だけ。それ以外は 404★【要確認】

`PATCH /prechecks/{id}/status` の `{id}` を `robot_error_reports.error_id` として引き、★次をすべて満たすときだけ書く★。

1. `error_id = {id}` の行がある
2. その行の `clean_timetable_id` が null でない
3. その `clean_timetables` 行があり、★`precheckMinutesFromCode(precheck)` が null でない（＝事前チェック行）★
4. その事前チェック行の `parent_id` が指す本清掃行がある（無ければ `prechecks()` と同じく★例外★＝500。`lib/db/index.ts:1290-1301`）

★1〜3 のどれかを満たさなければ 404★。これは `prismaSource.prechecks()` が事前チェック行を作る条件（`lib/db/index.ts:1265-1285`）と同じ。★条件を自前で書き直さず、同じ関数（`precheckMinutesFromCode()`）を使う★。

- ★出発失敗の id（`clean_timetables.id` の文字列）は、`error_id` として引いても当たらない → 404★。★別の分岐を作らない★
  （404 のメッセージは「対応状況を記録できるアラートが見つかりません（出発失敗には記録先がありません）」）
- ★N-5 の DDL 変更後に入ってくる D-S制御盤・セキュリティのエラー行★（`clean_timetable_id` が入らない見込み）も ★2 で 404 になる★ — ★`/prechecks` から D-Sec 側の行を書き換えられない★。これは意図した性質
- ★期間（`date_from`/`date_to`）は見ない★（一覧の絞り込みであって、行の性質ではない）
- fixtures 版は「`lib/fixtures/prechecks.ts` の id が一致し、かつ `severity === "precheck"`」で同じ判定にする

### 3-4. ★状態遷移と日時の規則（期待値表）★【要確認】

★純粋関数★ `planPrecheckStatusUpdate(current, request, now)` に閉じ込める（新設 `lib/prechecks/status-update.ts`）。
入力は★API の値★（`resolution_status` / `responded_at` / `resolved_at` は「真の瞬間」の `Date` か null / `note`）、
出力は★変える列だけ★を持つパッチ（キーが無い列は書かない）。★`toDbJst()` は出力をDBに書く直前に1回だけ通す★（この関数の中では通さない）。

不変条件（★この規則で保たれる形★。`d-watch-api.yaml:1795-1804` の「対応開始日時(対応中以降で設定)」に合わせた）:
**未解消 → 両方 null / 対応中 → `responded_at` のみ / 解消 → 両方あり**

`now` = 2026-10-02 08:15 JST、`t1` = 07:00、`t2` = 07:30 とする。

| # | 現在 | `responded_at` | `resolved_at` | 要求 | 出力パッチ |
|---|---|---|---|---|---|
| 1 | 未解消 | null | null | 対応中 | `status=対応中`, `responded_at=now` |
| 2 | 未解消 | null | null | 解消 | `status=解消`, `responded_at=now`, `resolved_at=now` |
| 3 | 未解消 | null | null | 未解消 | ★空（書かない）★ |
| 4 | 対応中 | t1 | null | 解消 | `status=解消`, `resolved_at=now`（★`responded_at` は変えない★） |
| 5 | 対応中 | ★null★（不正データ） | null | 解消 | `status=解消`, `responded_at=now`, `resolved_at=now` |
| 6 | 対応中 | t1 | null | 対応中 | ★空★ |
| 7 | 対応中 | t1 | null | 未解消 | `status=未解消`, `responded_at=null` |
| 8 | 解消 | t1 | t2 | 未解消 | `status=未解消`, `responded_at=null`, `resolved_at=null`（★戻すと両方消す★） |
| 9 | 解消 | t1 | t2 | 対応中 | `status=対応中`, `resolved_at=null`（`responded_at` は変えない） |
| 10 | 解消 | t1 | t2 | 解消 | ★空★（★`resolved_at` を now で上書きしない★） |
| 11 | 未解消 | ★t1★（先方が入れた等） | null | 対応中 | `status=対応中`（★既にある `responded_at` は上書きしない★） |
| 12 | 解消 | t1 | ★null★（不正データ） | 解消 | `resolved_at=now` |

- ★「戻すと両方消す」（#7・#8）を採る理由★: 上の不変条件を保てる。★ステージング検証で「元の4列に完全に戻せる」★（段階9）。
  却下した案: 戻しても日時を残す — 「未解消なのに対応開始日時がある」行ができ、画面の意味が説明できなくなる
- ★同時に2人が操作したら後勝ち★（行ロックはしない。P1 は社内の数名で、Prisma で `SELECT … FOR UPDATE` を書くには生SQLが要る）

### 3-5. ★リクエストの備考のキーは `note` に揃える（`memo` をやめる）★【要確認】

- いまの契約は★リクエストが `memo`、レスポンス（`PrecheckItem`）が `note`★（`d-watch-api.yaml:682` / `:1805`）。★同じ DB 列（`comment`）に2つの名前★がある
- ★`note` に揃える★。`CLAUDE.md` §6-1 のとおり ★先に YAML を直す★。理由: 型・fixtures・画面・D-S制御盤の備考 API（`/ds-devices/{serial_number}/note`）がすべて `note`。★まだ誰も `memo` を送っていないので互換の問題は無い★
- ★`/ds-alerts/{id}/status` の `memo` は変えない★（あちらはレスポンスの `DsAlert.memo` とも揃っていて食い違いが無い。範囲外）

### 3-6. 備考の規則（`/ds-devices/{serial_number}/note` の作法に揃える）

| 入力 | 扱い |
|---|---|
| `note` が無い | ★`comment` は変えない★（状態だけの変更） |
| 文字列以外（null・数値など） | 422 |
| ★401文字以上（トリム前で数える）★ | 422（`DEVICE_NOTE_MAX_LENGTH` と同じ理由。`app/api/ds-devices/[serial_number]/note/route.ts:76-87`） |
| 空文字・空白だけ | ★`comment = null`★（削除） |
| それ以外 | ★前後の空白を除いて保存★ |
| トリム後が現在値と同じ | ★`comment` は書かない★ |

- 上限 400 は ★`lib/config/index.ts` に `PRECHECK_NOTE_MAX_LENGTH = 400` として置く★（ハードコード禁止。YAML の `maxLength: 400` と一致させる）
- `status` は必須（YAML どおり）。★備考だけ変えるときも画面は現在の `status` を送る★（#3・#6・#10 の「空」になり、日時は動かない）
- ★本文に4つ以外のキー（`updated_at`・`error_type` など）が来ても無視する★。★パッチを作るのは `planPrecheckStatusUpdate()` だけ★なので、本文のキーが DB に届く経路を作らない（テストで固定する）

### 3-7. 書き込み監査ログ（`db.write`）

`docs/logging-design.md` §6（`:311-339`）の設計どおり、★成功・失敗の両方で1件ずつ出す★。

- ★`DbWriteLogEvent.record_id` を `number` → `string` に変える★（`lib/logger/events.ts:210`。`error_id` は varchar。★`Number()` を通さない★ — D-105 項2 と同じ理由）。★このイベントはまだ一度も出していないので、壊れる利用者はいない★
- 載せるもの: `table: "robot_error_reports"` / `record_id`（＝`error_id`）/ `columns`（★実際に SET した列だけ★）/
  `status_old`・`status_new`（API の enum 文字列）/ `responded_at_old/new`・`resolved_at_old/new`（JST の ISO 文字列か null）/
  ★`comment_changed: boolean`★ / `success` / 失敗時の `db_error_code`
- ★備考の中身は絶対に載せない★（`comment` / `note` / `memo` は禁止キー。`docs/logging-design.md:159-172`）。★監督の依頼文は「4列の旧値・新値」だが、備考は boolean だけにする★
- `db_error_code` は ★Prisma のエラーコードとドライバのエラー番号（例: 列の権限エラー 1143）だけ★。★エラーメッセージは載せない★（DB のユーザー名が入る）
- ★空パッチ（何も書かない）のときはログを出さない★（書き込みが起きていない）
- `docs/logging-design.md` §6 の表に「`record_id` は文字列（`robot_error_reports` は `error_id`）」と1行足す

### 3-8. 画面（`/alerts`）— 最小限

モックの事前チェック画面には操作が無い（§1 #12）。★D-Sec アラートのモックの「クリックで状態を回すボタン」★と、★D-Secログの「備考のインライン編集」★（既に実装済み）を★そのまま借りる★。新しい部品は作らない。【要確認: モックに無い操作を足すこと】

- ★`severity === "precheck"` の行だけ★:
  - 右上のバッジ（`AlertsScreen.tsx:295-312`）を★ボタンにする★。クリックで ★未解消 → 対応中 → 解消 → 未解消★（モック `:191-201` と同じ順）。表示は今のバッジと同じ（解消は「解消 (日時)」）
  - 「予定: HH:MM — 備考」の★備考の部分をクリックで編集★（備考が無ければ `t("クリックして備考を編集")` を薄く出す）。★`DsLogScreen.tsx:245-293` の作法をそのまま★: textarea・`onBlur` で確定・変えていなければ送らない・失敗時は編集状態のまま残す・`maxLength` は `PRECHECK_NOTE_MAX_LENGTH`
- ★`launch_failed` の行は今のまま（バッジのみ・編集不可）★
- 保存中はその行のボタンを無効にする（二度押しで2回進まない）
- ★成功したら、レスポンスの `PrecheckItem` でその1行だけ差し替える★（一覧を取り直さない。`DsLogScreen.tsx:252-256` の理由と同じ）。
  ★絞り込み（未解消のみ）に合わなくなっても、次に取り直すまで行を消さない★（押し間違いをその場で戻せるように）
- サマリ（未解消/解消/合計）は ★純粋関数 `applyResolutionChange(summary, before, after)`★（`lib/prechecks/query.ts` に追加）で差分だけ反映する
- i18n（`lib/i18n.ts`）: ★`"未解消"`・`"対応中"`・`"解消"` の対訳を足す★（D-105 で「キーが無い」と申し送られていたもの。足せば既存のバッジも英語になる）。
  新規は `"対応状況の更新に失敗しました": "Failed to update the status"` だけ。備考まわりは既存キー（`クリックして備考を編集` / `備考を入力` / `保存中…` / `保存に失敗しました`）を使う。★それ以外の未訳キーには手を出さない★

---

## 4. 段階

★各段階で `npx tsc --noEmit -p .` / `npx eslint .` / `npm test` が通ってからコミットする★（D-050）。

### 段階0 — 着手前の確認

1. ★`develop` で作業する★（`.claude/worktrees/` の中ではない。ワークツリーは古い `main` 基点）。`git pull` で最新にする
2. ★AR と AS が先に入っていること★を `git log` で確かめる（本書は AR → AS → AT の順で渡している）
3. `npx prisma generate` のあと `npm test` の件数を記録する（以後の増分の基準）
4. ★作業ツリーに自分の作業でない変更（例: `components/features/detail-log/CleanLogTable.tsx`）があっても触らない・コミットに含めない★
5. ★Next.js は版が新しい★。ルートを書く前に `node_modules/next/dist/docs/` の Route Handler の項を読む（`CLAUDE.md` 末尾）。★既存の `app/api/ds-devices/[serial_number]/note/route.ts` の形（`params` は Promise）に合わせる★

### 段階1 — API 契約と型・設定値

1. ★`d-watch-api.yaml` を先に直す★（`:659-690`）:
   - `memo` → ★`note`★（§3-5）
   - `description` を足す: 書き込むのは `robot_error_reports` の4列だけ / §3-4 の日時の規則（不変条件と「戻すと両方消す」）/ §3-6 の備考の規則 / ★出発失敗と事前チェック以外の行は 404★ / 何も変わらない要求は書かずに現在の値を返す
   - `404` の説明に「`launch_failed` の id もここ」を書く
2. `lib/types/index.ts` に ★`PrecheckStatusUpdateRequest`★（`{ status: PrecheckStatus; note?: string }`）を足す
3. `lib/config/index.ts` に ★`PRECHECK_NOTE_MAX_LENGTH = 400`★ と、パスの `error_id` の長さ上限 ★`PATH_ERROR_ID_MAX_LENGTH = 50`★（`varchar(50)`。`PATH_SERIAL_NUMBER_MAX_LENGTH` と同じ作法）を足す
4. `lib/api/path-params.ts` に ★`parsePathErrorId()`★ を足す（★空文字と51文字以上だけ 422、文字種は絞らない★。`parsePathSerialNumber()` `:66` と同じ作法。テストも同じ形で）

### 段階2 — `@updatedAt` を外し、ガードテストで固定する（§3-1）

1. `prisma/schema.prisma:336` の ★`@updatedAt` だけ★を消す。`@default(now())` と `@db.Timestamp(0)` は残す。★他のモデルの `@updatedAt` は1つも触らない★
2. 直上にコメントを書く（例）:
   ```prisma
   // ★@updatedAt を付けないこと（DDL に ON UPDATE current_timestamp() はある）★
   //   アプリがこの表の4カラム（status/comment/responded_at/resolved_at）を UPDATE するため。
   //   付けると Prisma が updated_at = <アプリの時刻(UTC)> を SET に足し、
   //   ①列単位 GRANT の更新用アカウントでは権限エラー ②JST の DB に UTC の値が入る。
   //   DB が ON UPDATE で自分で更新する。db pull 後に @updatedAt を付け直す作業の★唯一の例外★。
   //   lib/db/__tests__/robot-error-write-sql.test.ts が固定している（指示書AT §3-1）
   ```
3. ★ファイル冒頭のコメント（`:29-33` 付近の「db pull 後に移植し直すこと」）にも1行足す★: 「`RobotErrorReport.updated_at` には `@updatedAt` を付けない（指示書AT）」
4. `npx prisma generate`
5. ★ガードテストを書く★（新設 `lib/db/__tests__/robot-error-write-sql.test.ts`）:
   - ★本物の生成クライアント★（`@/lib/generated/prisma/client`）を、★SQL を記録するだけの偽アダプタ★でつなぐ（型は `import type { SqlDriverAdapterFactory } from "@prisma/client/runtime/client"`。`any` は使わない）。★DB には接続しない★
   - ★段階4 で作る書き込み関数そのもの★を、この偽クライアントを渡して呼ぶ（テスト用に別の書き方をしない）
   - 期待: 記録された UPDATE 文の `SET` の列が ★渡したパッチの列と完全に一致し、`updated_at` を含まない★。4列すべてを渡したときも同じ
   - 期待: 日時の引数が ★壁時計値★ になっている（`now` = 08:15 JST を渡すと引数は `"2026-10-02T08:15:00.000Z"`。`toDbJst()` の付け忘れを検出する）
   - ★参考★: 本書の作成時に同じ形の実験を `tsx` で行い、動くことを確認済み（§3-1）。vitest は `environment: "node"`（`vitest.config` `:13`）なので wasm のクエリコンパイラも動くはず。★動かなければ止まって報告★
   - ★CI は `npx prisma generate` のあとに `npm test` を走らせる★（`.github/workflows/ci.yml`）のでこのテストは CI でも動く
6. ★このテストが `@updatedAt` を戻すと落ちることを一度だけ手元で確かめる★（戻して generate → 赤 → 外して generate → 緑）。★戻した状態はコミットしない★

### 段階3 — 純粋関数（期待値表 → テスト → 実装。`CLAUDE.md` §6-2）

1. `lib/prechecks/__tests__/status-update.test.ts` に ★§3-4 の12行と §3-6 の備考の表★をそのまま書く。★先にテストを書いて赤を確かめる★
2. `lib/prechecks/status-update.ts` に `planPrecheckStatusUpdate()` を実装する。
   - 戻り値の型は ★4列だけのキーを持つ型★（例: `{ status?: PrecheckStatus; note?: string | null; responded_at?: Date | null; resolved_at?: Date | null }`）
   - ★`now` は引数で受け取る★（関数の中で `new Date()` しない）
3. `lib/prechecks/query.ts` に `applyResolutionChange()` とテスト（未解消↔対応中は件数が動かない / 解消に入る・出るで `unresolved` と `resolved` が1ずつ動く / `total` は不変）

### 段階4 — データ層

1. ★`prechecks()` の行の組み立て（`lib/db/index.ts:1303-1336`）を純粋関数に切り出す★ — `lib/db/precheck-mapping.ts` に `precheckItemFromErrorRow(report, precheckMinutes, mainRow)`。
   ★`prechecks()` もこの関数を使うように置き換える★（★同じ行の作り方を2箇所に書かない★）。
   ★既存の `app/api/prechecks/__tests__/route.test.ts` が無修正で通ること★＝挙動が変わっていない証拠
2. `DataSource`（`lib/db/index.ts:186`）に2つ足す:
   - `precheckItemById(id: string): Promise<PrecheckItem | null>` — §3-3 の1〜4。★事前チェックでなければ null★
   - `updatePrecheckErrorColumns(errorId: string, patch): Promise<{ matched: number }>` — ★パッチを DB の列に写して `updateMany` を1回★
     - `status` → `robotErrorStatusFromResolutionStatus()`（`lib/db/precheck-mapping.ts:48`。★docstring の「seed 用」を「seed とアプリの書き込み」に直す★）
     - `note` → `comment`
     - `responded_at` / `resolved_at` → ★`toDbJst()`★（null はそのまま null）
     - ★写した結果のキーが4列以外を含んだら例外★（実行時の歯止め。型で防げていても残す）
     - ★書き込みの中身は `lib/db/robot-error-write.ts`（新設）の関数1つに置き、クライアントを引数で受ける★（段階2 のガードテストが同じ関数を呼べるように）
3. ★fixtures 版★: `lib/fixtures/prechecks.ts` は書き換えず、★モジュール内の上書き表（id → 4項目）★を持つ。`fixtureSource.prechecks()` と `precheckItemById()` は上書きを反映して返す。テスト用に `resetFixturePrecheckOverrides()` を出す。★プロセスを再起動すると消える★（それで良い。fixtures は開発・デモ用。D-045）
4. `lib/db/prisma.ts` に `getWritePrisma()`（§3-2）
5. 古くなったコメントを直す:
   - `app/api/prechecks/route.ts:7`「PATCH は今回作らない」
   - `lib/format/date.ts:76-80`「アプリからの DB 書き込みはまだ無い」
   - `lib/db/index.ts:1392`「将来…書き込み先が無い」は ★事実なので残し★、「`PATCH` は 404 を返す（指示書AT §3-3）」を足す

### 段階5 — ルート `PATCH /prechecks/{id}/status`

新設 `app/api/prechecks/[id]/status/route.ts`。順序は ★形式 → 本文 → 実在 → 計画 → 書き込み → 読み直し★（`app/api/ds-devices/[serial_number]/note/route.ts` と同じ考え方）。

1. `parsePathErrorId()` → 失敗は 422
2. 本文: JSON でない → 422 / `status` が enum 外 → 422 / `note` の型・長さ（§3-6）→ 422
3. `const now = new Date()` を★1回だけ★作る
4. `db.precheckItemById(id)` → null なら 404。例外は 500（`logApiError`）
5. `planPrecheckStatusUpdate(before, request, now)` → ★空なら書かずに 200 で `before` を返す★
6. `db.updatePrecheckErrorColumns(id, patch)`:
   - 例外 → ★`db.write`（`success: false`）★ と `api.error` を出して 500
   - `matched === 0` → `db.write`（`success: false`, `db_error_code: "no_matching_row"`）を出して 404
7. `db.precheckItemById(id)` で読み直す → ★`db.write`（`success: true`）★ → 200 で返す（読み直しで null なら 404。ログは success のまま）
8. ★`withRequestId()` で包む★（既存ルートと同じ）

### 段階6 — 画面（§3-8）

`components/features/alerts/AlertsScreen.tsx`（と `.module.css`）、`lib/i18n.ts`。★`launch_failed` の行の見た目が変わっていないこと★を目で確かめる。

### 段階7 — 環境変数の検査

`lib/config/env-check.ts` と `lib/config/__tests__/env-check.test.ts`（§3-2）。`.env.example`。★テストに「出力に値が含まれない」ことを足す★（既存の `:210-222` と同じ形）。

### 段階8 — ローカルDB（`dwatch_local`）で確かめる

1. `.env.local` を `dwatch_local` に向け、`USE_FIXTURES=false`。`npm run seed:local`（★`lib/db/local-guard.ts` がローカル以外を拒否する★）
2. ★列単位の権限を再現したローカル専用ユーザーを作る★（★本番・ステージングの権限を模すため。パスワードは手元で決め、文書にもコミットにも書かない★）:
   ```sql
   CREATE USER 'dwatch_write_local'@'localhost' IDENTIFIED BY '<手元で決めた値>';
   GRANT SELECT (error_id) ON dwatch_local.robot_error_reports TO 'dwatch_write_local'@'localhost';
   GRANT UPDATE (status, comment, responded_at, resolved_at) ON dwatch_local.robot_error_reports TO 'dwatch_write_local'@'localhost';
   ```
   `.env.local` に `DATABASE_WRITE_USER` / `DATABASE_WRITE_PASSWORD`（ローカル専用の値）を入れる。
   ★認証方式の違いで接続できなければ、root と同じ方式で作り直す（`SELECT user, plugin FROM mysql.user`）。それでも駄目なら 8-2 は飛ばして報告★
3. `npm run dev` → `/alerts` で事前チェックの行を1件選び、★未解消 → 対応中 → 解消 → 未解消★、★備考を入れる → 空にする★を画面から行う。各回で:
   - 画面の表示・サマリの件数が §3-4 / §3-8 どおり
   - サーバーのログに `db.write` が `success: true` で出て、`columns` が実際に変えた列だけ・備考の中身が無い
   - ★権限エラー（1143）が出ない★ ＝ ★5列目を書いていない★ことの実地の確認
4. ★`@updatedAt` を戻した状態でも一度だけ試し、1143（`updated_at` への権限エラー）で落ちて `db.write` が `success: false` で出ることを確かめる★（§3-1 の実地の裏付け。★戻した状態はコミットしない★）
5. `mysql` で ★その1行の `updated_at` を読むだけ★ — 操作の時刻（JST）に更新されていること（★DB 側の ON UPDATE が動いている★）
6. 出発失敗の行・存在しない id への PATCH が 404 になること（`curl` 等で。認証 cookie が要る）
7. 結果（件数・時刻・ログの抜粋。★パスワードやユーザー名は貼らない★）を作業報告に書く

### 段階9 — ステージングで確かめる（★条件付き。満たさなければ止まって報告★）

**着手の条件（すべて）**:
- ステージングに接続できる（★2026-10-02 は認証エラーで接続できていない★。D-155 項4。★別の認証情報を探したり試したりしない★）
- ★ステージングに事前チェック行がある★ — `clean_timetables` は 10-01 時点で0行。第10回 N-6 で「10/5 中に何らかのデータを入れる」。★`/alerts` に事前チェックの行が1件も出なければ検証できない★ので、そこで止める

**手順**:
1. ★書き込むのは1行だけ★。`/alerts`（全件）から ★事前チェックで、未解消・`responded_at`/`resolved_at`/備考がすべて空の行★を1件選ぶ。★無ければ止まって報告（勝手に別の状態の行を選ばない）★
2. ★操作前の値を記録する★: `error_id` と4列（画面・API の値）。★`updated_at` は読むだけ★（`mysql` で `SELECT error_id, status, comment, responded_at, resolved_at, updated_at FROM robot_error_reports WHERE error_id = ?`。★UPDATE / DELETE / INSERT 文を手で打たない★）
3. 段階8-3 と同じ操作（未解消 → 対応中 → 解消 → 未解消、備考を入れる → 空にする）
4. ★最後に4列が操作前と完全に一致すること★を確かめる（§3-4 #8 の「戻すと両方消す」と §3-6 の「空で null」で元に戻る）。★`updated_at` だけは DB が更新するので戻らない★ — 値を記録して報告する
5. ★`updated_at` が操作の時刻の JST になっているか★を記録する（第10回 N-14「DB の既定を Asia/Tokyo にした」の再実測を兼ねる。★9時間ずれていても補正しない・報告だけ★）
6. ★途中でステージングのデータが入れ替わった（行が消えた・別物になった）ら止めて報告★（N-6）。★他の行に書き込んで代わりにしない★
7. ★`npm run seed:local` をステージングに向けない★（テーブルを消して入れ直すので実データが消える）

### 段階10 — 確認とコミット

1. `npx tsc --noEmit -p .` — 0件
2. `npx eslint .` — 0件（★`eslint-disable` を1つも足していないこと★）
3. `npm test` — 全件通過（段階0 の件数から増えていること）
4. `npm run build`（`USE_FIXTURES=true`）が通る
5. ★`git diff --stat` に `CLAUDE.md` / `docs/decisions.md` / `control_error_histories` を書く変更が無い★こと
6. ★`grep -rn "\$executeRaw\|\$queryRaw" app lib components`★ が0件のまま

---

## 5. テスト一覧

| ファイル | 中身 |
|---|---|
| `lib/prechecks/__tests__/status-update.test.ts`（新） | §3-4 の12行 / §3-6 の備考の表 / ★出力のキーが4つ以外を含まない★ / `now` 以外の時刻を使わない |
| `lib/prechecks/__tests__/query.test.ts` | `applyResolutionChange()` |
| `lib/db/__tests__/robot-error-write-sql.test.ts`（新） | ★本物の生成クライアント＋偽アダプタ★で、`SET` が渡した列だけ・`updated_at` を含まない / 日時が壁時計値 / ★4列以外のキーを渡すと例外★ |
| `lib/db/__tests__/precheck-mapping.test.ts` | `precheckItemFromErrorRow()`（既存の `prechecks()` の期待値と同じ行ができる） |
| `lib/api/__tests__/path-params.test.ts` | `parsePathErrorId()`（空 / 50文字 / 51文字 / 記号を含む） |
| `app/api/prechecks/[id]/status/__tests__/route.test.ts`（新。`getPrisma` / `getWritePrisma` をスタブ。`app/api/prechecks/__tests__/route.test.ts:153-170` の作法） | 200（各遷移）/ ★空パッチなら `updateMany` を呼ばない★ / ★`updateMany` の `data` のキーが4列のうちの該当分だけ★ / ★本文に `updated_at`・`error_type` を混ぜても `data` に出ない★ / 422（id・JSON・status・note の型・401文字）/ ★404: 行が無い・`clean_timetable_id` null・本清掃行を指すエラー・出発失敗の id★ / `matched === 0` で 404 / 書き込み例外で 500 / ★`db.write` が成功・失敗で1件ずつ出て、`record_id` が文字列・備考の中身を含まない★ / ★`responded_at` に `toDbJst()` が通っている★ |
| `app/api/prechecks/[id]/status/__tests__/route.fixtures.test.ts`（新） | fixtures で PATCH → GET `/prechecks` に反映される / ★`launch_failed` の id（`"4"`・`"8"`）は 404★ |
| `lib/config/__tests__/env-check.test.ts` | 書き込み用の変数: 未設定＝warn / 片方だけ＝error / 値が出力に出ない |
| `app/api/prechecks/__tests__/route.test.ts` | ★無修正で通ること★（段階4-1 の切り出しで挙動が変わっていない） |

---

## 6. 完了条件

1. `/alerts` で事前チェックの行の状態と備考を変えられ、★再読み込みしても残っている★（ローカルDB）
2. ★DB に送られる UPDATE が4列のうちの必要な分だけ★であることが、★テスト（偽アダプタ）と実地（列単位 GRANT のユーザーで 1143 が出ない）の両方★で確かめられている
3. ★`@updatedAt` を戻すとガードテストが落ちる★ことを確かめてある
4. 出発失敗・事前チェック以外の行は 404
5. `db.write` が成功・失敗とも出て、備考の中身・認証情報を含まない
6. `d-watch-api.yaml` が実装と一致している（`note`、404 の説明、日時の規則）
7. 段階9 は ★実施して元に戻したか、条件を満たさず止めたか★のどちらかが報告されている
8. tsc / eslint / test / build がすべて通る

---

## 7. 触ってはいけないもの・禁止事項

- ★`CLAUDE.md`・`docs/decisions.md`★（記録は監督が書く）
- ★`control_error_histories` への書き込み★・`PATCH /ds-alerts/{id}/status` の実装・D-Sec アラート画面（§2）
- ★完了率・清掃結果・予定外タスクのコード★（★指示書AS の範囲★。`lib/judge/kpi.ts` など）
- ★スキーマの変更★ — ★§3-1 の `@updatedAt` 1箇所とそのコメントだけ例外★。`db pull` は実行しない
- ★4カラム以外への書き込み★・★生SQL（`$executeRaw` / `$queryRaw`）と `eslint-disable` の追加★
- ★ステージングへの `npm run seed:local`★・★ステージングで1行より多く書き込むこと★・★`mysql` からの手動の UPDATE / INSERT / DELETE★
- ★認証情報（パスワード・接続文字列・DB のユーザー名）を文書・コード・テスト・コミット・作業報告に書くこと★（D-154 項3 の事故。例に実物を使わない）
- ★`lib/fixtures/prechecks.ts` の中身★（§9 の食い違いは申し送り）
- ★`git add -A`★ — ファイルパスを明示する

---

## 8. コミット

段階ごとに分けてよい（例: 段階1 / 段階2 / 段階3 / 段階4〜5 / 段階6〜7 / 段階8〜9 の記録）。
★段階2 のコミットメッセージには「なぜ `@updatedAt` を外すか（5列目の書き込み・権限・タイムゾーン）」と「ガードテストの場所」を書く★。

---

## 9. 申し送り（本書では直さない）

| # | 内容 | 次に動く契機 |
|---|---|---|
| 1 | ★N-5 の DDL 変更（`robot_id` / `serial_number` を NULL 可）★のあと db pull すると、`precheckItemFromErrorRow()` で `serial_number` が `string \| null` になり tsc が止まる。★その時に扱いを決める★（事前チェックの行はロボットのエラーなので NULL は来ない見込み）。★db pull のあと `RobotErrorReport.updated_at` に `@updatedAt` が付いていないこと★も確かめる（ガードテストが見張っている） | TeamWorks の DDL 変更 |
| 2 | ★D-Sec アラートの対応状況（`control_error_histories`）★は、N-5 で D-Sec のエラーが `robot_error_reports` に入るなら書き込み先が変わる。★`control_error_histories` には `updated_at` 列が無いので §3-1 の罠は無い★（実測） | N-5 の食い違いの確認（第11回） |
| 3 | ★更新用アカウントに SELECT 権限（少なくとも `error_id`）があるか★ — 無いと `updateMany` の WHERE で権限エラーになる。★別アカウントを使い始める前に TeamWorks に確認★ | 第11回 / 書き込み用アカウントの発行時 |
| 4 | `lib/fixtures/prechecks.ts` の `launch_failed`（id `"4"` / `"8"`）が ★対応中・解消で備考あり★になっている。★実DB では常に未解消・備考なし（D-104）★と食い違う | fixtures の見直し時 |
| 5 | ★出発失敗の対応状況の記録★（D-105 の2案: DFA 独自ストア / TeamWorks にエラー行を作ってもらう）。N-5 で「エラー全般の表」になるなら後者が通りやすい | 監督の判断 |
| 6 | 同時操作は後勝ち（§3-4）。問題が出たら `WHERE status = <旧値>` を足す楽観ロックを検討 | 運用で問題が出たとき |

---

## 10. 監督の確認が要る項目（一覧）

| # | 項目 | 推奨（本文に書き込み済み） |
|---|---|---|
| 1 | §3-1 `RobotErrorReport.updated_at` から `@updatedAt` を外す（D-059 の運用ルールに例外を1つ足す） | 外す＋ガードテスト |
| 2 | §3-2 書き込みは `updateMany` / 書き込み用の環境変数 `DATABASE_WRITE_USER`・`DATABASE_WRITE_PASSWORD`（未設定なら参照用で書く・片方だけなら例外） | そのとおり |
| 3 | §3-3 `/prechecks` から書けるのは事前チェック異常の行だけ（出発失敗・本清掃行のエラー・予定 ID が NULL の行は 404） | そのとおり |
| 4 | §3-4 日時の規則。特に ★戻すと `responded_at` / `resolved_at` を消す★・★解消済みを再度「解消」にしても日時を上書きしない★ | そのとおり |
| 5 | §3-5 リクエストの `memo` を `note` に改名（YAML を先に直す） | 改名する |
| 6 | §3-8 モックに無い操作（状態ボタン・備考編集）を事前チェックの行に足す | D-Sec のモックと D-Secログの作法を借りて足す |
| 7 | 段階9 ステージング検証 — 今は接続できず事前チェック行も無い。★ローカル検証だけで AT を完了扱いにし、ステージングは条件が揃ってから★でよいか | よい（完了条件 7） |
| 8 | 段階9 で `updated_at` だけは元に戻らない（DB が更新する）ことを受け入れるか | 受け入れる（ユーザーは実データの書き換えを承知済み。D-152） |
