Event-sourcing and GDPR

A few weeks ago, I completed a multiple-years-old side project we had started on this website called abaks.

As a reminder, abaks is a statement reconciliation tool, which relies on a simple event system defined as follows:

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE NoFieldSelectors #-}

module Spec
  ( main,
    AggregateId (..),
    Events,
    CommandHandler,
    applyCommand,
    EventSourceEffect,
    withEvents,
    listAggregateIds,
    aggregateIds,
    runCommand,
    runMemoryUnsafe,
  )
where

import Control.Monad (forM_, when)
import Data.Aeson
import Data.Aeson.Text (encodeToLazyText)
import Data.Function (on)
import Data.Kind
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.Lazy as TL
import Data.Time (ZonedTime)
import GHC.Generics
import Polysemy
import Polysemy.State
import Test.Hspec

-- * Entities

type CommandHandler a e = Events a -> Either e (Events a)

type Events a = [a]

-- * Use Cases

newtype AggregateId = AggregateId {getAggregateId :: Text}
  deriving stock (Eq, Ord, Show, Generic)
  deriving newtype (FromJSON, FromJSONKey, ToJSON, ToJSONKey)

data EventSourceEffect (eventType :: Type) (m :: Type -> Type) (a :: Type) where
  WithEvents :: AggregateId -> (Events eventType -> m (Events eventType, a)) -> EventSourceEffect eventType m a
  ListAggregateIds :: ([AggregateId] -> m a) -> EventSourceEffect eventType m a

makeSem ''EventSourceEffect

-- | Direct-return shortcut over the continuation-style 'listAggregateIds'.
aggregateIds :: forall eventType r. (Member (EventSourceEffect eventType) r) => Sem r [AggregateId]
aggregateIds = listAggregateIds @eventType pure

-- * Drivers

runEventSourceEffectMemory :: forall eventType r. InterpreterFor (EventSourceEffect eventType) r
runEventSourceEffectMemory = evalState @(Map.Map AggregateId (Events eventType)) mempty . reinterpretH go
  where
    go ::
      forall m x.
      EventSourceEffect eventType m x ->
      Tactical (EventSourceEffect eventType) m (State (Map.Map AggregateId (Events eventType)) ': r) x
    go =
      \case
        WithEvents aggregateId f -> do
          allEvents <- get @(Map.Map AggregateId (Events eventType))
          let originalAggregateEvents = Map.findWithDefault mempty aggregateId allEvents
          mf <- runTSimple $ f originalAggregateEvents
          inspector <- getInspectorT
          case inspect inspector mf of
            Nothing -> return ()
            Just (newEvents, _) -> put $ Map.insert aggregateId (originalAggregateEvents <> newEvents) allEvents
          return $ snd <$> mf
        ListAggregateIds k -> do
          allEvents <- get @(Map.Map AggregateId (Events eventType))
          mf <- runTSimple $ k (Map.keys allEvents)
          return mf

-- * Internals

applyCommand ::
  CommandHandler a e ->
  Events a ->
  Either e (Events a)
applyCommand = ($)

runCommand ::
  (Members '[EventSourceEffect eventType] r) =>
  CommandHandler eventType e ->
  AggregateId ->
  (Either e (Events eventType) -> Sem r a) ->
  Sem r a
runCommand handler aggregateId f =
  withEvents aggregateId $ \initialEvents ->
    let result = applyCommand handler initialEvents
     in (,) (either mempty id result) <$> f result

It is both simple and generic so that I can use it on other domains.

For instance, I could use it to represent the life cycle of a tour, as follows:

data TourEvent
  = TourCreated {name :: Text, start :: SpaceTime, end :: SpaceTime, groupSize :: Int}
  | BookingRequested {id :: BookingRequestId, buyer :: PersonIdentity, groupSize :: Int}
  | BookingAccepted {id :: BookingRequestId}
  deriving stock (Eq, Show, Generic)
  deriving (FromJSON, ToJSON) via (Generically TourEvent)

data SpaceTime = SpaceTime
  { place :: Text,
    at :: ZonedTime
  }
  deriving stock (Show, Generic)
  deriving (FromJSON, ToJSON) via (Generically SpaceTime)

instance Eq SpaceTime where
  (==) = (==) `on` show

newtype PersonIdentity = PersonIdentity {getPersonIdentity :: Text}
  deriving stock (Eq, Ord, Show, Generic)
  deriving newtype (FromJSON, ToJSON)

newtype BookingRequestId = BookingRequestId {getBookingRequestId :: Text}
  deriving stock (Eq, Ord, Show, Generic)
  deriving newtype (FromJSON, FromJSONKey, ToJSON, ToJSONKey)

I live in the EU, which means that I'm protected by the GDPR, but I should also support it.

Consequently, I have to be able to “remove” PII, which is the opposite of event sourcing conceptually.

There are traditionally three ways to deal with it:

  • Rewrite the event stream: If you ever speak with me, it is obvious that I'm a proponent of event stream rewrite when it comes to design change; however, not only is erasing PII common, but rewriting history is costly, and it won't prevent us from forgetting some element
  • Crypto-shredding: Each PII data is encrypted at rest, and we store the key outside the event stream. We don't really erase PII data; we simply erase the key, and make the data unreadable. It is costly to implement and to run, plus you have to pick a library and an algorithm strong enough to last years
  • Have a dedicated PII store: We only store references to the PII in aggregate.Pick the last approach; we can start by drafting some types for the PII life cycle, as follows:
data NewPII a = NewPII
  { owner :: PIIOwner,
    value :: a
  }
  deriving stock (Eq, Show, Generic)

data StoredPII a = StoredPII
  { owner :: PIIOwner,
    key :: PIIKey
  }
  deriving stock (Eq, Show, Generic)
  deriving (FromJSON, ToJSON) via (Generically (StoredPII a))

data FetchedPII a
  = FetchedPII a
  | ForgotPII
  deriving stock (Eq, Show, Generic)

newtype PIIOwner = PIIOwner {getPIIOwner :: Text}
  deriving stock (Eq, Ord, Show, Generic)
  deriving newtype (FromJSON, ToJSON)

newtype PIIKey = PIIKey {getPIIKey :: Text}
  deriving stock (Eq, Ord, Show, Generic)
  deriving newtype (FromJSON, ToJSON)

With these types, dedicated to each phase of the event sourcing workflow, we should rework our events to wrap PII as follows:

data TourEvent' f
  = TourCreated' {name :: Text, start :: SpaceTime, end :: SpaceTime, groupSize :: Int}
  | BookingRequested' {id :: BookingRequestId, buyer :: f PersonIdentity, groupSize :: Int}
  | BookingAccepted' {id :: BookingRequestId}

Along the way, f will be replaced by NewPII, StoredPII, and FetchedPII.

We can create a PII-aware effect as follows:

data PIIEventSourceEffect (eventType :: (Type -> Type) -> Type) (m :: Type -> Type) (a :: Type) where
  WithPIIEvents ::
    AggregateId ->
    (Events (eventType FetchedPII) -> m (Events (eventType NewPII), a)) ->
    PIIEventSourceEffect eventType m a
  ListPIIAggregateIds ::
    ([AggregateId] -> m a) ->
    PIIEventSourceEffect eventType m a

makeSem ''PIIEventSourceEffect

While this constraint forces us to do a large design work, it has the virtue of strong-typing events origin.

Before jumping on the implementation of an interpreter, we should also create an effect to store and fetch from a PII store, defined as follows:

data PIIStoreEffect (m :: Type -> Type) (a :: Type) where
  SavePII ::
    forall f a m.
    f NewPII ->
    PIIStoreEffect m (f StoredPII)
  FetchPII ::
    forall f a m.
    f StoredPII ->
    PIIStoreEffect m (f FetchedPII)

makeSem ''PIIStoreEffect

We are all set to write our first interpreter for PIIEventSourceEffect, defined as follows:

runEventSourcePII ::
  forall eventType r.
  (Members '[PlainEventSourceEffect (eventType StoredPII), PIIStoreEffect] r, Transformable eventType) =>
  InterpreterFor (PIIEventSourceEffect eventType) r
runEventSourcePII =
  interpretH $
    \case
      WithPIIEvents aggregateId f -> do
        withPlainEvents @(eventType StoredPII) aggregateId $ \storedEvents -> do
          fResult <- runTSimple . f =<< mapM fetchPII storedEvents
          ins <- getInspectorT
          s <- getInitialStateT
          let (newEvents, result) = fromMaybe (error "TODO") $ inspect ins fResult
          savedEvents <- mapM savePII newEvents
          pure (savedEvents, result <$ s)
      ListPIIAggregateIds k -> do
        mf <- listPlainAggregateIds @(eventType StoredPII) $ runTSimple . k
        return mf

The idea is simple: rely on PlainEventSourceEffect, which only deals with eventType StoredPII, and add a thin wrapper to go in and out of PIIStoreEffect.

Usually, key-value stores are simple; this one is an exception to the rule because it has to do the serialization work, including going through the events' structure.

We can naively define it as follows:

type PIIMemoryStore = Map.Map PIIOwner (Map.Map PIIKey ByteString)

runPIIStoreMemory :: forall r. InterpreterFor PIIStoreEffect r
runPIIStoreMemory = evalState @PIIMemoryStore mempty . reinterpret go
  where
    go ::
      forall rInitial x.
      PIIStoreEffect (Sem rInitial) x ->
      Sem (State PIIMemoryStore : r) x
    go =
      \case
        SavePII x -> do
          let store ::
                forall a.
                (Aeson.ToJSON a) =>
                NewPII a ->
                Sem (State PIIMemoryStore ': r) (StoredPII a)
              store NewPII {..} = do
                ownerPiis <- gets @PIIMemoryStore $ Map.findWithDefault mempty owner
                let serialized = Aeson.encode value
                    key = PIIKey $ T.pack $ show $ Map.size ownerPiis
                modify @PIIMemoryStore $ Map.insert owner $ Map.insert key serialized ownerPiis
                return $ StoredPII {..}
          transformM store x
        FetchPII x -> do
          let fetch ::
                forall a.
                (Aeson.FromJSON a) =>
                StoredPII a -> Sem (State PIIMemoryStore ': r) (FetchedPII a)
              fetch StoredPII {..} = do
                gets @PIIMemoryStore $ \piis ->
                  maybe ForgotPII FetchedPII $
                    Aeson.decode @a
                      =<< Map.lookup key (Map.findWithDefault mempty owner piis)
          transformM fetch x

There are many things preventing this code from compiling. First, there is transformM which is not defined.

transformM is weird; it is a mix of a natural transformation and traverse. It can be drafted as follows:

transformM :: (Applicative m) => (forall a. f a -> m (g a)) -> s f -> m (s g)

In plain English: we expect a function that transforms all values wrapped by f to a value wrapped by g, emitting an effect m; and a structure s with values wrapped in f to get the same structure with values wrapped in g inside and effect m.

It is not enough; get and store except aeson constraints we have to enrich transformM with, as follows:

transformM :: (Applicative m) => (forall a. (Aeson.ToJSON a, Aeson.FromJSON a) => f a -> m (g a)) -> s f -> m (s g)

Sadly, this function cannot be defined for every type; we have to turn it into the following type class:

class Transformable (s :: (Type -> Type) -> Type) where
  transformM ::
    (Applicative m) =>
    (forall a. (Aeson.ToJSON a, Aeson.FromJSON a) => f a -> m (g a)) -> s f -> m (s g)

We should also propagate the constraint to PIIStoreEffect operations, as follows:

data PIIStoreEffect (m :: Type -> Type) (a :: Type) where
  SavePII ::
    forall f a m.
    (Transformable f) =>
    f NewPII ->
    PIIStoreEffect m (f StoredPII)
  FetchPII ::
    forall f a m.
    (Transformable f) =>
    f StoredPII ->
    PIIStoreEffect m (f FetchedPII)

makeSem ''PIIStoreEffect

Similarly, our target, eventType, should also be Transformable, as follows:

runEventSourcePII ::
  forall eventType r.
  (Members '[PlainEventSourceEffect (eventType StoredPII), PIIStoreEffect] r, Transformable eventType) =>
  InterpreterFor (PIIEventSourceEffect eventType) r
runEventSourcePII =
  interpretH $
    \case
      WithPIIEvents aggregateId f -> do
        withPlainEvents @(eventType StoredPII) aggregateId $ \storedEvents -> do
          fResult <- runTSimple . f =<< mapM fetchPII storedEvents
          ins <- getInspectorT
          s <- getInitialStateT
          let (newEvents, result) = fromMaybe (error "TODO") $ inspect ins fResult
          savedEvents <- mapM savePII newEvents
          pure (savedEvents, result <$ s)
      ListPIIAggregateIds k -> do
        mf <- listPlainAggregateIds @(eventType StoredPII) $ runTSimple . k
        return mf

To end up this log on a practical example, implementing an instance is as simple as the following snippet:

instance Transformable TourEvent' where
  transformM f =
    \case
      TourCreated' {..} ->
        pure TourCreated' {..}
      BookingRequested' {..} ->
        BookingRequested' id <$> f buyer <*> pure groupSize
      BookingAccepted' {..} ->
        pure BookingAccepted' {..}

It should be possible to derive instances, but it goes beyond this already long log.