さくらコードSakura Code
小説/第23話

Novel

第23話 わかったふりのコード

Script

Cold Open

シーン1 他チームのマージ

 六月五日。月曜日。

 梅雨入り前の空は薄く曇っていて、窓の外のビルの輪郭が、少しだけ、にじんで見える朝だった。

 桜子が出社してノートPCを開くと、開発Slackのいちばん上に、別チームの名前があった。販売管理チームの、河野さん。中堅のエンジニアで、桜子とは、廊下で会釈を交わす程度。話したことは、ほとんどない。

@kawano: 金曜の夜にマージした #481 日次集計機能、
ユニットテストもステージングも全緑だったんですが、
今朝、販売管理のユーザーから「上司のExcelと数字が微妙にズレる」と
問い合わせが来ています。原因が見えるまで、画面に注意書きを出しました。
うちのチーム、今日は手が足りていません。見てもらえる人、いませんか

 名前に、見覚えがあった。五月の終わり、開発Slackの端に増えた、翔太ではない AI-assisted のPR通知。あの日、桜子は、そのリンクを開かなかった。

 今日は、開いた。

 PR本文には、ガイドライン第2項のとおり、AI使用の注記があった。

Note: ChatPort で草案生成・実装補助。
対話履歴: wiki/ai/chatport-history/kawano-2023-05.md
レビュー: 販売管理チーム内で1名

 差分は、三百行弱。日次集計の関数がいくつかと、そのテストが、新規で追加されていた。ロールバック手順の欄は、なかった。


Part A

シーン2 朝のズレ報告

 販売管理のユーザーからの問い合わせは、河野さんの投稿のスレッドに、そのまま、転記されていた。短い。

先週金曜 (6/2) の受注件数、
画面では 127件 / 上司のExcel集計では 131件。
4件、差があります

 (境界、だ)

 桜子の頭の中で、去年の五月の記憶が、一瞬、走った。isvalidforsignup の不等号。十七と十八と十九を並べて見た、あの朝。バグはだいたい、仕様の角に咲く、と、遥は言った。

 まだ、当たりとは限らない。桜子は、#481 のPR本文を、上から下まで、読んだ。設計セクションは、整っていた。テストも、parametrize で十数ケース、書いてあった。集計の核は、日付をひとつ受け取って、その一日分の受注を数える関数。画面は、それを日付ごとに呼んで、並べていた。

 次に、ステージングで使われたテストデータの、作成日時を、確認した。

 ぜんぶ、平日の昼に作られていた。日付の境目、二十三時五十九分と零時零分をまたぐレコードは、ひとつも、含まれていなかった。

 (テスト、ぜんぶ、真ん中で走ってる)

 真ん中しか走っていないなら、緑は、真ん中しか、保証しない。


シーン3 桜子が調査役

 十時、鈴木が桜子と翔太を呼んだ。

「河野さんのところと話して、#481 の原因調査、うちで引き取ることにした。桜子さん、お願いできる。翔太くん、桜子さんのサポート」

「はい」

「注意書きは、河野さんの側で、もう出てる。数字が正しくなるまでは、画面より、Excelを正にしてもらう。レプリカの読み取り権限は、河野さんが、二人分、出してくれてる」

 桜子と翔太は、空いていた小会議室に移って、#481 の中身を、一行ずつ、読んだ。

 集計関数の核は、こうだった。

def daily_order_count(target_date: date) -> int:
    today = datetime.today()
    start = datetime.combine(target_date, time.min)
    end = datetime.combine(target_date, time.max)
    return Order.query.filter(
        Order.created_at >= start,
        Order.created_at <= end,
    ).count()

 翔太が、最初に、指を止めた。

「この today、どこにも使われてない」

「うん。消し忘れ、というより、誰も、ここを読んでない」

「罠じゃないけど、印ではある」

 桜子は、次に、DBの orders テーブルの定義を開いた。

created_at  TIMESTAMP WITH TIME ZONE  NOT NULL  DEFAULT now()

 タイムゾーン付き。保存は、DB側の now()。受注が入った瞬間は、正しく、記録されている。画面に出すときは、アプリ側が、JSTに直して表示している。

 一方、集計関数の start と end は、targetdate を、タイムゾーン情報なしのまま、datetime.combine していた。

「あ」

 桜子は、声を、小さく上げた。

「これ、タイムゾーン付きの列と、タイムゾーンなしの値を、そのまま、比べてる」

「PostgreSQL は、タイムゾーンなしの値を、その接続のタイムゾーン設定で読む。このDB、設定、何」

 翔太が、読み取り専用のレプリカに、一行、打った。

SHOW timezone;
-- UTC

「UTC」

 翔太は、続けて、アプリ側のDB接続設定も開いた。タイムゾーンの指定は、どこにも、無い。本番のアプリも、この設定のまま、UTCで繋いでいる。

「だから、targetdate が六月二日なら、DBから見た範囲は、六月二日の零時から二十三時五十九分、UTC。JSTに直すと、二日の朝九時から、三日の朝八時五十九分」

「九時間、ずれてる」

「二日の零時から九時までに入った受注は、こぼれる。三日の零時から九時までの受注は、二日に、混ざる」

 翔太が、次に、#481 のテストを開いた。

「テストは、SQLite のインメモリで走ってる。timestamptz の型が、そもそも、ない。しかも、fixture の createdat が、ぜんぶ、正午」

「ステージングも、昼のデータだけ」

「昼のレコードは、JSTの一日にも、UTCで読んだ範囲にも、どっちにも入る。だから、全緑」

 全緑は、嘘ではなかった。ただ、緑が保証していた範囲が、真ん中だけだった。


Part B

シーン4 境界条件、再び

 桜子は、レプリカに、直接クエリを投げた。

SELECT count(*) FROM orders
WHERE created_at >= '2023-06-02 00:00:00+09:00'
  AND created_at <  '2023-06-03 00:00:00+09:00';

 結果は、131件。上司のExcelと、同じ数字。

 画面の dailyordercount(date(2023, 6, 2)) は、127件。

 差は、四件。ただ、四件がこぼれたのではなかった。続けて、こぼれた側と、混ざった側を、別々に数えた。二日の零時から九時までに入った受注が、六件。三日の零時から九時までが、二件。百三十一から六を引いて、二を足すと、百二十七。

 問い合わせの数字と、一致した。

 (境界が、タイムゾーンの角で、もう一度、咲いた)

 去年の五月は、十七と十八の間だった。今日は、二十三時五十九分と零時零分の間。しかも、その境目は、JSTとUTCで、九時間、別の場所にある。

 桜子は、二重の感触を持った。

 ひとつは、原因が、きれいに切り分けられた満足感。もうひとつは、このバグが、レビューを通り、ユニットテストを通り、ステージングを通って、本番でユーザーの画面に出るまで、誰にも気づかれなかったという事実の、重さだった。

 ChatPort は、この関数を、流暢に、書いた。テストも、正しそうな形で、書いた。列が timestamptz で、接続が UTC で、ユーザーが JST の一日で数字を見ている、ということは、聞かれていないので、知らない。知らないまま、整った形で、答えた。

 翔太は、河野さんの対話履歴の wiki ページを、開いた。

「前提ブロック、ないね」

「うん」

「『日次集計の関数を書いて』から、始まってる。DBの型も、タイムゾーンも、一度も、出てこない」

 翔太の声には、非難のトーンが、なかった。

「河野さん、たぶん、初めて ChatPort を業務で使って、まず、動くものを出した。四月の僕と、同じ形」

 桜子は、自分の観察ノートの、五月の終わりのページを思い出した。翔太の前提ブロックに、境界条件のテストを含める、と一行あったのは、翔太が四月に、浮いた案を出し続けたあとだった。前提が入るには、一度、浮く必要が、あったのかもしれない。

 ただ、今回は、浮いたコードが、本番まで、行った。


シーン5 遥の一言

 昼前、鈴木が、調査の共有を設定した。会議室「桜」に、鈴木、遥、翔太、桜子。河野さんは、販売管理のフロアから、Meet で入った。

 桜子は、タイムゾーン境界のズレを、画面共有で、説明した。原因、影響範囲、数字の内訳、ステージングとテストがなぜ緑だったか。

 河野さんが、画面の向こうで、眉を下げた。

「申し訳ない。月曜の週次報告に間に合わせたくて、金曜の夜に通しました。自分で動かして、テストも緑で、チームの一人にも見てもらった。ただ、二人とも、三百行を、読み切れてはいなかったと思います」

 鈴木が、

「責めてないよ、河野さん。ガイドラインの2項も3項も、守られてる。レビューも1名、通ってる。守った上で、通った。今日の話は、そこ」

 遥が、短く、

「ひとつ、だけ、言わせてください」

 と、口を開いた。

「ChatPort に限った話じゃない、と前置きした上で、言います」

 全員が、遥の方を見た。

「読めない速度は、武器にならない」

 会議室と、Meet の画面の向こうで、それぞれ、ひと呼吸が落ちた。

「速さは、人がその場で読み切れる範囲で、価値になる。人が読めないまま通った速さは、あとで、人が読む量を倍にする。今日、桜子さんと翔太くんが午前中まるごと使って読んだ三百行は、金曜の夜に、二人がかりでも、読み切れなかった三百行です」

 河野さんは、うなずいた。

 遥は、続けた。

「ステージングは、真ん中しか走らない。本番は、境目を、必ず踏む」

 そこで、言葉を切った。

 河野さんが、

「チェックリスト、うちのチームでも、要ります。自分ひとりの反省で、終わらせたくない」

 遥が、桜子の方を向いた。

「桜子さん、今日の調査の手順を、そのまま、叩きにして。翔太くんとわたしが、横につく。河野さん、販売管理の側から、読んでもらえますか」

「はい。むしろ、お願いします」


Part C

シーン6 修正とpostmortem

 午後。

 桜子は、修正PR #489 を上げた。集計関数を、JSTの日付境界で明示的に作り直し、parametrize テストには、境界の前後のレコードを、意図的に並べた。

def daily_order_count(target_date: date) -> int:
    tz = ZoneInfo("Asia/Tokyo")
    start = datetime.combine(target_date, time.min, tzinfo=tz)
    end = start + timedelta(days=1)
    return Order.query.filter(
        Order.created_at >= start,
        Order.created_at <  end,
    ).count()

 テストには、

@pytest.mark.parametrize("created_at, target_date, expected_in", [
    ("2023-06-01 23:59:59+09:00", date(2023, 6, 2), False),
    ("2023-06-02 00:00:00+09:00", date(2023, 6, 2), True),
    ("2023-06-02 08:30:00+09:00", date(2023, 6, 2), True),   # 旧コードでは、こぼれる
    ("2023-06-02 23:59:59+09:00", date(2023, 6, 2), True),
    ("2023-06-03 00:00:00+09:00", date(2023, 6, 2), False),
    ("2023-06-03 08:30:00+09:00", date(2023, 6, 2), False),  # 旧コードでは、混ざる
])
def test_daily_count_tz_boundary(pg_session, created_at, target_date, expected_in):
    ...

 テストDBは、SQLite ではなく、本番と同じ PostgreSQL を、接続のタイムゾーンも UTC に揃えて使う形に、変えた。SQLite には、timestamptz が、ない。本番と同じ赤は、本番と同じ型の上でしか、咲かない。

 修正を入れる前に、桜子は、新しいテストを、#481 のままの関数に、一度、当てた。六行のうち、四行が、赤く咲いた。それから、関数を差し替えて、緑にした。

 PR本文には、ロールバック手順を、書いた。#481 の関数に戻すだけ。戻しても、数字が金曜の形にずれるだけで、データは壊れない。ただし、戻すときは、画面の注意書きと、Excelを正にする運用も、一緒に戻す。その二行を書きながら、去年の秋、休日のカフェで見せてもらった、遥の私物ノートの星印を、思い出した。

 遥と翔太がレビューし、河野さんが販売管理側で承認して、十六時にマージ、本番に反映された。河野さんは、本番の画面で六月二日を開き、百三十一件が上司のExcelと一致するのを確かめてから、注意書きを外した。

 十七時、社内 wiki に、「AI生成コードの読み切りチェックリスト (v0.1)」の叩きが上がった。書いたのは桜子、横に遥と翔太、販売管理側の目で河野さん。

## AI生成コードの読み切りチェックリスト (v0.1)

PR作成時:
- [ ] プロンプトの「前提ブロック」が書かれている
  (タイムゾーン、DBの型、認証、排他、集計窓の幅などを、外に出せる一般化した言葉で)
- [ ] 社内固有の名前・接続先・顧客情報はプロンプトに入れず(ガイドライン第1項)、
  社内固有の条件は、社内のレビューとテストで確かめている
- [ ] AI生成コードと、手で書いた部分が、コミット単位で分かれている
- [ ] 境界条件テスト(日付/数値/NULL/空集合)が含まれている
- [ ] テストが、本番と同じDB・同じ型・同じタイムゾーン設定で走っている
- [ ] ロールバック手順がPR本文に明記されている

レビュー時:
- [ ] レビュアーが、関数ひとつを、自分の頭で一度、読み切れる分量か
- [ ] テストデータが、真ん中だけを走っていないか
- [ ] タイムゾーン・エンコーディング・NULL・境界の取り扱いが、明示されているか
- [ ] 使われていない変数や引数が残っていないか(誰も読んでいない印)

運用判断:
- [ ] 「流暢で、しかし、静かに間違える」可能性がある前提で、
  ズレに気づく手段(突き合わせ先)と、戻し方を決めているか

 桜子は、その v0.1 を、観察ノートに、書き写した。


Ending

シーン7 観察ノート#019、警戒

 十九時前。

 駅までの坂道の途中のカフェで、桜子はノートを開いた。

ChatPort 観察ノート #019
2023/06/05
- 他チームのAI生成コードで、タイムゾーン境界のバグ
- 見かけ上の成功: ユニットテスト全緑、ステージング全緑
- 実態: SQLiteのテスト、正午だけのfixture、昼だけのステージングデータ。
  境目は、どこも、未踏
- 山下さん: 「読めない速度は、武器にならない」

今日の観察:
- ChatPort は、流暢に書く
- 流暢なコードは、境界の罠を、隠すことがある
- 隠すつもりは、ChatPort にも、書き手にも、ない。ただ、現場の前提が、
  出力の中に、入っていないだけ
- 数字は、正確そうに見える。今回、信じすぎなかったのは、上司のExcelだった
- 前提ブロックは、入口。緑を確かめるのは、出口。片方では、足りない

警戒の言葉:
- もっともらしく、しかし、静かに、間違える
- 流暢さと、正しさは、別
- 境界条件は、タイムゾーンにも、咲く

 書き終えて、桜子は、ペンを置いた。

 遥の「読めない速度は、武器にならない」の響きが、まだ、胸のあたりに、薄く、残っていた。

 鞄の持ち手で、桜色のキーホルダーが、揺れていた。三月の最後の日、坂道で、握らなかったキーホルダー。今日は、一度だけ、握った。全緑のまま本番に出た三百行の重さは、まだ、桜子の指の先に、あった。

 去年の四月一日、遥に「ログを見よう」と言われてから、一年と二ヶ月。今日、桜子は、ログに書かれた赤い一行ではなく、ステージングで緑だった行を、疑って、前後を、読み直した。

 (赤より、緑のほうが、怖い日が、ある)

 観察ノートの新しい見開きに、桜子は、そのひと言だけを、書いた。