Featured image of post コードディスパッチャー【Audit Trail】「三ヶ月前と、同じように、動かしただけです」〜答えだけでは、証拠になりません〜

コードディスパッチャー【Audit Trail】「三ヶ月前と、同じように、動かしただけです」〜答えだけでは、証拠になりません〜

外部監査に三か月前の判定根拠を求められても、同じ入力から同じ答えは再現できない。入力・版・パラメータ・出力を紐づけて残すAudit Trailで、答えでなく道筋を残す設計を解説します。

第1幕: 持ち込み ── 説明できるはずのものが、説明できない

深夜の指令所は、静かというより、音の種類が絞られている時間帯だった。空調の低い唸りと、表示盤の点滅する音だけが残る。サカキは端末に向かい、明日のダイヤの調整項目を一つずつ潰していた。私(トウヤ)は当直交代の時刻を記録帳の余白に書き入れる。インクが紙に馴染む、いつもの重さの一行だった。

ドアが開く。サイトウさんが一人で入ってくる。会釈の声は落ち着いていたが、手に持った書類を握る指に、その落ち着きとは釣り合わない力が入っていた。書類の角が、指の形にわずかに反っている。声と手が、別々の調子で語っていた。私は記録帳へ視線を落としたまま、その食い違いをどの欄に書き留めればいいのか、しばらく決めかねていた。

「今日はどのような」

サカキが訊く。サイトウさんは勧められた椅子に浅く腰掛け、鞄から書類を一枚取り出すと、両手で持ったまま、しばらく差し出さずにいた。

「三か月前に判定した、ある申込みについて、外部の監査から、なぜその判定だったのか説明してほしいと言われています」

差し出されたのは、監査からの照会文書だった。私はその言葉を記録の欄に書き取りながら、答えそのものは残っているはずなのに、その理由を求められて困っている、という状況に引っかかった。動かなくなったのでも、変な値が返ってきたのでもない。むしろ、判定はちゃんと出ていた。出ているものを、なぜだと問われて説明できない——これまで扱ってきた相談は、たいてい「何が起きたか」を突き止めればよかった。今回は、起きたことそのものは分かっている。分からないのは、それをどう裏付けるかだった。

「その申込みの、判定結果自体は、業務ログに残っているんです。承認したことは分かります。ただ、なぜ承認だったのかを、監査に説明できる形では、何も残っていませんでした」

サイトウさんの説明は、順序立っていた。個人向け融資の一次審査に、LLMの補助を使い始めて2年になること。年収や勤続年数、既存の借入額といった申込みデータを読ませ、承認・否認・要確認の判定を出させていること。判定の負担は大きく減ったが、判定結果そのものしか記録に残していなかったこと。声の高さは変わらないまま、言葉と言葉の間だけが、いつもより長く空いた。審査歴18年という肩書きに見合った、崩れない話し方の下に、何かを測りかねている気配があった。

聞きながら思ったのは、指示どおりに走る無人運転の列車という、見慣れた構図だった。ここまでは、他の路線と変わらない骨格に見えた。違っていたのは、この先だった。

「それで、担当のエンジニアに頼んで、同じ申込みデータを、もう一度流してみたんです」

サイトウさんの声のトーンが、そこで僅かに揺れた。

「そうしたら……三か月前とも、また違う結果が返ってきました」

ペン先が、紙の上で止まった。同じものをもう一度試して、また違う答えが返ってきた、という相談は、初めてだった。止まったのでも、エラーになったのでもない。判定は、今も普通に出ている。ただ、その判定が、確かめるたびに違うものになる。

「その判定、これまでにも同じようなことが?」

サカキが訊いた。

「分かりません。今回、初めて確かめました」

「分かりました。今夜のところは、まだ何も判定しません」

サカキはそう言うと、端末の電源を入れ直し、画面が立ち上がるのを黙って待った。何を確かめようとしているのか、サイトウさんにも私にも、まだ見当がつかなかった。私は「症状」の欄に、今聞いた内容を書き写しながら、いつもとは違う書き方を選んだ。普段なら「〜が起きた」と結果を先に書く。今回は、まだ何も起きたと言い切れる形になっていない。私は「〜という報告があった」とだけ書いた。起きたことと、報告されたことの間には、まだ埋まっていない距離があった。

第2幕: 照合 ── もう一度、同じように通してみる

「まず、そのときの申込みデータは、残っていますか」

サカキが訊いた。サイトウさんが頷く。

「はい、それは残っています。……ただ、判定に使ったモデルの版や、細かい設定までは、正直、覚えていません」

サカキが、端末の画面をサイトウさんのほうへ向けた。

「これから、同じ入力を、もう一度流してみます。今回とも、三か月前とも、違う答えが返ってくる可能性があります。それは、異常だと考えてよいですか」

サイトウさんが少し考えてから、頷いた。

「……はい、それで」

サカキが、申込みデータを読み上げた。三か月前にサイトウさんが持ち込んだのと同じ申込み、APPLICATION_Xだった。

1
2
3
4
5
6
APPLICATION_X = CreditApplication(
    application_id="X-2026-0312",
    annual_income=4_800_000,
    years_employed=3.5,
    existing_debt=1_200_000,
)

「これから、この申込みを、今この場で2回続けて流します。三か月前の再現ではありません。今、同じものを、同じように通したら、どうなるかを確かめます」

サカキが台本を示した。

1
2
3
4
5
DEMO_SCRIPT = [Verdict.NEEDS_REVIEW, Verdict.APPROVED, Verdict.DENIED]

client = FlakyScriptedClient(script=list(DEMO_SCRIPT))
live_first = assess_credit_application(APPLICATION_X, client)   # 今、1回目
live_second = assess_credit_application(APPLICATION_X, client)  # 今、2回目

1回目の判定が返る。「要確認」。サイトウさんが、それを見て少し身を乗り出した。画面の文字を、確かめるように目で追っている。続けて、サカキがもう一度、同じAPPLICATION_Xを流す。指がキーを叩く音は、1回目とまったく同じ短さだった。2回目の判定が返る。「承認」。

サイトウさんの視線が、1回目の結果と2回目の結果のあいだを、何度か往復した。APPLICATION_Xは、1回目と2回目で、まったく同じ値だった。それでも、返ってきた判定は違っていた。私は記録の欄にペンを構えたまま、しばらく動かせずにいた。今書き留めようとしているのは、いつもの「何が起きたか」ではなく、「何度確かめても、同じにならなかった」という、動かなかった事実そのものだった。

その夜の記録は、こう並んだ。

同一の申込みデータAPPLICATION_Xを1回目・2回目とまったく同じ条件で呼び出しても、1回目は判定「要確認」、2回目は判定「承認」と異なる結果が返ることを示す運行図インフォグラフィック

「壊れているわけではありません。最初から、そういうものです」

サイトウさんが、息を呑む。三か月前の記録も、自分が確かめた1回も、そして今、目の前で見た2回も——期待していたような、同じ答えの繰り返しには、一度もならなかった。

私の独白。同じものを、同じように通しても、同じものが返ってくるとは限らない。この夜、私はそれを言葉としてではなく、目の前で流された2つの違う答えとして見た。

「……私のミスでは、なかったんですね」

サイトウさんの声から、力みが抜けていた。

「まだ、それを判断する材料がありません」

サカキの返事は、安心させるものでも、突き放すものでもなかった。サイトウさんの眉が、一瞬だけ持ち上がる。安易に免責されなかったことへの戸惑いのようにも見えたが、不快そうな色はなかった。むしろ、断定されずに済んだことへの、静かな余白のようだった。書類を握る指の力が、来たときよりわずかに緩んでいるのを、私は目の端で捉えていた。

「でも、それなら、監査には何を見せればいいんでしょうか。同じ答えが出せないなら……」

サイトウさんの問いは、後退ではなく前進の形をしていた。自分の落ち度を主張することにこだわらず、次に何をすべきかへ向かっている。

「三か月前と、同じように、動かしただけです」

サカキが言った。

「答えを、もう一度出そうとするより先に、確かめておくべきことがあります」

第3幕: 指令の一手 ── 答えでなく、道筋を綴じる

「これから組むのは、判定そのものを直す仕組みではありません。判定のたびに、何を通したかを、ひとまとめに残す記録です。この判定の呼び出し方を、ここで書き直します」

サカキが言った。私が普段書き留めているものとは別に、判定そのものについても、記録を残すという話だった。今組み直すのは、さっきの呼び出しそのものではなく、それに代わる新しい配線だった。

「入力と、使ったモデルの版と、パラメータと、出てきた答え。この4つを、ひとまとめにします」

「パラメータ、というのは」

サイトウさんが訊く。

「temperatureのような、出力のばらつき方を左右する設定値です。判定のたびに、どんな設定で動かしたかも、一緒に残しておきます」

「モデルの版や設定は、どこから分かるんですか」

「使ったクライアントが、自分がどの版で、どんな設定かを持っています。本番でも、呼び出しに使うクライアントは、たいてい同じように版と設定を属性として持っています」

サカキが、画面にコードを呼び出した。

1
2
3
4
5
6
7
8
@dataclass(frozen=True)
class AuditRecord:
    application_id: str
    application: CreditApplication
    model_version: str
    parameters: dict[str, float]
    output: Verdict
    recorded_at: str

「今、業務ログに残っているのは、このoutputの部分だけです。それ以外は、どこにも残っていません」

frozen=Trueというのは」

サイトウさんが、コードの一行を指す。

「一度作った記録は、あとから書き換えられません。監査の材料が、あとで都合よく直せてしまっては意味がありませんから」

「これがあれば、監査には十分なんでしょうか」

サイトウさんが訊いた。

「監査が求めているのは、同じ答えをもう一度出すことではないはずです。どの入力を、どの版で、どんな設定で通したか。それを辿れることです。答えの再現ではなく、経緯の追跡です」

サイトウさんが、コードを目で追った。「これを、どこに残すんですか」

「この記録を、どこに置くかは、差し替えられるようにしておきます」

サカキが、次のコードを示した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class AuditStore(Protocol):
    def record(self, entry: AuditRecord) -> None: ...


class InMemoryAuditStore:
    def __init__(self) -> None:
        self.records: list[AuditRecord] = []

    def record(self, entry: AuditRecord) -> None:
        self.records.append(entry)

「今夜は、確認のために、メモリの中だけに置きます。本番でどこに置くかは、また別の話です」

InMemoryAuditStoreは、AuditStoreを継承していませんが」

サイトウさんが、コードを見比べながら訊いた。

「継承しなくても構いません。recordという同じ形のメソッドさえ持っていれば、差し替え先として扱えます。継承の宣言でなく、形が合っているかどうかで見ます」

サイトウさんが、画面から顔を上げた。「これは、今夜のために考えた仕組みなんですか」

「いいえ」

サカキが、椅子に座り直した。

「これは、今夜のために考えたものではありません」

サカキが、順に3つの考え方を示した。

「まず、永続化の詳細を、扱う側から切り離しておく、という考え方があります。Repositoryパターンと呼ばれます。ドメインとデータの層を、コレクションのようなインターフェースで仲介する、という定義です」

「差し替え先の1つとして、メモリの中だけで動く版を使うのは、この設計の中でも名前が付いている使い方です。Ports and Adaptersという考え方では、『メモリの中で動く、模擬のデータベースへのアダプタ』という書き方が、はっきり示されています」

「今夜使ったInMemoryAuditStoreは、この設計の中で名前が付いている型にも当てはまります。テストの世界で、Fake Objectと呼ばれる型です。データベースの代わりに使う、メモリの中だけの軽量な実装、という位置づけです」

サイトウさんが、少し感心したような声を出した。「差し替えられる、というだけのことに、そんなに名前が付いているんですね」

「呼び方が違うのは、見ている場所が違うからです。設計全体としての呼び方がRepository、差し替え先の役割としての呼び方がPorts and Adapters、テストで使う実装としての呼び方がFake Object。同じものを、三つの角度から見ています」

私はその一連のやり取りを書き取りながら、これが今夜初めて聞く名前ばかりであることに気づいた。指令所で扱われる考え方には、たいてい名前が付いている。今回も同じだった。ただ、その名前が指しているものは、これまでのどの一手より地味だった。守るための仕組みでも、測るための仕組みでもない。ただ、置き場所を差し替えられるようにしておく、というだけの話に、三つも名前が連なっていた。

サカキが、続けてコードを示した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def assess_credit_application(
    application: CreditApplication,
    client: ScriptedClient,
    store: AuditStore | None,
    clock: Callable[[], str],
) -> Verdict:
    verdict = client.assess(application)
    if store is not None:
        store.record(AuditRecord(
            application_id=application.application_id,
            application=application,
            model_version=client.version,
            parameters=client.parameters,
            output=verdict,
            recorded_at=clock(),
        ))
    return verdict

ここまでで組み上がった配線を、記録の欄の隅に、私は図にして書き添えた。判定に至る道筋は、変わっていない。増えたのは、道の脇に伸びる、もう一本の細い線──記録へ向かう経路だけだった。

Before/After構造対比図。Beforeは申込みデータからassess_credit_application()を経て判定結果へ至る一本の実線のみ。Afterは同じ実線の判定結果への経路(Beforeと同じ値)に加え、assess_credit_application()から破線でAuditRecord(入力・版・パラメータ・出力・時刻)を経てAuditStore(差し替え可能)へ至る条件付きの記録経路が新たに分岐している

「時刻も、外から決められるようにしておきます」

「時刻まで、ですか」

サイトウさんが訊き返す。

「はい。でなければ、いつ確かめたかによって、今夜の結果そのものが変わってしまいます」

サイトウさんが、書類の束をめくりながら、ふと手を止めた。

「……この記録には、申込みの中身も入るんですよね。年収や、借入額のような」

「はい。ですから、この記録は、外へ出す前提のものではありません。外へ渡すときに何を見せてよいかを決めるのは、また別の仕組みです」

サイトウさんが頷く。以前扱った、送出前に中身を検める仕組みのことが頭をよぎった。あれは、外へ出す前に何を止めるかという話だった。今回は、内部にどう残すかという、別の層の話だった。同じ「情報を扱う」という言葉でも、指しているものが違う——私はその区別を、記録の欄に一言添えて書いておいた。

「これで、監査には何を出せばいいんですか」

サイトウさんが訊いた。

「今夜組んだのは、記録の形です。この記録をどこに置くか、どれだけ改竄されない形で残すか、どれだけの期間残すかは、また別の話になります」

サカキが、そのまま続けた。

「改竄されない形で残す、という点については、いくつかの団体が指針を出しています。ただし、書き方は抽象的です。『改竄されない記録』『検証可能な履歴』というような言い方に留まっていて、具体的な仕組みまでは指定していません」

「具体的には、どうするんですか」

「例えば、記録を1時間ごとにまとめ、前のまとまりへの参照とハッシュと署名を持たせて、鎖のようにつなげる仕組みがあります。ただし、これは特定のクラウドサービスの、記録配送機能とは別の、記録検証機能が持つ仕組みです。記録を配送するだけの機能には、この検証機能そのものは付いていません」

サイトウさんが、メモの手を止めた。「配送する機能と、検証する機能は、別なんですか」

「はい。書き換えられない置き場所そのものも、また別のサービスの役割です。今夜作ったのは、記録の形と、差し替えられる置き場所だけです。そこはまだ、今夜の範囲の外です」

「保持する期間は」

「業界や規制によって、先に決まっている期間があります。与信の判断であれば、通知から一定の期間は残すことが求められる、というような形です。具体的な期間は、業界の規制に照らして、そちらで確認していただく必要があります」

サイトウさんが、その言葉を手元のメモに書き留めた。ペン先が止まる。

「今夜組んだ仕組みだけでは、それは満たせない、ということですね」

「はい。今夜組んだのは、記録の形と、差し替えられる置き場所です。改竄されない置き場所と、期間を守る運用は、その形の上に、また別に組む必要があります」

サイトウさんが、小さく頷いた。落胆した様子はなかった。むしろ、何が足りていて、何がまだ足りていないかが、はっきりした分だけ、表情から力みが抜けたように見えた。

「保管の費用は、どのくらい見ておけばいいですか」

サカキが、一瞬、何かを言いかけて止めた。

「……それは、今夜の話には要りません」

それだけだった。私はその一瞬の間だけを、記録の欄に書き添えた。サカキが何を知っていて何を言わなかったのかは、私には分からないままだった。

第4幕: シミュレータと引き渡し ── 保証しないことを、自分の言葉で

「では、確かめましょう」

サカキが端末を操作し、6本の検証を続けて走らせた。今夜は、私がその結果を一つずつ告げる役だった。

「同じ申込みを繰り返し流しても判定結果が一致するとは限らない、通過」 「記録の配線を加えても判定結果自体はstoreの有無に関わらず一致する、通過」 「判定のたびに入力と版とパラメータと出力を紐づけた記録が作られる、通過」 「記録から入力と版とパラメータと出力を後から復元できる、通過」 「storeを渡さない場合は記録が一切作られない、通過」 「recorded_atは注入したclockの値がそのまま使われる、通過」

特に、2本目と3本目を、サカキが噛み砕いた。

「2本目は、記録を配線しても、判定そのものは変わらないことの確認です。storeを渡しても渡さなくても、同じ入力・同じ条件であれば、判定結果は完全に一致します」

「記録することが、判定に影響しない、ということですか」

サイトウさんが訊く。

「はい。記録は、判定に寄り添うだけで、判定を変えません。3本目は、記録が実際に作られることの確認です。判定のたびに、入力と版とパラメータと出力が、ひとまとめの記録として残ります」

サイトウさんが、コードとテストの結果を見比べた。

「判定結果自体は、同じ入力でも一致するとは限らないんですよね。でも、記録は、必ず作られる」

「はい。そこは、はっきり分けて考えてください」

サカキが、線を引くように、二つを分けて告げた。

「今夜の仕組みが約束するのは、判定のたびに、そのとき何を通したかを記録に残すことです。約束しないのは、同じ答えを再現できることです。記録は、何が起きたかを示すためのものであり、同じ結果を出すためのものではありません」

サイトウさんは、しばらく口を開かなかった。手元の書類へ視線を落とし、それからゆっくりと顔を上げて言った。

「……つまり、監査に出すのは、答えそのものではなく、そのとき何を通したかということですね。答えが再現できないことは、仕方がない、ということですね」

サカキは、頷くだけで、それ以上言葉を足さなかった。

私は、サイトウさんが自分の言葉で言い直した、そのやり取りを記録の欄に書き留めた。サイトウさんは書類を鞄へしまうと、椅子の背もたれに預けていた体を起こし、静かに立ち上がった。来たときよりも、書類を握る手の力が緩んでいるのが分かった。会釈だけを残し、廊下の明かりの中へ出ていく。

指令所に、また元どおりの音の少なさが戻った。私は記録帳を閉じずに、今夜聞いた言葉を頭の中でもう一度たどった。持ち込まれたのは、答えが欲しいという相談だった。渡したのは、答えではなく、答えの手前にあったものだった。三か月前の申込みデータも、今夜サカキが流した2回の判定も、記録という形にしてしまえば、同じ器に収まる。違うのは、何を残すべきだと最初から決めていたか、それだけだった。

記録の欄には、まだ空きがあった。私はそこへ、答えは再現できないままだということと、それでも説明できる形を持ち帰ってもらえたということを、短く書き添えた。ペンを置く。今夜の一行は、いつもより長く、そこに留まって見えた。


🚦 本日の指令記録(Dispatch Log)

  • 指令の定石(パターン名): Audit Trail(監査ログと再現性)── 入力・モデルの版・パラメータ・出力を一つの記録として紐づけて残す。再現できないことを前提に、判断の材料を保存する
  • 申告された症状: 「外部監査から、三か月前の判定根拠を求められた。同じ入力をもう一度流しても、同じ答えが返らない」
  • 今のダイヤの問題: 判定結果だけが業務ログに残り、判断に使った入力・モデルの版・パラメータが紐づけて記録されていない
  • 打った一手: 入力・モデルの版・パラメータ・出力を1つの記録として持つAuditRecordと、記録先を差し替え可能にするAuditStoreを導入した。判定結果そのものは変えない
  • 配線した場所: 判定ロジック自体は事業者の指示書が決める。記録するかどうか、記録先の実体は指令所側が用意する仕組みとして配線した
  • 保証しないこと: 同じ入力から同じ答えを再現できること。記録が示すのは「何を通したか」であって「同じ結果を出せること」ではない
  • シミュレータ結果: 6本。記録の配線を加えても判定結果自体が変わらないこと、記録から入力・版・パラメータ・出力を後から復元できることを確かめた
  • 次の当直への申し送り: 今夜作ったのは記録の形だけ。改竄されない保管先と保持期間は、まだこれから

答えが再現できないことは、直せません。それでも、なぜそう動いたかを説明できる形にする相談は承っています。

ご相談はこちら

comments powered by Disqus
システム開発・AIワークフローのご相談は Meetsource
Hugo で構築されています。
テーマ StackJimmy によって設計されています。