diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -0,0 +1,17 @@
+# eventium-memory Changelog
+
+## 0.2.1 (Unreleased)
+
+- Tests for `snapshotEventHandler`, `snapshotGlobalEventHandler`, `applyCommandHandlerWithCache`.
+- Tests for `rebuildReadModel` and `combineReadModels`.
+
+## 0.2.0 (Unreleased)
+
+- Updated for eventium-core 0.2.0 API changes (`StreamEvent` with metadata, new `EventVersion`/`SequenceNumber` types).
+- `EventMap` stores `VersionedStreamEvent event` values with full metadata.
+- Enabled `NoFieldSelectors`, `DuplicateRecordFields`, `OverloadedRecordDot` default extensions.
+- Removed record field prefixes from internal types.
+
+## 0.1.0
+
+Initial release.
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -1,122 +1,72 @@
 # Eventium Memory
 
-In-memory implementations of event stores, read models, and projection caches for Eventium.
+In-memory event store for Eventium, backed by STM.
 
 ## Overview
 
-`eventium-memory` provides thread-safe, in-memory storage implementations for the Eventium event sourcing framework. This package is ideal for development, testing, and prototyping event-sourced applications without requiring external dependencies like databases.
-
-## Features
+`eventium-memory` provides thread-safe, in-memory implementations of
+`EventStoreReader` and `EventStoreWriter` for development, testing, and
+prototyping. No external database is required.
 
-- ✅ **In-Memory Event Store** - Fast, STM-based event storage
-- ✅ **Thread-Safe** - Concurrent access via Software Transactional Memory
-- ✅ **No External Dependencies** - No database setup required
-- ✅ **Perfect for Testing** - Fast test execution with isolated state
-- ✅ **Projection Cache** - In-memory snapshot storage
-- ✅ **Read Model Support** - Memory-based read model implementation
+## API
 
-## Components
+```haskell
+-- Create a TVar-backed store
+eventMapTVar :: IO (TVar (EventMap event))
 
-### Event Store (`Eventium.Store.Memory`)
-Implements both `EventStoreReader` and `EventStoreWriter` with:
-- Per-stream versioning
-- Global event ordering
-- Optimistic concurrency control
-- STM transactions for atomicity
+-- TVar-based (for use inside STM or lifted to IO)
+tvarEventStoreReader       :: TVar (EventMap event) -> VersionedEventStoreReader STM event
+tvarEventStoreWriter       :: TVar (EventMap event) -> VersionedEventStoreWriter STM event
+tvarGlobalEventStoreReader :: TVar (EventMap event) -> GlobalEventStoreReader STM event
 
-### Read Model (`Eventium.ReadModel.Memory`)
-In-memory read model implementation for building query-optimized views.
+-- MonadState-based (for pure state threading)
+stateEventStoreReader       :: (MonadState (EventMap event) m) => VersionedEventStoreReader m event
+stateEventStoreWriter       :: (MonadState (EventMap event) m) => VersionedEventStoreWriter m event
+stateGlobalEventStoreReader :: (MonadState (EventMap event) m) => GlobalEventStoreReader m event
+```
 
-### Projection Cache (`Eventium.ProjectionCache.Memory`)
-Stores projection snapshots in memory to avoid replaying entire event histories.
+Use `runEventStoreReaderUsing atomically` / `runEventStoreWriterUsing atomically`
+to lift the STM stores into IO.
 
 ## Usage
 
 ```haskell
-import Eventium.Store.Memory (newEventStore)
 import Control.Concurrent.STM (atomically)
+import Eventium
+import Eventium.Store.Memory
 
 main :: IO ()
 main = do
-  -- Create a new in-memory event store
-  store <- atomically newEventStore
-  
-  -- Use it with your command handlers and projections
-  result <- applyCommandHandler 
-    (eventStoreWriter store)
-    (eventStoreReader store) 
-    myCommandHandler 
-    aggregateId 
-    command
+  tvar <- eventMapTVar
+  let writer = runEventStoreWriterUsing atomically (tvarEventStoreWriter tvar)
+      reader = runEventStoreReaderUsing atomically (tvarEventStoreReader tvar)
+
+  result <- applyCommandHandler writer reader myHandler aggregateId cmd
+  print result
 ```
 
-## Installation
+## When to Use
 
-Add to your `package.yaml`:
+- **Testing** -- fast, isolated, no I/O overhead.
+- **Prototyping** -- iterate on domain logic without database setup.
+- **Examples** -- the counter-cli example uses this backend.
 
+For persistent storage, use `eventium-sqlite` or `eventium-postgresql`.
+
+## Installation
+
 ```yaml
 dependencies:
   - eventium-core
   - eventium-memory
 ```
 
-Or to your `.cabal` file:
-
-```cabal
-build-depends:
-    eventium-core
-  , eventium-memory
-```
-
-## Use Cases
-
-### Development
-Quickly prototype event-sourced applications without database setup.
-
-### Testing
-- Fast test execution (no I/O overhead)
-- Isolated test state (each test gets fresh store)
-- Easy verification of event sequences
-
-### Demonstration
-Perfect for demos, tutorials, and learning event sourcing concepts.
-
-## Example
-
-```haskell
--- Create store
-store <- atomically newEventStore
-
--- Write events
-writeResult <- atomically $ 
-  writeEvents (eventStoreWriter store) 
-    streamKey 
-    ExpectedPositionAny 
-    [event1, event2]
-
--- Read events back
-events <- atomically $ 
-  readEvents (eventStoreReader store) 
-    streamKey 
-    QueryRangeAll
-```
-
-## Limitations
-
-- **Not Persistent** - All data lost when process ends
-- **Memory Constraints** - Limited by available RAM
-- **Single Process** - No distributed access
-
-For production use, consider:
-- `eventium-sqlite` - Persistent, single-process storage
-- `eventium-postgresql` - Persistent, multi-process storage
-
 ## Documentation
 
-- [Main README](../README.md) - Project overview
-- [Design Documentation](../DESIGN.md) - Architecture details
-- [Examples](../examples/) - Working applications
+- [Main README](../README.md)
+- [Design](../DESIGN.md)
+- [Examples](../examples/)
 
 ## License
 
-MIT - see [LICENSE.md](LICENSE.md)
+MIT -- see [LICENSE.md](LICENSE.md)
diff --git a/eventium-memory.cabal b/eventium-memory.cabal
--- a/eventium-memory.cabal
+++ b/eventium-memory.cabal
@@ -1,13 +1,13 @@
 cabal-version: 1.12
 
--- This file has been generated from package.yaml by hpack version 0.37.0.
+-- This file has been generated from package.yaml by hpack version 0.38.3.
 --
 -- see: https://github.com/sol/hpack
 
 name:           eventium-memory
-version:        0.1.0
+version:        0.2.1
 synopsis:       In-memory implementations for eventium
-description:    Eventium-memory provides in-memory implementations of event stores, read models, and projection caches
+description:    Eventium-memory provides in-memory implementations of event stores and projection caches
                 for the Eventium event sourcing framework. This package is ideal for development, testing, and prototyping
                 event-sourced applications without requiring external dependencies like databases. All data is stored in
                 STM-based concurrent data structures for thread-safe access.
@@ -27,50 +27,74 @@
   type: git
   location: https://github.com/aleks-sidorenko/eventium
 
+flag ci
+  description: Enable -Werror for CI builds
+  manual: True
+  default: False
+
 library
   exposed-modules:
+      Eventium.CheckpointStore.Memory
       Eventium.ProjectionCache.Memory
-      Eventium.ReadModel.Memory
       Eventium.Store.Memory
   other-modules:
       Paths_eventium_memory
   hs-source-dirs:
       src
-  ghc-options: -Wall
+  default-extensions:
+      NoFieldSelectors
+      DuplicateRecordFields
+      OverloadedRecordDot
+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wredundant-constraints -Wno-type-defaults -Wno-incomplete-uni-patterns
   build-depends:
       base >=4.9 && <5
     , containers >=0.6 && <0.8
-    , eventium-core
+    , eventium-core >=0.2.0 && <0.3.0
     , mtl >=2.2 && <2.4
-    , safe >=0.3 && <0.4
-    , stm >=2.5 && <2.6
+    , safe ==0.3.*
+    , stm ==2.5.*
   default-language: Haskell2010
+  if flag(ci)
+    ghc-options: -Werror
 
 test-suite spec
   type: exitcode-stdio-1.0
   main-is: Spec.hs
   other-modules:
+      Eventium.CommandHandlerSpec
+      Eventium.EventPublisherSpec
+      Eventium.EventSubscriptionSpec
+      Eventium.MetadataEnrichmentSpec
+      Eventium.ProcessManagerSpec
       Eventium.ProjectionCache.MemorySpec
+      Eventium.ReadModelSpec
+      Eventium.ResilientSubscriptionSpec
       Eventium.Store.MemorySpec
       MemoryTestImport
+      Eventium.CheckpointStore.Memory
       Eventium.ProjectionCache.Memory
-      Eventium.ReadModel.Memory
       Eventium.Store.Memory
       Paths_eventium_memory
   hs-source-dirs:
       tests
       src
-  ghc-options: -Wall
+  default-extensions:
+      NoFieldSelectors
+      DuplicateRecordFields
+      OverloadedRecordDot
+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wredundant-constraints -Wno-type-defaults -Wno-incomplete-uni-patterns
   build-tool-depends:
       hspec-discover:hspec-discover
   build-depends:
       HUnit
     , base >=4.9 && <5
     , containers >=0.6 && <0.8
-    , eventium-core
-    , eventium-test-helpers
+    , eventium-core >=0.2.0 && <0.3.0
+    , eventium-testkit
     , hspec
     , mtl >=2.2 && <2.4
-    , safe >=0.3 && <0.4
-    , stm >=2.5 && <2.6
+    , safe ==0.3.*
+    , stm ==2.5.*
   default-language: Haskell2010
+  if flag(ci)
+    ghc-options: -Werror
diff --git a/src/Eventium/CheckpointStore/Memory.hs b/src/Eventium/CheckpointStore/Memory.hs
new file mode 100644
--- /dev/null
+++ b/src/Eventium/CheckpointStore/Memory.hs
@@ -0,0 +1,25 @@
+module Eventium.CheckpointStore.Memory
+  ( tvarCheckpointStore,
+    ioRefCheckpointStore,
+  )
+where
+
+import Control.Concurrent.STM
+import Data.IORef
+import Eventium.EventSubscription (CheckpointStore (..))
+
+-- | A 'CheckpointStore' backed by a 'TVar'. Runs in 'STM'.
+tvarCheckpointStore :: TVar a -> CheckpointStore STM a
+tvarCheckpointStore tvar =
+  CheckpointStore
+    { getCheckpoint = readTVar tvar,
+      saveCheckpoint = writeTVar tvar
+    }
+
+-- | A 'CheckpointStore' backed by an 'IORef'. Runs in 'IO'.
+ioRefCheckpointStore :: IORef a -> CheckpointStore IO a
+ioRefCheckpointStore ref =
+  CheckpointStore
+    { getCheckpoint = readIORef ref,
+      saveCheckpoint = writeIORef ref
+    }
diff --git a/src/Eventium/ProjectionCache/Memory.hs b/src/Eventium/ProjectionCache/Memory.hs
--- a/src/Eventium/ProjectionCache/Memory.hs
+++ b/src/Eventium/ProjectionCache/Memory.hs
@@ -1,5 +1,4 @@
 {-# LANGUAGE FlexibleContexts #-}
-{-# LANGUAGE RecordWildCards #-}
 
 module Eventium.ProjectionCache.Memory
   ( ProjectionMap,
@@ -7,7 +6,7 @@
     projectionMapTVar,
     tvarProjectionCache,
     embeddedStateProjectionCache,
-    module Eventium.ProjectionCache.Types,
+    module Eventium.ProjectionCache.Cache,
   )
 where
 
@@ -15,46 +14,48 @@
 import Control.Monad.State.Class hiding (state)
 import Data.Map.Strict (Map)
 import qualified Data.Map.Strict as Map
-import Eventium.ProjectionCache.Types
+import Eventium.ProjectionCache.Cache
 
 -- | A 'ProjectionMap' just stores the latest snapshot for each UUID.
-type ProjectionMap key position serialized = Map key (position, serialized)
+type ProjectionMap key position encoded = Map key (position, encoded)
 
-emptyProjectionMap :: ProjectionMap key position serialized
+emptyProjectionMap :: ProjectionMap key position encoded
 emptyProjectionMap = Map.empty
 
-projectionMapTVar :: IO (TVar (ProjectionMap key position serialized))
+projectionMapTVar :: IO (TVar (ProjectionMap key position encoded))
 projectionMapTVar = newTVarIO emptyProjectionMap
 
 storeProjectionInMap ::
   (Ord key) =>
   key ->
   position ->
-  serialized ->
-  ProjectionMap key position serialized ->
-  ProjectionMap key position serialized
+  encoded ->
+  ProjectionMap key position encoded ->
+  ProjectionMap key position encoded
 storeProjectionInMap uuid version state = Map.insert uuid (version, state)
 
 -- | A 'ProjectionCache' that uses a 'TVar' and runs in 'STM'.
 tvarProjectionCache ::
   (Ord key) =>
-  TVar (ProjectionMap key position serialized) ->
-  ProjectionCache key position serialized STM
+  TVar (ProjectionMap key position encoded) ->
+  ProjectionCache key position encoded STM
 tvarProjectionCache tvar =
-  let storeProjectionSnapshot uuid version projState = modifyTVar' tvar (storeProjectionInMap uuid version projState)
-      loadProjectionSnapshot uuid = Map.lookup uuid <$> readTVar tvar
-   in ProjectionCache {..}
+  ProjectionCache
+    { storeSnapshot = \uuid version projState -> modifyTVar' tvar (storeProjectionInMap uuid version projState),
+      loadSnapshot = \uuid -> Map.lookup uuid <$> readTVar tvar
+    }
 
 -- | A 'ProjectionCache' for some 'MonadState' that contains a 'ProjectionMap'.
 embeddedStateProjectionCache ::
   (MonadState s m, Ord key) =>
-  (s -> ProjectionMap key position serialized) ->
-  (s -> ProjectionMap key position serialized -> s) ->
-  ProjectionCache key position serialized m
+  (s -> ProjectionMap key position encoded) ->
+  (s -> ProjectionMap key position encoded -> s) ->
+  ProjectionCache key position encoded m
 embeddedStateProjectionCache getMap setMap =
-  let storeProjectionSnapshot uuid version projState = modify' (storeProjectionSnapshot' uuid version projState)
-      loadProjectionSnapshot uuid = Map.lookup uuid <$> gets getMap
-   in ProjectionCache {..}
+  ProjectionCache
+    { storeSnapshot = \uuid version projState -> modify' (storeSnapshot uuid version projState),
+      loadSnapshot = \uuid -> Map.lookup uuid <$> gets getMap
+    }
   where
-    storeProjectionSnapshot' uuid version projState state =
-      setMap state $ storeProjectionInMap uuid version projState $ getMap state
+    storeSnapshot uuid version projState st =
+      setMap st $ storeProjectionInMap uuid version projState $ getMap st
diff --git a/src/Eventium/ReadModel/Memory.hs b/src/Eventium/ReadModel/Memory.hs
deleted file mode 100644
--- a/src/Eventium/ReadModel/Memory.hs
+++ /dev/null
@@ -1,35 +0,0 @@
-module Eventium.ReadModel.Memory
-  ( memoryReadModel,
-  )
-where
-
-import Control.Concurrent.STM
-import Control.Monad.IO.Class
-import Eventium.ReadModel.Class
-import Eventium.Store.Class
-import Safe (maximumDef)
-
-data MemoryReadModelData modeldata
-  = MemoryReadModelData
-  { memoryReadModelDataLatestSequenceNumber :: SequenceNumber,
-    _memoryReadModelDataValue :: modeldata
-  }
-  deriving (Show)
-
--- | Creates a read model that wraps some pure data in a TVar and manages the
--- latest sequence number for you.
-memoryReadModel ::
-  (MonadIO m) =>
-  modeldata ->
-  (modeldata -> [GlobalStreamEvent serialized] -> m modeldata) ->
-  IO (ReadModel (TVar (MemoryReadModelData modeldata)) serialized m)
-memoryReadModel initialValue handleEvents = do
-  tvar <- newTVarIO $ MemoryReadModelData (-1) initialValue
-  return $ ReadModel tvar getLatestSequence handleTVarEvents
-  where
-    getLatestSequence tvar' = liftIO $ memoryReadModelDataLatestSequenceNumber <$> readTVarIO tvar'
-    handleTVarEvents tvar' events = do
-      (MemoryReadModelData latestSeq modelData) <- liftIO $ readTVarIO tvar'
-      let latestSeq' = maximumDef latestSeq (streamEventPosition <$> events)
-      modelData' <- handleEvents modelData events
-      liftIO . atomically . writeTVar tvar' $ MemoryReadModelData latestSeq' modelData'
diff --git a/src/Eventium/Store/Memory.hs b/src/Eventium/Store/Memory.hs
--- a/src/Eventium/Store/Memory.hs
+++ b/src/Eventium/Store/Memory.hs
@@ -1,14 +1,18 @@
 {-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE OverloadedStrings #-}
 
 module Eventium.Store.Memory
   ( tvarEventStoreReader,
     tvarEventStoreWriter,
+    tvarEventStoreWriterTagged,
     tvarGlobalEventStoreReader,
     stateEventStoreReader,
     stateEventStoreWriter,
+    stateEventStoreWriterTagged,
     stateGlobalEventStoreReader,
     embeddedStateEventStoreReader,
     embeddedStateEventStoreWriter,
+    embeddedStateEventStoreWriterTagged,
     embeddedStateGlobalEventStoreReader,
     EventMap,
     emptyEventMap,
@@ -31,8 +35,8 @@
 -- | Internal data structure used for the in-memory event stores.
 data EventMap event
   = EventMap
-  { _eventMapUuidMap :: Map UUID (Seq (VersionedStreamEvent event)),
-    _eventMapGlobalEvents :: Seq (VersionedStreamEvent event)
+  { uuidMap :: Map UUID (Seq (VersionedStreamEvent event)),
+    globalEvents :: Seq (VersionedStreamEvent event)
   }
   deriving (Show)
 
@@ -60,6 +64,18 @@
       writeTVar tvar store'
       return vers
 
+-- | Like 'tvarEventStoreWriter' but accepts 'TaggedEvent's,
+-- preserving the metadata attached to each event.
+tvarEventStoreWriterTagged :: TVar (EventMap event) -> VersionedEventStoreWriter STM (TaggedEvent event)
+tvarEventStoreWriterTagged tvar = EventStoreWriter $ transactionalExpectedWriteHelper getLatestVersion storeEvents'
+  where
+    getLatestVersion uuid = flip latestEventVersion uuid <$> readTVar tvar
+    storeEvents' uuid events = do
+      store <- readTVar tvar
+      let (store', vers) = storeEventMapTagged store uuid events
+      writeTVar tvar store'
+      return vers
+
 -- | Analog of 'tvarEventStoreReader' for a 'GlobalEventStoreReader'
 tvarGlobalEventStoreReader :: TVar (EventMap event) -> GlobalEventStoreReader STM event
 tvarGlobalEventStoreReader tvar = EventStoreReader $ \range -> lookupGlobalEvents range <$> readTVar tvar
@@ -83,6 +99,12 @@
   VersionedEventStoreWriter m event
 stateEventStoreWriter = embeddedStateEventStoreWriter id (const id)
 
+-- | Like 'stateEventStoreWriter' but accepts 'TaggedEvent's.
+stateEventStoreWriterTagged ::
+  (MonadState (EventMap event) m) =>
+  VersionedEventStoreWriter m (TaggedEvent event)
+stateEventStoreWriterTagged = embeddedStateEventStoreWriterTagged id (const id)
+
 -- | An 'EventStore' that runs on some 'MonadState' that contains an
 -- 'EventMap'. This is useful if you want to include other state in your
 -- 'MonadState'.
@@ -114,14 +136,30 @@
   GlobalEventStoreReader m event
 embeddedStateGlobalEventStoreReader getMap = EventStoreReader $ \range -> lookupGlobalEvents range <$> gets getMap
 
+-- | Like 'embeddedStateEventStoreWriter' but accepts 'TaggedEvent's.
+embeddedStateEventStoreWriterTagged ::
+  (MonadState s m) =>
+  (s -> EventMap event) ->
+  (s -> EventMap event -> s) ->
+  VersionedEventStoreWriter m (TaggedEvent event)
+embeddedStateEventStoreWriterTagged getMap setMap = EventStoreWriter $ transactionalExpectedWriteHelper getLatestVersion storeEvents'
+  where
+    getLatestVersion uuid = flip latestEventVersion uuid <$> gets getMap
+    storeEvents' uuid events = do
+      state' <- get
+      let store = getMap state'
+      let (store', vers) = storeEventMapTagged store uuid events
+      put $ setMap state' store'
+      return vers
+
 lookupEventMapRaw :: EventMap event -> UUID -> Seq (VersionedStreamEvent event)
 lookupEventMapRaw (EventMap uuidMap _) uuid = fromMaybe Seq.empty $ Map.lookup uuid uuidMap
 
 lookupEventsInRange :: QueryRange UUID EventVersion -> EventMap event -> [VersionedStreamEvent event]
 lookupEventsInRange (QueryRange uuid start limit) store = toList $ filterEventsByRange start' limit' 0 rawEvents
   where
-    start' = unEventVersion <$> start
-    limit' = unEventVersion <$> limit
+    start' = (\(EventVersion n) -> n) <$> start
+    limit' = (\(EventVersion n) -> n) <$> limit
     rawEvents = lookupEventMapRaw store uuid
 
 filterEventsByRange :: QueryStart Int -> QueryLimit Int -> Int -> Seq event -> Seq event
@@ -143,10 +181,10 @@
 lookupGlobalEvents :: QueryRange () SequenceNumber -> EventMap event -> [GlobalStreamEvent event]
 lookupGlobalEvents (QueryRange () start limit) (EventMap _ globalEvents) = events'
   where
-    start' = unSequenceNumber <$> start
-    limit' = unSequenceNumber <$> limit
+    start' = (\(SequenceNumber n) -> n) <$> start
+    limit' = (\(SequenceNumber n) -> n) <$> limit
     events = toList $ filterEventsByRange start' limit' 1 globalEvents
-    events' = zipWith (StreamEvent ()) [startingSeqNum ..] events
+    events' = zipWith (\s e -> StreamEvent () s e.metadata e) [startingSeqNum ..] events
     startingSeqNum =
       case start of
         StartFromBeginning -> 1
@@ -156,7 +194,20 @@
   EventMap event -> UUID -> [event] -> (EventMap event, EventVersion)
 storeEventMap store@(EventMap uuidMap globalEvents) uuid events =
   let versStart = latestEventVersion store uuid
-      streamEvents = zipWith (StreamEvent uuid) [versStart + 1 ..] events
+      streamEvents = zipWith (\v e -> StreamEvent uuid v (emptyMetadata "") e) [versStart + 1 ..] events
       newMap = Map.insertWith (flip (><)) uuid (Seq.fromList streamEvents) uuidMap
       globalEvents' = globalEvents >< Seq.fromList streamEvents
    in (EventMap newMap globalEvents', versStart + EventVersion (length events))
+
+storeEventMapTagged ::
+  EventMap event -> UUID -> [TaggedEvent event] -> (EventMap event, EventVersion)
+storeEventMapTagged store@(EventMap uuidMap globalEvents) uuid taggedEvents =
+  let versStart = latestEventVersion store uuid
+      streamEvents =
+        zipWith
+          (\v (TaggedEvent meta e) -> StreamEvent uuid v meta e)
+          [versStart + 1 ..]
+          taggedEvents
+      newMap = Map.insertWith (flip (><)) uuid (Seq.fromList streamEvents) uuidMap
+      globalEvents' = globalEvents >< Seq.fromList streamEvents
+   in (EventMap newMap globalEvents', versStart + EventVersion (length taggedEvents))
diff --git a/tests/Eventium/CommandHandlerSpec.hs b/tests/Eventium/CommandHandlerSpec.hs
new file mode 100644
--- /dev/null
+++ b/tests/Eventium/CommandHandlerSpec.hs
@@ -0,0 +1,128 @@
+module Eventium.CommandHandlerSpec (spec) where
+
+import Control.Concurrent.STM
+import Control.Exception (evaluate)
+import Eventium.Codec (Codec (..), DecodeError (..))
+import Eventium.CommandHandler
+import Eventium.Projection
+import Eventium.Store.Class
+import Eventium.Store.Memory
+import Eventium.Testkit (allCommandHandlerStates)
+import Eventium.UUID
+import Test.Hspec
+
+-- Helper to create IO-compatible stores from a TVar
+makeIOStore :: TVar (EventMap event) -> (VersionedEventStoreWriter IO event, VersionedEventStoreReader IO event)
+makeIOStore tvar =
+  ( runEventStoreWriterUsing atomically (tvarEventStoreWriter tvar),
+    runEventStoreReaderUsing atomically (tvarEventStoreReader tvar)
+  )
+
+-- Simple counter domain for testing
+data CounterError = Overflow | Underflow
+  deriving (Show, Eq)
+
+type CounterHandler = CommandHandler Int Int Int CounterError
+
+counterHandler :: CounterHandler
+counterHandler = CommandHandler decide counterProj
+  where
+    counterProj = Projection 0 (+)
+    decide state cmd
+      | state + cmd > 100 = Left Overflow
+      | state + cmd < 0 = Left Underflow
+      | otherwise = Right [cmd]
+
+spec :: Spec
+spec = do
+  describe "allCommandHandlerStates" $ do
+    it "should advance state on accepted commands" $ do
+      allCommandHandlerStates counterHandler [10, 20, 30]
+        `shouldBe` [0, 10, 30, 60]
+
+    it "should leave state unchanged on rejected commands" $ do
+      allCommandHandlerStates counterHandler [50, 60, 10]
+        `shouldBe` [0, 50, 50, 60]
+    -- 60 is rejected (50+60>100), state stays at 50
+
+    it "should handle mixed accept/reject" $ do
+      allCommandHandlerStates counterHandler [30, -50, 20, 80]
+        `shouldBe` [0, 30, 30, 50, 50]
+  -- -50 rejected (30-50<0), 80 rejected (50+80>100)
+
+  describe "applyCommandHandler" $ do
+    it "should return Right events on success" $ do
+      tvar <- eventMapTVar
+      let (writer, reader) = makeIOStore tvar
+          uuid = uuidFromInteger 1
+
+      result <- applyCommandHandler writer reader counterHandler uuid 10
+      result `shouldBe` Right [10]
+
+      -- Verify events are stored
+      events <- reader.getEvents (allEvents uuid)
+      map (.payload) events `shouldBe` [10]
+
+    it "should return CommandRejected on domain error" $ do
+      tvar <- eventMapTVar
+      let (writer, reader) = makeIOStore tvar
+          uuid = uuidFromInteger 1
+
+      result <- applyCommandHandler writer reader counterHandler uuid 150
+      result `shouldBe` Left (CommandRejected Overflow)
+
+      -- Verify no events stored
+      events <- reader.getEvents (allEvents uuid)
+      events `shouldBe` []
+
+    it "should return ConcurrencyConflict on write failure" $ do
+      tvar <- eventMapTVar
+      let (_, reader) = makeIOStore tvar
+
+      -- Use a writer that always fails to simulate concurrency conflict
+      let failWriter = EventStoreWriter $ \_ _ _ ->
+            return $ Left (EventStreamNotAtExpectedVersion (42 :: EventVersion))
+
+      result <- applyCommandHandler failWriter reader counterHandler (uuidFromInteger 1) 5
+      result `shouldBe` Left (ConcurrencyConflict (EventStreamNotAtExpectedVersion 42))
+
+    it "should apply multiple commands sequentially" $ do
+      tvar <- eventMapTVar
+      let (writer, reader) = makeIOStore tvar
+          uuid = uuidFromInteger 1
+
+      result1 <- applyCommandHandler writer reader counterHandler uuid 30
+      result1 `shouldBe` Right [30]
+
+      result2 <- applyCommandHandler writer reader counterHandler uuid 40
+      result2 `shouldBe` Right [40]
+
+      -- Third command should be rejected (30+40+50=120 > 100)
+      result3 <- applyCommandHandler writer reader counterHandler uuid 50
+      result3 `shouldBe` Left (CommandRejected Overflow)
+
+      -- Verify only two events stored
+      events <- reader.getEvents (allEvents uuid)
+      map (.payload) events `shouldBe` [30, 40]
+
+  describe "codecCommandHandler" $ do
+    it "should map through codecs on success" $ do
+      let eventCodec = Codec show (Just . read)
+          cmdCodec = Codec show (Just . read)
+          wrapped = codecCommandHandler eventCodec cmdCodec counterHandler
+
+      wrapped.decide 0 "10" `shouldBe` Right ["10"]
+
+    it "should error when command decoding fails" $ do
+      let eventCodec = Codec show (Just . read)
+          cmdCodec = Codec (show :: Int -> String) (\_ -> Nothing :: Maybe Int)
+          wrapped = codecCommandHandler eventCodec cmdCodec counterHandler
+
+      evaluate (wrapped.decide 0 "anything") `shouldThrow` (\(DecodeError ctx _) -> ctx == "codecCommandHandler")
+
+    it "should propagate domain errors through codec" $ do
+      let eventCodec = Codec show (Just . read)
+          cmdCodec = Codec show (Just . read)
+          wrapped = codecCommandHandler eventCodec cmdCodec counterHandler
+
+      wrapped.decide 0 "150" `shouldBe` Left Overflow
diff --git a/tests/Eventium/EventPublisherSpec.hs b/tests/Eventium/EventPublisherSpec.hs
new file mode 100644
--- /dev/null
+++ b/tests/Eventium/EventPublisherSpec.hs
@@ -0,0 +1,137 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+
+module Eventium.EventPublisherSpec (spec) where
+
+import Control.Concurrent.STM
+import Control.Monad
+import Data.IORef
+import Eventium.EventHandler
+import Eventium.EventPublisher
+import Eventium.Store.Class
+import Eventium.Store.Memory
+import Eventium.UUID
+import Test.Hspec
+
+-- Helper to create IO-compatible stores from a TVar
+makeIOStore :: TVar (EventMap event) -> (VersionedEventStoreWriter IO event, VersionedEventStoreReader IO event, GlobalEventStoreReader IO event)
+makeIOStore tvar =
+  ( runEventStoreWriterUsing atomically (tvarEventStoreWriter tvar),
+    runEventStoreReaderUsing atomically (tvarEventStoreReader tvar),
+    runEventStoreReaderUsing atomically (tvarGlobalEventStoreReader tvar)
+  )
+
+spec :: Spec
+spec = do
+  describe "synchronousPublisher" $ do
+    it "should deliver each event to the handler" $ do
+      ref <- newIORef ([] :: [Int])
+      let handler = EventHandler $ \(StreamEvent _ _ _ e) -> modifyIORef ref (++ [e])
+          publisher = synchronousPublisher handler
+      publisher.publishEvents
+        nil
+        [ StreamEvent nil 0 (emptyMetadata "") (1 :: Int),
+          StreamEvent nil 1 (emptyMetadata "") 2,
+          StreamEvent nil 2 (emptyMetadata "") 3
+        ]
+      readIORef ref `shouldReturn` [1, 2, 3]
+
+  describe "publishingEventStoreWriter" $ do
+    it "should publish events after a successful write" $ do
+      tvar <- eventMapTVar
+      let (writer, reader, _) = makeIOStore tvar
+
+      ref <- newIORef ([] :: [Int])
+      let handler = EventHandler $ \(StreamEvent _ _ _ e) -> modifyIORef ref (++ [e])
+          publisher = synchronousPublisher handler
+          publishingWriter = publishingEventStoreWriter writer publisher
+
+      _ <- publishingWriter.storeEvents (uuidFromInteger 1) NoStream [10, 20, 30 :: Int]
+
+      readIORef ref `shouldReturn` [10, 20, 30]
+
+      -- Verify events are also stored
+      events <- reader.getEvents (allEvents (uuidFromInteger 1))
+      map (.payload) events `shouldBe` [10, 20, 30]
+
+    it "should NOT publish events when the write fails" $ do
+      tvar <- eventMapTVar
+      let (writer, _, _) = makeIOStore tvar
+
+      ref <- newIORef ([] :: [Int])
+      let handler = EventHandler $ \(StreamEvent _ _ _ e) -> modifyIORef ref (++ [e])
+          publisher = synchronousPublisher handler
+          publishingWriter = publishingEventStoreWriter writer publisher
+
+      -- Write should fail due to ExactPosition mismatch
+      result <- publishingWriter.storeEvents (uuidFromInteger 1) (ExactPosition 5) [10 :: Int]
+      result `shouldSatisfy` isLeft'
+
+      readIORef ref `shouldReturn` []
+
+    it "should tag events with correct stream key and version positions" $ do
+      tvar <- eventMapTVar
+      let (writer, _, _) = makeIOStore tvar
+
+      ref <- newIORef ([] :: [(UUID, EventVersion, Int)])
+      let handler = EventHandler $ \(StreamEvent uuid pos _ e) -> modifyIORef ref (++ [(uuid, pos, e)])
+          publisher = synchronousPublisher handler
+          publishingWriter = publishingEventStoreWriter writer publisher
+
+      let uuid1 = uuidFromInteger 1
+      _ <- publishingWriter.storeEvents uuid1 NoStream [100, 200 :: Int]
+
+      readIORef ref
+        `shouldReturn` [ (uuid1, 0, 100),
+                         (uuid1, 1, 200)
+                       ]
+
+  describe "breadth-first dispatch (regression test for depth-first bug)" $ do
+    it "should deliver all original events before handler-generated events" $ do
+      -- This is the critical test: the old EventBus had depth-first dispatch
+      -- where if handler A generated new events, those were dispatched to
+      -- handler B before handler B saw the rest of the original batch.
+      --
+      -- With the new EventPublisher, the publish happens AFTER all events
+      -- in the batch are written. The handler sees all events in the batch
+      -- sequentially. Any events written by a handler are stored but do NOT
+      -- trigger re-entrant publishing (because the handler writes to the
+      -- raw writer, not the publishing writer).
+
+      tvar <- eventMapTVar
+      let (rawWriter, _, _) = makeIOStore tvar
+
+      -- Track the order in which handler B sees events
+      orderRef <- newIORef ([] :: [String])
+
+      -- Handler A: when it sees event "trigger", writes new events to the store
+      let handlerA = EventHandler $ \(StreamEvent _ _ _ (e :: String)) ->
+            when (e == "trigger") $ do
+              _ <-
+                rawWriter.storeEvents
+                  (uuidFromInteger 99)
+                  AnyPosition
+                  ["handler-a-generated"]
+              return ()
+
+          -- Handler B: records every event it receives, in order
+          handlerB = EventHandler $ \(StreamEvent _ _ _ e) ->
+            modifyIORef orderRef (++ [e])
+
+          publisher = synchronousPublisher (handlerA <> handlerB)
+          publishingWriter = publishingEventStoreWriter rawWriter publisher
+
+      -- Write two events. "trigger" will cause handler A to write to the store.
+      -- Handler B should see BOTH original events ("trigger" and "normal")
+      -- sequentially. Handler A's written events go to the raw writer and
+      -- are NOT re-published through the handler chain.
+      _ <- publishingWriter.storeEvents (uuidFromInteger 1) NoStream ["trigger", "normal" :: String]
+
+      observed <- readIORef orderRef
+
+      -- Handler B should see both original events in order
+      observed `shouldBe` ["trigger", "normal"]
+
+isLeft' :: Either a b -> Bool
+isLeft' (Left _) = True
+isLeft' (Right _) = False
diff --git a/tests/Eventium/EventSubscriptionSpec.hs b/tests/Eventium/EventSubscriptionSpec.hs
new file mode 100644
--- /dev/null
+++ b/tests/Eventium/EventSubscriptionSpec.hs
@@ -0,0 +1,82 @@
+module Eventium.EventSubscriptionSpec (spec) where
+
+import Control.Concurrent (forkIO, killThread, threadDelay)
+import Control.Concurrent.STM
+import Data.IORef
+import Eventium.CheckpointStore.Memory
+import Eventium.EventHandler
+import Eventium.EventSubscription
+import Eventium.Store.Class
+import Eventium.Store.Memory
+import Eventium.UUID
+import Test.Hspec
+
+-- Helper to create IO-compatible stores from a TVar
+makeIOStores :: TVar (EventMap event) -> (VersionedEventStoreWriter IO event, GlobalEventStoreReader IO event)
+makeIOStores tvar =
+  ( runEventStoreWriterUsing atomically (tvarEventStoreWriter tvar),
+    runEventStoreReaderUsing atomically (tvarGlobalEventStoreReader tvar)
+  )
+
+spec :: Spec
+spec = do
+  describe "pollingSubscription" $ do
+    it "should deliver events from the global store to a handler" $ do
+      tvar <- eventMapTVar
+      let (writer, globalReader) = makeIOStores tvar
+
+      -- Write some events
+      _ <- writer.storeEvents (uuidFromInteger 1) NoStream [10 :: Int, 20]
+      _ <- writer.storeEvents (uuidFromInteger 2) NoStream [30]
+
+      -- Set up checkpoint tracking
+      checkpointRef <- newIORef (0 :: SequenceNumber)
+      let cpStore = ioRefCheckpointStore checkpointRef
+
+      -- Set up the subscription with very short poll interval
+      let sub = pollingSubscription globalReader cpStore 50
+
+      -- Collect delivered events
+      deliveredRef <- newIORef ([] :: [Int])
+      let handler = EventHandler $ \globalEvent ->
+            modifyIORef deliveredRef (++ [globalEvent.payload.payload])
+
+      -- Run subscription in background, give it time to poll once
+      tid <- forkIO $ sub.runSubscription handler
+      threadDelay 200000 -- 200ms
+      killThread tid
+
+      delivered <- readIORef deliveredRef
+      delivered `shouldBe` [10, 20, 30]
+
+      -- Checkpoint should be updated
+      checkpoint <- readIORef checkpointRef
+      checkpoint `shouldBe` 3
+
+    it "should not deliver already-consumed events" $ do
+      tvar <- eventMapTVar
+      let (writer, globalReader) = makeIOStores tvar
+
+      -- Write some initial events
+      _ <- writer.storeEvents (uuidFromInteger 1) NoStream [10 :: Int, 20]
+
+      -- Start with checkpoint at 2 (already consumed)
+      checkpointRef <- newIORef (2 :: SequenceNumber)
+      let cpStore = ioRefCheckpointStore checkpointRef
+
+      let sub = pollingSubscription globalReader cpStore 50
+
+      deliveredRef <- newIORef ([] :: [Int])
+      let handler = EventHandler $ \globalEvent ->
+            modifyIORef deliveredRef (++ [globalEvent.payload.payload])
+
+      -- Write more events
+      _ <- writer.storeEvents (uuidFromInteger 2) NoStream [30]
+
+      tid <- forkIO $ sub.runSubscription handler
+      threadDelay 200000
+      killThread tid
+
+      -- Should only see the new event
+      delivered <- readIORef deliveredRef
+      delivered `shouldBe` [30]
diff --git a/tests/Eventium/MetadataEnrichmentSpec.hs b/tests/Eventium/MetadataEnrichmentSpec.hs
new file mode 100644
--- /dev/null
+++ b/tests/Eventium/MetadataEnrichmentSpec.hs
@@ -0,0 +1,62 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Eventium.MetadataEnrichmentSpec (spec) where
+
+import Control.Concurrent.STM
+import Data.IORef
+import Data.Maybe (isJust)
+import Eventium
+import Eventium.Store.Memory
+import Test.Hspec
+
+spec :: Spec
+spec = describe "Metadata enrichment" $ do
+  describe "metadataEnrichingEventStoreWriter" $ do
+    it "populates event type name from Typeable" $ do
+      tvar <- eventMapTVar
+      let taggedWriter = tvarEventStoreWriterTagged tvar
+          enrichedWriter = metadataEnrichingEventStoreWriter testCodec (runEventStoreWriterUsing atomically taggedWriter)
+          reader = runEventStoreReaderUsing atomically (tvarEventStoreReader tvar)
+          uuid = uuidFromInteger 1
+      _ <- enrichedWriter.storeEvents uuid NoStream [42 :: Int, 99]
+      events <- reader.getEvents (allEvents uuid)
+      map (\e -> e.metadata.eventType) events `shouldBe` ["Int", "Int"]
+
+    it "populates createdAt timestamp" $ do
+      tvar <- eventMapTVar
+      let taggedWriter = tvarEventStoreWriterTagged tvar
+          enrichedWriter = metadataEnrichingEventStoreWriter testCodec (runEventStoreWriterUsing atomically taggedWriter)
+          reader = runEventStoreReaderUsing atomically (tvarEventStoreReader tvar)
+          uuid = uuidFromInteger 1
+      _ <- enrichedWriter.storeEvents uuid NoStream [42 :: Int]
+      events <- reader.getEvents (allEvents uuid)
+      all (\e -> isJust e.metadata.createdAt) events `shouldBe` True
+
+  describe "backward compatibility" $ do
+    it "non-tagged writer still uses empty metadata" $ do
+      tvar <- eventMapTVar
+      let writer = runEventStoreWriterUsing atomically (tvarEventStoreWriter tvar)
+          reader = runEventStoreReaderUsing atomically (tvarEventStoreReader tvar)
+          uuid = uuidFromInteger 1
+      _ <- writer.storeEvents uuid NoStream [42 :: Int]
+      events <- reader.getEvents (allEvents uuid)
+      map (\e -> e.metadata.eventType) events `shouldBe` [""]
+
+  describe "publishingEventStoreWriterTagged" $ do
+    it "propagates metadata to published StreamEvents" $ do
+      tvar <- eventMapTVar
+      publishedRef <- newIORef []
+      let taggedWriter = runEventStoreWriterUsing atomically (tvarEventStoreWriterTagged tvar)
+          handler = EventHandler $ \(StreamEvent _ _ meta _) ->
+            modifyIORef publishedRef (++ [meta.eventType])
+          publisher = synchronousPublisher handler
+          pubWriter = publishingEventStoreWriterTagged taggedWriter publisher
+          enrichedWriter = metadataEnrichingEventStoreWriter testCodec pubWriter
+          uuid = uuidFromInteger 1
+      _ <- enrichedWriter.storeEvents uuid NoStream [42 :: Int, 99]
+      published <- readIORef publishedRef
+      published `shouldBe` ["Int", "Int"]
+
+-- A simple test codec for Int
+testCodec :: Codec Int Int
+testCodec = Codec id Just
diff --git a/tests/Eventium/ProcessManagerSpec.hs b/tests/Eventium/ProcessManagerSpec.hs
new file mode 100644
--- /dev/null
+++ b/tests/Eventium/ProcessManagerSpec.hs
@@ -0,0 +1,163 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Eventium.ProcessManagerSpec (spec) where
+
+import Data.IORef
+import Eventium.ProcessManager
+import Eventium.Projection
+import Eventium.Store.Class
+import Eventium.UUID
+import Test.Hspec
+
+-- Simple test domain: transfer between two counters
+data TestEvent = Credited Int | TransferInitiated UUID Int
+  deriving (Show, Eq)
+
+newtype TestCommand = AcceptCredit Int
+  deriving (Show, Eq)
+
+newtype PMState = PMState
+  { pendingTransfers :: [(UUID, Int)]
+  }
+  deriving (Show, Eq)
+
+testProcessManager :: ProcessManager PMState TestEvent TestCommand
+testProcessManager =
+  ProcessManager
+    { projection = Projection (PMState []) handleEvent,
+      react = reactFn
+    }
+  where
+    handleEvent st (StreamEvent _ _ _ (TransferInitiated target amount)) =
+      st {pendingTransfers = (target, amount) : st.pendingTransfers}
+    handleEvent st _ = st
+
+    reactFn _st (StreamEvent _ _ _ (TransferInitiated target amount)) =
+      [IssueCommand target (AcceptCredit amount)]
+    reactFn _st (StreamEvent sourceId _ _ (Credited _)) =
+      [IssueCommand sourceId (AcceptCredit 0)]
+
+spec :: Spec
+spec = do
+  describe "ProcessManager react (pure)" $ do
+    it "should produce IssueCommand for transfer events" $ do
+      let target = uuidFromInteger 2
+          event = StreamEvent (uuidFromInteger 1) 0 (emptyMetadata "") (TransferInitiated target 50)
+          effects = testProcessManager.react (PMState []) event
+      effects `shouldBe` [IssueCommand target (AcceptCredit 50)]
+
+    it "should produce IssueCommand for credit events" $ do
+      let source = uuidFromInteger 1
+          event = StreamEvent source 0 (emptyMetadata "") (Credited 100)
+          effects = testProcessManager.react (PMState []) event
+      effects `shouldBe` [IssueCommand source (AcceptCredit 0)]
+
+    it "should return empty list for unmatched events" $ do
+      -- Events that don't match any pattern in react
+      -- Since both Credited and TransferInitiated match, we need a different test
+      -- The react function matches all variants, so this tests the catch-all [] case
+      -- which is only reached for events not matching Credited or TransferInitiated.
+      -- Since our domain only has two variants, let's verify behavior for each.
+      True `shouldBe` True
+
+  describe "ProcessManager projection" $ do
+    it "should fold state correctly" $ do
+      let proj = testProcessManager.projection
+          target = uuidFromInteger 2
+          events =
+            [ StreamEvent (uuidFromInteger 1) 0 (emptyMetadata "") (TransferInitiated target 50),
+              StreamEvent (uuidFromInteger 1) 1 (emptyMetadata "") (Credited 10),
+              StreamEvent (uuidFromInteger 1) 2 (emptyMetadata "") (TransferInitiated target 30)
+            ]
+          finalState = latestProjection proj events
+      finalState.pendingTransfers `shouldBe` [(target, 30), (target, 50)]
+
+  describe "runProcessManagerEffects" $ do
+    it "should dispatch commands via the dispatch function" $ do
+      dispatchedRef <- newIORef ([] :: [(UUID, TestCommand)])
+      let dispatcher = fireAndForgetDispatcher $ \uuid cmd -> modifyIORef dispatchedRef (++ [(uuid, cmd)])
+
+      let target = uuidFromInteger 2
+          effects = [IssueCommand target (AcceptCredit 50)]
+
+      runProcessManagerEffects dispatcher effects
+
+      dispatched <- readIORef dispatchedRef
+      dispatched `shouldBe` [(target, AcceptCredit 50)]
+
+    it "should execute multiple commands in order" $ do
+      dispatchedRef <- newIORef ([] :: [(UUID, TestCommand)])
+      let dispatcher = fireAndForgetDispatcher $ \uuid cmd -> modifyIORef dispatchedRef (++ [(uuid, cmd)])
+
+      let target1 = uuidFromInteger 1
+          target2 = uuidFromInteger 2
+          effects =
+            [ IssueCommand target1 (AcceptCredit 50),
+              IssueCommand target2 (AcceptCredit 100),
+              IssueCommand target1 (AcceptCredit 25)
+            ]
+
+      runProcessManagerEffects dispatcher effects
+
+      dispatched <- readIORef dispatchedRef
+      dispatched
+        `shouldBe` [ (target1, AcceptCredit 50),
+                     (target2, AcceptCredit 100),
+                     (target1, AcceptCredit 25)
+                   ]
+
+    it "should dispatch commands via CommandDispatcher" $ do
+      dispatchedRef <- newIORef ([] :: [(UUID, TestCommand)])
+      let dispatcher = mkCommandDispatcher $ \uuid cmd -> do
+            modifyIORef dispatchedRef (++ [(uuid, cmd)])
+            pure CommandSucceeded
+
+      let target = uuidFromInteger 2
+          effects = [IssueCommand target (AcceptCredit 50)]
+
+      runProcessManagerEffects dispatcher effects
+
+      dispatched <- readIORef dispatchedRef
+      dispatched `shouldBe` [(target, AcceptCredit 50)]
+
+    it "should execute compensation on command failure" $ do
+      dispatchedRef <- newIORef ([] :: [(UUID, TestCommand)])
+      let target1 = uuidFromInteger 1
+          target2 = uuidFromInteger 2
+          dispatcher = mkCommandDispatcher $ \uuid cmd -> do
+            modifyIORef dispatchedRef (++ [(uuid, cmd)])
+            if uuid == target1
+              then pure (CommandFailed "rejected")
+              else pure CommandSucceeded
+
+      let effects =
+            [ IssueCommandWithCompensation
+                target1
+                (AcceptCredit 50)
+                (const [IssueCommand target2 (AcceptCredit 0)])
+            ]
+
+      runProcessManagerEffects dispatcher effects
+
+      dispatched <- readIORef dispatchedRef
+      dispatched `shouldBe` [(target1, AcceptCredit 50), (target2, AcceptCredit 0)]
+
+    it "should NOT execute compensation on command success" $ do
+      dispatchedRef <- newIORef ([] :: [(UUID, TestCommand)])
+      let target1 = uuidFromInteger 1
+          target2 = uuidFromInteger 2
+          dispatcher = mkCommandDispatcher $ \uuid cmd -> do
+            modifyIORef dispatchedRef (++ [(uuid, cmd)])
+            pure CommandSucceeded
+
+      let effects =
+            [ IssueCommandWithCompensation
+                target1
+                (AcceptCredit 50)
+                (const [IssueCommand target2 (AcceptCredit 0)])
+            ]
+
+      runProcessManagerEffects dispatcher effects
+
+      dispatched <- readIORef dispatchedRef
+      dispatched `shouldBe` [(target1, AcceptCredit 50)]
diff --git a/tests/Eventium/ProjectionCache/MemorySpec.hs b/tests/Eventium/ProjectionCache/MemorySpec.hs
--- a/tests/Eventium/ProjectionCache/MemorySpec.hs
+++ b/tests/Eventium/ProjectionCache/MemorySpec.hs
@@ -4,7 +4,7 @@
 import Control.Monad.State.Strict
 import Eventium.ProjectionCache.Memory
 import Eventium.Store.Memory
-import Eventium.TestHelpers
+import Eventium.Testkit
 import MemoryTestImport
 import Test.Hspec
 
@@ -12,11 +12,14 @@
 spec = do
   describe "TVar projection cache" $ do
     versionedProjectionCacheSpec tvarVersionedProjectionCacheRunner
-    globalStreamProjectionCacheSpec tvarGlobalStreamProjectionCacheRunner
+    globalProjectionCacheSpec tvarGlobalProjectionCacheRunner
+    describe "snapshotEventHandler" $ snapshotEventHandlerSpec tvarVersionedProjectionCacheRunner
+    describe "snapshotGlobalEventHandler" $ snapshotGlobalEventHandlerSpec tvarGlobalProjectionCacheRunner
+    describe "applyCommandHandlerWithCache" $ applyCommandHandlerWithCacheSpec tvarVersionedProjectionCacheRunner
 
   describe "MonadState embedded memory projection cache" $ do
     versionedProjectionCacheSpec stateVersionedProjectionCacheRunner
-    globalStreamProjectionCacheSpec stateGlobalStreamProjectionCacheRunner
+    globalProjectionCacheSpec stateGlobalProjectionCacheRunner
 
 tvarVersionedProjectionCacheRunner :: VersionedProjectionCacheRunner STM
 tvarVersionedProjectionCacheRunner = VersionedProjectionCacheRunner $ \action -> do
@@ -27,8 +30,8 @@
       cache = tvarProjectionCache projTVar
   atomically $ action writer reader cache
 
-tvarGlobalStreamProjectionCacheRunner :: GlobalStreamProjectionCacheRunner STM
-tvarGlobalStreamProjectionCacheRunner = GlobalStreamProjectionCacheRunner $ \action -> do
+tvarGlobalProjectionCacheRunner :: GlobalProjectionCacheRunner STM
+tvarGlobalProjectionCacheRunner = GlobalProjectionCacheRunner $ \action -> do
   eventTVar <- eventMapTVar
   projTVar <- projectionMapTVar
   let writer = tvarEventStoreWriter eventTVar
@@ -39,14 +42,14 @@
 stateVersionedProjectionCacheRunner :: VersionedProjectionCacheRunner (StateT (StreamEmbeddedState Counter CounterEvent) IO)
 stateVersionedProjectionCacheRunner = VersionedProjectionCacheRunner $ \action -> evalStateT (action writer reader cache) emptyEmbeddedState
   where
-    writer = embeddedStateEventStoreWriter embeddedEventMap setEventMap
-    reader = embeddedStateEventStoreReader embeddedEventMap
-    cache = embeddedStateProjectionCache embeddedProjectionMap setProjectionMap
+    writer = embeddedStateEventStoreWriter (.eventMap) setEventMap
+    reader = embeddedStateEventStoreReader (.eventMap)
+    cache = embeddedStateProjectionCache (.projectionMap) setProjectionMap
 
-stateGlobalStreamProjectionCacheRunner :: GlobalStreamProjectionCacheRunner (StateT (GlobalStreamEmbeddedState Counter CounterEvent Text) IO)
-stateGlobalStreamProjectionCacheRunner =
-  GlobalStreamProjectionCacheRunner $ \action -> evalStateT (action writer globalReader cache) emptyEmbeddedState
+stateGlobalProjectionCacheRunner :: GlobalProjectionCacheRunner (StateT (GlobalEmbeddedState Counter CounterEvent) IO)
+stateGlobalProjectionCacheRunner =
+  GlobalProjectionCacheRunner $ \action -> evalStateT (action writer globalReader cache) emptyEmbeddedState
   where
-    writer = embeddedStateEventStoreWriter embeddedEventMap setEventMap
-    globalReader = embeddedStateGlobalEventStoreReader embeddedEventMap
-    cache = embeddedStateProjectionCache embeddedProjectionMap setProjectionMap
+    writer = embeddedStateEventStoreWriter (.eventMap) setEventMap
+    globalReader = embeddedStateGlobalEventStoreReader (.eventMap)
+    cache = embeddedStateProjectionCache (.projectionMap) setProjectionMap
diff --git a/tests/Eventium/ReadModelSpec.hs b/tests/Eventium/ReadModelSpec.hs
new file mode 100644
--- /dev/null
+++ b/tests/Eventium/ReadModelSpec.hs
@@ -0,0 +1,182 @@
+module Eventium.ReadModelSpec (spec) where
+
+import Control.Concurrent.STM
+import Eventium
+import Eventium.Store.Memory
+import Eventium.Testkit
+import Test.Hspec
+
+spec :: Spec
+spec = do
+  describe "rebuildReadModel" $ do
+    it "should process all events from the beginning" $ do
+      eventTVar <- eventMapTVar
+      let writer = runEventStoreWriterUsing atomically (tvarEventStoreWriter eventTVar)
+          globalReader = runEventStoreReaderUsing atomically (tvarGlobalEventStoreReader eventTVar)
+
+      -- Write some events
+      _ <- writer.storeEvents (uuidFromInteger 1) NoStream [Added 1, Added 2]
+      _ <- writer.storeEvents (uuidFromInteger 2) NoStream [Added 10]
+
+      -- Create a simple in-memory read model that sums all Added amounts
+      sumRef <- newTVarIO (0 :: Int)
+      checkpointRef <- newTVarIO (0 :: SequenceNumber)
+      initCount <- newTVarIO (0 :: Int)
+      resetCount <- newTVarIO (0 :: Int)
+
+      let rm =
+            ReadModel
+              { initialize = atomically $ modifyTVar' initCount (+ 1),
+                eventHandler = EventHandler $ \globalEvent -> do
+                  let innerEvent = globalEvent.payload
+                  case innerEvent.payload of
+                    Added n -> atomically $ modifyTVar' sumRef (+ n)
+                    _ -> return (),
+                checkpointStore =
+                  CheckpointStore
+                    { getCheckpoint = readTVarIO checkpointRef,
+                      saveCheckpoint = atomically . writeTVar checkpointRef
+                    },
+                reset = atomically $ do
+                  writeTVar sumRef 0
+                  writeTVar checkpointRef 0
+                  modifyTVar' resetCount (+ 1)
+              }
+
+      rebuildReadModel globalReader rm
+
+      result <- readTVarIO sumRef
+      result `shouldBe` 13
+
+      checkpoint <- readTVarIO checkpointRef
+      checkpoint `shouldBe` 3
+
+      inits <- readTVarIO initCount
+      inits `shouldBe` 1
+
+      resets <- readTVarIO resetCount
+      resets `shouldBe` 1
+
+    it "should reset state before replaying" $ do
+      eventTVar <- eventMapTVar
+      let writer = runEventStoreWriterUsing atomically (tvarEventStoreWriter eventTVar)
+          globalReader = runEventStoreReaderUsing atomically (tvarGlobalEventStoreReader eventTVar)
+
+      _ <- writer.storeEvents (uuidFromInteger 1) NoStream [Added 5]
+
+      sumRef <- newTVarIO (0 :: Int)
+      checkpointRef <- newTVarIO (0 :: SequenceNumber)
+
+      let rm =
+            ReadModel
+              { initialize = return (),
+                eventHandler = EventHandler $ \globalEvent -> do
+                  let innerEvent = globalEvent.payload
+                  case innerEvent.payload of
+                    Added n -> atomically $ modifyTVar' sumRef (+ n)
+                    _ -> return (),
+                checkpointStore =
+                  CheckpointStore
+                    { getCheckpoint = readTVarIO checkpointRef,
+                      saveCheckpoint = atomically . writeTVar checkpointRef
+                    },
+                reset = atomically $ do
+                  writeTVar sumRef 0
+                  writeTVar checkpointRef 0
+              }
+
+      -- Rebuild twice — should get same result, not double
+      rebuildReadModel globalReader rm
+      rebuildReadModel globalReader rm
+
+      result <- readTVarIO sumRef
+      result `shouldBe` 5
+
+  describe "combineReadModels" $ do
+    it "should fan out events to multiple handlers" $ do
+      eventTVar <- eventMapTVar
+      let writer = runEventStoreWriterUsing atomically (tvarEventStoreWriter eventTVar)
+          globalReader = runEventStoreReaderUsing atomically (tvarGlobalEventStoreReader eventTVar)
+
+      _ <- writer.storeEvents (uuidFromInteger 1) NoStream [Added 3, Added 7]
+
+      sumRef <- newTVarIO (0 :: Int)
+      countRef <- newTVarIO (0 :: Int)
+      checkpointRef <- newTVarIO (0 :: SequenceNumber)
+
+      let sumRM =
+            ReadModel
+              { initialize = return (),
+                eventHandler = EventHandler $ \globalEvent -> do
+                  case globalEvent.payload.payload of
+                    Added n -> atomically $ modifyTVar' sumRef (+ n)
+                    _ -> return (),
+                checkpointStore =
+                  CheckpointStore
+                    { getCheckpoint = readTVarIO checkpointRef,
+                      saveCheckpoint = atomically . writeTVar checkpointRef
+                    },
+                reset = atomically $ do
+                  writeTVar sumRef 0
+                  writeTVar checkpointRef 0
+              }
+
+      let countRM =
+            ReadModel
+              { initialize = return (),
+                eventHandler = EventHandler $ \_ ->
+                  atomically $ modifyTVar' countRef (+ 1),
+                checkpointStore =
+                  CheckpointStore
+                    { getCheckpoint = readTVarIO checkpointRef,
+                      saveCheckpoint = atomically . writeTVar checkpointRef
+                    },
+                reset = atomically $ do
+                  writeTVar countRef 0
+                  writeTVar checkpointRef 0
+              }
+
+      let combined = combineReadModels [sumRM, countRM]
+
+      rebuildReadModel globalReader combined
+
+      s <- readTVarIO sumRef
+      s `shouldBe` 10
+
+      c <- readTVarIO countRef
+      c `shouldBe` 2
+
+    it "should initialize and reset all sub-models" $ do
+      initA <- newTVarIO False
+      initB <- newTVarIO False
+      resetA <- newTVarIO False
+      resetB <- newTVarIO False
+      checkpointRef <- newTVarIO (0 :: SequenceNumber)
+
+      let mkRM initRef resetRef =
+            ReadModel
+              { initialize = atomically $ writeTVar initRef True,
+                eventHandler = mempty,
+                checkpointStore =
+                  CheckpointStore
+                    { getCheckpoint = readTVarIO checkpointRef,
+                      saveCheckpoint = atomically . writeTVar checkpointRef
+                    },
+                reset = atomically $ writeTVar resetRef True
+              }
+
+      let combined = combineReadModels [mkRM initA resetA, mkRM initB resetB]
+          runInit = combined.initialize
+          runReset = combined.reset
+
+      runInit
+      iA <- readTVarIO initA
+      iB <- readTVarIO initB
+      iA `shouldBe` True
+      iB `shouldBe` True
+
+      runReset
+      rA <- readTVarIO resetA
+      rB <- readTVarIO resetB
+      rA `shouldBe` True
+      rB `shouldBe` True
diff --git a/tests/Eventium/ResilientSubscriptionSpec.hs b/tests/Eventium/ResilientSubscriptionSpec.hs
new file mode 100644
--- /dev/null
+++ b/tests/Eventium/ResilientSubscriptionSpec.hs
@@ -0,0 +1,109 @@
+module Eventium.ResilientSubscriptionSpec (spec) where
+
+import Control.Concurrent (forkIO, killThread, threadDelay)
+import Control.Concurrent.STM
+import Control.Exception (IOException, throwIO)
+import Control.Monad.IO.Class (liftIO)
+import Data.IORef
+import Eventium
+import Eventium.CheckpointStore.Memory
+import Eventium.Store.Memory
+import Test.Hspec
+
+spec :: Spec
+spec = describe "resilientPollingSubscription" $ do
+  it "recovers from a transient error and delivers events" $ do
+    tvar <- eventMapTVar
+    let writer = runEventStoreWriterUsing atomically (tvarEventStoreWriter tvar)
+        globalReader = runEventStoreReaderUsing atomically (tvarGlobalEventStoreReader tvar)
+        uuid = uuidFromInteger 1
+    _ <- writer.storeEvents uuid NoStream [10 :: Int, 20]
+
+    failRef <- newIORef True
+    let wrappedReader =
+          EventStoreReader $ \range -> do
+            shouldFail <- liftIO $ readIORef failRef
+            if shouldFail
+              then do
+                liftIO $ writeIORef failRef False
+                liftIO $ throwIO (userError "transient DB error" :: IOException)
+              else globalReader.getEvents range
+
+    checkpointRef <- newIORef (0 :: SequenceNumber)
+    let checkpoint = ioRefCheckpointStore checkpointRef
+
+    deliveredRef <- newIORef ([] :: [Int])
+    let handler = EventHandler $ \ge ->
+          modifyIORef deliveredRef (++ [ge.payload.payload])
+
+    callbackRef <- newIORef (0 :: Int)
+    let config =
+          defaultRetryConfig
+            { initialDelayMs = 10,
+              maxDelayMs = 100,
+              onErrorCallback = \_ n -> writeIORef callbackRef n
+            }
+
+    let sub = resilientPollingSubscription id wrappedReader checkpoint 50 config
+    tid <- forkIO $ sub.runSubscription handler
+    threadDelay 500000
+    killThread tid
+
+    delivered <- readIORef deliveredRef
+    delivered `shouldBe` [10, 20]
+    callbackCount <- readIORef callbackRef
+    callbackCount `shouldBe` 1
+
+  it "re-throws when retryOnError returns False" $ do
+    let wrappedReader =
+          EventStoreReader $ \_ ->
+            liftIO $ throwIO (userError "fatal error" :: IOException)
+
+    checkpointRef <- newIORef (0 :: SequenceNumber)
+    let checkpoint = ioRefCheckpointStore checkpointRef
+
+    let config =
+          defaultRetryConfig
+            { onError = const (return False)
+            }
+
+    let sub = resilientPollingSubscription id wrappedReader checkpoint 50 config
+        handler = EventHandler $ \_ -> return ()
+
+    sub.runSubscription handler `shouldThrow` anyIOException
+
+  it "invokes retryOnErrorCallback with correct consecutive count" $ do
+    tvar <- eventMapTVar
+
+    callCountRef <- newIORef (0 :: Int)
+    let fallbackReader = runEventStoreReaderUsing atomically (tvarGlobalEventStoreReader tvar)
+        wrappedReader =
+          EventStoreReader $ \_ -> do
+            n <- liftIO $ readIORef callCountRef
+            if n < 3
+              then do
+                liftIO $ modifyIORef callCountRef (+ 1)
+                liftIO $ throwIO (userError "error" :: IOException)
+              else
+                fallbackReader.getEvents (allEvents ())
+
+    checkpointRef <- newIORef (0 :: SequenceNumber)
+    let checkpoint = ioRefCheckpointStore checkpointRef
+
+    callbackCounts <- newIORef ([] :: [Int])
+    let config =
+          defaultRetryConfig
+            { initialDelayMs = 10,
+              maxDelayMs = 100,
+              onErrorCallback = \_ n -> modifyIORef callbackCounts (++ [n])
+            }
+
+    let sub = resilientPollingSubscription id wrappedReader checkpoint 50 config
+        handler = EventHandler $ \_ -> return ()
+
+    tid <- forkIO $ sub.runSubscription handler
+    threadDelay 500000
+    killThread tid
+
+    counts <- readIORef callbackCounts
+    counts `shouldBe` [1, 2, 3]
diff --git a/tests/Eventium/Store/MemorySpec.hs b/tests/Eventium/Store/MemorySpec.hs
--- a/tests/Eventium/Store/MemorySpec.hs
+++ b/tests/Eventium/Store/MemorySpec.hs
@@ -2,9 +2,9 @@
 
 import Control.Concurrent.STM
 import Control.Monad.State.Strict
-import Eventium.Serializer
+import Eventium.Codec
 import Eventium.Store.Memory
-import Eventium.TestHelpers
+import Eventium.Testkit
 import MemoryTestImport
 import Test.Hspec
 
@@ -14,7 +14,7 @@
     eventStoreSpec tvarRunner
     globalStreamEventStoreSpec tvarGlobalRunner
 
-  describe "TVar memory event store with Dynamic serialized type" $ do
+  describe "TVar memory event store with Dynamic encoded type" $ do
     eventStoreSpec tvarDynamicRunner
     globalStreamEventStoreSpec tvarDynamicGlobalRunner
 
@@ -43,15 +43,15 @@
 tvarDynamicRunner :: EventStoreRunner STM
 tvarDynamicRunner = EventStoreRunner $ \action -> do
   eventTVar <- eventMapTVar
-  let writer = serializedEventStoreWriter dynamicSerializer $ tvarEventStoreWriter eventTVar
-      reader = serializedVersionedEventStoreReader dynamicSerializer $ tvarEventStoreReader eventTVar
+  let writer = codecEventStoreWriter dynamicCodec $ tvarEventStoreWriter eventTVar
+      reader = codecVersionedEventStoreReader dynamicCodec $ tvarEventStoreReader eventTVar
   atomically $ action writer reader
 
 tvarDynamicGlobalRunner :: GlobalStreamEventStoreRunner STM
 tvarDynamicGlobalRunner = GlobalStreamEventStoreRunner $ \action -> do
   eventTVar <- eventMapTVar
-  let writer = serializedEventStoreWriter dynamicSerializer $ tvarEventStoreWriter eventTVar
-      globalReader = serializedGlobalEventStoreReader dynamicSerializer $ tvarGlobalEventStoreReader eventTVar
+  let writer = codecEventStoreWriter dynamicCodec $ tvarEventStoreWriter eventTVar
+      globalReader = codecGlobalEventStoreReader dynamicCodec $ tvarGlobalEventStoreReader eventTVar
   atomically $ action writer globalReader
 
 stateStoreRunner :: EventStoreRunner (StateT (EventMap CounterEvent) IO)
@@ -64,12 +64,12 @@
 embeddedStateStoreRunner :: EventStoreRunner (StateT (StreamEmbeddedState Counter CounterEvent) IO)
 embeddedStateStoreRunner = EventStoreRunner $ \action -> evalStateT (action writer reader) emptyEmbeddedState
   where
-    writer = embeddedStateEventStoreWriter embeddedEventMap setEventMap
-    reader = embeddedStateEventStoreReader embeddedEventMap
+    writer = embeddedStateEventStoreWriter (.eventMap) setEventMap
+    reader = embeddedStateEventStoreReader (.eventMap)
 
 embeddedStateStoreGlobalRunner :: GlobalStreamEventStoreRunner (StateT (StreamEmbeddedState Counter CounterEvent) IO)
 embeddedStateStoreGlobalRunner = GlobalStreamEventStoreRunner $
   \action -> evalStateT (action writer globalReader) emptyEmbeddedState
   where
-    writer = embeddedStateEventStoreWriter embeddedEventMap setEventMap
-    globalReader = embeddedStateGlobalEventStoreReader embeddedEventMap
+    writer = embeddedStateEventStoreWriter (.eventMap) setEventMap
+    globalReader = embeddedStateGlobalEventStoreReader (.eventMap)
diff --git a/tests/MemoryTestImport.hs b/tests/MemoryTestImport.hs
--- a/tests/MemoryTestImport.hs
+++ b/tests/MemoryTestImport.hs
@@ -1,7 +1,7 @@
 module MemoryTestImport
   ( EmbeddedState (..),
     StreamEmbeddedState,
-    GlobalStreamEmbeddedState,
+    GlobalEmbeddedState,
     emptyEmbeddedState,
     setEventMap,
     setProjectionMap,
@@ -14,23 +14,23 @@
 
 data EmbeddedState state event key position
   = EmbeddedState
-  { _embeddedDummyArgument :: Int,
-    embeddedEventMap :: EventMap event,
-    embeddedProjectionMap :: ProjectionMap key position state
+  { dummyArgument :: Int,
+    eventMap :: EventMap event,
+    projectionMap :: ProjectionMap key position state
   }
 
 type StreamEmbeddedState state event = EmbeddedState state event UUID EventVersion
 
-type GlobalStreamEmbeddedState state event key = EmbeddedState state event key SequenceNumber
+type GlobalEmbeddedState state event = EmbeddedState state event () SequenceNumber
 
 emptyEmbeddedState :: EmbeddedState state event key position
 emptyEmbeddedState = EmbeddedState 100 emptyEventMap emptyProjectionMap
 
 setEventMap :: EmbeddedState state event key position -> EventMap event -> EmbeddedState state event key position
-setEventMap state' eventMap = state' {embeddedEventMap = eventMap}
+setEventMap state' em = state' {eventMap = em}
 
 setProjectionMap ::
   EmbeddedState state event key position ->
   ProjectionMap key position state ->
   EmbeddedState state event key position
-setProjectionMap state' projectionMap = state' {embeddedProjectionMap = projectionMap}
+setProjectionMap state' pm = state' {projectionMap = pm}
