AI時代のObservability設計 - PIIとAIの検索性を両立させ、自動修復する(実践編)
みなさまこんにちは!エアークローゼットでCTOをしている辻です。
設計編では、アプリケーション / インフラ / CI / LLMの4軸を、それぞれの問いの性質に合わせて別々の形でObservableにする話を書きました。ここまでで観測スタックの書き込み側は一旦区切ったところです。
ただし、「Observableにしただけ」で話は終わりません。観測スタックには本番データが流れる以上、ここにPIIが混入する経路を断たないといけない ── これはAIとは無関係に、observability設計で手を抜くと漏洩事故に直結する古典的な問題です。
従来、ログを読める人の集合はDBを読める人の集合とほぼ重なっていました。DB権限を持つエンジニアにとって、ログは個人情報への「追加の」経路ではなかった ── つまりログ側の防御は、システム全体の防御線を実質的には動かしにくい位置にあった、というのが多くの現場の実態だったはずです。
AIはこの前提を壊します。MCP経由でログを引く非エンジニアはDB権限を持ちません。ログは初めて 「DBにアクセスできない人が個人情報に到達しうる経路」 になった。さらにログの内容はAIの入力にも載るため、モデルへの送信・出力への再表出という新しい漏洩面も生まれる。ログのPII対策は「やっておくと良い衛生」から「信頼境界の再設計として必須」に変わった、というのが本記事の前提です。
そしてその上でAIが観測スタックを引ける状態を保たないと、そもそも「AIに渡せるobservability」という前編の目標が成立しません。
実践編ではこの2つの両立 ── PIIを守りつつ、AIから検索できる── をどう実現したか、そしてその結果としてCI失敗からPR提案まで繋ぐ自動修復がどう成立したかを書きます。
観測スタックはPIIの通り道になりやすい
アプリケーションがログを出す → Lokiに流れる → AIがMCP経由で引く ── この素直な流れを組んだだけだと、そこに以下のようなものが混入してきます:
- お客様のemailや電話番号がエラーログに含まれる
- 注文情報のレスポンスがtraceのpayloadに乗る
- DBクエリログにテーブル全行が出る
平文PIIが観測スタックに溜まると、そのままAIから検索可能になります。これはAIの能力以前の話で、観測スタックがPIIの通り道になっていること自体がリスク。そして同時に、PIIを完全に消してしまうと、「お客様Aの問い合わせを調査したい」という当然のサポート業務ができなくなる。
cortex(社内AIプラットフォーム)では、この対立をどう解いたか。大事なのは、「PIIの通り道を断つ」と「PIIで検索できる」を二者択一にしない設計です。
多層PII設計 ── 6つの層で守る
cortexのPII対応は、役割の違う6つの層が組み合わさっています:
| 層 | 目的 | 仕組み |
|---|---|---|
| 書き込み: BQ Policy Tag | 列レベルのアクセス制御 |
pii_high / pii_medium / pii_low の3層分類。fine-grained readerを持たない権限から該当列にSELECTすると Access Denied でクエリ自体が弾かれる(純粋なCLS (Column-Level Security)、動的マスキングは使っていない) |
| 書き込み: ETL DLP | 平文PIIを派生テーブルに残さない | Cloud DLPでカスタマーサポートデータ等を変換時にredact。[EMAIL_ADDRESS] / [PHONE_NUMBER] のplaceholderで構造は残す |
| 書き込み:ログハッシュ化 | Lokiに平文を残さない | アプリケーション側で hashEmail (HMAC-SHA256で12-char prefix、鍵は観測スタック外)を通してからlog出力 |
| 検索:同関数で照合 | 平文を介さず特定顧客のログ抽出 | クエリ側も同じ hashEmail を通してからLokiに投げる |
| 出力: MCPマスキング | AIに渡る時点で隠す | カラム名検出でローカル部を伏字化(例: r***@air-closet.com)。@domainは残して一次対応時のドメイン特定を保つ |
| Identity分離 | 社員emailは顧客PIIとは別トラックで扱う | HMAC署名された認証emailとしてattributionに使い、マスキング対象にはしない |
このうち4つ目の「検索:同関数で照合」が、セキュリティと使いやすさの両立で一番美味しいパターンです。
なお本記事ではemailを代表例に説明していきますが、この6層の枠組みが守る対象はemailだけではありません。氏名(カナ含む)・電話番号・住所・郵便番号・生年月日・カード/銀行情報・外部サービスのIDなど、PIIは多岐にわたります。PIIの性質ごとに匿名化の手法は変わり(email/電話のように同関数ハッシュで相関を残すもの、氏名/住所のように部分マスクするもの、カードやトークンのように完全redactするもの)、この判断は項目単位で持ちます。ただし、「6層のどこで、どう守るか」という構造は共通で、そこがこの設計の再利用可能な部分です。
さらに、この匿名化は観測ログ(Loki)だけの話でもありません。たとえばサービスのDBを引くMCPツールでも、クエリ結果にお客様の氏名・住所・電話番号などが載ってくるので、AIに返す手前で同じPII匿名化ルールを通しています。「AIに渡るデータ経路すべてでPIIを匿名化する」という一貫した規則を、データソースの種類を越えて適用している、ということです。
ハッシュ化を「書き込み」と「検索」の両端で通す
普通に「ログからPIIを消す」と、後で「お客様Aのログを探したい」ができなくなります。でも、書き込み時にハッシュ化した値をログに残しておけば、検索クエリ側でも同じハッシュ関数を通すことで該当ログをヒットさせられる。平文のemailは両端のどこにも流れない。

具体的にはこういう流れになります。
書き込み側:
// アプリケーションコード
logger.info("Subscription updated", {
user: hashEmail(user.email), // → '7a3f9c2e0b1d' (HMAC-SHA256 12-char prefix)
plan: "monthly",
});
// → LokiにはhashEmailの結果しか残らない
検索側(特定顧客のログを抽出したいとき):
ここで一つ悩ましい点があります。「お客様Aのログを引きたい」時、素直に組むとAIに生emailを渡してMCPツールに検索させることになる。でもそれはAI(=モデル、その先のベンダー)に平文PIIを渡すということで、Lokiの中をhashで守っても、その手前の検索入力で漏れてしまう。
そこでcortexでは、検索ツールを「非PIIなIDを受け取り、MCPサーバー内部でemailに解決し、その場でhash化してhashだけ返す」形にしています。emailはMCPサーバー内部にしか存在せず、モデルには一度も渡らない:
// MCPツール resolve_email_hash(サーバー内部で実行)
// 入力はID(非PII)。emailは呼び出し元=AIには一切返さない
const email = await resolveEmailById(userId); // サーバー内部でDBから解決
const hash = hashEmail(email, secret); // 書き込み側と同じ関数・同じ鍵
// → AIに返すのは hash だけ、emailは返さない
AIはこの hash を受け取って、Grafana MCP経由でLokiを {service_name="subscription"} |~ "${hash}" と検索する。書き込み側と検索側で同じ hashEmail 関数・同じ鍵を通すので、同じ顧客から出たログは同じhashでヒットする。一方で:
- Lokiに平文のemailは一度も入らない
- 検索クエリのLokiに届く文字列にも平文emailは含まれない(ハッシュ化後の値だけが届く)
- そしてAI(モデル)にも平文emailは一度も渡らない。AIが触るのは非PIIなIDと、既にLokiにあるhash値だけ。平文emailはMCPサーバーという信頼境界の内側から一歩も出ない
- ログ漏洩時の列挙耐性はHMACの鍵を観測スタック外に置くことで担保。emailは入力空間が狭く列挙可能なので、素の単方向ハッシュ(SHA-256等)だと破れます。ハッシュ関数は公開されているので、ログが漏れたら攻撃者は「よくあるemail候補」を自分のマシンで片っ端からハッシュして、漏れた値と突合するだけで平文を復元できる ── 鍵は要りません。HMACはハッシュの計算そのものに秘密の鍵を混ぜるので、鍵を知らない攻撃者はそもそも候補emailを「漏れたハッシュと同じ形」に変換できない。総当たりの土俵に立てなくなります。鍵を書き込み側 / 検索ツール側の2箇所だけに置き、Loki本体には置かないことで、「ログが漏れても鍵が漏れない限り平文は守られる」= 攻撃に必要な条件が1つ増える、という状態を作っています
- 12-char prefix (48 bit)に切り詰めているので理論上は衝突しうるが、顧客母数規模では実用上無視できる。誕生日問題で見ると、衝突が確率50%で起き始めるのは概ね2000万件規模(≈ 2^24.5)で、それより手前なら期待衝突数はごく小さい。さらに重要なのは、仮に衝突しても平文が漏れるわけではないこと ── このハッシュはセキュリティ境界ではなく顧客識別のためのログ相関キーなので、衝突の影響は「別顧客のログがまれに同じhashに重なる = 相関精度の劣化」に留まります
これはハッシュ関数の「同じ入力には同じハッシュ値が返る」という性質を、「両端で同じロジックを通せば検索が成立する」という形で再利用したものです。セキュリティとデバッグ利便性のトレードオフをぐっと圧縮できる。
そして当然ですが、この仕組みは「アプリログ層」だけの話で、BQ側はまた別のPolicy Tagによる列レベルアクセス制御で守られている(上の表の1〜2行目)、という多層構造になっています。
この「IDで受けて内部で解決・hash化する」形が効いているのは、平文emailがMCPサーバーという信頼境界を一度も越えない点です。よくある楽な実装(AIに生emailを渡してツールで検索させる)だと、Lokiの中をどれだけ守っても検索入力でモデルに平文が渡ってしまう。「ベンダー規約で外に出ないから大丈夫」と言うこともできますが、それは規約依存で、監査の目には弱い。IDで受けて内部でhash化すれば、規約に頼らず構造的に平文を渡さない。冒頭で「PII対策は信頼境界の再設計として必須」と書いたのは、まさにこういう設計判断のことです。
余談ですが、この設計を詰める時にAIに相談したら「管理画面を作って人間が手動でhash化する機能を用意するのが安全では」と提案されました。たしかにPIIをモデルに渡さない一つの解ではあるのですが、それだと自動運用に載らない(人間が毎回介在しないと調査が始まらない)。cortexは「気づく前に直る」自動修復まで含めて回すのが前提なので、人手を挟む解は採れない。「IDで受けてMCP内部でhash化」に辿り着いたのはこの制約からで、どういう解が許容されるかを決めるのは結局こちら側の設計判断でした。
統合面 ── 「人間 = Web、AI = MCP」で同じ裏側を共有する
3つのbackend(Prometheus / BQ / Loki)にObservable情報を流し込み、PIIもちゃんと守る形にした。次の問題は、誰がどう引くか。ここでよくある罠は、「人間向けのダッシュボード集計」と「AI向けのデータ提供」を別々に作ってしまうことです。そうすると:
- 同じ問いに対して2つの集計実装を抱える
- 数字が微妙にずれ始める
- どっちが正なのか分からなくなる
- AI用の集計の更新と人間用の集計の更新が非同期になる
cortexはここを「同じObservable基盤を共有して、消費者向けインターフェースだけ分ける」という設計にしています。

人間側: AI運用ポータル
社内向けにAI運用ポータル (内部呼称: PI Lab)があり、ここに観測対象別のダッシュボードが集約されています:
- Claude Code利用量 (設計編で見せたcc-usage画面)
- MCPツール利用量 (server別 / tool別 / user別 / team別)
- インフラコスト (Gemini / GCP / AWS / GitHubを1画面で)
- アラート状況、デプロイ履歴、等々
例えばMCP利用ダッシュボードは実物だとこんな感じです:

過去30日で service-product-graph が37,458 calls (うちエラー7,024)、gws が19,339 calls、db-graph が17,037 calls ── という形で、どのMCPがどれだけ使われているか、どこで失敗が出てるかが毎日眺められる状態になっています。なおエラー率が高めに見えるserverがあるのは、typed error (= 期待される拒否、例:権限なし)もエラーカウントに含まれている都合で、解釈は別途必要です。前回の連載(code-graph deep dive後編)で「annotation graphのMCPが約50,000 calls / 73 usersに使われている」と書いた数字も、ここから引いています。
これらのReact側のページは、内部API経由でBQ / Prometheus / Lokiを引いて表示する構造。集計ロジックはAPI側に集約されています。
AI側: MCP
同じデータソースをAIエージェントが叩く時は、用途別のMCPを経由します:
- Grafana MCP ── Loki / Mimir / Prometheus / TempoにLogQL / PromQLでクエリを投げる。AIエージェント側が「先週、サービスXでerrorが一番多かった時間帯は?」のような自然言語の問いをLogQL / PromQLに落としてMCP経由で投げる、という分担になる
-
BQ MCP (cortex-product-graph経由) ──
claude_usage.claude_usage/cortex.mcp_tool_callsをSQLで引く
ここの設計のミソは、人間ダッシュボードとAI MCPが同じbackendを共有している点です。「AI用の集計テーブル」と「人間用の集計テーブル」を分けない。Observable基盤は1つだけ作って、そこに対する消費者ごとのインターフェース層 (Webダッシュボード / MCP)を別に提供する、という形。
DDDの語彙で言えば、MCPもWebダッシュボードもどちらもプレゼンテーション層であって、同じドメイン(= Observable基盤)に対する別の入出力チャネルに過ぎない、という整理になります。「MCPは何か特別な仕組み」と捉えるより、ただのpresentation layerの一形態として位置付けたほうが、重複実装を避ける判断がブレません。
これがあるからこそ、「観測スタックがAIから見えている」が成立しています。Observable基盤を作っても、AI向けのプレゼンテーション層(= MCP)が無ければAIから引けない、という意味で、MCPは「AIに渡す」を成立させる必須ピースです。
自動修復の本当の駆動源
ここまで設計した観測スタックを「単なる見るための画面」で終わらせない層が自動修復です。これはAI Harness連載Part 4で全体は書いたので詳細は省きますが、観測スタック側から見ると、起点と末端は明確です。

具体的な流れ:
- 検知:本番アラート / CI失敗がLoki LogQL alertで発火
- 配送: event-relay (社内webhookハブ)にPOST
- 起動: auto-review bot (= Claude Codeを背負ったエージェント)が起動
- コンテキスト収集: botがGrafana MCPでfull logを取得、Product Graph MCPで関連PR / commit / コードを辿る
- 修正提案:修正PRを起票
- 検証: CIが通ればbot自身がauto-merge、通らなければ別のbotが更にレビュー
つまり自動修復の起点は観測スタックが「何が壊れたか」を正しい形で渡せる状態にあるかどうか、です。errorとして認識される / stacktraceが残っている / 関連コード(PR / commit / graph)に辿れる ── このどれかが欠けていたら、自動修復は止まります(具体的な壊れ方は後述の残課題セクションで掘ります)。別の言い方をすると、
観測の質がAI自律運用の天井になる
これが実践編で一番伝えたい主張です。観測スタックは「監視するための仕組み」ではなく、「AIを動かすための入力」だと位置づけ直すと、設計判断の順位が変わります。
残課題 ── 「何をerrorとして扱うか」とstacktrace設計こそ命
最後に、ここまで作っても残っている一番大きな課題を素直に書きます。
観測スタックをいくらObservableにしても、そもそも何をerrorとして扱うか、そのときstacktraceが残っているかの設計が崩れていると全部無駄になります。これはAI Harness連載Part 2でもcortex内部のナレッジグラフの文脈で触れた話ですが、同じ問題が観測スタック側でも本筋にいます。
具体的にどう崩れるか:
-
try ~ catchで握り潰してログすら出ない → 観測スタックに何も残らない - catchではログしているが、
console.log相当のinfoレベルで出していて、errorと認識されない - errorとして出しているが、
error.messageだけ書いてstacktraceを出していない → 原因コードに辿り着けない - そもそも非同期エラーがunhandledで落ちている
これらはすべて、観測スタックの問題ではなく、観測の入口を作るコード側の問題です。観測スタックがどれだけ完成度高くても、入口の蛇口が崩れていればそこから何も流れてこない。
現状の対策は3層に分かれていて、どれも完璧ではありません:
-
lint(静的検査) ──
no-silent-catchルールが空catchや.catch(() => null)系の握り潰しを禁止。ただし「catch内で何か関数呼び出しさえあれば許容」という構造なので、「logger.info(err.message)でinfoレベルに格落としする」「error.messageだけ拾ってstacktraceは捨てる」のような壊れ方は静的に拾えない -
ガイドラインドキュメント ── 「
serializeError(error)を通してstacktraceを構造化フィールドに格納する」「logger.error(err.message)でstackを捨てるのはMajor違反」等は社内guidelinesに明記。ただし静的検査できず、人間 / AIレビュー依存 - AI auto-review ── PRの自動レビューbotはTestカバレッジの観点で「エラーケースをテストしているか」を見るが、observabilityに特化したチェック項目を持たないので、stacktrace設計の質をsystematicには拾えない
つまり「ガイドラインはある、lintで一部は拾える、AIレビューも一応みる、でも完璧じゃない」が正直な状態です。本物のギャップは、新規コードが書かれる時点で「ここはerrorとして扱う / stacktraceを残す」をAIが能動的に提案・補完するハーネスが組めていないこと。観測の入口の設計をAIが前のめりに保証してくれる仕掛けまでは整っていません。
「観測スタックは整った、だが観測対象の設計自体は人間が頑張っている」── ここが現状の正直な絵です。ここをハーネス化するのが次のステップになります。
閉じ ── 静的編 + 動的編は揃った、ただし合流は次の宿題
code-graph連載で書いた「静的解析グラフをAIから引ける形にする」が、コードの構造を事実として渡す話だったとすると、今回の前後編は本番でいま起きていることを事実として渡す話でした。
| 形 | 何を渡すか | |
|---|---|---|
| 静的編(code-graph + db-graph + annotation graph) | 3グラフ並列接続 + SAME_ENTITY | コードと意味 |
| 動的編(前編 + 本記事) | Prometheus / BQ / Loki + MCP | 本番の挙動とコスト |
ただし正直に書いておくと、この2つは現状ではまだ別々に存在しているだけです。cortexが標榜する「AIには推論させない、事実として渡す」を本当の完成形に持っていくには、静的グラフに動的データを流し込んで一体化するステップが残っています。これはcode-graph連載後編で残課題として挙げた「動的解析の不在」とまったく同じ問題で、「あのedgeが本番でどれくらい使われているか」を静的グラフのノード上に乗せる ── ここまでいって初めて『事実として渡す』が完成形になります。
そしてこの静的編・動的編の上に自動修復が乗ることで「AIが自律的に運用する」までは成立していますが、静的編と動的編の合流は次の連載の宿題です。
最後に、観測スタックそのものより観測対象(=何をerrorとして扱うか、stacktrace残すか)の設計こそ命、という話。ハーネス化の次の宿題はここになります。
長文をお読みいただきありがとうございました。
私がCTOをしている株式会社エアークローゼットでは、AIと共に新しい開発体験を作り上げていくエンジニアを募集しています。興味のある方は、ぜひエンジニア採用サイトエアクロクエストをご覧ください!
Discussion
Vinicius Pereira さん(via dev.to · 原文):
両側HMACは、plaintextを外に出さないための構造としては正しいと思います。ただ、これは何かを「取り除く」のではなく「移動させる」仕組みだ、という点に名前を付けておきたいです。決定的ハッシュは安定した仮名(pseudonym)として働きます。同じemailなら必ず同じ値になる——それが検索を成立させている一方で、クエリ権限さえあれば、そのトークンを手がかりに一人分の履歴をまるごと再構築できてしまう余地も残します。plaintextの流出は塞がれていますが、リンク可能性(linkability)は塞がれていません。
さらに検索側は、いわばオンラインのオラクルになっています。任意の入力をハッシュして一致を探す仕組みなので、クエリできる立場の人なら、当たりを付けたemailがログに存在するかどうかを、1回1推測ずつ確かめられてしまいます。HMACは保存済みハッシュへのオフライン総当たりは防ぎますが、検索エンドポイントのほうは「そのemailが含まれているか」という問いにタダで答えてしまうわけです。どちらもこの設計を壊すものではなく、設計の隣に置くべき脅威モデルの話です。境界はplaintextに対しては本物ですが、相関(correlation)や存在確認(confirmation)まではカバーしていません。emailのように取り得る値の範囲が既知のフィールドでは、まさにそのギャップが再識別(re-identification)の入り口になります。
自分の返信(via dev.to · 原文):
どちらも正しくて、どちらも脅威モデルにきちんと名前を付けておく価値があります。ただ、これらはこの設計が引いている境界の「向こう側」に着地する話なんです。なので境界そのものをはっきりさせておきます——記事では、そこを暗黙にしすぎていました。
ここで想定している検索者は、そもそもPIIにアクセスできる人です。サポートやエンジニアリングは、DB上でその人のデータをすでに見られます。それが彼らの仕事だからです。この設計が取り除くのは、彼らの相関や存在確認の能力ではありません(それはもとから持っています)。取り除くのは、ログ調査のためにplaintextのPIIをモデルに通す必要性のほうです。境界は「AIがplaintextを見るかどうか」であって、「権限のある人間が相関できるかどうか」ではないんです。だから、あなたが挙げたリンク可能性(1つの安定した仮名から履歴を復元できる)も、オンラインのオラクル(クエリでの存在確認)も、どちらも実在しますが、それらは検索者がDBアクセスによってもともと握っている能力です。ハッシュがそれを新たに与えるわけではなく、同じ権限を持つ人がemailをモデルに渡さずにログ側の作業を済ませられるようにする、それだけなんです。
あなたのフレーミングが私の議論を鋭くしてくれるのはここです。クエリアクセスはあるけれどDBアクセスはない人にとっては、その2つのギャップは本物の権限昇格(escalation)になります。まさにそこが守るべきケースです。検索側は監査可能に保っています(resolveもクエリも全部ログに残す)。これは「仕事として使っている権限者」と「オラクルを釣っている人」を、事後にでも区別できるようにするためです。これは予防(prevention)ではなく検知(detection)のコントロールで、emailのように値域が既知のフィールドでは存在確認こそが鋭い刃だ、というのはその通りです。plaintextの境界の隣にそれも明記するのが、図を正直に描くやり方だと思います。境界が実際以上をカバーしているふりをするより、両方の線を引いておきたいです。
Vinicius Pereira さん(via dev.to · 原文):
境界については同意です。監査証跡も、DBを持たない人間のケースには正しい判断だと思います。ただ1か所だけ薄くなるのが、まさにあなたの記事が向かっている方向です。検知が効くのは、オラクルを釣る人間が「異常」に見えるからですよね。でも、self-healingのためにクエリを大量に投げるエージェントは、設計上それを当たり前にやります。だから確認プローブは、自分自身の通常トラフィックの中に紛れてしまう。本来フラグを立てたい異常のほうが、ベースラインになってしまうわけです。そのペルソナに対しては、私なら監査ログに検索側の予防コントロールを組み合わせます。lookupを案件のコンテキストにスコープするとか、セッションあたりの識別子数に上限を設けるとかして、権限のあるクエリでも「ログするだけ」ではなく「上限で縛る」ようにする。良いやり取りですね。
自分の返信(via dev.to · 原文):
検知がまさにこのシリーズの向かう先で薄くなる、という指摘はその通りで、しかも一番鋭い言い方だと思います。クエリする主体がself-healingを大量にこなすエージェントになった時点で、「異常」はもう使えるシグナルではなくなります。量そのものがベースラインだからです。
ただ、フレーミングについて1つだけ押し返させてください。エージェントが確認プローブを撃つのは、実際にはエージェントの悪意ではありません。ほぼ必ず、prompt injectionされてそうさせられたエージェントです。そしてinjectionが前提に入った瞬間、オラクルは下流の小さな症状の1つにすぎなくなります。同じ侵害された経路は、ハッシュlookupだけでなく、到達できるあらゆるツールを通じて情報を持ち出せるからです。なので私は、予防コントロールをinjection層に置きます(ツール権限のスコープ、入力の来歴(provenance)、出力レビュー)。PII検索側に識別子の数を数えさせる方向ではありません。injectionを未対処のままオラクルに上限をかけるのは、窓を1枚だけ閉めるようなものだと感じます。
とはいえ、あなたのセッション単位の上限は多層防御(defense-in-depth)として正しい一手ですし、見合うかどうかはプロダクト判断です。1回の再識別が致命的なドメイン(医療、金融)なら、injectionが突破された後でも保つ検索側の第二の壁は、コストに見合います。検索者もエージェントも信頼境界の内側にいる社内プラットフォームなら、私はまずその予算をinjection面の強化に回します。本当に良いやり取りです——こういう脅威モデルの往復は、なかなかできるものではないので。
Vinicius Pereira さん(via dev.to · 原文):
injectionが根本で、オラクルはその症状の1つ、だからまずinjection面を固める——そこは完全に同意です。それと並べて残しておきたい点が1つだけあります。なぜ検索側の壁が置くに値するのか、という理由です。ツールスコープも、来歴も、出力レビューも、すべて確率的(probabilistic)なコントロールで、偽陰性率は未知ですし、攻撃者はそれを下げにかかれます。一方でセッション単位の上限は、injectionが巧妙になっても保証が揺らがない唯一のコントロールです。なのでプロダクト判断は、ドメインでというより、その観点で枠づけたいところです——「確率的な保証が破れることが許容できない場所」ならどこでも決定的な壁を置く、と。それは医療と金融だけよりも、ずっと広い範囲になることが多いはずです。本当に良いやり取りでした。
自分の返信(via dev.to · 原文):
その捉え直しは、私もそのまま採用します——「確率的な保証が破れることが許容できない場所には、必ず決定的な壁を置く」。ドメインで線を引くよりきれいですし、セキュリティの外にも一般化できます。この件をあらゆる層で突いてくれて、本当に感謝しています。ここでできた中でも、屈指のやり取りでした。Niteróiの砂浜、楽しんでください。
Mike Czerwinski さん(via dev.to · 原文):
MCPサーバーの中でresolveする設計は、構造として正しい選択だと思います。そして、管理画面という代替案を却下した理由付けは、記事の中でいちばん誠実な部分です。たいていの記事は、human-in-the-loop版を検討したうえで、あえて自律性を選んだ、とは書きませんから。
birthday boundの計算は、今の顧客規模に対しては確かに合っています。ただそれは「今日のサイズ」に対する計算であって、信頼境界の実際の寿命に対する計算ではありません。2,000万レコードで衝突確率50%、と言われれば十分先の話に聞こえます——会社が5年成長して、その定数を誰も見直さないままなら別ですが。現在のスケールでは緩やかに劣化するだけの相関キーも、将来のスケールでは静かに劣化しうるんです。設計の中に、前提を再チェックする仕組みが何もないからです。書かれた時点では正しかったけれど、今も正しいかを誰も見ていない、という状態です。
もう1つ、触れられていなかった点があります。HMACのキーローテーション時に何が起きるか、です。もしキーをローテートする必要が出てきたら、(a)過去のログすべてが新しいログと相関できなくなる(「顧客Aの履歴を引く」という、6層設計まるごとで守ろうとした能力の、静かな喪失)か、(b)読み取り時に再ハッシュするために古いキーをどこかに保持し続けることになり、それは列挙耐性の議論が依存している「キーがスタックの外にある」境界を、また開いてしまいます。実際にどちらを取るのかを明記する価値があると思います。今の設計は、キーが決してローテートしない前提で書かれているように読めますし、「決してローテートしない」というのは、秘密であり続けることが仕事の秘密にとっては、かなり強い前提だからです。
自分の返信(via dev.to · 原文):
どちらも刺さりますし、2つ目(ローテーション)は記事の本当の穴です。書かれたままの設計は、確かにキーが無期限であるかのように読めますし、「決してローテートしない」が秘密にとって悪い前提だ、というのはその通りです。
あなたの両方の指摘をまとめて解くのが、私が明示しそこねた1点です——ログは永遠には保持されません。保持期間(retention window)で必ずローテートアウトされます。そこに乗っかれば、きれいな設計は「ハッシュとキーを、それらが仕えるログと同じ時計に載せる」ことになります。
具体的には、キーの寿命を、そのキーでハッシュしたログの保持期間に結びつけます。ローテートするときは、そのキーでハッシュしたログがまだ生きている間だけ古いキーを残し、読み取り時にはそのログの時代をカバーするキーで再ハッシュします。そのログが期限切れになった瞬間、古いキーも一緒に削除します。これであなたのジレンマの両方の角を避けられます——相関はログの寿命の内側では生き残りますし(カバーするキーがまだ残っているので)、キーがスタックの外に溜まったり永久に生き続けたりもしません(自分のログと一緒に消えるので)。
副次的に、1つ目の指摘も和らぎます。birthday boundの計算対象が、際限なく増える母集団ではなく、保持期間で頭打ちになる母集団になるからです。衝突の前提は「誰も見直さない定数」ではなく「保持期間の関数」になります。それは、すでに誰かが日常的に見ている数字です。
正直に言うと、これは「設計」であって「現状」ではありません。まだかなり初期で、キーはまだローテートしませんし、retentionとの結合も配線されていません。なので今日の実態は、まさにあなたが指摘した無期限キー版です。ローテーションが必要になる前に作らなければならないものを、あなたはピンポイントで言い当てました。どちらも良い指摘です。
Mike Czerwinski さん(via dev.to · 原文):
キーを保持期間に結びつけるのは、無期限キー問題へのきれいな修正だと思います。そして、それによって、記事の当初のゴールが次に答えるべきトレードオフが浮かび上がります。各時代(era)がそれぞれのキーを持つなら、同じemailが時代Nと時代N+1で別の値にハッシュされます。単一ログのlookupには問題ありませんが、6層設計が守ろうとした、まさにその能力を壊します——「顧客Aの履歴をまるごと引く」ことです。2つの時代にまたがる検索は、キーの境界を越えて同一人物を認識できません。生きている全時代分のキーに対して、全候補を再ハッシュして個別に突き合わせない限りは。そうなると、ハッシュlookupがまたファンアウトに逆戻りします。
なので正直な問いはこうです。時代をまたぐ相関は、意図的に諦めたものなのか(検索はつねに「この保持期間の内側」でしか答えない——それで十分かもしれません。たいていの調査は5年前まで遡りませんから)。それとも、ローテーションを生き延びるために存在する、第二の・より長命なインデックスキーがあるのか。後者なら、そのキーは、あなたがログハッシュキーで今解いたばかりの無期限キー問題を、1層ずらして引き継ぐことになります。どちらなのかを明記する価値があります。今の設計は、correlate-forever(永遠に相関)をcorrelate-within-a-window(ウィンドウ内で相関)へ静かにすり替えたように読めて、しかもそれを口に出していないからです。
自分の返信(via dev.to · 原文):
答えは「保持期間のウィンドウの内側でだけ相関する」ほうです。しかもそれは、静かな格下げではなく正しい境界なんだ、と主張したいです。別の判断としてわざわざ持ち込むのではなく、同じ保持期間の時計から自然に導かれるものだからです。
具体的には、第二の長命なインデックスキーは作りません。それだと、あなたの言うとおり無期限キー問題を1層ずらすだけです。キーの寿命を保持期間と同じに設定します。すると生きているキーの数は、最大でも2つに抑えられます。まだ生きているログはどれも、現在のキーか1つ前のキーのどちらかでハッシュされているからです——1ローテーションより古いものは、すでにLokiから期限切れになっています。なのでlookupは、入力を最大2つのキーでハッシュして結果をORするだけです。あなたの言うファンアウトは実在しますが、2倍で固定であって、際限のない再ハッシュではありません。それをエージェント向けにはMCPツール、人間向けにはAPI層の裏に隠せば、呼び出し側は結局「顧客Aの履歴」を1回尋ねるだけで済みます。
なのでこれは、correlate-forever(永遠に相関)でも、correlate-within-one-key(1キー内だけで相関)でもありません。correlate-as-far-back-as-the-logs-still-exist(ログがまだ残っている限り遡って相関)です。それはどのみち正直な上限です——すでにLokiからローテートアウトされた履歴は、キーがあろうがなかろうが相関できません。保持期間は、もともとlookupがどこまで遡れるかの本当の限界でした。キーの寿命をそこに結びつけるのは、暗号の境界を、すでに存在していた境界に一致させるだけで、構造上、生きているキーの数を2つに保ちます。ただ、声に出して書くべきだ、というのはその通りです——「相関は設計上retentionで境界づけられる」という一文が、記事に欠けています。
Mike Czerwinski さん(via dev.to · 原文):
2倍で固定、しかも別の判断ではなく同じ保持期間の時計から導かれる——この版が、実際に問題を閉じると思います。correlate-as-far-back-as-the-logs-still-existが正直な上限ですし、暗号をそこに結びつけるのは、前提を1つ足すのではなく1つ減らす動きです。世に出す前に、1つだけエッジケースを明記する価値があります。ちょうどローテーションの境界で、ローテーションの1秒前に書かれ、1秒後に読まれるログが、すでに退役したキーを必要とする瞬間はあるのか。それとも、retentionがローテーションより十分な余裕をもって遅れるので、実際には決して起きないのか。ローテーションとretentionベースの削除が互いにアトミックでないと、境目にあるログの薄い一片について、一瞬だけ3つ目のキーが必要になったり、逆にどのキーもカバーしない状態になったりしえます。ローテーション周期が保持期間よりずっと短ければ、おそらく非問題です。ただ、インシデントのさなかに誰かが再発見せずに済むよう、設計ドキュメントに一文の保証を書いておく価値はあります。良いやり取りでした。無期限キーの問いがまた出てきたときに、リンクして戻ってくる価値のあるスレッドです。
自分の返信(via dev.to · 原文):
まさに境目の話ですね。一文の保証はこうです——0キーのギャップに落ちうるログは、すでに保持の端にあるもの、つまりどのみち期限切れになりつつあるものだけです。ローテーションの1秒前に書かれ、1秒後に読まれるログは、定義上、まさにその瞬間に保持期間の外へ出ていくログです。だから「キーが見つからない」と「保持期間を過ぎている」は、同じケースに畳まれます。削除がローテーションより先に走らない限り(retentionはローテーションに遅れることはあっても、先んじることはない)、その一片には、すでに去りつつあったログしか含まれません。それで保証は「生きているログが、カバーするキーを欠くことは決してない」になります。あなたの言うとおり、それが設計ドキュメントに書くべき一文で、これなら誰もインシデントの最中に再発見せずに済みます。
このスレッドは本当に楽しかったです。私が暗黙のままにして、いちばん後悔したはずの2点——retentionとの結合と、ローテーションの境目——をあなたが突いてくれて、そのおかげでどちらも記事の中で今は鋭くなっています。ありがとう、Mike。