ブログエッセイ

決して削除しないログで「忘れられる権利」を実現する

イベントソーシングはすべての事実を永遠に残し、GDPR は本人に消去を求める権利を与えます。クリプトシュレッディングがその両方をどう両立させるか、何を取りこぼすか、そしてイベントストアが取る三つのアプローチ。

10 分で読めます

英語の原文からの機械翻訳です。 原文を読む · 誤りを報告する

ライブデモで動きを見る

遅かれ早かれ、こんなメールが届きます。私について保有しているデータをすべて削除してください。状態をテーブルに保存するシステムなら、DELETE 文を書いてバックアップを確認する午後の作業で済みます。イベントソーシングのシステムなら、存在意義が揺らぎます。イベントストアの要点は、そこに入ったものは決して変更も削除もされないことです。だからこそ再構築は正確で、監査証跡は完全で、バグは再現できます。ところが法律、つまり GDPR 第17条は、ある一人の人に関する部分は消さなければならないと言います。

チームが取りがちな答えは二つあり、どちらも間違っています。一つ目は履歴を書き換えること。その人のイベントを探して、その場で編集します。これはログが約束していたことをすべて破り、バックアップにも、コンシューマーがすでに持っていったコピーにも届かず、追記のみのストアを「ときどき追記のみではない」ストアに変えてしまいます。二つ目は、ログは特別だから法律は適用されないと決めてしまうこと。適用されます。

三つ目の答えがあります。そしてそれは、多くのチームが飛ばしてしまう問いから始まります。

そのフィールドは、そもそもイベントに入れるべきか?

イベントは決定を記録するものです。OrderPlaced は、顧客 c-42 が商品を二つ注文したという事実です。その人の名前、住所、電話番号はこの事実の一部ではありません。配送業者が必要とし、請求書に印字される詳細情報です。「コマンドに入っていたから」とそれらをイベントにコピーするコマンドハンドラーは、システムの別の部分が持つべきデータの所有者をイベントにしてしまっています。

ですから、最初の道具は暗号ではなく境界です。イベントは ID だけを持ち、詳細は削除できる実装を持つポートの向こう側に置きます。その人を消すことは、そのストアに対する DELETE です。Mathias Verraes はこれを forgettable payloads と呼んでいます。過去を説明するのにそのデータが要らない限りはこれで十分で、しかもデータをログのすべてのコピーから遠ざけられます。

けれども、ときにはデータこそが決定そのものです。AddressChanged は引っ越し先の住所を記録します。EmailVerified はどのメールアドレスが検証されたかを記録します。ComplaintFiled は顧客が書いた内容を記録します。データを取り除けば、イベントは意味を失います。こうしたフィールドはログに残り、それを忘れる仕組みはログそのものの上で、すべてのバックアップに対して、1 バイトも書き換えずに機能しなければなりません。

鍵を捨てる

それがクリプトシュレッディングです。一人ひとりに鍵があり、その人のフィールドはその鍵で暗号化して保存されます。その人を忘れるとは、鍵を削除することです。イベントはまったく同じ場所に残り、中身はそれ以降ただのノイズになります。稼働中のデータベースでも、それ以前に取られたすべてのコピーでも。

ある顧客のストリームの前後を見てみましょう。

ポジション イベント 忘れる前のメール 忘れた後のメール
000 118 CustomerRegistered ana@example.com null
000 342 EmailChanged ana@work.example null
000 977 OrderPlaced (個人フィールドなし) (個人フィールドなし)
001 204 PersonalDataForgotten

最後の行より上は何も編集されていません。ポジションも、イベントの型も同じです。注文は今までどおり畳み込まれ、リードモデルも今までどおり再構築されます。消えたのは鍵だけです。

Bounda では、フィールドに印を付けるのはイベントのスキーマに一語足すだけです。

// app/domain/customer/customer-registered.ts
export const payload = ({ z, personal }: Event.PayloadArgs) =>
  z.object({ email: personal(z.email()), plan: z.enum(["free", "pro"]) });

そして誰かを忘れるのは、その人の ID を渡す呼び出し一回です(ここでは顧客 c-42)。

await app.forget("c-42");

肝心なのは null

私がいちばん大事にしている細部は型です。イベントを書く側は string を渡します。それを読む側、つまり畳み込み、プロジェクション、ポリシーは string | null を受け取ります。

この null は好みの問題ではありません。メールアドレスが必ずあると思い込んでいたすべての場所へ、コンパイラーが連れて行ってくれるのです。NOT NULL の列にコピーしていたプロジェクション、小文字に変換していた畳み込み、ウェルカムメールを送るポリシー。忘れられた人のイベントも、畳み込まれ、投影され、再生され続けます。データがあった場所に穴が空いているだけです。そしてあなたのコードは、最初の削除依頼が届く前に、その穴が何を意味するかを決めておくことを強いられます。後からではありません。

空の値を返して、既存のコードを変更なしで動かし続けるのも妥当な選択です。Bounda はその反対を選びました。個人フィールドを読むすべての場所で、値がないときに何を意味するかを決める必要があります。

難しいのはコピーのほう

鍵を削除すればイベントは読めなくなります。しかし、イベントから作られたものには何もしません。そしてイベントソーシングのシステムでは、イベントから多くのものが作られます。

  • リードモデル。 顧客ディレクトリはメールアドレスを行に投影しました。
  • スナップショット。 集約の畳み込まれた状態。長いストリームを速く読み込むため、平文でキャッシュされています。
  • 記憶された応答。 冪等性キー付きで送られたコマンドは自分の応答を保存し、拒否のメッセージには判断の元になった状態が引用されているかもしれません。
  • 予約された処理。 来週のリマインダーがキューに入っていて、ペイロードにメールアドレスがあります。
  • デッドレター。 あきらめたコマンドが、ペイロードごと運用者のために残されています。
  • 外の世界。 ウェルカムメールを送ったとき、メール配信サービスは連絡先を受け取りました。

Bounda では、forget が鍵を削除するのと同じトランザクションの中で、ランタイム自身のコピーを処理します。その人のフィールドを含むスナップショットと記憶された応答を削除し、それを運ぶ予約済みコマンドを取り消し、そのデッドレターを削除します。そのうえで、その人自身のストリームに PersonalDataForgotten というイベントをもう一つ追加します。

このイベントこそ、アプリの残りの部分が必要とするものです。個人フィールドを投影するリードモデルは「忘れる」ことも投影しなければならず、そうするまでは Bounda が生成する型がコンパイルに失敗し、どのリードモデルにどのファイルを足せばよいかを教えてくれます。

// app/read/customer-directory/projections/bounda/personal-data-forgotten.ts
export const project = async ({ event, table }: Projection.Args) => {
  await table.delete({ customerId: event.payload.personId });
};

そして同じイベントに対するポリシーが、外の世界に知らせる場所です。メール配信サービスの連絡先を消し、検索インデックスからプロフィールを外します。

忘れるための三つの方法

イベントストアがたどり着いた答えは三つあり、それぞれ合うシステムが違います。2026年11月時点では次のとおりです。

  • イベントを書き換える。 Marten はマスキングを行います。イベント型ごとの規則がフィールドの変え方を決め、それを実行すると保存済みの行が更新されます。鍵は不要で、読み込み側にも新しいものは要りません。
  • ストリームを削除する。 KurrentDB はストリーム全体を削除または期限切れにし、後で scavenge がディスクから取り除きます。一人につき一つのストリームを持ち、その人のすべてが一か所にある設計に向いています。
  • 鍵を捨てる。 Axon は Data Protection モジュールで印の付いたフィールドを暗号化し、鍵を削除することで忘れます。忘れられた String はデフォルトで "" として読まれるので、既存のコードは変更なしで動き続けます。Bounda も同じことを標準機能として行います。
書き換える ストリームを削除する 鍵を捨てる
履歴 その場で変わる ストリームごと失われる そのまま残る
以前に取ったコピー データが残る データが残る 暗号文が残り、鍵があるときだけ読める
求められること イベント型ごとのマスキング規則 一人につき一つのストリーム 一人につき一つの鍵と、その保管場所

三つ目の方法について、Bounda は三つの選択をしており、それぞれに代償があります。鍵はイベントと同じデータベースにあるので、バックアップと復元の整合が保たれ、テストも簡単ですが、すべてのバックアップに鍵も入ります。忘れられた値はデータでも型でも null なので、コンパイラーがすべての読み手を見つけてくれますが、すべての読み手がそれを扱う必要があります。そして「忘れる」ことはイベントなので、リードモデルとポリシーはそれに反応できます。

解決しないこと

フレームワークが提供できるのは仕組みです。法令を守るのは、あくまでデータを扱う側の責任です。

  • バックアップは鍵を保持します。 鍵はイベントの隣にあるので、データベースのバックアップは、保管されている限りデータのコピーです。
  • ログは出力したものを保持します。 ハンドラーがペイロードをログに書けば、そこに平文で残ります。
  • システムの外に出たものは外に残ります。 ポリシーが消しに行かない限り。
  • 印のないフィールドは平文です。 印はイベントごと、フィールドごとです。付け忘れれば、永遠に読めてしまいます。
  • 鍵のない暗号文がなお個人データにあたるかどうかは法律の問題です。

実際に見てみる

ライブデモは、訪問者ごとに一つの Durable Object で Bounda を動かしている劇場のチケット窓口です。席を買ってから、Forget me というシーンを開いてください。あなたのメールアドレスは、どれほど古いイベントからも、売上台帳からも消えます。予約はリードモデルから外れ、あなた宛てに予約されていたリマインダーは取り消されます。ストリームはすべてのポジションを保ったままです。

残りは個人データのガイド(英語)にあります。ポートで十分な場合とそうでない場合については、Forgetting in an immutable log(英語)が詳しく扱っています。