Newer
Older
client_dwatch / docs / handoff / 指示書D_実装解析の不整合.md

指示書D — 実装解析で見つかった不整合を直す

実行状態

項目 値
状態 完了
初版 2026-09-03(作成時点の決定事項最終番号 D-059)
改訂 2026-09-03(第2版)— 全項目を実ファイルで再検証し、初版の誤り5件を修正
実行環境 Cursor で実装する(Claude Code は調査・検証・指示書作成まで) — 実際には Claude Code のサブエージェント(opus)4体に分担させ、Claude Code 側で実行した
実行日 2026-09-03
実行コミット b7940af
取得した決定番号 D-062(赤3・判定基準の範囲表記統一)/ D-063(3-6・logging-design.md §5記録条件)/ D-064(4-1・クエリバリデーション共通化+4-2の500経路統一)

実行後はこの表を更新してください。


この改訂について(★初版から変わった点。ここだけは必ず読むこと★)

初版(2026-09-03)の記述を全項目、実ファイルに当てて検証しました。 指摘11件の中身はすべて正しいことを確認しています。ただし 「直す箇所の数」が初版より多い項目が3つ、引用が不正確な箇所が2つありました。

# 初版の記述 実際
1 赤3「10〜69%」表記は 2箇所 6箇所(lib/judge/grade.ts:15 / lib/judge/advice.ts:19 / lib/judge/advice.ts:72 / d-watch-api.yaml:274 が漏れ)
2 3-4「5ルート」表記は 3箇所 7箇所(docs/logging-design.md:20 / :303 / :304 / lib/logger/__tests__/api.test.ts:4 が漏れ)
3 3-5 構成図の欠落は 2ファイル 6ファイル(テスト4本も欠落。うち __tests__/smoke.test.ts は §10 完了条件が名指しで要求している)
4 3-7「§4 は『3段構えで強制する』と書いてある」 その文字列は原文に存在しない。 実在するのは見出し ### 強制方法(3段構え)(:167)
5 4-2 の対象は app/api/prechecks/route.ts:52-56 実際は 57行目(1行ズレ)

さらに、初版が「画面に出る文言」と書いていた点に補足があります。

  • 赤3 の lib/config/advice-rules.ts:101 の condition は、現状 GET /api/settings/advice の レスポンスに載るだけで、画面表示はまだ実装されていません(app/(dashboard)/system/page.tsx は PlaceholderScreen)。将来ユーザーに見える文言であることに変わりはないので、直す判断は変えません。
  • この condition 文字列を固定しているテストは存在しません(advice.test.ts:9-10 に 「文言は運用設定で編集できるためテストしない」と明記)。文言を変えてもテストは落ちません。

以下、行番号はすべて 2026-09-03 時点です。着手時にズレていたら周辺の原文で照合してください。


この指示書の位置づけ

docs/handoff/analysis/code-analysis-pure.md / code-analysis-impure.md / code-analysis-app.md (合計約10,100行)で全ファイルを精読し、26件の不整合を洗い出しました。 そのうち 直すべき11件 をここに整理します。全件の根拠は analysis 配下の3ファイルにあります。

★やらないことは末尾に列挙してあります。着手前に必ず読んでください。★

★docs/handoff/指示書E_スキーマを実DDLに一致.md と同時に実行しないこと。★ 両方が docs/decisions.md の決定番号を取るため衝突します。 (指示書E は d20be80 で完了済みなので現時点で衝突しません。将来 DDL を再受領して 指示書E をやり直すときに、この制約が再び効きます。)

着手前の確認

git status                 # クリーンであること
npm test                   # 16ファイル / 260テスト 全パス(これが基準値)
npx tsc --noEmit           # エラー0

docs/decisions.md の最終番号を確認すること。 2026-09-03 時点の最終は D-061(D-060 = Schedule.end_time の表記形式 / D-061 = TeamWorks 第1回回答の反映。 いずれも指示書Dとは無関係の決定)。この指示書で取る番号は D-062 からになります。


1. 赤3件(★本題★)

この3件は「信じると実装を壊す方向に誘導する記述」です。 単なる書き間違いではなく、 読んだ人が実装を誤った向きに直してしまう危険があるため、最優先で直します。

赤1. lib/config/index.ts のコメントが実装と逆

現状(コメントが16行目 / poor: 10 が17行目):

export const GRADE_THRESHOLDS = {
  /** 完了率がこの値を「超える」と ◎(90.0 は ○ になる) */
  excellent: 90,
  /** 完了率がこの値を「超える」と ○(70.0 は △ になる) */
  good: 70,
  /** 完了率がこの値「未満」は ×(10.0 は × になる) */
  poor: 10,
} as const;

実装は逆です。 lib/judge/grade.ts:26 は

if (rate < GRADE_THRESHOLDS.poor) return "×";

10 < 10 は偽なので、10.0 は × ではなく △ です。

実装が正しい根拠(4つすべてが △ で一致。すべて実ファイルで確認済み):

  1. lib/judge/grade.ts:22-28 の実装(rate < poor)
  2. lib/judge/__tests__/grade.test.ts:26 の境界値テスト ({ name: "△/×の境界(10%未満が×なので10.0は△)", rate: 10.0, grade: "△", display: "10%" } を :33 と :45 の2つの it.each が回している) および lib/judge/__tests__/advice.test.ts:66 (it("10.0%はR2ではない(10%未満が条件。D-020b)", ...))
  3. docs/decisions.md:82-91 の D-020b(境界値10.0の矛盾を解消した確定合意。コミット dbec0df)
  4. lib/fixtures/clean-logs.ts:75,87(10.0 △ のコメント付き期待値)

→ コメントだけを直します。

★実装を直してはいけません。 grade.ts:22-28 を書き換えるとテスト3本と D-020b の確定合意を破ります。★

変更前(lib/config/index.ts:16):

  /** 完了率がこの値「未満」は ×(10.0 は × になる) */

変更後:

  /** 完了率がこの値を「下回る」と ×(10.0 は △ のまま。D-020b) */

同じブロックの excellent / good のコメント(「90.0 は ○ になる」「70.0 は △ になる」)は rate > threshold の実装と整合しているので触りません。


赤2. docs/logging-design.md §4 の禁止キー一覧に認証情報8件が漏れている

docs/logging-design.md:151 ### 記録禁止(allowlist 型に含めないこと) に列挙されているのは :156-157(2行に折り返し)の 13件のみです:

facility_name / address / phone / email / manager_name / charger_name / inspector_name / customer_name / notes / comment(内容) / memo / note / detail

一方 lib/logger/redact.ts は 21件を検査しています:

定数 場所 件数 内容
FORBIDDEN_PII_FIELDS redact.ts:12-26 13 §4 と同じ順・同じ綴り
FORBIDDEN_CREDENTIAL_FIELDS redact.ts:35-44 8 文書に無い: cookie / set-cookie / authorization / token / session / secret / password / api_key
FORBIDDEN_LOG_FIELDS redact.ts:46-49 21 上2つのスプレッド

lib/logger/__tests__/redact.test.ts:68 が expect(FORBIDDEN_LOG_FIELDS).toHaveLength(21) で 件数を固定しているため、実装側は動きません。 さらに redact.test.ts:28-65 が両リストを順序まで完全一致で固定しています。

★文書だけを読んで実装すると、パスワードやトークンがログに素通りします。★ 認証(proxy.ts / cookie)はまだ未着手なので、実装時にこの文書を見た人が 認証情報の禁止に気づかない状態です。

→ docs/logging-design.md §4 に認証情報8件を追記します。実装(redact.ts)が正です。

書き方の指定:

  1. 個人情報13件と認証情報8件は性質が違うので、群を分けて示すこと。 分ける理由は redact.ts:28-34 のコメントが述べています(原文):
    認証情報。§4 は認証未着手の時点で書かれたため漏れていた。
    個人情報と違い、漏れると「読まれる」だけでなく「なりすまされる」ので分けて明示する。
    
    cookie 名そのもの(`dwatch_session` 等)は未決なので入れない。
    認証実装時に決まった名前をここへ追加すること。
    この「読まれるだけでなくなりすまされる」という理由を文書側にも書くこと。
  2. cookie 名そのもの(dwatch_session 等)はリストに入れないこと。 認証方式が未決だからです。 そのうえで「認証を実装して名前が決まった時点で lib/logger/redact.ts と本節の両方へ追加する」旨を明記。
  3. 列挙順は実装(redact.ts)の並びに揃えること。redact.test.ts が順序まで固定しています。
  4. 合計21件であること、redact.test.ts が件数と並びを機械検証していることが読み取れるようにすること。 追加時は redact.ts / redact.test.ts / 本節の3箇所をセットで扱う旨も書くこと。

赤3. 「10〜69%」という曖昧な範囲表記が実装と食い違う(★対象は6箇所★)

実装は 10 以上 70 以下 が △(rate > 70 で ○、rate < 10 で ×、それ以外が △)です。 「10〜69%」だと 69.0 超 〜 70.0 の範囲を取りこぼします (例: 完了率 69.5% は実装では △ ですが、表記どおりなら △ にも ○ にも該当しません)。

初版は2箇所としていましたが、実際は6箇所です。全部直してください。

# 場所 現在の原文
1 CLAUDE.md:102(§4.1 判定基準表) ` △ 完了率 10〜69% `
2 lib/config/advice-rules.ts:101(B1 の condition) condition: "完了率が10〜69%(△判定)",
3 lib/judge/grade.ts:15(JSDoc の判定表) ` * △ 完了率 10〜69% `
4 lib/judge/advice.ts:19(JSDoc) ` * B1 完了率10〜69%(マップ更新の確認) `
5 lib/judge/advice.ts:72(実装コメント) // B1: △判定(完了率10〜69%)のときにマップ更新を疑う
6 d-watch-api.yaml:274(OpenAPI の description) B1: 完了率10〜69%→マップ更新の確認 / B2: ...

表記のルール(★これを外すと同じ問題が再発します★):

  • 「10〜70%」のような曖昧な範囲表記に置き換えるだけでは不可。
  • 境界を含む/含まないが読み取れる形にすること。推奨: 「完了率 10%以上 70%以下」。
  • 同じ表の他の行(◎ 完了率 90%超 / ○ 完了率 70%超 / × 完了率 10%未満、または未実施)は すでに境界が読み取れる形なので触らないこと。空白の入れ方はその行に合わせる (CLAUDE.md と grade.ts の表は 完了率 90%超 と半角スペース区切り。 advice.ts の表・advice-rules.ts の condition・OpenAPI の description は詰め書き)。
  • advice-rules.ts:101 は GET /api/settings/advice のレスポンスに載る文言なので、 エンドユーザーが読んで分かる日本語にすること。

lib/config/advice-rules.ts の他の6件の condition(R1/R2/R3/R4/B2/B3)は すべて実装と整合していることを確認済みなので触らないこと。 特に R3 は仕様が未確定(C-1)なので、仕様も文言も動かさないこと(下記「やらないこと」参照)。


2. テスト漏れ1件

lib/judge/__tests__/advice.test.ts の B1 で境界の 70.0 が未固定

現状(118-126行目。初版は「118〜125行目」と書いていましたが1行ズレています):

describe("Phase B — B1/B2/B3", () => {
  it("B1: △判定でマップ更新の確認", () => {
    expect(matchAdviceRules(input({ completionRate: 45 }))).toContain("B1");
    expect(matchAdviceRules(input({ completionRate: 69.9 }))).toContain("B1");
  });

  it("B1: ○以上では出ない", () => {
    expect(matchAdviceRules(input({ completionRate: 70.1 }))).not.toContain("B1");
  });

70.0 が抜けています(ファイル全体190行を検索して不在を確認済み)。

実装 lib/judge/advice.ts:72-75 は judgeGrade(completionRate) === "△" で B1 を出し、 judgeGrade(70.0) は △ なので 70.0 では B1 が出るのが正しい挙動です。 これが固定されていないため、しきい値の比較を >= に書き換えてもテストが通ってしまいます。

→ completionRate: 70.0 で B1 が出ることをアサートするケースを1件追加します。

既存の書式に合わせること。ヘルパー input() はファイル13-22行目にあります(completionRate の既定は 95)。 同ファイル66行目の it("10.0%はR2ではない(10%未満が条件。D-020b)", ...) が、 決定番号を括弧書きで添える既存の慣例です。追加例:

  it("B1: 70.0%でも出る(70%超が○の条件。D-020b)", () => {
    expect(matchAdviceRules(input({ completionRate: 70.0 }))).toContain("B1");
  });

赤3 の修正と同じ境界の話なので、あわせて実施すること。


3. 文書整合7件

3-1. lib/logger/index.ts:65-68 が存在しない原文を引用している

変更前:

 * 本番で検出した場合、元のログ(禁止キーを除去したもの・元のレベルのまま)とは
 * **別に** warn を1行出す(docs/logging-design.md §10 の「別途 warn で記録する」)。
 * 元のレベルを warn へ格上げして1行にまとめる方式だと、元が error のときに
 * 「禁止フィールドが渡された」事実が warn として残らないため、常に分離する。

検証結果: docs/logging-design.md §10 に「別途 warn で記録する」という文は存在しません。 (別途 のヒットは :29 と :128 のみで、いずれも §0 / §3) §10 で最も近いのは :383 の 「本番では投げず、該当キーを取り除いて warn で記録し、ログ呼び出し自体は成功させる。」ですが、 これは「元のログを warn で記録する」とも読め、「別途=別行」という含意はありません。

一方 実装の判断としては正しい: index.ts:91-97 が log.forbidden_fields_stripped を target.warn() で独立に1行出し、:100-104 で元イベントを元のレベルで出しています(確かに2行)。

→ 鉤括弧による §10 への帰属をやめ、実装側の判断として書き直します。 参考文面:

 * 本番で検出した場合、元のログ(禁止キーを除去したもの・元のレベルのまま)とは
 * **別に** warn を1行出す。docs/logging-design.md §10 は「該当キーを取り除いて warn で
 * 記録し、ログ呼び出し自体は成功させる」としか定めておらず、**別行にするのは実装側の判断**。
 * 分ける理由: 元のログを元のレベルのまま残しつつ、除去が起きた事実を warn で可視化するため。
 * 元のレベルを warn へ格上げして1行にまとめる方式だと、元が error のときに
 * 「禁止フィールドが渡された」事実が warn として残らないため、常に分離する。

最終行の warn 側には値を一切載せず… は変更不要です。

3-2. lib/judge/advice.ts:23-24 のコメントが実態と不一致

変更前:

 * ★「連続」はスケジュール存在日の連続★(D-026)
 *   `consecutiveProblemDays` は `judgeEscalation()` が算出した値を渡してください。

実態(すべて実コードで確認済み):

  • judgeEscalation()(lib/judge/escalation.ts:62)はプロダクションコードからの呼び出しが0件。 呼んでいるのは lib/judge/__tests__/escalation.test.ts の5箇所だけ
  • 唯一の matchAdviceRules 呼び出し元 app/api/clean-logs/[id]/advice/route.ts:53-59 は consecutiveProblemDays: log.consecutive_problem_days(データ源が持っている既製の値)を渡している
  • その値は lib/fixtures/clean-logs.ts:50 の手書き定数で、countConsecutiveProblemDays() も judgeEscalation() も経由していない
  • そもそも judgeEscalation() の戻り値は { consecutiveDays, level } のオブジェクトなので、 そのままは渡せない
  • lib/judge/escalation.ts:13-15 は「どの日がスケジュール存在日かの判断はデータ取得側の責任」と明記

→ judgeEscalation() の名指しをやめ、「データ取得側がスケジュール存在日ベースで算出した 連続日数を渡す」という趣旨に書き直します。 23行目の D-026 への言及は正しいので残すこと。 advice.ts の JSDoc は「です・ます調」(他ファイルは常体)なので、その文体に合わせること。参考文面:

 * ★「連続」はスケジュール存在日の連続★(D-026)
 *   `consecutiveProblemDays` には、データ取得側がスケジュール存在日ベースで
 *   算出済みの連続日数を渡してください(どの日がスケジュール存在日かの判断は
 *   データ取得側の責任です)。

3-3. lib/logger/events.ts:106 の関数名が古い

変更前(JudgeEscalationLogEvent の JSDoc、:105-108):

/**
 * `judgeEscalation()` 呼び出し直後。
 * 連続日数はスケジュール存在日ベース(CLAUDE.md §4.5)。
 */

実態: 実際の記録箇所 app/api/clean-logs/[id]/advice/route.ts:98-110 は judgeEscalationLevel(log.consecutive_problem_days) の直後に judge.escalation を出しています。 judgeEscalation() はプロダクション呼び出し0件なので、このイベントは発生しえません。 docs/logging-design.md:191 の §5 対応表は judgeEscalationLevel と書いており、文書側が正しい。 型の形も一致します(JudgeEscalationLogEvent.consecutive_problem_days: number(events.ts:114)は judgeEscalationLevel(consecutiveDays: number) の引数そのもの)。

→ 106行目を judgeEscalationLevel() に直します。 2行目はそのまま。

同ファイルの :86(judgeGrade() 呼び出し直後)と :124(matchAdviceRules() 呼び出し直後)は 正しいので触らないこと(:86 は現時点で呼び出し0ですが、§5 が「Prisma 実装時」と明記しているため予約として整合)。

3-4. 「5ルート」表記が実測と食い違う(★対象は7箇所★)

withRequestId を呼んでいるのは 9ファイル10箇所(実測):

# ファイル 行
1 app/api/dashboard/route.ts 31
2 app/api/clean-logs/route.ts 30
3 app/api/clean-logs/export/route.ts 59
4 app/api/clean-logs/[id]/areas/route.ts 27
5 app/api/clean-logs/[id]/advice/route.ts 32
6-7 app/api/settings/advice/route.ts 27(GET)/ 51(PUT)
8 app/api/ds-devices/route.ts 28
9 app/api/ds-alerts/route.ts 32
10 app/api/prechecks/route.ts 30

find app/api -name route.ts も 9本です。

初版は3箇所としていましたが、実際は7箇所です。全部直してください。

# 場所 現在の原文
1 docs/logging-design.md:20(§0 前提表) ` 実際のAPIルート `app/api/clean-logs/**`, `app/api/settings/advice` の計5本 `find app/api -name route.ts` `
2 docs/logging-design.md:303(§8 ステップ9) 9. 既存5ルートの \console.error(...)` を…`
3 docs/logging-design.md:304(同上・次行) ルート先頭で \runWithRequestId()` ラップを追加(5箇所・同一パターンなので一気に行う)`
4 docs/logging-design.md:397(§10 見出し) ### 既存5ルート(§8 ステップ9 の対象)
5 docs/logging-design.md:399-405 コードフェンスで5パスのみ列挙
6 lib/logger/context.ts:68 * ★5ルートに同じラップ処理を書き写さないための共通ヘルパー★
7 lib/logger/api.ts:26 * 5ルートすべてが通る関数なので、実出力をテストで検証できる接合部を用意する
8 lib/logger/__tests__/api.test.ts:4 * 5ルートすべての catch 節がこの関数を通るため、ここが無検証だと

(※ :303 と :304 を1件と数えれば7箇所、別々に数えれば8行)

書き方の指定:

  • 数字を書き換えるだけにしないこと。ルートが増えるたびに同じズレが再発します。 「全APIルート」のような件数に依存しない表現を主にし、実測値を添えるなら 「(2026-09-03 時点で9ファイル10箇所)」のように時点が分かる形にすること。
  • :20 は「確認方法」欄に find app/api -name route.ts と書いてあるのに実測と食い違っているので、 実測値(9本)に直すこと。
  • :399-405 のパス列挙は9本すべてに更新し、settings/advice の行に「GET と PUT の2ハンドラ」と付記すること。
  • api.test.ts:4 は JSDoc の途中(「ここが無検証だと」で次行に続く)なので、 前後の文がつながるように書き換えること。
  • context.ts:68 の ★…★ の強調記法はリポジトリの慣例なので残すこと。
  • api.ts:26 は関数の性質上「エラー経路のみ」なので、件数を書かず 「全APIルートのエラー経路が通る関数」とすれば更新義務がゼロになります。

3-5. docs/logging-design.md §2 の構成図に実在ファイルが載っていない(★6ファイル★)

§2 構成図(:72-93)の lib/logger/ は4ファイル + __tests__/redact.test.ts しか載っていません。

ファイル 図に載っているか
index.ts / context.ts / events.ts / redact.ts ○
timestamp.ts / api.ts ×
__tests__/redact.test.ts ○
__tests__/api.test.ts / __tests__/context.test.ts / __tests__/smoke.test.ts / __tests__/timestamp.test.ts ×

初版は2件としていましたが、実際は6件です。全部追記してください。 特に __tests__/smoke.test.ts は §10 の完了条件(:422)が名指しで要求しているのに図にありません。

★揃え先の見本があります★: docs/architecture.md:132-141 の「lib/logger/ の内訳」表は 6ファイル全部を正しく載せており、redact.ts の欄も「個人情報13キー+認証情報8キー」と最新です。 各ファイルの説明文はこの表と矛盾しないように書くこと(図の他の行と同じ # 説明 形式で)。

図の他の記述はそのまま: proxy.ts は未作成ですが「新規」表記なので矛盾しません。 instrumentation.ts は実在します。

3-6. docs/logging-design.md §5「記録条件」を実装に合わせる(★文書を実装に寄せる。逆をやらないこと★)

§5(:219 ### 記録条件(★ここが本設計で最も重要)、本文 :221-234)は未実装です。 JUDGE_LOG_BOUNDARY_MARGIN / LOG_JUDGE_VERBOSE はリポジトリ全体で logging-design.md:368-369 に しか出現せず、lib/config/ にも .env.example にもありません。

しかも実装は明示的に逆の判断を宣言しています。 app/api/clean-logs/[id]/advice/route.ts:61-63(原文):

    // 判定根拠の記録。記録条件は設けず無条件に出す
    // (このルートは行クリックで1件だけ呼ばれるため件数の問題が無く、
    //  条件分岐を入れると「根拠が必要な場面に限って記録が無い」事故のほうが起きやすい)

これを受けて :64(judge.advice)と :99(judge.escalation)の logEvent("info", …) が どちらも無条件で走ります。judge.grade の記録は0箇所(§5 :192 が「Prisma 実装時」としているのと整合)。

→ §5 を「1回の呼び出しで何件を判定するか」で経路を分ける形に書き換えます。 参考文面:

### 記録条件(★ここが本設計で最も重要)

記録条件は**1回の呼び出しで何件を判定するか**で分ける。1リクエストで1件しか判定しない
経路と、一覧生成で数百件を一度に判定する経路を同じ条件で縛ると、前者では
「根拠が必要な場面に限って記録が無い」事故のほうが起きやすい。

| 経路 | 1回あたりの判定件数 | 記録条件 | 状態 |
|---|---|---|---|
| `app/api/clean-logs/[id]/advice/route.ts`(`judge.advice` / `judge.escalation`) | 1件(行クリックで呼ばれる) | **条件を設けず無条件に記録する** | 実装済み |
| `lib/db/` の一覧変換(`judge.grade`) | 約360行/日・年間約13万行 | 下記のいずれか | **未実装**(Prisma 実装と同時) |

一覧変換側は全行常時記録をしない。次の**いずれか**に該当する行のみ記録する。

1. 判定が **△ または ×**
2. `escalation_level` が null でない
3. アドバイスが1件以上ある
4. **生値がいずれかのしきい値の近傍(±0.5)にある**

条件4を必須にする理由: CLAUDE.md §4.3 は「表示用に丸めた値で判定すること」を最も事故る箇所と
明記している。`89.5` は**表示90%だが判定は ○** であり、条件1〜3だけだとこの行は
◎○側なので記録されず、まさに疑義が生じる場面のログが無いことになる。近傍の幅(±0.5)は
`lib/config/` から読む(ハードコード禁止)。

全行記録が必要な調査のために、環境変数による verbose モードを用意する(既定 OFF)。

**上記のうち条件1〜4・verbose モードは未実装。** `JUDGE_LOG_BOUNDARY_MARGIN`(§10)は
`lib/config/` に、`LOG_JUDGE_VERBOSE` は `.env.example` にまだ無い。一覧変換側の
`judge.grade` を実装するときに同時に追加する。

★この書き換えに連動して直さなければならない箇所が2つあります★ (放置すると「今すぐ条件4を実装せよ」と新§5 が正面から矛盾します):

場所 現在の原文 対応
§8 ステップ11 (§5。記録条件4を必ず実装する) 「advice ルートは無条件記録。一覧変換側の judge.grade は Prisma 実装と同時で、そのとき §5 の記録条件(条件4を含む)を実装する」旨に
§10 フェーズ表 フェーズ3 判定ログ。§5 の記録条件4を必ず実装する 同上の趣旨に

:202 の「したがって、後述の記録条件4(しきい値の近傍)が実際に効き始めるのは Prisma 実装以降。」は そのまま残してよい(新しい表と矛盾せず、同段落後半の「エスカレーションのしきい値は日数の整数なので 近傍という概念が意味を持たない」は新表に無い固有情報のため)。

3-7. マスキングの限界を §4 に追記

redact.ts の判定はキー名に対するものなので、 許可されたフィールドの「値」の中に個人情報が入っている場合は防げません。

★初版の「§4 は『3段構えで強制する』と書いてある」は誤りです。その文字列は原文にありません。★ §4 の実際の構造:

:141  ## 4. マスキング規約
:143  ### なぜ必須か
:151  ### 記録禁止(allowlist 型に含めないこと)
:159  ### 記録可(…)
:165  **施設は必ず facility_id で参照する。名前を書かない。**
:167  ### 強制方法(3段構え)
:169-173   1. イベント型 allowlist / 2. redact.ts 実行時 / 3. redact.test.ts 機械検証
:175  ---

→ 追記先は :173 の直後(3項目リストの後、--- の前)に ### キー名検査で防げないもの という節を新設すること。 ### 強制方法 の中に混ぜると3項目リストの一部に見えてしまいます。

内容:

  • 見ているのはキー名であって値ではないこと
  • 許可キーの「値」に個人情報が入る場合は防げないこと。実在する例を使うこと (api.error の error_message に生の例外文字列 / err.message err.stack に施設名 / data.anomaly の自由記述由来の文字列。 ※ message という許可キーは events.ts に実在しないので例に使わないこと)
  • したがって値を組み立てる側の責任であること。許可キーへ渡してよいのは 定型文・enum・ID・数値・boolean に限る、中身を伝えたい場合は comment_changed(§6)のように boolean や件数へ落とす、といった指針を1〜2行で

4. コード2件

4-1. /prechecks の 422 が項目名を返さない

app/api/prechecks/route.ts:32-42 は、クエリパラメータのバリデーション失敗時に details: null を返します。どのパラメータが不正なのか呼び出し側に分かりません。

32        const query = parsePrecheckQuery(searchParams);
33        if (!query) {
34          const body: ApiError = {
35            error: { code: "validation_failed", message: "クエリパラメータの値が不正です", details: null },
36-40          };
41          return errorResponse(body, 422);
42        }

/clean-logs 側(lib/clean-logs/query.ts:21-23, 42-53)は既に union + details を返せる形です:

export type QueryParseResult =
  | { ok: true; query: CleanLogQuery }
  | { ok: false; error: ApiError; status: number };

function fail(status, code, message, details?: Record<string, string[]>): QueryParseResult { ... }

lib/api/__tests__/client.test.ts:80 に details: { date_from: [...] } の実例があり、 d-watch-api.yaml:936-942 も details を nullable object(値は string 配列)と定義済みです。 API仕様上すでに項目名を返してよい形になっています。

★スコープの確定(初版が判断を委ねていた点)★

初版は「必須範囲は /prechecks のみ。ds-alerts / ds-devices は同時に直すか、 直さない理由をコメントで残すかを判断せよ」としていました。3本とも同時に直してください。 根拠:

  1. 3本ともパーサが失敗した項目名を既に保持している。「情報が無いから返せない」ケースではない

    | ルート | パーサ | 現在の戻り値 | 失敗項目 | |---|---|---|---| | prechecks | lib/prechecks/query.ts:36 | PrecheckQuery \| null | date_from / date_to / status | | ds-alerts | lib/ds-alerts/query.ts:32 | DsAlertQuery \| null | type / status | | ds-devices | lib/ds-devices/query.ts:27 | DsDeviceQuery \| null | status |

  2. 3本とも route 側に errorResponse(error: ApiError, status: number) が同一シグネチャで重複定義 されている(prechecks:25-27 / ds-alerts:27-29 / ds-devices:23-25)。1本だけ直すと 「同じ形のコードが2種類ある」状態が新たに生まれ、この指示書が解消しようとしている不整合と 同種の負債を作ります

  3. 変更コストが小さい(ds-devices の失敗パターンは1つ、ds-alerts は2つ)

変更計画

(a) 共通型とヘルパーの置き場 → lib/api/query-result.ts を新規作成(推奨)

QueryParseResult の参照は現在 lib/clean-logs/query.ts 内(21/47/57行)のみで外部参照ゼロ、 fail も module-private です。ここから3モジュールに使わせるには置き場を決める必要があります。

推奨: lib/api/query-result.ts へ切り出す。 理由:

  1. 依存の向き。 clean-logs に置いたまま export すると、prechecks / ds-alerts / ds-devices が 「清掃ログ」という無関係なドメインに、汎用ヘルパー目当てで依存することになります (既存の parseDate の export はドメイン規則(JST日付解釈を /dashboard と共有)なので性質が違います)
  2. lib/api/ は既に「API共通の形」の置き場だと宣言済み。 lib/api/client.ts:10-12 が 「エラー形式 { error: { code, message, details? } } … は CLAUDE.md §9.3 が全API共通と定めている。 共通の形が決まっているものは、共通の場所で解釈するのが筋」と書いています

型は次の形を推奨します(失敗側を独立させると fail() を非ジェネリックのままにでき、 戻り値位置での型引数推論に頼らずに済みます):

export interface QueryParseFailure { ok: false; error: ApiError; status: number }
export type QueryParseResult<T> = { ok: true; query: T } | QueryParseFailure;
export function fail(status: number, code: ErrorCode, message: string,
                     details?: Record<string, string[]>): QueryParseFailure { ... }

lib/clean-logs/query.ts はローカル定義を削除して import に置き換えるだけ。 既存の挙動を1ミリも変えないこと(parseCleanLogQuery のロジックは無変更)。

(b) lib/prechecks/query.ts — PrecheckQuery | null → QueryParseResult<PrecheckQuery>。 3つの失敗を fail(422, "validation_failed", ...) に。 page / per_page は clamp するだけで失敗しません(clean-logs と同じ)。

message と details の文面は /clean-logs の既存表現に完全に揃えること (lib/clean-logs/query.ts:63-99 を読むこと)。clean-logs の実例:

  • date_from 形式不正 → message date_fromの形式が不正です / details { date_from: ["YYYY-MM-DD形式で指定してください"] }
  • date_to 形式不正 → 同形
  • result 不正 → { result: ["problem / critical / all のいずれかを指定してください"] }

status の選択肢は STATUS_FILTERS.join(" / ") から生成すること(選択肢が増えても文言が自動追従します)。 parsePrecheckQuery の docstring(33行目付近)に「不正な値があれば null」とあるので更新すること。 filterPrechecksByDate(items, query: PrecheckQuery)(:75)は シグネチャ変更不要(呼び出し側が parsed.query を渡す)。

(c) app/api/prechecks/route.ts:32-42 — app/api/clean-logs/route.ts:32-35 と同じ形に:

const parsed = parsePrecheckQuery(searchParams);
if (!parsed.ok) return errorResponse(parsed.error, parsed.status);

手組みの body は消えます。ApiError の import は 500 経路で使うので残すこと。

(d) ds-alerts / ds-devices — lib/ds-alerts/query.ts / lib/ds-devices/query.ts を同形に。 app/api/ds-alerts/route.ts:33-43 / app/api/ds-devices/route.ts:29-39 を (c) と同形に。

(e) テストの書き換え(★これが一番量があります★)

lib/prechecks/__tests__/query.test.ts の describe("parsePrecheckQuery")(30-75行)7ケースは すべて PrecheckQuery | null 前提で、約20箇所の書き換えが必要です。

  • expect(parsed).not.toBeNull() → expect(parsed.ok).toBe(true) ★not.toBeNull() のままだと union 化後は常に真になり、テストが無意味になります。必ず直すこと★
  • parsed?.status / parsed?.page / parsed?.perPage → if (!parsed.ok) throw ...; parsed.query.status 等
  • .toBeNull() の3ケース(47 / 52 / 58行)→ parsed.ok === false + status === 422 + error.error.details の中身を検証する形に
  • if (!parsed) throw new Error("query") が 116 / 130 / 141 / 161 / 194行付近に5箇所ある。 union 化すると !parsed は常に false になり、以降の filterPrechecksByDate(dated, parsed) が 型エラーになります(tsc --noEmit で検出されます)。if (!parsed.ok) throw ... + parsed.query に直すこと

★この直後に別の変更が控えています。★ TeamWorks の回答(D-061)により PrecheckItem.id を number → string に変えることが確定しました (id も error_id も英字を含むため数値型にできない)。 これは同じ lib/prechecks/query.ts の sortPrechecks()(:112 の a.id - b.id)を触ります。 指示書D を先に完了させてから着手してください。 逆順にすると衝突します。

★書き方の見本は lib/clean-logs/__tests__/query.test.ts:41-52★ (expect(parsed.ok).toBe(false); if (!parsed.ok) { ... } の形)。これを踏襲すること。 lib/clean-logs/__tests__/query.test.ts 自体は既に union 前提なので変更不要です。

lib/ds-alerts/__tests__/query.test.ts(26/32/39/49/54行付近)と lib/ds-devices/__tests__/query.test.ts も同型の書き換えが必要です。

テストを追加すること(完了条件3を確実に満たすため)。details の中身を必ずアサートすること:

ファイル 追加 内容
prechecks +3 date_from / date_to / status 各失敗の message と details を toEqual で完全一致検証
ds-alerts +2 type / status
ds-devices +1 status

参考: この方針で実装すると 260 → 266 テスト(赤3のB1追加と合わせて 267)になることを 検証済みです(tsc --noEmit エラー0 / eslint clean)。

補足: lib/api/client.ts は既に details を保持できます(:35, 51, 80, 128)が、 UI で details を表示している箇所はありません(app/ components/ に参照ゼロ)。 4-1 の効果は API 契約の改善であって、画面の変化はありません。

4-2. errorResponse() ヘルパーがあるのに 500 経路で直呼びしている

app/api/prechecks/route.ts は 25-27行に errorResponse(error: ApiError, status: number) を 定義しているのに、500 経路(57行目。初版の「52-56」は1行ズレ)だけ直呼びしています:

52        } catch (e) {
53          const body: ApiError = { error: { code: "internal_error", message: "データの取得に失敗しました", details: null } };
56          logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
57          return NextResponse.json(body, { status: 500 });
58        }

→ return errorResponse(body, 500); に置換(完全に等価)。 NextResponse の import は成功応答(81行)で引き続き必要です。

全9ルートの 500 経路を調べた結果、ヘルパーは共通モジュールに無く、7ルートで重複定義されており、 形が2種類あります。

形A: errorResponse(error: ApiError, status: number)(body を渡す)— 置換可能。4本とも直すこと

ルート 定義 500 経路
app/api/prechecks/route.ts 25-27 57
app/api/clean-logs/route.ts 25-27 45
app/api/ds-alerts/route.ts 27-29 56
app/api/ds-devices/route.ts 23-25 52

★形B: errorBody(code, message) + errorResponse(status, code, message) — 絶対に触らないこと★

ルート 500 経路
app/api/clean-logs/[id]/advice/route.ts 45
app/api/clean-logs/[id]/areas/route.ts 40
app/api/settings/advice/route.ts 108

理由: これらは logApiError に渡すために body を先に組み立てる必要があるのに、errorResponse が body を受け取れないシグネチャです。置き換えると body が2回組み立てられ、 lib/logger/api.ts:4-6 の設計意図(「クライアントへ返す ApiError をそのまま受け取る。 ログ側で組み立て直すとレスポンスとズレる」)を正面から壊します。 ヘルパーの共通化は本指示書の範囲外です。

app/api/clean-logs/export/route.ts:74 と app/api/dashboard/route.ts:70 は そもそもヘルパーが無いので対象外。

補足: `app/api//testsは存在しません**(APIルートの統合テストはゼロ)。vitest.config.tsの include は["lib/**/*.test.ts", "app/**/*.test.ts"]なので、 **4-2 の変更で落ちるテストはありません。tsc --noEmit` だけが番人です。**


5. docs/decisions.md への記録

★着手直前に最終番号を確認してから番号を振ること★(衝突歴あり。 2026-09-03 時点の最終は D-061 なので、D-062 から振ることになります)。

記録すべきもの:

内容 備考
判定基準の範囲表記を「10%以上 70%以下」の形に統一する(赤3) ユーザー向け文言の変更を含む(advice-rules.ts の condition は GET /api/settings/advice の応答に載る)。対象6箇所を列挙すること
docs/logging-design.md §5 の記録条件は実装(advice ルートは無条件記録)を正とする(3-6) 一覧変換側の条件1〜4は Prisma 実装と同時、と明記
クエリバリデーションの 422 は details に失敗項目名を返す。prechecks / ds-alerts / ds-devices を clean-logs に揃える(4-1) 共通型の置き場(lib/api/query-result.ts)も記録すること

赤2(認証情報8件の文書追記)は実装が既に正しく、文書を実装に合わせるだけなので 決定事項ではありませんが、記録するなら「§4 と redact.ts の同期は3箇所セット」を残すと有用です。


★やらないこと★

次のものはこの指示書の範囲外です。手を付けないでください。

対象 理由
lib/judge/grade.ts の判定ロジック(rate < poor 等) ★赤1。テスト3本と D-020b の確定合意を破る★
形Bの3ルートの 500 経路 ★4-2。logApiError の設計意図を壊す★
errorResponse ヘルパーの共通モジュール化 7ルートに重複定義があるが、統合は別件
色の2重管理(lib/theme.ts と app/globals.css が同じ16色を持つ) 統合するかが未決。見た目に影響する変更なので文書整合と混ぜない
pino-pretty の配線 開発時の見やすさの話で、不整合ではない
AppShell の完全一致判定 挙動の変更になる。別途検討
lib/i18n.ts のプロトタイプキー 実害が確認できていない
R3 の仕様(条件に「出発失敗」を含むか) ★未確定(C-1)。★ CLAUDE.md §4.5 は「2日以上連続△以下」、lib/config/advice-rules.ts:75 は「△以下または出発失敗」で食い違っている。これは TeamWorks 案件ではなく DFA 内部で決めるもの。決まるまで仕様も文言も動かさない
prisma/schema.prisma ★指示書E の範囲。★ こちらで触ると差分が混ざる
下の「追加で見つかった不整合」3件 本指示書の範囲外。判断が必要なので別途

★追加で見つかった不整合(今回の範囲外。判断が必要)★

検証中に見つかりましたが、初版の11件に含まれないため手を付けていません。

# 場所 内容
E-1 docs/logging-design.md:17-19(§0 前提表) pino は導入済み(package.json に pino ^10.3.1 / pino-pretty ^13.1.3)、instrumentation.ts は実在、lib/logger/ も作成済みなのに、表は「未導入」「いずれも未作成」「未作成」のまま。※proxy.ts / middleware.ts が未作成なのは事実
E-2 docs/logging-design.md 4箇所 文書は runWithRequestId()、実装は withRequestId() で関数名が食い違う(:92 構成図の app/api/** 行 / §8 ステップ4 / §8 ステップ9 ほか)。実装が正なら文書側を統一すべき
E-3 docs/logging-design.md §10 完了条件3 「既存の5テスト」が実測と食い違う。現在 lib/logger/ 以外のテストは11本(lib/api / lib/clean-logs / lib/dashboard / lib/ds-alerts / lib/ds-devices / lib/fixtures / lib/judge×4 / lib/prechecks)

E-2 は 3-4 と同じ「文書と実装の名前ズレ」なので、3-4 のついでに直すのが自然です。 直すなら決定事項に記録すること(本指示書の範囲を広げる判断になるため)。


完了条件

  1. 赤3件・テスト1件・文書整合7件(3-7 を含む)・コード2件を実施
  2. npx tsc --noEmit がエラー0
  3. npm test のテスト数が減っていないこと (基準値 260。B1 の追加1件 + 4-1 の追加6件で 267 になるのが期待値)
  4. 赤3 の修正内容(ユーザー向け文言の変更を含む)・3-6・4-1 を docs/decisions.md に記録 — ★着手直前に最終番号を確認してから番号を振ること★(衝突歴あり)
  5. 検証が通ったら即コミット(D-050)。 「レビュー前だから」で先送りしないこと(未コミットの成果が20ファイル以上消えた事故があります)