監査ログの取り込み、ベストエフォートなDB、そして「読み取りの正」としてAI/LLMやSlackからの再実行にデータが活用される様子を描いた概念図。機能再利用や状態遷移の検討点も示し、中央の人物が設計判断を提示しています。

はじめに

「書き込みはベストエフォート、失敗しても本流を止めない」という前提で作られたテーブルは、たいてい監査ログや診断用の記録として設計されています。

ところが、そこに後から「このデータを読んで、人に返信する」という機能を足そうとすると、暗黙の前提が一気に崩れます。

これまで「失敗しても握りつぶしてよかった」データが、今度は「なければユーザーに間違った状態を伝えてしまう」データに変わるからです。

あるAI開発エージェントの運用チームで、Slack経由でジョブの状態確認・再実行コマンドを追加する作業がありました。

この記事では、そのときに実際に検討された設計判断を、汎用的なパターンとして整理します。

信頼レベルの非対称性をどう許容するか、インフラを増やさずにギャップと向き合う判断、構造的部分型による再利用、状態遷移グラフの暗黙の前提の壊し方、そしてLLMへの信頼境界の広げ方まで、既存システムに「読み取り機能」を後付けする際に一度は直面しやすい論点をまとめました。

こんな人におすすめ

  • 監査ログ・イベントログ用のテーブルを、ユーザー向け機能の読み取り元に転用しようとしている方
  • 「べき等な書き込み」に対するリトライ戦略をシンプルに保ちたい方
  • 権限チェックのようなロジックを複数の設定オブジェクト間で使い回したい方
  • 状態遷移を扱うコードに、例外的な逆方向の遷移を追加しようとしている方
  • LLMに渡すプロンプトの信頼境界設計に関心がある方

監査ログ用DBを「読み取りの正」に昇格させるときの非対称性

同じストレージの中でも、書き込みごとに要求される信頼レベルは異なります。

これまでの「ジョブ作成・開始時のログ」はベストエフォートのまま維持しつつ、新しく追加した「再実行時の新規レコード作成」だけは失敗を握りつぶさずに呼び出し元へエラーを返す、という設計が採られていました。

// 既存経路: 失敗しても本流を止めない(監査ログとしての性質を維持)
async function recordJobStarted(jobId: string): Promise<void> {
  try {
    await db.insertJobLog(jobId, "started");
  } catch (err) {
    logger.warn("job log write failed, continuing", { jobId, err });
  }
}

// 新規経路: 失敗を呼び出し元に伝える(ユーザー向け応答の正になるため)
async function recordAccepted(input: AcceptedInput): Promise<AcceptedResult> {
  // ここは握りつぶさない。失敗したら呼び出し元がリトライ判断を行う
  return db.insertAcceptedRecord(input);
}

1つのストレージの中に非対称な信頼レベルが同居することを意図的に許容し、その理由を設計判断記録(ADR)に明文化しておく、という進め方が取られていました。

「なぜここだけ扱いが違うのか」を後から追う人のために残しておく価値のある情報です。

べき等性を武器にした「その場一回リトライ」

一意キー(例: イベントID)に対して INSERT のうえで一意制約違反をキャッチする実装が、すでにべき等になっている場合があります。

そうであれば、一時的な書き込み失敗に対しては、新しい永続ストレージを足さずに「同じ処理をその場でもう一回呼ぶ」だけのシンプルなリトライで十分なことが多いです。

async function recordAcceptedWithRetry(
  input: AcceptedInput,
): Promise<AcceptedResult> {
  try {
    return await recorder.recordAccepted(input);
  } catch (err) {
    // 一意キーに支えられたべき等な書き込みなので、
    // その場で一回だけ再試行しても重複行は作られない
    return await recorder.recordAccepted(input);
  }
}

テスト側では、リトライを経ても重複行が生まれないことを確認しています。

const result = await recorder.recordAccepted({ /* ...同じ入力... */ });

expect(result).toEqual({ taskId: "DEV-5", attemptId: "DEV-5-a1" });
const { results } = await db.prepare("SELECT id FROM tasks").all();
expect(results).toHaveLength(1); // リトライによる重複行がないことを確認

二次インデックスや修復キューといった新しい仕組みを足す前に、「よくある一過性の失敗」だけをこの単純なリトライで潰せないか検討するのは、コストの低い選択だと言えそうです。

なお、「書き込みが2回連続で失敗した場合はレコードが見えなくなる」という残存ギャップについては、実際の発生頻度が確認できていない段階ではYAGNIとして見送り、「見つかりません」という正直な応答で済ませる判断が取られていました。見直すタイミングの条件だけを設計記録に残しておけば、後から必要になったときに迷わず着手できます。

権限チェック関数を構造的部分型で再利用する

元々「設定オブジェクト全体」を受け取っていた認可関数を、必要なフィールドだけを持つ構造的な型に絞り込む、という変更も行われていました。

/**
 * allowedUsers が空/未設定の場合は「チャンネルへの参加自体がアクセス境界」を意味する。
 * フルの設定オブジェクトではなく allowedUsers の形だけを受け取ることで、
 * 別の設定(専用のリポジトリ設定を持たないものなど)からも
 * このロジックを再利用できる。
 */
export function isUserAllowed(
  scope: { allowedUsers?: readonly string[] },
  userId: string | undefined,
): boolean {
  if (!scope.allowedUsers || scope.allowedUsers.length === 0) return true;
  return userId !== undefined && scope.allowedUsers.includes(userId);
}

継承やアダプタークラスを新設せず、TypeScriptの構造的部分型だけで別文脈への再利用性を確保できた、分かりやすい例です。

必要なフィールドだけを引数の型として切り出しておくと、将来別の設定オブジェクトが増えたときにも、そのオブジェクトが同じ形の部分構造を持ってさえいれば、そのまま同じ関数を呼べます。

終端状態の暗黙の等価関係を明示的な集合定義に切り替える

状態遷移グラフを扱うコードでは、「終端状態=グラフ上で出て行くエッジがない状態」という前提で書かれていることがよくあります。

そこに「失敗→再キュー」のような、終端状態からの例外的な後戻りエッジを1本追加すると、この等価関係は静かに崩れます。

// Before: グラフの形状から終端状態を暗黙的に判定していた
function isTerminal(state: JobState, graph: TransitionGraph): boolean {
  return graph.edgesFrom(state).length === 0;
}

// After: 終端状態を固定集合として明示的に定義し、グラフの形状から独立させる
const TERMINAL_STATES: ReadonlySet<JobState> = new Set(["succeeded", "failed", "cancelled"]);

function isTerminal(state: JobState): boolean {
  return TERMINAL_STATES.has(state);
}

こうしておけば、後戻りエッジを追加してもグラフの形状と「終端かどうか」の判定が切り離されるため、影響範囲を限定できます。

ただし、こうした定義の変更は「完了時刻をいつクリアするか」のような派生プロパティにも波及しやすいので、合わせて洗い出す必要があります。

例外的な後戻りエッジを追加する変更を見かけたら、コードが暗黙のうちに依存している等価関係が他にもないか、一度疑ってみる価値があります。

未検証テキストのフェンシングをAI自身の出力にも広げる

LLMに送るプロンプトの中で「ここからここまではユーザー入力であり、指示ではなくデータとして扱う」ことを明示するフェンシング(区切りタグ)の仕組みは、多くのAIアプリケーションで採用されています。

今回興味深かったのは、この扱いを「AIエージェント自身が前回の実行で書いた要約テキスト」にも同じように適用していた点です。

/**
 * ID に紐づくランダムな区切りタグ。固定の区切りタグは、それを知っている
 * 誰かが先に閉じタグを書いて中断させることができてしまうため、
 * 生成時点で予測不可能な区切りにしている。
 * ユーザーからのリクエストテキストだけでなく、エージェント自身が前回の
 * 実行について書いた要約テキストにも同じ「未検証データ」としての
 * 扱いを適用する。
 */
export function untrustedBlockTags(
  label: string,
  taskId: string,
): { open: string; close: string } {
  return { open: `<${label}-${taskId}>`, close: `</${label}-${taskId}>` };
}

ラベル名だけを変数化することで、性質の異なる2種類の未検証データに同じ防御ロジックを適用できるようにしています。

「AIが生成したテキストであっても、このシステム自身が今書いたものでなければ信頼境界の外側」という考え方が一貫しているのが、このパターンの肝だと感じます。

Webhookのペイロードなど、他の外部入力経路にも同じ考え方を適用できる余地は広そうです。

つまづきやすいポイント

  • 監査ログ用のテーブルをユーザー向け機能の読み取り元に転用する際、書き込みごとの信頼レベルの違いを曖昧にしたまま進めると、後から「なぜこのデータだけ欠けるのか」を追うのが難しくなります
  • べき等性が保証されていない書き込みに対して安易にその場リトライを足すと、重複レコードのリスクが生まれます。まずべき等かどうかの確認が先です
  • 状態遷移に例外的なエッジを追加するときは、グラフの形状に依存した暗黙の判定ロジックが他にもないか、テストだけでなくコード全体を見直す必要があります
  • プロンプトのフェンシング対象を広げる場合、区切りタグの予測不可能性やエスケープ処理を使い回しつつ、ラベルの意味だけを差し替える設計にしておくと保守しやすくなります

まとめ

既存のストレージに新しい読み取り経路を足す作業は、単なる機能追加ではなく、暗黙の信頼レベルや等価関係を明文化し直す作業でもあります。

信頼レベルの非対称性を意図的に許容して記録に残す、べき等性を根拠にリトライの複雑さを抑える、インフラを増やす前にギャップを正直に返す、構造的部分型で再利用性を確保する、そして信頼境界の考え方をAI自身の出力にまで一貫させる。

どれも派手な技術ではありませんが、後から読む人が「なぜそうなっているか」を追える設計にしておく、という点で共通しています。

同じような改修に取り組む際の判断材料の一つになれば幸いです。