Newer
Older
client_dwatch / docs / handoff / 指示書AE_セキュリティ強化と仕上げ.md

指示書AE — セキュリティ強化と仕上げ(群1 「上位10件に戻す」をリンクに / 群2 セキュリティ / 群3 小さな積み残し)

項目 内容
実行環境 ★Cursor 内の Claude が実装します★(.claude/commands/d-watch.md の役割分担表を参照)
実行状態 ★★完了(群1〜群3 の全段階。2026-09-13)★★ — ★決定番号 D-121〜D-127★。★詳細は文書末尾の「実行状態」表★。
★★残した積み残しが2つあります★★: ★段階3-1 ①(同一エリア同日2回清掃の二重計上。★仕様判断が要るため見送り★)/ ⑤(docs/handoff/analysis/code-analysis-pure.md の古い転記。★別エージェント作業中のため見送り★)★。
★完了時の実測: tsc = 0 / lint = 0 / npm test = 50ファイル / 1200テスト 全パス(着手前 1192 → 段階3-1 ② で8件増)★
位置づけ ★ユーザー指示(2026-09-13)★ ①「★『上位10件に戻す』を押せるとわかるように他と同じようにリンク化★」/②「★他にSQLインジェクションなどのセキュリティ面も強化★」に対応する1本です。③ として監督の照合で挙がった小さな積み残しを付けてあります
実行順 ★★群1 → 群2 → 群3★★(★群どうしは独立なので入れ替えても壊れませんが、この順が一番短く区切れます★)
対象 ★本書に全部書いてあります★(他の指示書を参照しません)
★着手前確認★ ★★なし★★ — ★①②はユーザー指示(2026-09-13)で確定、③は監督判断★。★★ユーザーの回答を待たずに群1 から実行してよい★★。
★ただし「やらない」という判断をしてよい段階が2つあります★(★段階2-4 のCSV★ / ★段階2-7 のセキュリティヘッダ★)。★どちらも「やらない」で構いませんが、その場合は理由を docs/decisions.md に書くこと★
取得する決定番号 ★★**着手直前に `grep -nE "^### D-(0\ 1)[0-9][0-9]" docs/decisions.md \ tail -3で最終番号を実測すること**★★。★★^### D-0だけでは3桁台(D-100 以降)を拾えない★★。★2026-09-07 にD-068` の二重採番が実際に起きています★。★★本書には見込み番号を書きません★★。★群ごとに1つ取るのが自然です(群1 はモックから意図的に外れる記録、群2 は禁止事項の機械化、群3 は積み残し)★
段階数 ★群1★ = 段階1-0〜1-1(2段階。コミット1つ)/ ★群2★ = 段階2-0〜2-8(9段階。コミット最大6つ)/ ★群3★ = 段階3-0〜3-2(3段階。コミット最大2つ)
着手前の基準 ★★着手直前に自分で npm test / npx tsc --noEmit / npm run lint を実行し、その実測値を基準にすること★★。★作成時点の実測(2026-09-13。★★鵜呑みにしない★★): npm test = 48ファイル / 1137テスト(全パス) / tsc = 0 / lint = 0 / db.cleanLogs() = 538件 / db.prechecks() = 58件★
作成 2026-09-13(作成時点の決定事項最終番号 D-120(docs/decisions.md:7762)/ HEAD = 041a809 / ブランチ = develop。★着手時に実測すること★)

実行後はこの表と、文書末尾の「実行状態」表を更新してください。

★この指示書を読んでいるあなたへ★ — .claude/commands/d-watch.md の「実装は Cursor 側」は 2つの環境の役割分担です。Cursor の中で動いている Claude は実装担当です。 ★調査や改訂ではなく実装を行ってください。★ ★★途中で止めてよい指示書です★★ — ★各群の終わりに「★ここで止めてもよい★」と明記してあります★。


★★冒頭に読むべきこと — 本書は何で、何ではないか★★

★★本書は「脆弱性を直す指示書」ではありません★★

★★作成時点の実測では、アプリコードに実際に危険な箇所は見つかっていません★★。

調べたこと 実測結果(2026-09-13)
生SQL($queryRaw / $executeRaw / *Unsafe / Prisma.raw) ★アプリコードに1件も無い★(lib/generated/** = Prisma の生成物にある型宣言のみ)
orderBy のカラム名 ★固定リテラル1箇所だけ★(lib/db/index.ts:673 の orderBy: { reported_at: "desc" })。★sort / order というクエリパラメータはリポジトリ全体に存在しない★
take / skip にユーザー入力 ★渡している箇所は無い★。Prisma への take は lib/db/index.ts:674 の take: 1 のみ
エラーレスポンスへの内部情報 ★err.message / スタックトレース / 接続文字列 / テーブル名を本文に載せている箇所は1つも無い★。全 catch が固定文言+details: null
DBへの書き込み ★アプリコードに1件も無い★(scripts/seed-local-db.ts はローカル検証用シードで、アプリの書き込みではない)
リポジトリ内の認証情報 ★実在の値は無い★(pw / s3cret / root@localhost / example.com 等のダミーのみ)

★★だから本書の群2 は「穴を塞ぐ」作業ではなく、「いま安全であることを、人の注意力に頼らず機械で固定する」作業です★★。

★★無理に脆弱性を作り出さないこと★★(ユーザー指示 2026-09-13)。 ★「直すところが無い」は失敗ではありません★。★段階2-0 の棚卸しで「無い」と確認できたら、 それをそのまま記録して次へ進んでください★。 ★★「何か直さないと成果に見えない」と考えて、動いているコードを触らないこと★★。

★★ただし、危険だと判断した箇所が1つだけあります★★

★★アプリコードではなく、DB接続の経路です★★。→ ★詳細は段階2-8(申し送り)★。

  • ★テスト用DBが公開IPに平文(TLS無効)で開いている★(★監督実測★: have_ssl=DISABLED / require_secure_transport=OFF / 参照用アカウント@%)
  • ★npm audit が mariadb コネクタ(@prisma/adapter-mariadb の依存)に ★「ssl: true を指定してもMitMに平文パスワードが漏れる」★ と ★「機密情報の平文送信」★ を報告している。★★No fix available(上げても直らない)★★
  • ★lib/db/prisma.ts:38 は new PrismaMariaDb(databaseUrl) で、★TLSの指定が無い★

★この2つが重なると、DB接続の資格情報が経路上で保護されていない状態になります★。 ★★これは DFA のアプリコードでは直せません(TeamWorks とインフラ側の話)★★。 ★段階2-8 で「申し送り」として文書に落とし、コードは触らないこと★。

★本書で新しく決めたこと★

# 決めたこと どこ
1 ★collapseBtn は color だけを var(--pr) に変える★(★クラスを消したり、別モジュールから composes したりしない★) 段階1-1
2 ★生SQL禁止は ESLint ルールで機械化する★(★grep を手順書に書くだけにしない★) 段階2-1
3 ★settings/advice の PATCH に長さ上限を入れる★(★lib/config/ の定数として外出し★) 段階2-3
4 ★トークンの定数時間比較はすでに実装済みなので何もしない★ 段階2-5

★★実行順とその理由★★

★指示書AC とは独立。★ただし同時に走らせないこと★★

  • ★本書は docs/handoff/指示書AC_絞り込みをSQLに降ろす.md と★前後どちらでもよい★
  • ★★ただし同時に走らせないこと★★ — ★指示書AC は lib/db/index.ts と lib/*/query.ts を 大きく書き換えます。本書の段階2-1(ESLint ルール)と段階2-2(パスパラメータ)が 同じ時間帯に同じ木を触ると、どちらの検証結果も信用できなくなります★
  • ★どちらを先にやってもよいが、★終わってからもう一方を始めること★

群の中の順序

  1. ★群1 が先★ — ★1行の CSS 変更で、他のどの群とも重なりません★。★一番短く区切れるので最初に通す★
  2. ★群2 が中★ — ★段階2-0(棚卸し)を必ず先にやること★。★棚卸し前に「直し」を始めると、 直した後の姿を「元からそうだった」と誤記録します★
  3. ★群3 が最後★ — ★群3-1(docs/handoff.md の先頭)は、★本書の成果を追記した後★に 直すのが自然です(直後にまた古くなるため)★

★★コミットと検証の規律(★全群に効きます★)★★

  1. ★★群ごとに区切ってコミットすること★★ — ★群をまたいだ1コミットを作らない★
  2. ★★群の切れ目で npx tsc --noEmit / npm run lint / npm test の3つが通ること★★ (★群の中の各段階でも通すこと。段階割りはそうなるように組んであります★)
  3. ★★git add -A 禁止★★(CLAUDE.md §7。★過去に別作業の途中ファイルを巻き込む事故あり★)— ★★必ずファイルパスを明示して git add すること★★
  4. ★★docs/decisions.md をコミットするときは git diff docs/decisions.md を先に見ること★★ — ★未コミットの追記が同居していると巻き込みます★(★指示書W で実際に起きた★)
  5. ★★docs/handoff/ 配下の他ファイルに触らないこと★★ — ★★別のエージェントが docs/handoff/指示書AC_絞り込みをSQLに降ろす.md を作成中です★★。 ★本書が触ってよいのは docs/handoff.md(群3-1)と本書自身だけ★
  6. ★★途中で止めてよい★★ — ★各群の終わりに「ここで止めてもよい」と書いてあります★。 ★止めるときは、どの群のどの段階まで終わったかを作業メモに残すこと★

★禁止事項(★本書の全群・全段階に効きます★)★

  1. ❌ ★★git add -A の使用★★ — ★ファイルパスを明示する★(CLAUDE.md §7)
  2. ❌ ★★メインDBのスキーマ変更・マイグレーション作成★★(TeamWorks の領域。D-056)
  3. ❌ ★★robot_error_reports / control_error_histories の status / comment / responded_at / resolved_at ★以外★への DB 書き込み★★(D-042)。 ★★本書のどの群も、アプリコードから DB に書き込みません★★
  4. ❌ ★★閾値・コード値のハードコード★★ — ★lib/config/ から読む★(CLAUDE.md §7)。 ★段階2-3 の長さ上限を数値で直書きしないこと★
  5. ❌ ★★lib/fixtures/ の既存の値を変更すること★★ — ★本書はどの群も fixtures の値を変えません★
  6. ❌ ★★モックに無い配色を発明すること★★ — ★群1 で使うのは var(--pr) だけ。 これは★同じフッタの隣のボタンが既に使っている色★です(モック d-watch-mock.jsx:818)★
  7. ❌ ★★npm audit fix --force を使うこと★★ — ★prisma@6 へのダウングレードを含む破壊的変更が走ります★
  8. ❌ ★★依存パッケージの major を勝手に上げること★★ — ★段階2-6 は記録するだけ★
  9. ❌ ★★セキュリティの調査で、実在の攻撃手順を書き残すこと★★ — ★★「どこが守られているか」「何を検証したか」だけを書くこと★★。 ★再現手順・ペイロード・具体的な突破方法を docs/ にも commit message にも残さないこと★
  10. ❌ ★★次のファイルに触ること★★: README.md / docs/code-map.md / docs/handoff/管理画面_設計検討_20260910.md / ★.claude/worktrees/**★ / ★docs/handoff/ 配下の本書以外のファイル(docs/handoff.md は除く)★
  11. ❌ ★★一時スクリプトをリポジトリに残すこと★★ — ★一時ファイルは作業用ディレクトリに置き、終わったら消すこと★。 ★★成功時にも必ず process.exit(0) を書くこと★★(★Prisma の接続が残ってハングした事故あり★)
  12. ❌ ★★DBへの書き込み・スキーマ変更を伴う調査★★ — ★★調査は SELECT のみ★★
  13. ❌ ★★仕様が不明な箇所を推測で実装すること★★(CLAUDE.md §7)— ★止めて確認を求める★

★★群1 — 「上位10件に戻す」をリンクに見せる★★

やること

★施設別パフォーマンス表のフッタにある「上位10件に戻す」が、★押せるように見えない★★。 ★同じフッタの「全N施設を表示 →」はリンク色(var(--pr))なのに、 「上位10件に戻す」だけ地味な文字色(var(--tx-m))で、ただのラベルに見えます★。 ★★これを隣のボタンと同じ見た目に揃えます★★。

★★これはモックから意図的に外れる変更です(★記録に残すこと★)★★

★CLAUDE.md §5 は「モック(d-watch-mock.jsx)が画面レイアウト・文言・遷移の正」と定めています★。 ★★そして現状の実装は、モックに忠実です★★:

d-watch-mock.jsx:818  ... style={{ fontSize: 12, color: c.pr,  ... }}>全{RANK.length}施設を表示 →
d-watch-mock.jsx:823  ... style={{ fontSize: 12, color: c.txM, ... }}>上位10件に戻す

★★つまり「実装ミス」ではありません★★。★モックがそう描いていたので、そのとおり移植してあります★。

★★今回はユーザー指示(2026-09-13)でモックより利用者の判断を優先します★★。 ★指示書AA(D-120)が「モックに無いUIを例外として新規に作った」のと同じ扱いです★。 ★★docs/decisions.md に「モックから意図的に外れた」と理由つきで記録すること★★ — ★記録しないと、次にモックと実装を突き合わせた人が「バグだ」と言って元に戻します★。


段階1-0 — 実測(★読むだけ・コミットなし★)

★★行番号を信じないこと★★。★次を自分で実測して控えること★:

grep -n "expandBtn\|collapseBtn" components/features/reports/FacilityPerformanceTable.tsx
grep -n "^\.expandBtn\|^\.collapseBtn" components/features/reports/FacilityPerformanceTable.module.css
grep -n "^\.linkBtn\|^\.linkBtnMuted" components/features/reports/ReportsScreen.module.css
grep -n "上位10件に戻す" lib/i18n.ts

★作成時点の実測(2026-09-13。★鵜呑みにしない★)★:

場所 内容
components/features/reports/FacilityPerformanceTable.tsx:251 「全N施設を表示 →」= styles.expandBtn
components/features/reports/FacilityPerformanceTable.tsx:260 「上位10件に戻す」= styles.collapseBtn
components/features/reports/FacilityPerformanceTable.module.css:107 .expandBtn → color: var(--pr)
components/features/reports/FacilityPerformanceTable.module.css:115 .collapseBtn → color: var(--tx-m)
components/features/reports/ReportsScreen.module.css:210 .linkBtn → ★.expandBtn と完全に同一の4宣言★
components/features/reports/ReportsScreen.module.css:218 .linkBtnMuted → ★.collapseBtn と完全に同一。★どこからも使われていない(デッドCSS)★★
lib/i18n.ts:88 上位10件に戻す: "Back to top 10" ★既に登録済み★

★★.expandBtn と .collapseBtn の差は color の1宣言だけです★★:

.expandBtn  { font-size: 12px; color: var(--pr);   background: none; border: none; cursor: pointer; }
.collapseBtn{ font-size: 12px; color: var(--tx-m); background: none; border: none; cursor: pointer; }

★cursor: pointer は両方に既にあります★。★足りないのは色だけです★。


段階1-1 — ★collapseBtn の色を揃える★(1コミット)

1-1-1. ★★やること — 変更は CSS の1行だけ★★

components/features/reports/FacilityPerformanceTable.module.css の .collapseBtn:

.collapseBtn {
  font-size: 12px;
  color: var(--pr);        /* ← ここだけ。var(--tx-m) から変える */
  background: none;
  border: none;
  cursor: pointer;
}

★コメントを添えること★(★次の人がモックと突き合わせて戻さないように★):

/* ★モックは c.txM(地味な色)だが、押せると分からないためリンク色に揃えた★
   (ユーザー指示 2026-09-13 / D-xxx)。★隣の .expandBtn と同じ見た目にする★ */

★D-xxx は着手時に実測した番号に置き換えること★。

1-1-2. ★★やらないこと(★ここを外すと差分が膨らみます★)★★

❌ やらない なぜ
★.collapseBtn を消して TSX で styles.expandBtn を使い回す★ ★クラス名は「どちらのボタンか」を表しています★。消すと module.css:100 のコメント(/* フッタ(全N施設を表示 → / 上位10件に戻す)... */)と食い違う
★composes: linkBtn from "./ReportsScreen.module.css" を書く★ ★表コンポーネントが画面コンポーネントの CSS に依存します★。★責務の境界(FacilityPerformanceTable.tsx:22-29 のコメント)を壊す★
★ReportsScreen.module.css / ReportsScreen.tsx を触る★ ★ユーザー指示で明示的に禁止★(「ReportsScreen.tsx の他の部分に差分を出さないこと」)
★text-decoration: underline を足す★ ★隣の .expandBtn に下線はありません★。★「既存の作法に合わせる」=下線なし・色だけ★
★文言を変える★ ★t("上位10件に戻す") のまま★。★lib/i18n.ts:88 に対訳も既にある★
★:hover を新設する★ ★.expandBtn に :hover はありません★。★片方だけ足すと非対称になります★
★.linkBtnMuted(デッドCSS)を消す★ ★本書の範囲外★。★ReportsScreen.module.css に差分を出さない★

1-1-3. ★★検証★★

  1. ★npx tsc --noEmit / npm run lint / npm test が通ること★ (★CSS だけの変更なので、テスト件数は48ファイル / 1137件から1件も増減しないはずです★。 ★増減したら手が滑っています★)
  2. ★★画面で確認すること★★ — ★npm run dev → レポート画面(/reports)→ 施設別パフォーマンス表の「全N施設を表示 →」を押す → フッタが「上位10件に戻す」に変わる★
    • ★★ライトとダークの両方で見ること★★ (--pr は app/globals.css:7(ダーク #3b82f6)と :27(ライト #2563eb)の ★両方に定義があります★ので、新しい配色を発明せずに両テーマで成立します)
    • ★「全N施設を表示 →」と「上位10件に戻す」が★同じ色・同じサイズ★に見えること★
  3. ★★EN 表示でも確認すること★★(Back to top 10 になる。★器を壊さない★)

★表を10件以下に絞ると「全N施設を表示 →」自体が出ません★ (FacilityPerformanceTable.tsx:163 の showExpand = !showAll && totalCount > items.length)。 ★確認するときは施設が11件以上出る期間・絞り込みを選ぶこと★。

この段階の完了条件

  • .collapseBtn の color が var(--pr) になっている(★変更は CSS の1宣言のみ★)
  • ★git diff --stat が FacilityPerformanceTable.module.css の1ファイルだけ★
  • tsc 0 / lint 0 / npm test が着手前と同じ件数で全パス
  • ★ライト・ダーク両方で隣のボタンと同じ見た目★
  • docs/decisions.md に ★「モックから意図的に外れた」と理由つき★ で記録
  • コミット(★ファイルパスを明示★)

★群1 の終わり★

★★ここで止めてもよい★★ — ★ユーザー指示①はこれで完結しています★。


★★群2 — セキュリティ強化★★

★★この群の考え方★★

★★「人が気をつける」で終わらせないこと★★(ユーザー指示 2026-09-13)。 ★禁止事項を文書に書き足すだけなら、次の実装者は読まずに書きます★。 ★★npm run lint で落ちる形にすること★★ — ★CI(.github/workflows/ci.yml:29)が npm run lint を走らせているので、ESLint ルールにすれば自動的に PR で検知されます★。


段階2-0 — ★★棚卸し(★読むだけ・コミットなし★)★★

★★自分で実測すること★★。★下の表は作成時点(2026-09-13)の実測値で、★鵜呑みにしないこと★★。

# 生SQL(★lib/generated は Prisma の生成物なので除外して数える★)
grep -rn 'queryRaw\|executeRaw\|Unsafe\|Prisma\.raw' --include=*.ts --include=*.tsx app lib components scripts \
  | grep -v '^lib/generated/'

# orderBy と sort/order パラメータ
grep -rn 'orderBy' --include=*.ts app lib | grep -v '^lib/generated/'
grep -rn '"sort"\|"order"' --include=*.ts --include=*.tsx app lib components

# API ルートの一覧と searchParams の扱い
find app/api -name route.ts | sort
grep -rln 'searchParams' app/api

# リポジトリ内の認証情報(★ヒットは全部ダミーのはず★)
git grep -nIE "mysql://[^U]|password[[:space:]]*[:=][[:space:]]*[\"'][^\"']{4,}" -- ':!node_modules' ':!docs'

★作成時点の実測(2026-09-13)★

調査項目 結果
生SQL ★0件★(lib/generated/prisma/internal/*.ts の型宣言のみ。★あれは Prisma のメソッド定義で、呼び出しではない★)
orderBy ★1箇所のみ★ lib/db/index.ts:673 orderBy: { reported_at: "desc" }(★固定リテラル★)
sort / order クエリパラメータ ★リポジトリ全体に存在しない★。並べ替えは全部 JS 側の固定コンパレータ(sortDsAlerts 等)
where にユーザー入力 ★無い★。where は deleted_at: null 等の固定条件のみ。絞り込みは全部 JS 側(filterXxx)
Prisma への take / skip ★ユーザー入力からは渡していない★。take: 1(lib/db/index.ts:674)のみ
per_page / limit のクランプ ★7つの parseXxxQuery() すべてが Math.min(PAGINATION.maxPerPage, Math.max(1, toInt(...)))★。★maxPerPage = 200(lib/config/index.ts:177-193)★
エラー本文への内部情報 ★0件★。全 catch が固定文言+details: null。例外は logApiError() でログにだけ出る
DB書き込み ★アプリコードに0件★
リポジトリ内の認証情報 ★実在の値0件★(pw / s3cret / abc123 / root@localhost / db.example.internal 等のダミーのみ)

★★すでに守られていること(★触らないこと★)★★

守り 実装 状態
トークンの定数時間比較 lib/auth/token.ts:26-44(node:crypto の timingSafeEqual。★長さ違いも同じ時間で false★) ★実装済み。何もしない★
X-Forwarded-For の★末尾★を見る lib/auth/client-ip.ts:37-51(parts[parts.length - hops]。★先頭は使わない★。D-077 項5) ★実装済み★
足りない段数を先頭で埋めない lib/auth/client-ip.ts:46(parts.length < hops なら null) ★実装済み★
ALLOW_DIRECT_ACCESS の fail-closed lib/config/allowed-ips.ts:56(=== "true"。★!== "false" にしない★。D-101) ★実装済み★
cookie の httpOnly / secure / sameSite lib/auth/cookie.ts:29-34 ★実装済み★
ログの秘匿化 lib/logger/redact.ts(PII 13種+認証情報9種。★キー名で判定、大文字小文字無視★) ★実装済み★
ログイン失敗時に入力値を出さない app/api/auth/login/route.ts:46-51 ★実装済み★
シードが本番DBに書かないガード lib/db/local-guard.ts:23-31(★dwatch_local 以外なら例外★) ★実装済み★
エラーに DATABASE_URL の値を出さない lib/config/env-check.ts / lib/db/local-guard.ts(★「値はこのエラーには出しません」★) ★実装済み★

★★この表の行を「念のため」書き直さないこと★★ — ★どれも理由つきで確定しています(D-072 / D-077 / D-079 / D-101)★。

この段階の完了条件

  • 上の実測コマンドを全部自分で走らせ、★結果を控えた★
  • ★作成時点の表と食い違う項目があれば、自分の実測を正として本書を直した★
  • ★コミットしない★

段階2-1 — ★★生SQL禁止を ESLint で機械化する★★(1コミット)

2-1-1. なぜ ESLint か

★CLAUDE.md §3 は「なるべく MySQL 互換の SQL を使う」「生SQL を書くときは特に注意する」と 書いていますが、★禁止とは書いていません★★。 ★本番は MariaDB 10.11 / ローカル検証は MySQL 8.4 なので、★生SQL はそもそも 「ローカルで通っても本番で通らない」リスクがあります★。 ★★加えて *Unsafe 系と文字列連結は SQL インジェクションの入口そのものです★★。

★grep を検証手順に書くだけでは、次の実装者が読まなければ素通りします★。 ★★npm run lint が CI(.github/workflows/ci.yml:29)で走っているので、ESLint ルールにすれば PR で自動的に落ちます★★。

2-1-2. ★★やること★★

eslint.config.mjs に ★no-restricted-syntax★ のブロックを1つ追加する。

  // ★生SQL禁止(指示書AE 段階2-1 / D-xxx)★
  //   SQLインジェクションの入口を構造的に塞ぐ。Prisma のクエリビルダ経由に統一する。
  //   ★lib/generated/** は先頭の ignores で除外済み★ —
  //   あそこには Prisma が生成した $queryRaw の「型宣言」があり、呼び出しではない。
  //   ★どうしても生SQLが必要になったら、このルールを消すのではなく
  //     タグ付きテンプレート(`prisma.$queryRaw`...`)だけを個別に eslint-disable し、
  //     理由を docs/decisions.md に書くこと。★文字列連結と *Unsafe は例外を作らない★
  {
    files: ["app/**", "lib/**", "components/**", "scripts/**", "proxy.ts"],
    rules: {
      "no-restricted-syntax": [
        "error",
        {
          selector:
            "MemberExpression[property.name=/^\\$(query|execute)Raw(Unsafe)?$/]",
          message:
            "生SQLは使わないこと(CLAUDE.md §3 / 指示書AE 段階2-1)。Prisma のクエリビルダを使う。",
        },
        {
          selector: "MemberExpression[object.name='Prisma'][property.name='raw']",
          message:
            "Prisma.raw は使わないこと(指示書AE 段階2-1)。Prisma のクエリビルダを使う。",
        },
      ],
    },
  },

★D-xxx は着手時に実測した番号に置き換えること★。

2-1-3. ★★ルールが本当に効くことを確かめる(★必須★)★★

★★書いただけで満足しないこと★★。★セレクタのタイポは「常に何も検知しない」形で失敗します★。

  1. ★一時的に★ 任意の lib/ 配下のファイルへ次の1行を足す:
    const _tmp = () => prisma.$queryRawUnsafe("SELECT 1");
  2. npm run lint を走らせ、★その行でエラーになること★を目で確認する
  3. ★$queryRaw(Unsafe なし)と Prisma.raw でも同様に確認する★
  4. ★★足した行を消す★★。★git diff が eslint.config.mjs だけになっていることを確認する★
  5. npm run lint が ★0★ に戻ることを確認する

2-1-4. ★併せて文書に明文化すること★

★CLAUDE.md は本書では触りません★(★本書の担当外★)。★代わりに docs/decisions.md の 決定エントリに、次の3つを禁止として書くこと★:

  • ❌ $queryRawUnsafe / $executeRawUnsafe(★例外なし★)
  • ❌ SQL 文字列の連結(★ユーザー入力の有無にかかわらず★)
  • ❌ orderBy のカラム名をユーザー入力から組み立てること(★許可リストのみ★)
  • △ $queryRaw / $executeRaw の★タグ付きテンプレート★は、★どうしても必要な場合に限り★ 個別 eslint-disable +理由の記録で許す。★★その箇所を TeamWorks に知らせること★★ (CLAUDE.md §3 の「MariaDB 特有の DML を使いたい場合は TeamWorks に知らせる」)

この段階の完了条件

  • eslint.config.mjs にルールが入っている
  • ★一時コードで実際に落ちることを確認した★(★確認したら一時コードを消した★)
  • npm run lint が 0
  • npx tsc --noEmit 0 / npm test が着手前と同じ件数で全パス
  • コミット(★eslint.config.mjs のみ★)

段階2-2 — ★★パスパラメータの検証を厳しくする★★(1コミット)

2-2-1. ★現状(★自分で実測すること★)★

ルート 現状
app/api/clean-logs/[id]/advice/route.ts:33-37 Number.parseInt(id, 10) → Number.isNaN のみ 422
app/api/clean-logs/[id]/areas/route.ts:47-51 ★同一実装★
app/api/ds-devices/[serial_number]/note/route.ts:48 ★形式・長さとも未検証★

★★Number.parseInt は前方一致なので、次が黙って通ります★★:

入力 parseInt の結果
-5 -5(★負数★)
12abc 12(★ゴミが付いていても通る★)
1.9 1(★非整数が切り捨てられる★)
1e9 1(★指数表記が意図と違う値になる★)

2-2-2. ★★いま危険ではない理由(★記録すること★)★★

★★この値は DB クエリにもファイルパスにも渡りません★★。 ★用途は logs.find((l) => l.id === logId) の等値比較だけで、一致しなければ 404 です★ (clean-logs/[id]/advice/route.ts:48-51 / [id]/areas/route.ts:70)。 ★serial_number も devices.some(...) の実在チェックを通らなければ 404 で、 保存先のパスは lib/settings/ds-device-notes.ts:53-55 で固定、 マップは Object.create(null) です★。

★★だからこれは「穴を塞ぐ」ではなく「入口を厳しくして、将来 DB に降ろしたときに崩れないようにする」作業です★★。 ★★指示書AC が絞り込みを SQL に降ろすので、そのとき id が Prisma の where に入ります★★。 ★その前に入口を締めておくのが本段階の意味です★。

2-2-3. ★★やること★★

★共通の小さな純粋関数を1つ作り、3ルートから呼ぶ★。★置き場所は lib/api/ が自然です★ (既に lib/api/query-result.ts の fail() がある)。

  • ★^\d+$ の完全一致で判定する★(★parseInt の前方一致に頼らない★)。 ★lib/reports/query.ts:88-92 の toIntStrict(^-?\d+$)が★既にある前例★なので、 それに倣うこと。★新しい流儀を発明しない★
  • ★上限を設ける★ — ★Number.isSafeInteger を超える値を弾く★。 ★上限値は lib/config/ の定数として外出しすること★(CLAUDE.md §7。★直書き禁止★)
  • ★負数・0 を弾く★(★ID は 1 以上★)
  • ★外れたら 422 validation_failed★(★既存ルートと同じ形★)。 ★★エラー文言に入力値をエコーしないこと★★
  • ★serial_number は★長さ上限★と★文字種★を決めて検証する★。 ★★実データの形式が不明なら、長さ上限だけにして文字種は絞らないこと★★ (CLAUDE.md §7「仕様が不明な箇所を推測で実装しない」)。 ★長さ上限は lib/config/ の定数に★

2-2-4. ★★テストを書くこと★★

★lib/ 配下の該当する __tests__/ に純粋関数のテストを追加する★。★期待値表を先に作ること★:

入力 期待
"1" 通る(1)
"538" 通る(538)
"0" ★422★
"-5" ★422★
"1.9" ★422★
"12abc" ★422★
"1e9" ★422★
"" ★422★
"9007199254740993"(安全整数超) ★422★
全角数字 "1" ★422★

この段階の完了条件

  • 純粋関数+テストがある(★上の期待値表を全部含む★)
  • 3ルートが同じ関数を通している
  • ★上限値が lib/config/ の定数★(★直書きしていない★)
  • ★エラー文言に入力値をエコーしていない★
  • tsc 0 / lint 0 / npm test 全パス(★テストは増える★)
  • コミット(★ファイルパスを明示★)

段階2-3 — ★★settings/advice の PATCH に長さ上限を入れる★★(1コミット)

2-3-1. ★★これが群2 で一番実のある直しです★★

★app/api/settings/advice/route.ts:76-81 の検証は★型チェックだけ★です★:

フィールド 現状の検証 足りないもの
rule(ruleId) ★DEFAULT_ADVICE_RULES[ruleId] の存在チェック★ ★★十分(許可リスト方式)★★
title typeof === "string" ★長さ上限なし★
text typeof === "string" ★長さ上限なし★
actions Array.isArray +要素の typeof === "string" ★配列長の上限なし・要素長の上限なし★

★★これはすぐ隣の実装と非対称です★★:

  • app/api/ds-devices/[serial_number]/note/route.ts:69-76 は ★DEVICE_NOTE_MAX_LENGTH(= 200、lib/config/index.ts:236)で上限をかけています★
  • ★その理由は lib/config/index.ts:230-235 に書いてあります★:

    上限が無いと1件の備考でストアのファイル全体が肥大し、 ★全件読み込み→書き戻しという保存方式ごと遅くなる★ため。

  • ★★settings/advice はまったく同じ保存方式です★★(lib/settings/advice.ts:126-139 が JSON ストアを全件読み→書き戻し)。★同じ理由が同じだけ当てはまるのに、上限だけ無い★

★さらに、ここに入れた値は GET /settings/advice と /clean-logs/[id]/advice(app/api/clean-logs/[id]/advice/route.ts:85-96)で ★そのまま画面に返ります★★。

2-3-2. ★★やること★★

★lib/config/index.ts に定数を足し、DEVICE_NOTE_MAX_LENGTH と同じ書き方で理由を書く★。

★★値は次を推奨します(★実行者が変えてよい。変えたら理由を書くこと★)★★ — ★lib/config/advice-rules.ts の DEFAULT_ADVICE_RULES の実物から逆算した数字です★:

定数 推奨値 根拠(★着手時に実物を見て確かめること★)
ADVICE_TITLE_MAX_LENGTH 100 ★既定値は「出発失敗(R1)」等で15文字前後★。余裕を見て100
ADVICE_TEXT_MAX_LENGTH 400 ★既定値は60文字前後★。R2 の「現場の通路状況+…」が最長
ADVICE_ACTION_MAX_LENGTH 100 ★既定値は15文字前後★
ADVICE_ACTIONS_MAX_ITEMS 10 ★既定値は3件★

★超過したら 422 validation_failed★。★details には上限値だけを入れ、入力値をエコーしないこと★ (★ds-devices/[serial_number]/note/route.ts:74 が DEVICE_NOTE_MAX_LENGTH を details に 入れている前例に倣う★)。

2-3-3. ★★やらないこと★★

  • ❌ ★actions の中身を許可リストで縛ること★ — ★自由入力のメモです★
  • ❌ ★既定値(DEFAULT_ADVICE_RULES)を書き換えること★
  • ❌ ★上限を数値で直書きすること★(★lib/config/ へ★)
  • ❌ ★ds-device-notes 側の DEVICE_NOTE_MAX_LENGTH(= 200)を変えること★

2-3-4. テスト

★既存の settings/advice のテストに境界値を足す★:

入力 期待
title = 上限ちょうど 通る
title = 上限+1 ★422★
text = 上限+1 ★422★
actions = 上限件数ちょうど 通る
actions = 上限件数+1 ★422★
actions の1要素が要素長上限+1 ★422★

この段階の完了条件

  • 4定数が lib/config/index.ts にあり、★DEVICE_NOTE_MAX_LENGTH と同じ密度で理由が書いてある★
  • app/api/settings/advice/route.ts が4定数すべてで検証している
  • ★details に入力値をエコーしていない★
  • 境界値テストが全部ある
  • tsc 0 / lint 0 / npm test 全パス
  • コミット

段階2-4 — ★★CSVの仕上げ(★やらない判断をしてよい★)★★(1コミット。任意)

2-4-1. ★見つかったこと★

app/api/clean-logs/export/route.ts:18-23 の csvCell():

function csvCell(value: string): string {
  if (/[",\n\r]/.test(value)) {
    return `"${value.replaceAll('"', '""')}"`;
  }
  return value;
}

★" , 改行は正しくエスケープしています★。 ★★ただし、先頭が = + - @ の値は表計算ソフトが数式として解釈します★★ (いわゆる CSV インジェクション)。 ★出力される値の出所は DB 由来の施設名・機種名・シリアルで、 これらを書いているのは★TeamWorks のデータ収集処理★です(CLAUDE.md §2)。 ★★つまり DFA 側でその内容を保証できません★★。

2-4-2. ★★判断が要ります★★

★★これは「やらない」で構いません★★。理由:

  • ★P1 はDFA社内のみの利用★(CLAUDE.md §1)で、CSV を開くのは運用担当だけ
  • ★対策(先頭に ' を付ける等)は★出力される CSV の中身が変わる★ため、 ★受け取る側の手順に影響します★

★★どちらを選んでもよいので、選んだ理由を docs/decisions.md に書くこと★★。

  • ★やる場合★: csvCell() の中だけで完結させる。★★' を前置する方式にし、 条件(先頭が = + - @ のとき)と理由をコメントに書く★★。 ★テストを必ず足すこと★(★=SUM(1) → '=SUM(1)、正常な施設名 → そのまま★)。 ★既存の "/,/改行のエスケープを壊さないこと★
  • ★やらない場合★: ★csvCell() の上に「★検討したうえで P1 では対策しない★」とコメントを1つ残すこと★。 ★次に見た人が同じ調査を最初からやり直さずに済みます★

2-4-3. ★併せて見つかったこと(★こちらは直さない★)★

★app/api/clean-logs/export/route.ts:79-81 が、parseCleanLogQuery を通した後に ★生の searchParams をもう一度読んで★ Content-Disposition のファイル名に入れています★:

const from = searchParams.get("date_from") ?? "";
const to = searchParams.get("date_to") ?? "";
const range = from && to ? `_${from}_${to}` : "";

★★いまは安全です★★ — ★parseCleanLogQuery が先に ^\d{4}-\d{2}-\d{2}$ (lib/clean-logs/query.ts:28)で弾いているので、ヘッダに制御文字は入りません★。 ★★ただし「検証した場所」と「使う場所」が別の関数に分かれた暗黙の依存です★★。

★★直さなくてよいですが、コメントを1行足すこと★★:

// ★生値を使ってよいのは、上の parseCleanLogQuery が ^\d{4}-\d{2}-\d{2}$ で
//   既に弾いているから★。★この検証を緩めるならここも直すこと★

★parsed.query 側に正規化済みの値があるなら、そちらを使うほうが素直です★。 ★★どちらにするかは実行者判断★★。

この段階の完了条件

  • ★やる / やらない を決めて、理由を docs/decisions.md に書いた★
  • (やった場合)テストがある / (やらない場合)コメントが残っている
  • ★Content-Disposition の暗黙依存にコメントを足した★
  • tsc 0 / lint 0 / npm test 全パス

段階2-5 — ★★本番前チェックリストを docs/deploy.md に集約する★★(1コミット)

2-5-1. ★★コードは変えません★★

★段階2-0 の表のとおり、認証・IP制限まわりはすべて実装済みです★。 ★足りないのは「本番へ出す直前に何を確かめるか」が1箇所にまとまっていないことです★。

★docs/deploy.md には既に材料があります★(★着手時に実測すること★):

節 内容
docs/deploy.md:57 ★ALLOW_DIRECT_ACCESS は本番で設定しないこと★
docs/deploy.md:74 ★X-Forwarded-For に「追記」すること(D-077 項5)★
docs/deploy.md:88 ★アプリのポートに nginx 以外が到達できないようにすること★
docs/deploy.md:125 ★許可IPリストの変更にはデプロイが必要★

2-5-2. ★★やること — 「セキュリティ確認」の節を1つ足す★★

★チェックボックス形式で、★それぞれに「どう確かめるか」のコマンドか観点★を添えること★。

# 確認項目 確かめ方
1 ★AUTH_TOKEN が changeme のままでない★ ★npm run check:env★(lib/config/env-check.ts:56-70 が★エラーで落とします★)
2 ★ALLOW_DIRECT_ACCESS が本番で未設定★ ★npm run check:env★(検査2。★本番判定でエラー★)
3 ★USE_FIXTURES=false★ ★npm run check:env★(検査3。★未設定だと true 扱いで、実DBに繋がらないまま動きます★)
4 ★ALLOW_ALL_WHEN_EMPTY = false かつ ALLOWED_IPS が埋まっている★ ★lib/config/allowed-ips.ts を目視★。★★環境変数ではないのでデプロイが必要★★
5 ★TRUSTED_PROXY_HOPS が実構成と合っている★ ★docs/deploy.md:170 の判定表★。★2段構成になったら 2 にする★
6 ★nginx が XFF を「追記」している(上書きでない)★ ★docs/deploy.md:74 の手順★
7 ★リポジトリに認証情報が入っていない★ ★段階2-0 の git grep コマンド★
8 ★npm audit の結果を確認した★ ★段階2-6★

2-5-3. ★★「実装済みだから確認不要」にしないこと★★

★項目4 と 5 は★コードに書いてあるので .env.local を直しても変わりません★ (docs/deploy.md:125-139 / .env.production.example の末尾)。 ★★デプロイ担当がここを見落とすと、ALLOW_ALL_WHEN_EMPTY = true のまま本番へ出ます★★。 ★チェックリストに入れる意味はここにあります★。

2-5-4. ★過去の事故を記録に残すこと★

★docs/deploy.md の同じ節に、★過去に実在のDBユーザー名がコミットされた★ことを書くこと★:

  • ★c832891(fix: テストから実在のDBユーザー名を除去する)で除去済み★
  • ★★ただしGit の履歴には残っています★★
  • ★だから「いま git grep して出ない」=「漏れていない」ではありません★
  • ★★本番の資格情報は、履歴に入った値と別のものを発行すること★★(→ 段階2-8 の申し送り)

この段階の完了条件

  • docs/deploy.md にセキュリティ確認の節がある(★8項目+確かめ方★)
  • ★c832891 の事故と「履歴には残る」ことが書いてある★
  • ★★実在の攻撃手順を書いていない★★
  • コミット(★docs/deploy.md のみ★)

段階2-6 — ★★npm audit を検証手順に入れ、結果をそのまま記録する★★(1コミット)

2-6-1. ★★直すかどうかは別判断です★★

★★この段階でやるのは「走らせて、出た結果をそのまま書き残す」ことだけです★★。

  • ❌ ★npm audit fix --force 禁止★(★prisma@6.19.3 へのダウングレードが走ります★)
  • ❌ ★major を勝手に上げること禁止★
  • ★npm audit fix(--force なし)も、★この段階では実行しないこと★★ — ★package-lock.json が動くと、段階2-1〜2-3 の差分と混ざります★

2-6-2. ★作成時点の実測(2026-09-13)★

8 vulnerabilities (1 moderate, 7 high)
パッケージ 経路 内容 修正
★mariadb 3.4.0-3.4.5★ ★@prisma/adapter-mariadb(直接依存)★ ★ssl: true でも MitM に平文パスワードが漏れる★ / ★機密情報の平文送信★ / ★特定クライアント文字セット(big5 / gbk / sjis / cp932 / gb18030)下での SQL インジェクション★ ★★No fix available★★
mysql2 ≤3.23.0 prisma 経由 認証プラグインのダウングレードで平文資格情報が漏れる / 圧縮プロトコルの解凍爆弾 --force のみ(★使わない★)
deepmerge-ts <8.0.0 @prisma/config → prisma 再帰オブジェクトでスタック枯渇 --force のみ(★使わない★)
js-yaml 4.0.0-4.3.1 — maxTotalMergeKeys が CPU 使用を制限しない npm audit fix で可
sharp <0.35.4 — libheif 由来 npm audit fix で可

2-6-3. ★★mariadb の SQL インジェクション勧告について(★確かめること★)★★

★★これは「アプリコードに生SQLが無い」では防げない層の話です★★ — ★コネクタがパラメータをエスケープする処理の問題だからです★。

★★ただし、影響を受けるのは特定のクライアント文字セット(big5 / gbk / sjis / cp932 / gb18030)の場合だけです★★。

★作成時点の実測では lib/db/prisma.ts:38 は new PrismaMariaDb(databaseUrl) で、 DATABASE_URL(.env.example / .env.production.example)にも文字セット指定はありません★。 ★★つまり既定(utf8mb4)で、該当しないと読めます★★。

★★着手時に自分で確かめること★★:

grep -n "charset\|collation" .env.example .env.production.example lib/db/prisma.ts

★★DATABASE_URL に charset= / collation= が入っていないこと★★を確認し、 ★その事実を記録すること★。 ★★もし該当する文字セットが指定されていたら、そこで止めてユーザーに報告すること★★ (★勝手に変えない★)。

2-6-4. ★★やること★★

  1. ★npm audit を走らせ、★出力をそのまま★ docs/deploy.md のセキュリティ節(段階2-5 で作った節)に ★日付つきで貼る★
  2. ★上の「該当しない」確認の結果を1行で書く★
  3. ★★mariadb の「No fix available」を段階2-8 の申し送りに繋げること★★
  4. ★package-lock.json を変更しないこと★(★git diff --stat で確認★)

この段階の完了条件

  • npm audit の結果が日付つきで記録されている
  • ★文字セットの確認結果が書いてある★
  • ★package-lock.json に差分が無い★
  • ★npm audit fix を実行していない★
  • コミット(★docs/deploy.md のみ★)

段階2-7 — ★★セキュリティヘッダ(★やらない判断をしてよい★)★★(1コミット。任意)

2-7-1. ★現状★

next.config.ts は serverExternalPackages: ["pino"] だけで、★headers() の設定はありません★。 ★つまり X-Content-Type-Options / Referrer-Policy / X-Frame-Options / CSP のいずれも出ていません★。

2-7-2. ★★判断が要ります★★

★★「やらない」で構いません★★。理由:

  • ★P1 はDFA社内のみ・IP制限つき★。★外部からそもそも到達しません★
  • ★★ヘッダは nginx 側で付けるほうが素直です★★(★アプリを再デプロイせずに直せる★)。 ★docs/deploy.md:72 に既に「nginx 側の前提」の節があります★
  • ★★CSP を入れると画面が壊れる可能性があります★★(Next.js のインラインスクリプト)。 ★壊れたら無理に通さず戻すこと★

★★推奨: アプリには入れず、docs/deploy.md の「nginx 側の前提」に申し送りとして書く★★。

  • ★やる場合★: ★CSP は入れないこと★。★X-Content-Type-Options: nosniff / Referrer-Policy: same-origin / X-Frame-Options: DENY の3つだけにする★。 ★入れたら npm run dev で全画面を開いて壊れていないことを確認すること★
  • ★やらない場合★: ★docs/deploy.md の nginx 節に「★アプリ側では付けていない。nginx で付けること★」と書く★

この段階の完了条件

  • ★やる / やらない を決めて、理由を docs/decisions.md に書いた★
  • (やった場合)★全画面が壊れていないことを目視確認した★
  • tsc 0 / lint 0 / npm test 全パス

段階2-8 — ★★申し送り(TeamWorks / インフラ)★★(1コミット)

2-8-1. ★★これはコードでは直せません★★

★★DFA のアプリコードに手を入れても解決しない問題です★★。 ★★文書に落として、TeamWorks とインフラ側に渡すこと★★。

2-8-2. ★書くこと★

★docs/handoff.md に追記する★(★★docs/handoff/ 配下の他の新規ファイルを作らないこと★★ — ★別エージェントが指示書AC を作成中です★)。

# 事実 依頼したいこと
1 ★テスト用DBが公開IPに平文で開いている★(★監督実測★: have_ssl=DISABLED / require_secure_transport=OFF / 参照用アカウント@%) ★接続元の制限(参照用アカウント@% を★固定IP★に)★ / ★TLS の有効化★ / ★パスワードの再発行★
2 ★npm audit が mariadb コネクタに「ssl: true でも MitM に平文パスワードが漏れる」を報告。★No fix available★★ ★★TLS を有効にしても、このコネクタでは資格情報が保護されない可能性がある★★。★経路そのものを閉じる(固定IP / 内部ネットワーク)ことを優先してほしい★
3 ★過去に実在のDBユーザー名がコミットされ c832891 で除去。★履歴には残っている★★ ★★本番の資格情報は、履歴に入った値と別のものを発行すること★★
4 ★本番はさくらインターネットのデータベースアプライアンス★(CLAUDE.md §3 / D-100) ★★初めから閉じた構成になる見込みだが、明示の確認は未取得★★(D-100)。★確認したい★

2-8-3. ★★いつまでに、を書くこと★★

★★「ステージングに実データが入る前に決めること」★★ — ★これが期限です★。 ★実データが入った後では、平文経路を通った資格情報を前提に運用が始まってしまいます★。

2-8-4. ★★書かないこと★★

  • ❌ ★★実在のホスト名・IP・ユーザー名・パスワードを書くこと★★ (★CLAUDE.md §9.3。★★docs/ はリポジトリに入ります★★)
  • ❌ ★★実在の攻撃手順・再現コマンドを書くこと★★(★本書の禁止事項9★)
  • ★「公開IPに平文で開いている」という事実の記述までにとどめること★

この段階の完了条件

  • docs/handoff.md に申し送りが追記されている(★4項目+期限★)
  • ★★実在の資格情報・ホスト名・攻撃手順を書いていない★★
  • コミット

★群2 の終わり★

★★ここで止めてもよい★★ — ★ユーザー指示②はこれで完結しています★。


★★群3 — 小さな積み残し★★

段階3-0 — ★★docs/handoff.md の先頭を直す(★実害あり。必ずやること★)★★(1コミット)

3-0-1. ★★何が起きているか★★

docs/handoff.md:6:

- ★**最新の追記: 2026-09-11(追記36)**★ — **下の「追記36」が最新です。**

★★実際の最終追記は「追記40」です★★(docs/handoff.md:3363 ## 2026-09-11(追記40))。

★★この文書は、自分自身で警告を書いています★★:

docs/handoff.md:3  **この文書は日付ごとの追記が下へ積み上がる形式です。必ず最後まで読んでください。**
docs/handoff.md:4  先頭だけ読むと最初の追記時点の状態で止まります。

★★警告しておきながら、その先頭行が4件ぶん古い★★。 ★次に読む人は「追記36 が最新」と信じて止まり、★追記37〜40 の成果(指示書AB の4群 = D-117〜D-120)を まるごと見落とします★★。

3-0-2. ★★やること★★

  1. ★docs/handoff.md:6 を★実測した最終番号★に直す★ (★★着手時にもう一度数えること★★。★群2-8 で自分が追記していれば、その番号が最新です★)
    grep -n "^## .*(追記[0-9]*)" docs/handoff.md | tail -3
  2. ★要約文も、★その最新の追記の内容★に合わせて書き直すこと★ (★番号だけ直して中身が追記36 の説明のままだと、かえって紛らわしくなります★)
  3. ★★docs/handoff.md:817 にも古い記述があります★★:
    **最初に読むのは ... 追記7(この節)まで。**
    ★これも stale です★。★直すか、せめて「★この節は古い★」と1行足すこと★

3-0-3. ★★再発を防ぐ★★

★★同じことが5回起きています(追記36 → 40 で4件放置)★★。 ★docs/handoff.md の先頭に、★「追記したら必ずこの行も直すこと」★と1行書き足すこと★。

この段階の完了条件

  • ★先頭の「最新の追記」が実測値と一致している★
  • ★要約文が最新の追記の内容になっている★
  • ★:817 の古い記述に手当てをした★
  • ★「追記したらこの行も直す」の注意書きがある★
  • コミット(★docs/handoff.md のみ★)

段階3-1 — ★軽微な積み残し(★実行者判断で拾ってよい★)★(1コミット。任意)

★★どれもいま実害はありません★★。★時間があれば拾う、無ければ飛ばしてよい★。 ★★飛ばす場合は、その旨を作業メモに残すこと★★。

① ★同じエリアが同じ日に2回清掃されると二重計上される★

  • ★場所★: lib/clean-logs/areas.ts:110 buildAreaCompletions()
  • ★現状★: rows.map(...) のみで section_id の一意化なし。 ★分母・分子の積み上げ(:125-131)も全行を無条件に加算★:
    for (const r of rows) {
      cleanArea += r.clean_area;
      taskArea  += r.task_area;
    }
  • ★前段の絞り込み selectAreaRowsForLog()(:55-62)も facility_id と date の一致だけ★
  • ★★現データでは0件★★(★着手時に確かめること★)。 ★ただし「同一ロボットの同日2回清掃」は実在前提として扱われています★(追記33)
  • ★★拾う場合の注意★★: ★どう集約するかは仕様判断です★ (★最後の1回を採るのか / 面積を合算するのか / 両方出すのか★)。 ★★決められないなら止めて確認を求めること★★(CLAUDE.md §7)。 ★★推測で実装しないこと★★

② ★buildPageItems() の page に非整数・NaN のガードが無い★

  • ★場所★: lib/pagination/page-items.ts:93-98
  • ★現状★: totalPages には Number.isFinite ガードがあるが、 ★page は clamp() を通るだけ★。★Math.min(max, Math.max(min, NaN)) は NaN を素通りさせます★
  • ★★いまは到達不能★★ — ★呼び出し元は components/ui/Pagination.tsx:67 の1箇所だけで、 6画面すべて useState(1) の整数 state。★URL クエリから page を読む経路は存在しません★★
  • ★★ただしユーザー指摘のとおり「ページ番号をURLに持たせた瞬間に穴が開く」★★
  • ★拾う場合★: totalPages と同じ形で Number.isFinite(page) + Math.trunc() を足し、 ★テストに NaN / 2.5 / Infinity を追加する★

③ ★501 / not_implemented の残骸★

  • ★テスト名★: lib/api/__tests__/client.test.ts:98 test("501 の定型メッセージも serverMessage で取れる(エリア別完了率)", ...) ★同種がもう1本: lib/logger/__tests__/api.test.ts:81, 94★
  • ★型★: lib/types/index.ts:28 の ErrorCode に | "not_implemented"
  • ★★返している実装は0件★★(エリア別完了率は D-119 で本実装済み。 app/api/clean-logs/[id]/areas/route.ts:13 にその旨のコメントあり)
  • ★★d-watch-api.yaml には not_implemented も 501 も出てきません★★ (★実測済み。error.code は type: string のまま enum 無し★)。 ★★つまり型から外しても API 契約と乖離しません★★。むしろ CLAUDE.md §9.2 (「型は YAML のスキーマ名と揃える」)に近づきます
  • ★拾う場合★: ★テスト名を「汎用の 5xx」等に直す★(★テスト自体は消さないこと。 serverMessage の取り出しは今も有効な検証です★)+ ★ErrorCode から "not_implemented" を外す★
  • ★★併せて古い記述が残っている文書★★(★直すかは任意★): docs/handoff/PR説明_develop_to_main_骨子.md:212 / docs/handoff/analysis/code-analysis-impure.md:3195 ★★ただし docs/handoff/ 配下は別エージェントが作業中です。★触るなら git status を先に見ること★★

④ ★scripts/seed-local-db.ts のコメントの件数がずれている★

  • ★場所★: scripts/seed-local-db.ts:506
     *   ★分布は `lib/fixtures/prechecks.ts` の8件に倣う(未解消3 / 対応中2 / 解消3)★。
  • ★実測★: lib/fixtures/prechecks.ts:38 の配列は ★9件★ (id = "1","2","3","9","4","5","6","7","8")。★分布は 未解消4 / 対応中2 / 解消3★
  • ★原因★: 後から足した id: "9"(prechecks.ts:94、境界値検証用)が反映されていない
  • ★★直下の ERROR_RESOLUTION_ROTATION(:508-517)自体は8要素で自己整合しています★★。 ★★ずれているのは説明文のほうなので、コメントだけ直すこと★★。 ★★配列を触らないこと★★(★禁止事項5★)
  • ★ついでに見つかった別のずれ(★任意★)★: scripts/seed-local-db.ts:268 /** 完了行の何件ごとに 2/3/4 を当てるか(240行なら各5件ずつになる) */ — ABNORMAL_CLEAN_ERROR_EVERY = 17(:269)なので 240行なら該当14行、 ★3値ローテーションで 5/5/4★。★「各5件ずつ」にならない★。 ★★ただし「完了行=240」という前提自体が未検証です。直す前に実測すること★★

⑤ ★code-analysis-pure.md の古い転記★

  • ★場所★: docs/handoff/analysis/code-analysis-pure.md の ★4箇所★ (★監督の照合では「3箇所」とされていましたが、実測は4箇所です★):
行 内容
2977 算出基準はTeamWorks確認事項の回答待ちのため、暫定で稼働率ベース。(コード引用ブロック)
3071 同文(「63〜67行目のコメント(引用)」の再掲)
3075 **★「TeamWorks確認事項の回答待ち」= 仕様が未確定である★**。
3195 加えて 65行目「算出基準はTeamWorks確認事項の回答待ち…」
  • ★★現行コードでは既に否定済み★★: lib/judge/kpi.ts:70 これは DFA 側の UI 判断であって、TeamWorks 待ちではない、 kpi.ts:72 (2026-09-11 に全文検索で確認。ヒット0)。以前ここに「回答待ち」と書かれていたのは誤りで、 ★docs/handoff.md:3143(追記37)にも同旨★
  • ★★注意★★: ★docs/handoff/analysis/ 配下です★。 ★★別エージェントが docs/handoff/ で作業中なので、触る前に git status を見ること★★。 ★競合しそうならこの項目は飛ばすこと★
  • ★拾う場合★: ★元の引用(2977 / 3071)は「当時のコード」の記録なので消さない★。 ★★「★この記述は D-118 / 追記37 で否定済み★」という注記を添える形にすること★★ (★解析文書は「その時点の観測記録」なので、書き換えると記録として壊れます★)

この段階の完了条件

  • ★拾った項目 / 飛ばした項目を作業メモに書いた★
  • ★①を拾った場合、仕様判断を推測で埋めていない★
  • tsc 0 / lint 0 / npm test 全パス
  • コミット

段階3-2 — ★記録★(1コミット)

  1. ★docs/decisions.md に決定エントリを書く★(★着手時に実測した番号★)
    • ★群1: モックから意図的に外れた理由★
    • ★群2: 生SQL禁止を ESLint で機械化したこと / 長さ上限を入れたこと / ★棚卸しの結果「アプリコードに危険な箇所は無かった」という事実★
    • ★段階2-4 / 2-7 で「やらない」を選んだ場合、その理由★
  2. ★docs/handoff.md に追記する★(★段階3-0 の先頭行も一緒に直すこと★)
  3. ★本書冒頭の表と末尾の「実行状態」表を更新する★

この段階の完了条件

  • docs/decisions.md に記録(★git diff docs/decisions.md を先に見た★)
  • docs/handoff.md に追記(★先頭の「最新の追記」も直っている★)
  • 本書の表2つを更新
  • コミット

★群3 の終わり★

★★ここで止めてもよい★★ — ★本書の全群が完了です★。


実行状態

★★全段階完了(2026-09-13)★★。★ブランチ develop。git push はしていません★。

群 段階 状態 決定番号 コミット
群1 1-0 実測 ★完了★(読むだけ) — —
群1 1-1 collapseBtn の色 ★完了★ ★D-121★ 25172dc
群2 2-0 棚卸し ★完了★(★危険な箇所は0件★。読むだけ) — —
群2 2-1 生SQL禁止を ESLint 化 ★完了★ ★D-122★ f90ed25
群2 2-2 パスパラメータ検証 ★完了★ ★D-123★ 76f31e6
群2 2-3 advice の長さ上限 ★完了★ ★D-124★ 25ede81
群2 2-4 CSV(任意) ★完了(★「やる」を選択★)★ ★D-125★ 5d9cc7b
群2 2-5 本番前チェックリスト ★完了★(docs/deploy.md 7節) — e75d319
群2 2-6 npm audit の記録 ★完了★ — 6fc2255
群2 2-7 セキュリティヘッダ(任意) ★完了(★「アプリに入れない」を選択★)★ ★D-126★ 03f7fce
群2 2-8 申し送り ★完了★(docs/handoff.md 追記41) — a592053
群3 3-0 handoff.md の先頭 ★完了★(追記36 → ★追記41★ に修正。★再発防止の注意書きも追加★) — 4c5beeb
群3 3-1 軽微な積み残し(任意) ★完了(★②③④を拾い、★①⑤は見送り★)★ ★D-127★ b20790e
群3 3-2 記録 ★完了★(D-127 + docs/handoff.md 追記42 + 本表) ★D-127★ (3-2 のコミット)

★段階3-1 の内訳(★拾った / 見送った★)★

# 内容 判断 理由
① ★同一エリアが同日2回清掃されると二重計上★ ★★見送り★★ ★どう集約するか(最後の1回 / 合算 / 両方)が仕様判断★。★CLAUDE.md §7 のとおり推測で埋めず、確認を求める★
② ★buildPageItems() の page に NaN ガードが無い★ ★拾った★ ★normalizePage() に寄せ、pageRange() にも同じガードを適用(同じ clamp() を通っていて同じ穴があった)★。★テスト8件追加(1192 → 1200)★
③ ★501 / not_implemented の残骸★ ★拾った(★コードのみ★)★ ★ErrorCode から外し、501 前提のテスト2本は消さずに 503 / 404 へ置き換えた★。★docs/handoff/ 配下の文書は別エージェント作業中のため触っていない★
④ ★シードのコメントの件数のずれ★ ★拾った★ ★実測 9件(未解消4 / 対応中2 / 解消3)に修正。★コメントのみ★。ローテーション表と fixtures は触っていない★
⑤ ★code-analysis-pure.md の古い転記★ ★★見送り★★ ★docs/handoff/analysis/ は★別エージェントが作業中★。指示書の「競合しそうなら飛ばす」に従った★

★完了時の検証(実測 2026-09-13)★

項目 結果
npx tsc --noEmit ★0★
npm run lint ★0★
npm test ★50ファイル / 1200テスト 全パス★(着手前 1192 → 段階3-1 ② で★8件増★)

★★触っていないファイル★★: README.md / docs/code-map.md / docs/handoff/管理画面_設計検討_20260910.md(★いずれも他作業の未コミット変更。着手時に git status で確認★)。