みなさまこんにちは!エアークローゼットで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 (= 期待される拒否、例
これらの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 / コードを辿る
- 修正提案
- 検証: 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残すか)の設計こそ命、という話。ハーネス化の次の宿題はここになります。
長文をお読みいただきありがとうございました。
comments (16)
the entry point design gap is where this lands for me. the stack is solid but the faucet is still owned by humans and lint, and that's the actual ceiling for autonomous operation. pattern i've seen work: type the log call site directly. if logger.error requires an Error object and not a string, dropped stacktraces stop being a guideline violation and become a type error the compiler catches before CI. the lint rule enforces the type, not the convention. is that the direction you're thinking for the harness, or does the problem look different from where you're sitting?
Yeah, that's the direction, and I already run a weaker version of it. Lint doesn't warn here, it blocks. A catch that swallows without logging fails the build, and so does a log call that drops the stacktrace. Typing the call site so logger.error takes an Error and not a string is the stronger form of the same move, and I agree it's better. It moves the same block from CI lint to the compiler, which is strictly earlier and harder to skip past. But that closes the half that was always mechanizable. "The stacktrace got dropped" is a correctness property, and correctness properties belong in the type system. The ceiling I meant sits one level up, and types don't reach it. It's deciding whether a condition is an error at all. Concrete case from this week. A scheduled importer walks a list of articles and one returns 404 because it isn't published yet. Is that an error? I decided no, it's an expected skip, log a warning and exit 0. A rate-limit exhaustion on the same loop is an error, exit 1. Nothing in the type system tells you which is which. logger.error taking an Error forces me to log a real Error once I've decided it's an error, but the decision that a 404 here means "not ready" rather than "broken" is a claim about what the absence means in the business, and that claim is the faucet. So the framing I'd land on is that you push every mechanizable property down to the compiler precisely so the only thing left at the top is the irreducible classification call. Your typed-call-site move is how you clear the mechanizable layer. What stays human isn't "did we handle the error right," it's "is this even an error." That one doesn't compile.
Smart take on PII: hash at write and search time so AI can find what it needs without exposing data. Curious about collision handling and performance overhead in practice. Also loved the CI-to-PR self-healing angle—chef's kiss.
Thanks, really glad the self-healing angle landed. On both counts the honest answer is "bounded, so neither really bites." Collisions are birthday-bound against a population capped by the log retention window, not an ever-growing set, so the space stays comfortably clear at our scale. If that ever stops being true, the collision constant just becomes a retention knob someone's already watching. Performance is a non-issue in the same spirit. It's one hash on write and one on search, which is nothing next to the query and the model cost it sits beside. The expensive part of an investigation was never the hash.
The resolve-inside-the-MCP-server design is the right structural move, and the reasoning for rejecting the admin-screen alternative is the most honest part of the post, most writeups don't admit they considered the human-in-the-loop version and picked autonomy over it on purpose. The birthday-bound math checks out for the customer base you have today, but it's computed against today's size, not the trust boundary's actual lifetime. 20M records at 50% collision sounds comfortably far off, until the company grows for five years and nobody revisits the constant. A correlation key that degrades gracefully at current scale can still degrade silently at future scale, because nothing in the design re-checks the assumption, it was true when written and nobody's watching whether it's still true. The other thing I didn't see addressed: what happens on HMAC key rotation. If the key ever needs to rotate, either every historical log becomes uncorrelatable with new ones (a quiet loss of the "look up Customer A's history" capability you built the whole six-layer design to preserve), or the old key has to be retained somewhere to re-hash on read, which reopens exactly the key-outside-the-stack boundary the enumeration-resistance argument depends on. Worth stating which of those two you'd actually do, because right now the design reads as if the key never rotates, and "never rotates" is a strong assumption for a secret whose whole job is staying secret.
Both land, and the second one (rotation) is a real gap in the post, the design as written does read as if the key is immortal, and you're right that "never rotates" is a bad assumption for a secret. The thing that resolves both of your points is one I didn't make explicit: logs aren't kept forever. They rotate out on a retention window, and once you lean on that, the clean design is to put the hash and the key on the same clock as the logs they serve. Concretely: tie a key's lifetime to the retention window of the logs hashed under it. When you rotate, you keep the old key only as long as logs hashed with it are still alive, and re-hash on read against whichever key covers that log's era. The moment those logs age out, the old key is deleted with them. That dodges both horns of your dilemma, correlation survives within a log's lifetime because the covering key is still there, and the key never accumulates or lives forever outside the stack because it dies when its logs do. It also softens your first point as a side effect. The birthday-bound math is against a bounded population, not an ever-growing one, because retention caps how many records coexist. The collision assumption stops being a constant nobody revisits and becomes a function of the retention window, which is a number someone is already looking at. Full honesty: this is the design, not the current state. It's early enough that the key doesn't rotate yet and retention-coupling isn't wired in, so today it genuinely is the immortal-key version you called out. You named the exact thing that has to get built before rotation is ever needed. Good catch, both of them.
Tying the key to the retention window is the clean fix for the immortal-key problem, and I think it exposes a tradeoff the post's original goal has to answer next. If each era gets its own key, the same email hashes to a different value in era N and era N+1. That's fine for a single log lookup, but it breaks the exact capability the six-layer design was built to preserve: look up Customer A's whole history. A search spanning two eras can't recognize the same person across the key boundary without re-hashing every candidate against every live era key and matching independently, which turns a hash lookup back into a fan-out. So the honest question is whether cross-era correlation is a deliberate loss (search only ever answers within-this-retention-window, which may be fine, most investigations don't need five years back) or whether there's a second, longer-lived index key that exists precisely to survive rotation, in which case that key inherits the immortal-key problem you just solved for the log-hash key, just moved one layer over. Worth stating which one this is, because right now the design reads as having quietly traded correlate-forever for correlate-within-a-window without saying so out loud.
It's the window, and I'd argue the window is the right boundary rather than a quiet downgrade, because it falls out of the same retention clock instead of being a separate decision. Concretely, I wouldn't build a second long-lived index key, that just moves the immortal-key problem one layer over like you said. I'd set the key lifetime equal to the retention window, which caps the number of live keys at two: any log still alive was hashed under either the current key or the previous one, because anything older than one rotation has already aged out of Loki. So a lookup hashes the input under at most two keys and ORs the results. The fan-out you're describing is real but fixed at 2x, not an unbounded re-hash. Hide that behind the MCP tool for agents and the API layer for humans, and the caller still asks "Customer A's history" once. So it's not correlate-forever and it's not correlate-within-one-key. It's correlate-as-far-back-as-the-logs-still-exist, which is the honest ceiling anyway: you can't correlate history that's already been rotated out of Loki, key or no key. The retention window was always the real limit on how far back a lookup can see. Tying the key lifetime to it just makes the crypto boundary agree with the boundary that already existed, and keeps the live-key count at two by construction. You're right it should be stated out loud, though, "correlation is bounded by retention, by design" is the line the post is missing.
Fixed at 2x and derived from the same retention clock instead of a separate decision is the version that actually closes it, correlate-as-far-back-as-the-logs-still-exist is the honest ceiling and tying the crypto to it removes an assumption instead of adding one. Worth stating the one edge case before this ships: at the exact rotation boundary, is there a moment where a log written a second before rotation and read a second after needs a key that's already been retired, or does retention lag rotation by enough margin that this never actually happens in practice? If the rotation and the retention-based deletion aren't atomic with each other, you could briefly need a third key or have zero keys covering a thin sliver of logs right at the seam. Probably a non-issue if rotation cadence is much shorter than retention window, but worth the one-sentence guarantee in the design doc so nobody has to rediscover it during an incident. Good exchange, this is the kind of thread that's worth linking back to when the immortal-key question comes up again.
Right on the seam, and here's the one-sentence guarantee: the only logs that can fall into a zero-key gap are the ones already at the retention edge, i.e. the ones being aged out anyway. A log written a second before rotation and read a second after is, by definition, a log that's crossing out of the retention window at that same moment, so "can't find a key for it" and "it's past retention" collapse into the same case. As long as deletion never runs ahead of rotation (retention lags rotation, never leads it), the sliver only ever contains logs that were already leaving. That makes the guarantee "no live log is ever without a covering key," which is the line for the design doc, exactly as you said, so nobody rediscovers it mid-incident. This whole thread was a genuine pleasure. You pushed on the two things I'd have most regretted leaving implicit, retention-coupling and the rotation seam, and both are sharper in the post now because of it. Linking back here when the immortal-key question resurfaces is exactly right. Thanks, Mike.
The dual-sided HMAC is the right shape for keeping plaintext out, but I'd name what it moves rather than removes. A deterministic hash is a stable pseudonym: same email, same value, which is what makes search work and also what lets anyone with query access reconstruct one person's whole history under that token. Plaintext egress is closed; linkability is not. And the search side is an online oracle: it hashes arbitrary input and looks for a match, so anyone who can query can confirm whether an email they already suspect is in the logs, one guess at a time. HMAC stops offline brute force of the stored hash, but the search endpoint answers the membership question for free. Neither breaks your design, they just belong in the threat model beside it: the boundary is real for plaintext, and does not cover correlation or confirmation. For a known-domain field like email, that gap is where re-identification actually lands.
Both correct, and both worth naming explicitly in the threat model. But they land on the other side of the boundary this design draws, so let me make the boundary itself explicit, because I left it too implicit in the post. The searcher here is assumed to already have PII access. Support and engineering can already see this person's data in the DB; that's their job. What this design removes is not their ability to correlate or confirm, they already have it, it's the need to route plaintext PII through the model to do a log investigation. The boundary is "does the AI ever see the plaintext," not "can an authorized human correlate." So the linkability you describe (one stable pseudonym reconstructs a history) and the online oracle (confirm-by-query) are both real, but they're capabilities the searcher already holds by virtue of DB access. The hash doesn't grant them; it just lets the same authorized person do the log side without handing the email to the model. Where your framing sharpens mine: for someone who has query access but not DB access, those two gaps would be a genuine escalation, and that's exactly the case to guard. We keep the search side auditable (every resolve + query is logged) precisely so that "authorized human doing their job" and "someone fishing the oracle" are distinguishable after the fact. That's a detection control, not a prevention one, and you're right that for a known-domain field like email, confirmation is the sharp edge. Naming it beside the plaintext boundary is the honest way to draw the diagram, and I'd rather have both lines on it than pretend the boundary covers more than it does.
Agreed on the boundary, and the audit trail is the right call for the human-without-DB case. The one place it thins is the direction your post is driving toward. Detection works because a person fishing the oracle looks anomalous, but an agent doing self-healing queries at volume by design, so a confirmation probe hides inside its own normal traffic. The baseline is the anomaly you would otherwise flag. For that persona I would pair the audit log with a prevention control on the search side, scoping a lookup to a case context or capping distinct identifiers per session, so an authorized query is bounded and not just logged. Good exchange.
You're right that detection thins exactly where the series is heading, and that's the sharp version of it. Once the querying persona is an agent doing self-healing at volume, "anomalous" stops being a usable signal because volume is the baseline. Where I'd push on the framing: an agent firing a confirmation probe isn't really the agent's malice, it's almost always an agent that's been prompt-injected into doing it. And once injection is on the table, the oracle is a small downstream symptom, the same compromised path can exfiltrate through any tool it can reach, not just the hash lookup. So I'd put the prevention control at the injection layer (tool-permission scoping, input provenance, output review) rather than teaching the PII search side to count distinct identifiers. Capping the oracle while injection is unhandled feels like bolting one window shut. That said, your per-session bound is the right move as defense-in-depth, and whether it's worth it is a product call. For a domain where a single re-identification is catastrophic (health, finance), a second wall on the search side that holds even after injection succeeds is worth the cost. For an internal platform where the searcher and the agent are both inside the trust boundary, I'd spend that budget on hardening the injection surface first. Good exchange, genuinely, this is the kind of threat-model back-and-forth that's hard to get.
You're right that injection is the root and the oracle is one symptom, harden the injection surface first, I'm with you there. The one thing I'd keep beside that is why the search wall earns its place: tool-scoping, provenance, and output review are all probabilistic, their false-negative rate is unknown and the attacker gets to lower it. The per-session bound is the one control whose guarantee doesn't move when injection gets better. So I'd frame the product call less by domain and more by that, put the deterministic wall wherever a probabilistic guarantee failing is unacceptable, which is often wider than just health and finance. Good exchange, genuinely.
That reframe is the version I'll keep: put the deterministic wall wherever a probabilistic guarantee failing is unacceptable. Cleaner than drawing it by domain, and it generalizes past security. Thanks for pushing on every layer of this, genuinely one of the best exchanges I've had here. Enjoy the sand in Niterói.