diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2017 Tweag I/O
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/cas-store.cabal b/cas-store.cabal
new file mode 100644
--- /dev/null
+++ b/cas-store.cabal
@@ -0,0 +1,80 @@
+Name:                cas-store
+Version:             1.0.0
+Synopsis:            A content-addressed storage
+Description:
+            A content-addressed storage supporting a remote caching. The API mainly consists of the cacheKleisliIO function which takes a (a -> m b) function
+            and runs it only if the store doesn't already contain a result for it. Part of the funflow ecosystem.
+License:             MIT
+License-file:        LICENSE
+Author:              Tom Nielsen, Nicholas Clarke, Andreas Herrmann
+Maintainer:          yves.pares@tweag.io
+build-type:          Simple
+Cabal-Version:       >= 1.10
+homepage:            https://github.com/tweag/funflow
+bug-reports:         https://github.com/tweag/funflow
+category:            Control
+Tested-With:         GHC == 7.8.4, GHC == 7.10.2, GHC == 7.10.3, GHC == 8.0.1
+
+extra-source-files:
+                   changelog.md
+
+Library
+   ghc-options:       -Wall -fno-warn-type-defaults
+   hs-source-dirs:    src
+   default-language:  Haskell2010
+
+   Exposed-modules:  Data.CAS.ContentStore
+                   , Data.CAS.Lock
+                   , Data.CAS.RemoteCache
+   Other-modules: Data.CAS.ContentStore.Notify
+   Build-depends:
+                 base                    >= 4.6 && <5
+               , aeson                   >= 1.2.3.0
+               , async
+               , bytestring
+               , cas-hashable            >= 1.0.0 && < 2
+               , containers
+               , cryptonite
+               , directory
+               , filepath
+               , hashable
+               , hostname
+               , lens
+               , monad-control
+               , path
+               , path-io
+               , random
+               , safe-exceptions
+               , sqlite-simple
+               , store
+               , tar
+               , text
+               , unix
+   if os(linux)
+     CPP-options: -DOS_Linux
+     Other-modules: Data.CAS.ContentStore.Notify.Linux
+     Build-depends: hinotify             >= 0.3.9
+   else
+     if os(darwin) || os(freebsd)
+       CPP-options: -DOS_BSD
+       Other-modules: Data.CAS.ContentStore.Notify.BSD
+       Build-depends: kqueue
+
+Test-suite unit-tests
+  type:               exitcode-stdio-1.0
+  default-language:   Haskell2010
+  hs-source-dirs:     test
+  main-is:            Test.hs
+  other-modules:      CAS.ContentStore
+  ghc-options:        -Wall -threaded
+  build-depends:      base
+                    , async
+                    , cas-hashable
+                    , cas-store
+                    , containers
+                    , path
+                    , path-io
+                    , safe-exceptions
+                    , tasty
+                    , tasty-hunit
+                    , unix
diff --git a/changelog.md b/changelog.md
new file mode 100644
--- /dev/null
+++ b/changelog.md
diff --git a/src/Data/CAS/ContentStore.hs b/src/Data/CAS/ContentStore.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/CAS/ContentStore.hs
@@ -0,0 +1,1155 @@
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+{-# LANGUAGE DeriveGeneric              #-}
+{-# LANGUAGE FlexibleContexts           #-}
+{-# LANGUAGE FlexibleInstances          #-}
+{-# LANGUAGE GADTs                      #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE LambdaCase                 #-}
+{-# LANGUAGE MultiParamTypeClasses      #-}
+{-# LANGUAGE NamedFieldPuns             #-}
+{-# LANGUAGE OverloadedStrings          #-}
+{-# LANGUAGE PatternSynonyms            #-}
+{-# LANGUAGE QuasiQuotes                #-}
+{-# LANGUAGE RecordWildCards            #-}
+{-# LANGUAGE ScopedTypeVariables        #-}
+{-# LANGUAGE StandaloneDeriving         #-}
+
+-- | Hash addressed store in file system.
+--
+-- Associates a key ('Data.CAS.ContentHashable.ContentHash')
+-- with an item in the store. An item can either be
+-- 'Data.CAS.ContentStore.Missing',
+-- 'Data.CAS.ContentStore.Pending', or
+-- 'Data.CAS.ContentStore.Complete'.
+-- The state is persisted in the file system.
+--
+-- Items are stored under a path derived from their hash. Therefore,
+-- there can be no two copies of the same item in the store.
+-- If two keys are associated with the same item, then there will be
+-- only one copy of that item in the store.
+--
+-- The store is thread-safe and multi-process safe.
+--
+-- It is assumed that the user that the process is running under is the owner
+-- of the store root, or has permission to create it if missing.
+--
+-- It is assumed that the store root and its immediate contents are not modified
+-- externally. The contents of pending items may be modified externally.
+--
+-- __Implementation notes:__
+--
+-- The hash of an item can only be determined once it is completed.
+-- If that hash already exists in the store, then the new item is discarded.
+--
+-- Store state is persisted in the file-system:
+--
+-- * Pending items are stored writable under the path @pending-\<key>@.
+-- * Complete items are stored read-only under the path @item-\<hash>@,
+--   with a link under @complete-\<key>@ pointing to that directory.
+module Data.CAS.ContentStore
+  (
+  -- * Open/Close
+    withStore
+  , open
+  , close
+
+  -- * High-level API
+  , CacherM (..)
+  , Cacher
+  , defaultCacherWithIdent
+  , defaultIOCacherWithIdent
+  , cacheKleisliIO
+  , putInStore
+  , contentPath
+  
+  -- * List Contents
+  , listAll
+  , listPending
+  , listComplete
+  , listItems
+
+  -- * Query/Lookup
+  , query
+  , isMissing
+  , isPending
+  , isComplete
+  , lookup
+  , lookupOrWait
+  , waitUntilComplete
+
+  -- * Construct Items
+  , constructOrAsync
+  , constructOrWait
+  , constructIfMissing
+  , withConstructIfMissing
+  , markPending
+  , markComplete
+
+  -- * Remove Contents
+  , removeFailed
+  , removeForcibly
+  , removeItemForcibly
+
+  -- * Aliases
+  , assignAlias
+  , lookupAlias
+  , removeAlias
+  , listAliases
+
+  -- * Metadata
+  , getBackReferences
+  , setInputs
+  , getInputs
+  , setMetadata
+  , getMetadata
+  , createMetadataFile
+  , getMetadataFile
+
+  -- * Accessors
+  , itemHash
+  , itemPath
+  , itemRelPath
+  , contentItem
+  , contentFilename
+  , root
+
+  -- * Types
+  , ContentStore
+  , Item
+  , Content (..)
+  , (^</>)
+  , Alias (..)
+  , Status (..)
+  , Status_
+  , Update (..)
+  , StoreError (..)
+  ) where
+
+
+import           Prelude                             hiding (lookup)
+
+import           Control.Arrow                       (second)
+import           Control.Concurrent                  (threadDelay)
+import           Control.Concurrent.Async
+import           Control.Concurrent.MVar
+import           Control.Exception.Safe
+import           Control.Lens
+import           Control.Monad                       (forM_, forever, unless,
+                                                      void, when, (<=<), (>=>),
+                                                      mzero)
+import           Control.Monad.IO.Class              (MonadIO, liftIO)
+import           Control.Monad.Trans.Control         (MonadBaseControl)
+import           Crypto.Hash                         (hashUpdate)
+import           Data.Aeson                          (FromJSON, ToJSON)
+import           Data.Bits                           (complement)
+import           Data.ByteString                     (ByteString)
+import qualified Data.ByteString                     as BS
+import qualified Data.ByteString.Char8               as C8
+import           Data.CAS.ContentStore.Notify
+import           Data.Foldable                       (asum)
+import qualified Data.Hashable
+import           Data.List                           (foldl', stripPrefix)
+import           Data.Maybe                          (fromMaybe, listToMaybe)
+import           Data.Monoid                         ((<>))
+import qualified Data.Store
+import           Data.String                         (IsString (..))
+import qualified Data.Text                           as T
+import           Data.Typeable                       (Typeable)
+import           Data.Void
+import qualified Database.SQLite.Simple              as SQL
+import qualified Database.SQLite.Simple.FromField    as SQL
+import qualified Database.SQLite.Simple.ToField      as SQL
+import           GHC.Generics                        (Generic)
+import           Path
+import           Path.IO
+import           System.Directory                    (removePathForcibly)
+import           System.FilePath                     (dropTrailingPathSeparator)
+import           System.IO                           (Handle, IOMode (..),
+                                                      openFile)
+import           System.Posix.Files
+import           System.Posix.Types
+
+import           Data.CAS.ContentHashable            (ContentHash,
+                                                      ContentHashable (..),
+                                                      DirectoryContent (..),
+                                                      contentHashUpdate_fingerprint,
+                                                      decodeHash, encodeHash, pathToHash,
+                                                      toBytes)
+import           Data.CAS.Lock
+import qualified Data.CAS.RemoteCache                as Remote
+
+
+-- | Status of an item in the store.
+data Status missing pending complete
+  = Missing missing
+  -- ^ The item does not exist, yet.
+  | Pending pending
+  -- ^ The item is under construction and not ready for consumption.
+  | Complete complete
+  -- ^ The item is complete and ready for consumption.
+  deriving (Eq, Show)
+
+type Status_ = Status () () ()
+
+-- | Update about the status of a pending item.
+data Update
+  = Completed Item
+  -- ^ The item is now completed and ready for consumption.
+  | Failed
+  -- ^ Constructing the item failed.
+  deriving (Eq, Show)
+
+-- | Errors that can occur when interacting with the store.
+data StoreError
+  = NotPending ContentHash
+  -- ^ An item is not under construction when it should be.
+  | AlreadyPending ContentHash
+  -- ^ An item is already under construction when it should be missing.
+  | AlreadyComplete ContentHash
+  -- ^ An item is already complete when it shouldn't be.
+  | CorruptedLink ContentHash FilePath
+  -- ^ The link under the given hash points to an invalid path.
+  | FailedToConstruct ContentHash
+  -- ^ A failure occurred while waiting for the item to be constructed.
+  | IncompatibleStoreVersion (Path Abs Dir) Int Int
+  -- ^ @IncompatibleStoreVersion storeDir actual expected@
+  --   The given store has a version number that is incompatible.
+  | MalformedMetadataEntry ContentHash SQL.SQLData
+  -- ^ @MalformedMetadataEntry hash key@
+  --   The metadata entry for the give @hash@, @key@ pair is malformed.
+  deriving (Show, Typeable)
+instance Exception StoreError where
+  displayException = \case
+    NotPending hash ->
+      "The following input hash is not pending '"
+      ++ C8.unpack (encodeHash hash)
+      ++ "'."
+    AlreadyPending hash ->
+      "The following input hash is already pending '"
+      ++ C8.unpack (encodeHash hash)
+      ++ "'."
+    AlreadyComplete hash ->
+      "The following input hash is already completed '"
+      ++ C8.unpack (encodeHash hash)
+      ++ "'."
+    CorruptedLink hash fp ->
+      "The completed input hash '"
+      ++ C8.unpack (encodeHash hash)
+      ++ "' points to an invalid store item '"
+      ++ fp
+      ++ "'."
+    FailedToConstruct hash ->
+      "Failed to construct the input hash '"
+      ++ C8.unpack (encodeHash hash)
+      ++ "'."
+    IncompatibleStoreVersion storeDir actual expected ->
+      "The store in '"
+      ++ fromAbsDir storeDir
+      ++ "' has version "
+      ++ show actual
+      ++ ". This software expects version "
+      ++ show expected
+      ++ ". No automatic migration is available, \
+         \please use a fresh store location."
+    MalformedMetadataEntry hash key ->
+      "The metadtaa entry for hash '"
+      ++ C8.unpack (encodeHash hash)
+      ++ "' under key '"
+      ++ show key
+      ++ "' is malformed."
+
+-- | A hash addressed store on the file system.
+data ContentStore = ContentStore
+  { storeRoot     :: Path Abs Dir
+  -- ^ Root directory of the content store.
+  -- The process must be able to create this directory if missing,
+  -- change permissions, and create files and directories within.
+  , storeLock     :: Lock
+  -- ^ Write lock on store metadata to ensure multi thread and process safety.
+  -- The lock is taken when item state is changed or queried.
+  , storeNotifier :: Notifier
+  -- ^ Used to watch for updates on store items.
+  , storeDb       :: SQL.Connection
+  -- ^ Connection to the metadata SQLite database.
+  }
+
+-- | A completed item in the 'ContentStore'.
+data Item = Item { itemHash :: ContentHash }
+  deriving (Eq, Ord, Show, Generic)
+
+instance Monad m => ContentHashable m Item where
+  contentHashUpdate ctx item =
+    flip contentHashUpdate_fingerprint item
+    >=> pure . flip hashUpdate (toBytes $ itemHash item)
+    $ ctx
+
+instance FromJSON Item
+instance ToJSON Item
+instance Data.Hashable.Hashable Item
+instance Data.Store.Store Item
+
+-- | File or directory within a content store 'Item'.
+data Content t where
+  All :: Item -> Content Dir
+  (:</>) :: Item -> Path Rel t -> Content t
+infixr 5 :</>
+deriving instance Eq (Content t)
+deriving instance Show (Content t)
+instance Monad m => ContentHashable m (Content Dir) where
+  contentHashUpdate ctx x = case x of
+    All i ->
+      flip contentHashUpdate_fingerprint x
+      >=> flip contentHashUpdate i
+      $ ctx
+    i :</> p ->
+      flip contentHashUpdate_fingerprint x
+      >=> flip contentHashUpdate i
+      >=> flip contentHashUpdate p
+      $ ctx
+instance Monad m => ContentHashable m (Content File) where
+  contentHashUpdate ctx x = case x of
+    i :</> p ->
+      flip contentHashUpdate_fingerprint x
+      >=> flip contentHashUpdate i
+      >=> flip contentHashUpdate p
+      $ ctx
+
+-- | Append to the path within a store item.
+(^</>) :: Content Dir -> Path Rel t -> Content t
+All item ^</> path = item :</> path
+(item :</> dir) ^</> path = item :</> dir </> path
+infixl 4 ^</>
+  
+newtype Alias = Alias { unAlias :: T.Text }
+  deriving (ContentHashable IO, Eq, Ord, Show, SQL.FromField, SQL.ToField, Data.Store.Store)
+
+-- | The root directory of the store.
+root :: ContentStore -> Path Abs Dir
+root = storeRoot
+
+-- | The scoped path to a content item within the store.
+itemRelPath :: Item -> Path Rel Dir
+itemRelPath (Item x) = prefixHashPath itemPrefix x
+
+-- | The store path of a completed item.
+itemPath :: ContentStore -> Item -> Path Abs Dir
+itemPath store = mkItemPath store . itemHash
+
+-- | Store item containing the given content.
+contentItem :: Content t -> Item
+contentItem (All i)    = i
+contentItem (i :</> _) = i
+
+contentFilename :: Content File -> Path Rel File
+contentFilename (_ :</> relPath) = filename relPath
+
+-- | The absolute path to content within the store.
+contentPath :: ContentStore -> Content t -> Path Abs t
+contentPath store (All item)      = itemPath store item
+contentPath store (item :</> dir) = itemPath store item </> dir
+
+-- | @open root@ opens a store under the given root directory.
+--
+-- The root directory is created if necessary.
+--
+-- It is not safe to have multiple store objects
+-- refer to the same root directory.
+open :: Path Abs Dir -> IO ContentStore
+open storeRoot = do
+  createDirIfMissing True storeRoot
+  storeLock <- openLock (lockPath storeRoot)
+  withLock storeLock $ withWritableStoreRoot storeRoot $ do
+    storeDb <- SQL.open (fromAbsFile $ dbPath storeRoot)
+    initDb storeRoot storeDb
+    createDirIfMissing True (metadataPath storeRoot)
+    storeNotifier <- initNotifier
+    return ContentStore {..}
+
+-- | Free the resources associated with the given store object.
+--
+-- The store object may not be used afterwards.
+close :: ContentStore -> IO ()
+close store = do
+  closeLock (storeLock store)
+  killNotifier (storeNotifier store)
+  SQL.close (storeDb store)
+
+-- | Open the store under the given root and perform the given action.
+-- Closes the store once the action is complete
+--
+-- See also: 'Data.CAS.ContentStore.open'
+withStore :: (MonadIO m, MonadMask m)
+  => Path Abs Dir -> (ContentStore -> m a) -> m a
+withStore root' = bracket (liftIO $ open root') (liftIO . close)
+
+-- | List all elements in the store
+-- @(pending keys, completed keys, completed items)@.
+listAll :: MonadIO m => ContentStore -> m ([ContentHash], [ContentHash], [Item])
+listAll ContentStore {storeRoot} = liftIO $
+  foldr go ([], [], []) . fst <$> listDir storeRoot
+  where
+    go d prev@(builds, outs, items) = fromMaybe prev $ asum
+      [ parsePending d >>= \x -> Just (x:builds, outs, items)
+      , parseComplete d >>= \x -> Just (builds, x:outs, items)
+      , parseItem d >>= \x -> Just (builds, outs, x:items)
+      ]
+    parsePending :: Path Abs Dir -> Maybe ContentHash
+    parsePending = pathToHash <=< stripPrefix pendingPrefix . extractDir
+    parseComplete :: Path Abs Dir -> Maybe ContentHash
+    parseComplete = pathToHash <=< stripPrefix completePrefix . extractDir
+    parseItem :: Path Abs Dir -> Maybe Item
+    parseItem = fmap Item . pathToHash <=< stripPrefix itemPrefix . extractDir
+    extractDir :: Path Abs Dir -> FilePath
+    extractDir = dropTrailingPathSeparator . fromRelDir . dirname
+
+-- | List all pending keys in the store.
+listPending :: MonadIO m => ContentStore -> m [ContentHash]
+listPending = fmap (^._1) . listAll
+
+-- | List all completed keys in the store.
+listComplete :: MonadIO m => ContentStore -> m [ContentHash]
+listComplete = fmap (^._2) . listAll
+
+-- | List all completed items in the store.
+listItems :: MonadIO m => ContentStore -> m [Item]
+listItems = fmap (^._3) . listAll
+
+-- | Query the state of the item under the given key.
+query :: MonadIO m => ContentStore -> ContentHash -> m (Status () () ())
+query store hash = liftIO . withStoreLock store $
+  internalQuery store hash >>= pure . \case
+    Missing _ -> Missing ()
+    Pending _ -> Pending ()
+    Complete _ -> Complete ()
+
+-- | Check if there is no complete or pending item under the given key.
+isMissing :: MonadIO m => ContentStore -> ContentHash -> m Bool
+isMissing store hash = (== Missing ()) <$> query store hash
+
+-- | Check if there is a pending item under the given key.
+isPending :: MonadIO m => ContentStore -> ContentHash -> m Bool
+isPending store hash = (== Pending ()) <$> query store hash
+
+-- | Check if there is a completed item under the given key.
+isComplete :: MonadIO m => ContentStore -> ContentHash -> m Bool
+isComplete store hash = (== Complete ()) <$> query store hash
+
+-- | Query the state under the given key and return the item if completed.
+-- Doesn't block if the item is pending.
+lookup :: MonadIO m => ContentStore -> ContentHash -> m (Status () () Item)
+lookup store hash = liftIO . withStoreLock store $
+  internalQuery store hash >>= \case
+    Missing () -> return $ Missing ()
+    Pending _ -> return $ Pending ()
+    Complete item -> return $ Complete item
+
+-- | Query the state under the given key and return the item if completed.
+-- Return an 'Control.Concurrent.Async' to await an update, if pending.
+lookupOrWait
+  :: MonadIO m
+  => ContentStore
+  -> ContentHash
+  -> m (Status () (Async Update) Item)
+lookupOrWait store hash = liftIO . withStoreLock store $
+  internalQuery store hash >>= \case
+    Complete item -> return $ Complete item
+    Missing () -> return $ Missing ()
+    Pending _ -> Pending <$> internalWatchPending store hash
+
+-- | Query the state under the given key and return the item once completed.
+-- Blocks if the item is pending.
+-- Returns 'Nothing' if the item is missing, or failed to be completed.
+waitUntilComplete :: MonadIO m => ContentStore -> ContentHash -> m (Maybe Item)
+waitUntilComplete store hash = lookupOrWait store hash >>= \case
+  Complete item -> return $ Just item
+  Missing () -> return Nothing
+  Pending a -> liftIO (wait a) >>= \case
+    Completed item -> return $ Just item
+    Failed -> return Nothing
+
+-- | Atomically query the state under the given key and mark pending if missing.
+--
+-- Returns @'Complete' item@ if the item is complete.
+-- Returns @'Pending' async@ if the item is pending, where @async@ is an
+-- 'Control.Concurrent.Async' to await updates on.
+-- Returns @'Missing' buildDir@ if the item was missing, and is now pending.
+-- It should be constructed in the given @buildDir@,
+-- and then marked as complete using 'markComplete'.
+constructOrAsync
+  :: forall m remoteCache.
+     (MonadIO m, MonadBaseControl IO m, MonadMask m, Remote.Cacher m remoteCache)
+  => ContentStore
+  -> remoteCache
+  -> ContentHash
+  -> m (Status (Path Abs Dir) (Async Update) Item)
+constructOrAsync store remoteCacher hash =
+  constructIfMissing store remoteCacher hash >>= \case
+    Complete item -> return $ Complete item
+    Missing path -> return $ Missing path
+    Pending _ -> Pending <$> liftIO (internalWatchPending store hash)
+
+-- | Atomically query the state under the given key and mark pending if missing.
+-- Wait for the item to be completed, if already pending.
+-- Throws a 'FailedToConstruct' error if construction fails.
+--
+-- Returns @'Complete' item@ if the item is complete.
+-- Returns @'Missing' buildDir@ if the item was missing, and is now pending.
+-- It should be constructed in the given @buildDir@,
+-- and then marked as complete using 'markComplete'.
+constructOrWait
+  :: (MonadIO m, MonadMask m, MonadBaseControl IO m, Remote.Cacher m remoteCache)
+  => ContentStore
+  -> remoteCache
+  -> ContentHash
+  -> m (Status (Path Abs Dir) Void Item)
+constructOrWait store remoteCacher hash = constructOrAsync store remoteCacher hash >>= \case
+  Pending a -> liftIO (wait a) >>= \case
+    Completed item -> return $ Complete item
+    -- XXX: Consider extending 'Status' with a 'Failed' constructor.
+    --   If the store contains metadata as well, it could keep track of the
+    --   number of failed attempts and further details about the failure.
+    --   If an external task is responsible for the failure, the client could
+    --   choose to resubmit a certain number of times.
+    Failed -> liftIO . throwIO $ FailedToConstruct hash
+  Complete item -> return $ Complete item
+  Missing dir -> return $ Missing dir
+
+-- | Atomically query the state under the given key and mark pending if missing.
+constructIfMissing
+  :: (MonadIO m, MonadBaseControl IO m, MonadMask m, Remote.Cacher m remoteCache)
+  => ContentStore
+  -> remoteCache
+  -> ContentHash
+  -> m (Status (Path Abs Dir) () Item)
+constructIfMissing store remoteCacher hash = withStoreLock store $
+  internalQuery store hash >>= \case
+    Complete item -> return $ Complete item
+    Missing () -> withWritableStore store $ do
+      let destDir :: Path Abs Dir = mkItemPath store hash
+      Remote.pull remoteCacher hash destDir >>= \case
+        Remote.PullOK () -> return $ Complete (Item hash)
+        Remote.NotInCache ->
+          Missing <$> liftIO (internalMarkPending store hash)
+        Remote.PullError _ ->
+          Missing <$> liftIO (internalMarkPending store hash)
+    Pending _ -> return $ Pending ()
+
+-- | Atomically query the state under the given key and mark pending if missing.
+-- Execute the given function to construct the item, mark as complete on success
+-- and remove on failure. Forcibly removes if an uncaught exception occurs
+-- during item construction.
+withConstructIfMissing
+  :: (MonadIO m, MonadBaseControl IO m, MonadMask m, Remote.Cacher m remoteCache)
+  => ContentStore
+  -> remoteCache
+  -> ContentHash
+  -> (Path Abs Dir -> m (Either e a))
+  -> m (Status e () (Maybe a, Item))
+withConstructIfMissing store remoteCacher hash f =
+  bracketOnError
+    (constructIfMissing store remoteCacher hash)
+    (\case
+      Missing _ -> removeForcibly store hash
+      _ -> return ())
+    (\case
+      Pending () -> return (Pending ())
+      Complete item -> return (Complete (Nothing, item))
+      Missing fp -> f fp >>= \case
+        Left e -> do
+          removeFailed store hash
+          return (Missing e)
+        Right x -> do
+          item <- markComplete store hash
+          _ <- Remote.push remoteCacher (itemHash item) (Just hash) (itemPath store item)
+          return (Complete (Just x, item)))
+
+-- | Mark a non-existent item as pending.
+--
+-- Creates the build directory and returns its path.
+--
+-- See also: 'Data.CAS.ContentStore.constructIfMissing'.
+markPending :: MonadIO m => ContentStore -> ContentHash -> m (Path Abs Dir)
+markPending store hash = liftIO . withStoreLock store $
+  internalQuery store hash >>= \case
+    Complete _ -> throwIO (AlreadyComplete hash)
+    Pending _ -> throwIO (AlreadyPending hash)
+    Missing () -> withWritableStore store $
+      internalMarkPending store hash
+
+-- | Mark a pending item as complete.
+markComplete :: MonadIO m => ContentStore -> ContentHash -> m Item
+markComplete store inHash = liftIO . withStoreLock store $
+  internalQuery store inHash >>= \case
+    Missing () -> throwIO (NotPending inHash)
+    Complete _ -> throwIO (AlreadyComplete inHash)
+    Pending build -> withWritableStore store $ liftIO $ do
+      do
+        let metadataDir = mkMetadataDirPath store inHash
+        exists <- doesDirExist metadataDir
+        when exists $
+          unsetWritableRecursively metadataDir
+      -- XXX: Hashing large data can take some time,
+      --   could we avoid locking the store for all that time?
+      outHash <- contentHash (DirectoryContent build)
+      let out = mkItemPath store outHash
+          link' = mkCompletePath store inHash
+      doesDirExist out >>= \case
+        True -> removePathForcibly (fromAbsDir build)
+        False -> do
+          renameDir build out
+          unsetWritableRecursively out
+      rel <- makeRelative (parent link') out
+      let from' = dropTrailingPathSeparator $ fromAbsDir link'
+          to' = dropTrailingPathSeparator $ fromRelDir rel
+      createSymbolicLink to' from'
+      addBackReference store inHash (Item outHash)
+      pure $! Item outHash
+
+-- | Remove a pending item.
+--
+-- It is the callers responsibility to ensure that no other threads or processes
+-- will attempt to access the item's contents afterwards.
+removeFailed :: MonadIO m => ContentStore -> ContentHash -> m ()
+removeFailed store hash = liftIO . withStoreLock store $
+  internalQuery store hash >>= \case
+    Missing () -> throwIO (NotPending hash)
+    Complete _ -> throwIO (AlreadyComplete hash)
+    Pending build -> withWritableStore store $
+      removePathForcibly (fromAbsDir build)
+
+-- | Remove a key association independent of the corresponding item state.
+-- Do nothing if no item exists under the given key.
+--
+-- It is the callers responsibility to ensure that no other threads or processes
+-- will attempt to access the contents afterwards.
+--
+-- Note, this will leave an orphan item behind if no other keys point to it.
+-- There is no garbage collection mechanism in place at the moment.
+removeForcibly :: MonadIO m => ContentStore -> ContentHash -> m ()
+removeForcibly store hash = liftIO . withStoreLock store $ withWritableStore store $
+  internalQuery store hash >>= \case
+    Missing () -> pure ()
+    Pending build -> liftIO $ removePathForcibly (fromAbsDir build)
+    Complete _out -> liftIO $
+      removePathForcibly $
+        dropTrailingPathSeparator $ fromAbsDir $ mkCompletePath store hash
+      -- XXX: This will leave orphan store items behind.
+      --   Add GC in some form.
+
+-- | Remove a completed item in the store.
+-- Do nothing if not completed.
+--
+-- It is the callers responsibility to ensure that no other threads or processes
+-- will attempt to access the contents afterwards.
+--
+-- Note, this will leave keys pointing to that item dangling.
+-- There is no garbage collection mechanism in place at the moment.
+removeItemForcibly :: MonadIO m => ContentStore -> Item -> m ()
+removeItemForcibly store item = liftIO . withStoreLock store $ withWritableStore store $
+  removePathForcibly (fromAbsDir $ itemPath store item)
+  -- XXX: Remove dangling links.
+  --   Add back-references in some form.
+
+-- We need this orphan instance here so cas-hash doesn't depend on sqlite
+instance SQL.FromField ContentHash where
+  fromField f = do
+    bs <- SQL.fromField f
+    case decodeHash bs of
+      Just h  -> pure h
+      Nothing -> mzero
+
+instance SQL.ToField ContentHash where
+  toField = SQL.toField . encodeHash
+
+-- | Link the given alias to the given item.
+-- If the alias existed before it is overwritten.
+assignAlias :: MonadIO m => ContentStore -> Alias -> Item -> m ()
+assignAlias store alias item =
+  liftIO . withStoreLock store $ withWritableStore store $ do
+    hash <- contentHash alias
+    SQL.executeNamed (storeDb store)
+      "INSERT OR REPLACE INTO\
+      \  aliases\
+      \ VALUES\
+      \  (:hash, :dest, :name)"
+      [ ":hash" SQL.:= hash
+      , ":dest" SQL.:= itemHash item
+      , ":name" SQL.:= alias
+      ]
+
+-- | Lookup an item under the given alias.
+-- Returns 'Nothing' if the alias does not exist.
+lookupAlias :: MonadIO m => ContentStore -> Alias -> m (Maybe Item)
+lookupAlias store alias =
+  liftIO . withStoreLock store $ do
+    hash <- contentHash alias
+    r <- SQL.queryNamed (storeDb store)
+      "SELECT dest FROM aliases\
+      \ WHERE\
+      \  hash = :hash"
+      [ ":hash" SQL.:= hash ]
+    pure $! listToMaybe $ Item . SQL.fromOnly <$> r
+
+-- | Remove the given alias.
+removeAlias :: MonadIO m => ContentStore -> Alias -> m ()
+removeAlias store alias =
+  liftIO . withStoreLock store $ withWritableStore store $ do
+    hash <- contentHash alias
+    SQL.executeNamed (storeDb store)
+      "DELETE FROM aliases\
+      \ WHERE\
+      \  hash = :hash"
+      [ ":hash" SQL.:= hash ]
+
+-- | List all aliases and the respective items.
+listAliases :: MonadIO m => ContentStore -> m [(Alias, Item)]
+listAliases store = liftIO . withStoreLock store $
+  fmap (map (second Item)) $
+    SQL.query_ (storeDb store)
+      "SELECT name, dest FROM aliases"
+
+-- | Get all hashes that resulted in the given item.
+getBackReferences :: MonadIO m => ContentStore -> Item -> m [ContentHash]
+getBackReferences store (Item outHash) = liftIO . withStoreLock store $
+  map SQL.fromOnly <$> SQL.queryNamed (storeDb store)
+    "SELECT hash FROM backrefs\
+    \ WHERE\
+    \  dest = :out"
+    [ ":out" SQL.:= outHash ]
+
+-- | Define the input items to a subtree.
+setInputs :: MonadIO m => ContentStore -> ContentHash -> [Item] -> m ()
+setInputs store hash items = liftIO $
+  withStoreLock store $
+  withWritableStore store $
+  internalQuery store hash >>= \case
+    Pending _ -> forM_ items $ \(Item input) ->
+      SQL.executeNamed (storeDb store)
+        "INSERT OR REPLACE INTO\
+        \  inputs (hash, input)\
+        \ VALUES\
+        \  (:hash, :input)"
+        [ ":hash" SQL.:= hash
+        , ":input" SQL.:= input
+        ]
+    _ -> throwIO $ NotPending hash
+
+-- | Get the input items to a subtree if any were defined.
+getInputs :: MonadIO m => ContentStore -> ContentHash -> m [Item]
+getInputs store hash = liftIO . withStoreLock store $
+  map (Item . SQL.fromOnly) <$> SQL.queryNamed (storeDb store)
+    "SELECT input FROM inputs\
+    \ WHERE\
+    \  hash = :hash"
+    [ ":hash" SQL.:= hash ]
+
+-- | Set a metadata entry on an item.
+setMetadata :: (SQL.ToField k, SQL.ToField v, MonadIO m )
+            => ContentStore -> ContentHash -> k -> v -> m ()
+setMetadata store hash k v = liftIO $
+  withStoreLock store $
+  withWritableStore store $
+  SQL.executeNamed (storeDb store)
+    "INSERT OR REPLACE INTO\
+    \  metadata (hash, key, value)\
+    \ VALUES\
+    \  (:hash, :key, :value)"
+    [ ":hash" SQL.:= hash
+    , ":key" SQL.:= k
+    , ":value" SQL.:= v
+    ]
+
+-- | Retrieve a metadata entry on an item, or 'Nothing' if missing.
+getMetadata :: (SQL.ToField k, SQL.FromField v, MonadIO m)
+  => ContentStore -> ContentHash -> k -> m (Maybe v)
+getMetadata store hash k = liftIO . withStoreLock store $ do
+  r <- SQL.queryNamed (storeDb store)
+    "SELECT value FROM metadata\
+    \ WHERE\
+    \  (hash = :hash AND key = :key)"
+    [ ":hash" SQL.:= hash
+    , ":key" SQL.:= k
+    ]
+  case r of
+    []    -> pure Nothing
+    [[v]] -> pure $ Just v
+    _     -> throwIO $ MalformedMetadataEntry hash (SQL.toField k)
+
+-- | Create and open a new metadata file on a pending item in write mode.
+createMetadataFile
+  :: MonadIO m
+  => ContentStore -> ContentHash -> Path Rel File -> m (Path Abs File, Handle)
+createMetadataFile store hash file = liftIO . withStoreLock store $
+  internalQuery store hash >>= \case
+    Pending _ -> do
+      let path = mkMetadataFilePath store hash file
+      createDirIfMissing True (parent path)
+      handle <- openFile (fromAbsFile path) WriteMode
+      pure (path, handle)
+    _ -> throwIO $ NotPending hash
+
+-- | Return the path to a metadata file if it exists.
+getMetadataFile
+  :: MonadIO m
+  => ContentStore -> ContentHash -> Path Rel File -> m (Maybe (Path Abs File))
+getMetadataFile store hash file = liftIO . withStoreLock store $ do
+  let path = mkMetadataFilePath store hash file
+  exists <- doesFileExist path
+  if exists then
+    pure $ Just path
+  else
+    pure Nothing
+
+----------------------------------------------------------------------
+-- Internals
+
+lockPath :: Path Abs Dir -> Path Abs Dir
+lockPath = (</> [reldir|lock|])
+
+dbPath :: Path Abs Dir -> Path Abs File
+dbPath = (</> [relfile|metadata.db|])
+
+metadataPath :: Path Abs Dir -> Path Abs Dir
+metadataPath = (</> [reldir|metadata|])
+
+-- | Holds a lock on the global 'MVar' and on the global lock file
+-- for the duration of the given action.
+withStoreLock :: MonadBaseControl IO m => ContentStore -> m a -> m a
+withStoreLock store = withLock (storeLock store)
+
+prefixHashPath :: C8.ByteString -> ContentHash -> Path Rel Dir
+prefixHashPath pref hash
+  | Just dir <- Path.parseRelDir $ C8.unpack $ pref <> encodeHash hash
+  = dir
+  | otherwise = error
+      "[Data.CAS.ContentStore.prefixHashPath] \
+      \Failed to construct hash path."
+
+pendingPrefix, completePrefix, hashPrefix, itemPrefix :: IsString s => s
+pendingPrefix = "pending-"
+completePrefix = "complete-"
+hashPrefix = "hash-"
+itemPrefix = "item-"
+
+-- | Return the full build path for the given input hash.
+mkPendingPath :: ContentStore -> ContentHash -> Path Abs Dir
+mkPendingPath ContentStore {storeRoot} hash =
+  storeRoot </> prefixHashPath pendingPrefix hash
+
+-- | Return the full link path for the given input hash.
+mkCompletePath :: ContentStore -> ContentHash -> Path Abs Dir
+mkCompletePath ContentStore {storeRoot} hash =
+  storeRoot </> prefixHashPath completePrefix hash
+
+-- | Return the full store path to the given output hash.
+mkItemPath :: ContentStore -> ContentHash -> Path Abs Dir
+mkItemPath ContentStore {storeRoot} hash =
+  storeRoot </> prefixHashPath itemPrefix hash
+
+-- | Return the full store path to the given metadata directory.
+mkMetadataDirPath :: ContentStore -> ContentHash -> Path Abs Dir
+mkMetadataDirPath ContentStore {storeRoot} hash =
+  metadataPath storeRoot </> prefixHashPath hashPrefix hash
+
+-- | Return the full store path to the given metadata file.
+mkMetadataFilePath
+  :: ContentStore -> ContentHash -> Path Rel File -> Path Abs File
+mkMetadataFilePath store hash file =
+  mkMetadataDirPath store hash </> file
+
+-- | Query the state under the given key without taking a lock.
+internalQuery
+  :: MonadIO m
+  => ContentStore
+  -> ContentHash
+  -> m (Status () (Path Abs Dir) Item)
+internalQuery store inHash = liftIO $ do
+  let build = mkPendingPath store inHash
+      link' = mkCompletePath store inHash
+  buildExists <- doesDirExist build
+  if buildExists then
+    pure $! Pending build
+  else do
+    linkExists <- doesDirExist link'
+    if linkExists then do
+      out <- readSymbolicLink
+        (dropTrailingPathSeparator $ fromAbsDir link')
+      case pathToHash =<< stripPrefix itemPrefix out of
+        Nothing      -> throwIO $ CorruptedLink inHash out
+        Just outHash -> return $ Complete (Item outHash)
+    else
+      pure $! Missing ()
+
+-- | Create the build directory for the given input hash
+--   and make the metadata directory writable if it exists.
+internalMarkPending :: ContentStore -> ContentHash -> IO (Path Abs Dir)
+internalMarkPending store hash = do
+  let dir = mkPendingPath store hash
+  createDir dir
+  setDirWritable dir
+  let metadataDir = mkMetadataDirPath store hash
+  metadirExists <- doesDirExist metadataDir
+  when metadirExists $
+    setWritableRecursively metadataDir
+  return dir
+
+-- | Watch the build directory of the pending item under the given key.
+-- The returned 'Async' completes after the item is completed or failed.
+internalWatchPending
+  :: ContentStore
+  -> ContentHash
+  -> IO (Async Update)
+internalWatchPending store hash = do
+  let build = mkPendingPath store hash
+  -- Add an inotify/kqueue watch and give a signal on relevant events.
+  let notifier = storeNotifier store
+  signal <- newEmptyMVar
+  -- Signal the listener. If the 'MVar' is full,
+  -- the listener didn't handle earlier signals, yet.
+  let giveSignal = void $ tryPutMVar signal ()
+  watch <- addDirWatch notifier (fromAbsDir build) giveSignal
+  -- Additionally, poll on regular intervals.
+  -- Inotify/Kqueue don't cover all cases, e.g. network filesystems.
+  ticker <- async $ forever $ threadDelay 3007000 >> giveSignal
+  let stopWatching = do
+        cancel ticker
+        removeDirWatch watch
+  -- Listen to the signal asynchronously,
+  -- and query the status when it fires.
+  -- If the status changed, fill in the update.
+  update <- newEmptyMVar
+  let query' = liftIO . withStoreLock store $ internalQuery store hash
+      loop = takeMVar signal >> query' >>= \case
+        Pending _ -> loop
+        Complete item -> tryPutMVar update $ Completed item
+        Missing () -> tryPutMVar update Failed
+  void $ async loop
+  -- Wait for the update asynchronously.
+  -- Stop watching when it arrives.
+  async $ takeMVar update <* stopWatching
+
+setRootDirWritable :: MonadIO m => Path Abs Dir -> m ()
+setRootDirWritable storeRoot = liftIO $
+  setFileMode (fromAbsDir storeRoot) writableRootDirMode
+
+writableRootDirMode :: FileMode
+writableRootDirMode = writableDirMode
+
+setRootDirReadOnly :: MonadIO m => Path Abs Dir -> m ()
+setRootDirReadOnly storeRoot = liftIO $
+  setFileMode (fromAbsDir storeRoot) readOnlyRootDirMode
+
+readOnlyRootDirMode :: FileMode
+readOnlyRootDirMode = writableDirMode `intersectFileModes` allButWritableMode
+
+withWritableStoreRoot :: (MonadMask m, MonadIO m) => Path Abs Dir -> m a -> m a
+withWritableStoreRoot storeRoot =
+  bracket_ (setRootDirWritable storeRoot) (setRootDirReadOnly storeRoot)
+
+withWritableStore :: (MonadMask m, MonadIO m) => ContentStore -> m a -> m a
+withWritableStore ContentStore {storeRoot} =
+  withWritableStoreRoot storeRoot
+
+setDirWritable :: Path Abs Dir -> IO ()
+setDirWritable fp = setFileMode (fromAbsDir fp) writableDirMode
+
+writableDirMode :: FileMode
+writableDirMode = foldl' unionFileModes nullFileMode
+  [ directoryMode, ownerModes
+  , groupReadMode, groupExecuteMode
+  , otherReadMode, otherExecuteMode
+  ]
+
+-- | Set write permissions on the given path.
+setWritable :: Path Abs t -> IO ()
+setWritable fp = do
+  mode <- fileMode <$> getFileStatus (toFilePath fp)
+  setFileMode (toFilePath fp) $ mode `unionFileModes` ownerWriteMode
+
+-- | Unset write permissions on the given path.
+unsetWritable :: Path Abs t -> IO ()
+unsetWritable fp = do
+  mode <- fileMode <$> getFileStatus (toFilePath fp)
+  setFileMode (toFilePath fp) $ mode `intersectFileModes` allButWritableMode
+
+allButWritableMode :: FileMode
+allButWritableMode = complement $ foldl' unionFileModes nullFileMode
+  [ownerWriteMode, groupWriteMode, otherWriteMode]
+
+-- | Set write permissions on all items in a directory tree recursively.
+setWritableRecursively :: Path Abs Dir -> IO ()
+setWritableRecursively = walkDir $ \dir _ files -> do
+  mapM_ setWritable files
+  setWritable dir
+  return $ WalkExclude []
+
+-- | Unset write permissions on all items in a directory tree recursively.
+unsetWritableRecursively :: Path Abs Dir -> IO ()
+unsetWritableRecursively = walkDir $ \dir _ files -> do
+  mapM_ unsetWritable files
+  unsetWritable dir
+  return $ WalkExclude []
+
+storeVersion :: Int
+storeVersion = 1
+
+-- | Initialize the database.
+initDb :: Path Abs Dir -> SQL.Connection -> IO ()
+initDb storeDir db = do
+  [[version]] <- SQL.query_ db "PRAGMA user_version"
+  if version == 0 then
+    SQL.execute_ db $
+      "PRAGMA user_version = " <> fromString (show storeVersion)
+  else
+    unless (version == storeVersion) $
+      throwIO $ IncompatibleStoreVersion storeDir version storeVersion
+  -- Aliases to items.
+  SQL.execute_ db
+    "CREATE TABLE IF NOT EXISTS\
+    \  aliases\
+    \  ( hash TEXT PRIMARY KEY\
+    \  , dest TEXT NOT NULL\
+    \  , name TEXT NOT NULL\
+    \  )"
+  -- Back-references from items @dest@ to hashes @hash@.
+  SQL.execute_ db
+    "CREATE TABLE IF NOT EXISTS\
+    \  backrefs\
+    \  ( hash TEXT PRIMARY KEY\
+    \  , dest TEXT NOT NULL\
+    \  )"
+  -- Inputs @input@ to hashes @hash@.
+  SQL.execute_ db
+    "CREATE TABLE IF NOT EXISTS\
+    \  inputs\
+    \  ( hash TEXT NOT NULL\
+    \  , input TEXT NOT NULL\
+    \  , UNIQUE (hash, input)\
+    \  )"
+  -- Arbitrary metadata on hashes.
+  SQL.execute_ db
+    "CREATE TABLE IF NOT EXISTS\
+    \  metadata\
+    \  ( hash  TEXT NOT NULL\
+    \  , key   TEXT NOT NULL\
+    \  , value TEXT\
+    \  , PRIMARY KEY(hash, key)\
+    \  )"
+
+-- | Adds a link between input hash and the output hash.
+--
+-- Assumes that the store is locked and writable.
+addBackReference :: ContentStore -> ContentHash -> Item -> IO ()
+addBackReference store inHash (Item outHash) =
+  SQL.executeNamed (storeDb store)
+    "INSERT OR REPLACE INTO\
+    \  backrefs (hash, dest)\
+    \ VALUES\
+    \  (:in, :out)"
+    [ ":in" SQL.:= inHash
+    , ":out" SQL.:= outHash
+    ]
+
+-- | A cacher is responsible for controlling how steps are cached.
+data CacherM m i o =
+    NoCache -- ^ This step cannot be cached (default).
+  | Cache
+    { -- | Function to encode the input into a content
+      --   hash.
+      --   This function additionally takes an
+      --   'identities' which gets incorporated into
+      --   the cacher.
+      cacherKey        :: Int -> i -> m ContentHash
+    , cacherStoreValue :: o -> ByteString
+      -- | Attempt to read the cache value back. May throw exceptions.
+    , cacherReadValue  :: ByteString -> o
+    }
+
+-- | A pure 'CacherM'
+type Cacher = CacherM Identity
+
+-- | Constructs a 'Cacher' that will use hashability of input and
+-- serializability of output to make a step cacheable
+defaultCacherWithIdent :: forall m i o.
+                          (ContentHashable m i, Data.Store.Store o)
+                       => Int -- ^ Seed for the cacher
+                       -> CacherM m i o
+defaultCacherWithIdent ident = Cache
+  { cacherKey = \i ident' -> contentHash (ident', ident, i)
+  , cacherStoreValue = Data.Store.encode
+  , cacherReadValue = Data.Store.decodeEx
+  }
+
+-- | Looks for a @CacherM IO@, then lifts it
+defaultIOCacherWithIdent :: (MonadIO m, ContentHashable IO i, Data.Store.Store o)
+                         => Int -- ^ Seed for the cacher
+                         -> CacherM m i o
+defaultIOCacherWithIdent ident = c{cacherKey = \x i -> liftIO $ cacherKey c x i}
+  where c = defaultCacherWithIdent ident
+
+
+-- | Caches a Kleisli of some MonadIO action in the store given the required
+-- properties
+cacheKleisliIO
+  :: (MonadIO m, MonadBaseControl IO m, MonadMask m, Remote.Cacher m remoteCache)
+  => Maybe Int  -- ^ This can be used to disambiguate the same program run in
+                -- multiple configurations. If Nothing, then it means this
+                -- program has no identity, this implies that steps will be
+                -- executed without cache, even if 'Cache' has been given.
+  -> CacherM m i o
+  -> ContentStore
+  -> remoteCache
+  -> (i -> m o)
+  -> i
+  -> m o
+cacheKleisliIO confIdent c@Cache{} store remoteCacher f i
+  | Just confIdent' <- confIdent = do
+      chash <- cacherKey c confIdent' i
+      let computeAndStore fp = do
+            res <- f i  -- Do the actual computation
+            liftIO $ BS.writeFile (toFilePath $ fp </> [relfile|out|])
+                   . cacherStoreValue c $ res
+            return $ Right res
+          readItem item = do
+            bs <- liftIO . BS.readFile $ simpleOutPath item
+            return . cacherReadValue c $ bs
+      withConstructIfMissing store remoteCacher chash computeAndStore >>= \case
+        Missing e -> absurd e
+        Pending _ ->
+          liftIO (waitUntilComplete store chash) >>= \case
+            Just item -> readItem item
+            Nothing -> throwM $ FailedToConstruct chash
+        Complete (Just a, _) -> return a
+        Complete (_, item) -> readItem item
+  where
+    simpleOutPath item =
+      toFilePath $ itemPath store item </> [relfile|out|]
+cacheKleisliIO _ _ _ _ f i = f i
+
+-- | Caches an action that writes content-addressed data to the store. Returns
+-- the Item of the written content.
+putInStore
+  :: (MonadIO m, MonadMask m, MonadBaseControl IO m
+     ,Remote.Cacher m remoteCacher
+     ,ContentHashable IO t)
+  => ContentStore
+  -> remoteCacher
+  -> (ContentHash -> m ()) -- ^ In case an exception occurs
+  -> (Path Abs Dir -> t -> m ()) -- ^ The action that writes to the new store
+                                 -- directory
+  -> t
+  -> m Item -- ^ The Item in the store to which @t@ has been written
+putInStore store remoteCacher ifExc f x = do
+  chash <- liftIO $ contentHash x
+  constructOrWait store remoteCacher chash >>= \case
+    Pending y -> absurd y
+    Complete item -> return item
+    Missing fp ->
+      do
+        f fp x
+        finalItem <- markComplete store chash
+        _ <- Remote.push remoteCacher (itemHash finalItem) (Just chash) (itemPath store finalItem)
+        pure finalItem
+      `onException`
+        (do ifExc chash
+            removeFailed store chash)
diff --git a/src/Data/CAS/ContentStore/Notify.hs b/src/Data/CAS/ContentStore/Notify.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/CAS/ContentStore/Notify.hs
@@ -0,0 +1,28 @@
+{-# LANGUAGE CPP #-}
+
+-- | Generic file change notifier library for unix-based systems.
+--
+--   This library abstracts over specific implementations for BSD and linux
+--   systems.
+--
+--   It provides facilities to watch specific directories for the following changes:
+--   - File moves
+--   - File deletion
+--   - Attribute changes.
+module Data.CAS.ContentStore.Notify
+  ( Notifier
+  , initNotifier
+  , killNotifier
+
+  , Watch
+  , addDirWatch
+  , removeDirWatch
+  ) where
+
+#ifdef OS_Linux
+import           Data.CAS.ContentStore.Notify.Linux
+#else
+#  ifdef OS_BSD
+import           Data.CAS.ContentStore.Notify.BSD
+#  endif
+#endif
diff --git a/src/Data/CAS/ContentStore/Notify/BSD.hs b/src/Data/CAS/ContentStore/Notify/BSD.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/CAS/ContentStore/Notify/BSD.hs
@@ -0,0 +1,78 @@
+{-# LANGUAGE LambdaCase          #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+
+-- | Implementation of filesystem watch for BSD-based systems using KQueue.
+module Data.CAS.ContentStore.Notify.BSD
+  ( Notifier
+  , initNotifier
+  , killNotifier
+
+  , Watch
+  , addDirWatch
+  , removeDirWatch
+  ) where
+
+import           Control.Concurrent
+import           Control.Concurrent.Async
+import           Control.Exception.Safe
+import           Control.Monad
+import           Data.Typeable
+import           Foreign.Ptr
+import           System.KQueue
+import           System.Posix.IO
+import           System.Posix.Types
+
+type Notifier = KQueue
+
+data NotifierException
+  = RequiresThreadedRuntime
+  deriving (Show, Typeable)
+instance Exception NotifierException where
+  displayException = \case
+    -- XXX: Compile time check?
+    RequiresThreadedRuntime ->
+      "Threaded runtime required! Please rebuild with -threaded flag."
+
+initNotifier :: IO Notifier
+initNotifier = do
+  unless rtsSupportsBoundThreads $ throwIO RequiresThreadedRuntime
+  kqueue
+
+killNotifier :: Notifier -> IO ()
+killNotifier _ = return ()
+
+data Watch = Watch KQueue Fd (Async ())
+
+addDirWatch :: Notifier -> FilePath -> IO () -> IO Watch
+addDirWatch kq dir f = do
+  fd <- openFd dir ReadOnly Nothing defaultFileFlags
+  a <- async $ do
+    let event = KEvent
+          { ident = fromIntegral fd
+          , evfilter = EvfiltVnode
+          , flags = [EvAdd]
+          , fflags = [NoteDelete, NoteAttrib, NoteRename, NoteRevoke]
+          , data_ = 0
+          , udata = nullPtr
+          }
+        loop = do
+          chgs <- kevent kq [event] 1 Nothing
+          unless (null chgs) f
+          loop
+    loop `finally` closeFd fd
+  return $! Watch kq fd a
+
+removeDirWatch :: Watch -> IO ()
+removeDirWatch (Watch kq fd a) = do
+  closeFd fd
+  let event = KEvent
+        { ident = fromIntegral fd
+        , evfilter = EvfiltVnode
+        , flags = [EvDelete]
+        , fflags = []
+        , data_ = 0
+        , udata = nullPtr
+        }
+  void (kevent kq [event] 0 Nothing)
+    `catch` \(_ :: KQueueException) -> return ()
+  cancel a
diff --git a/src/Data/CAS/ContentStore/Notify/Linux.hs b/src/Data/CAS/ContentStore/Notify/Linux.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/CAS/ContentStore/Notify/Linux.hs
@@ -0,0 +1,60 @@
+{-# LANGUAGE CPP                 #-}
+{-# LANGUAGE LambdaCase          #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+
+-- | Implementation of filesystem watching functionality for linux based on
+--   inotify.
+module Data.CAS.ContentStore.Notify.Linux
+  ( Notifier
+  , initNotifier
+  , killNotifier
+
+  , Watch
+  , addDirWatch
+  , removeDirWatch
+  ) where
+
+import           Control.Exception.Safe (catch)
+#if MIN_VERSION_hinotify(0,3,10)
+import qualified Data.ByteString.Char8 as BS
+#endif
+import           System.INotify
+
+type Notifier = INotify
+
+initNotifier :: IO Notifier
+initNotifier = initINotify
+
+killNotifier :: Notifier -> IO ()
+killNotifier = killINotify
+
+type Watch = WatchDescriptor
+
+addDirWatch :: Notifier -> FilePath -> IO () -> IO Watch
+addDirWatch inotify dir f = addWatch inotify mask dir' $ \case
+  Attributes True Nothing -> f
+  MovedSelf True -> f
+  DeletedSelf -> f
+  _ -> return ()
+  where
+    mask = [Attrib, MoveSelf, DeleteSelf, OnlyDir]
+#if MIN_VERSION_hinotify(0,3,10)
+    dir' = BS.pack dir
+#else
+    dir' = dir
+#endif
+
+removeDirWatch :: Watch -> IO ()
+removeDirWatch w =
+  -- When calling `addWatch` on a path that is already being watched,
+  -- inotify will not create a new watch, but amend the existing watch
+  -- and return the same watch descriptor.
+  -- Therefore, the watch might already have been removed at this point,
+  -- which will cause an 'IOError'.
+  -- Fortunately, all event handlers to a file are called at once.
+  -- So, that removing the watch here will not cause another handler
+  -- to miss out on the event.
+  -- Note, that this may change when adding different event handlers,
+  -- that remove the watch under different conditions.
+  removeWatch w
+    `catch` \(_::IOError) -> return ()
diff --git a/src/Data/CAS/Lock.hs b/src/Data/CAS/Lock.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/CAS/Lock.hs
@@ -0,0 +1,120 @@
+{-# LANGUAGE FlexibleContexts    #-}
+{-# LANGUAGE QuasiQuotes         #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+
+-- | Thread and process write lock.
+--
+-- Allows synchronisation between threads and processes.
+-- Uses an 'MVar' for synchronisation between threads
+-- and fcntl write locks for synchronisation between processes.
+--
+-- Only ever have one 'Lock' object per lock file per process!
+module Data.CAS.Lock
+  ( Lock
+  , openLock
+  , closeLock
+  , withLock
+  ) where
+
+import           Control.Concurrent
+import           Control.Exception.Safe
+import           Control.Monad               (unless)
+import           Control.Monad.Trans.Control (MonadBaseControl, liftBaseOp_)
+import           Network.HostName            (getHostName)
+import           Path
+import           Path.IO
+import           System.Posix.Files
+import           System.Posix.IO
+import           System.Posix.Process
+import           System.Random
+
+-- | Thread and process write lock.
+--
+-- Only ever have one 'Lock' object per lock file per process!
+data Lock = Lock
+  { lockMVar :: MVar ()
+  , lockDir  :: Path Abs Dir
+  }
+
+-- | Open the lock file and create a lock object.
+--
+-- This does not acquire the lock.
+--
+-- Only ever have one 'Lock' object per lock file per process!
+openLock :: Path Abs Dir -> IO Lock
+openLock dir = do
+  mvar <- newMVar ()
+  createDirIfMissing True dir
+  return $! Lock
+    { lockMVar = mvar
+    , lockDir = dir
+    }
+
+-- | Close the lock file.
+--
+-- Does not release the lock.
+--
+-- Blocks if the lock is taken.
+closeLock :: Lock -> IO ()
+closeLock lock = do
+  takeMVar (lockMVar lock)
+
+-- | Acquire the lock for the duration of the given action and release after.
+withLock :: MonadBaseControl IO m => Lock -> m a -> m a
+withLock lock = liftBaseOp_ $ \action ->
+  withMVar (lockMVar lock) $ \() ->
+    bracket_ (acquireDirLock $ lockDir lock) (releaseDirLock $ lockDir lock) $
+      action
+
+----------------------------------------------------------------------
+-- Internals
+
+-- | Generate unique (per process) filename.
+--
+-- Combines the host name and process ID.
+getUniqueFileName :: IO (Path Rel File)
+getUniqueFileName = do
+  hostName <- getHostName
+  pid <- getProcessID
+  parseRelFile $ hostName ++ show pid
+
+lockFileName :: Path Rel File
+lockFileName = [relfile|lock|]
+
+-- | Acquire the lock.
+--
+-- Uses an algorithm that is described in the man-page of open (2) in the
+-- last paragraph to @O_EXCL@ in release 4.14 of the Linux man-pages project.
+--
+-- Creates a file under a unique (per process) filename.
+-- Attempts to hard-link that file to a common lock path.
+-- If the operation succeeds, then the lock was acquired.
+-- If not, but if the link count of the file under the unique filename
+-- increased to two, then the lock was acquired.
+-- Otherwise, another process holds the lock and this process waits
+-- and retries.
+acquireDirLock :: Path Abs Dir -> IO ()
+acquireDirLock dir = do
+  file <- getUniqueFileName
+  let path = dir </> file
+  fd <- createFile (fromAbsFile path) ownerWriteMode
+  closeFd fd
+  r <- try $ createLink (fromAbsFile path) (fromAbsFile $ dir </> lockFileName)
+  case r of
+    Right () -> return ()
+    Left (_::IOError) -> do
+      count <- linkCount <$> getFileStatus (fromAbsFile path)
+      unless (count == 2) $ do
+        delay <- randomRIO (50000, 100000)
+        threadDelay delay
+        acquireDirLock dir
+
+-- | Release the lock.
+--
+-- Unlinks the file under the unique file name and the common lock file.
+releaseDirLock :: Path Abs Dir -> IO ()
+releaseDirLock dir = do
+  file <- getUniqueFileName
+  let path = dir </> file
+  removeLink (fromAbsFile $ dir </> lockFileName)
+  removeLink (fromAbsFile path)
diff --git a/src/Data/CAS/RemoteCache.hs b/src/Data/CAS/RemoteCache.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/CAS/RemoteCache.hs
@@ -0,0 +1,134 @@
+{-# LANGUAGE FlexibleInstances     #-}
+{-# LANGUAGE LambdaCase            #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+
+-- |
+-- This module defines the remote caching mechanism of funflow which is used to
+-- keep several funflow stores (possibly on different machines) in sync.
+module Data.CAS.RemoteCache
+  ( Cacher(..)
+  , PullResult(..), PushResult(..), AliasResult(..)
+  , NoCache(..), memoryCache
+  , pullAsArchive, pushAsArchive
+  ) where
+
+import qualified Codec.Archive.Tar               as Tar
+import           Control.Concurrent.MVar
+import           Control.Monad.IO.Class          (MonadIO, liftIO)
+import           Data.ByteString.Lazy            (ByteString)
+import           Data.CAS.ContentHashable
+import           Data.Map.Strict                 (Map)
+import qualified Data.Map.Strict                 as Map
+import           Path
+
+
+-- |
+-- The result of a tentative pull from the remote cache
+data PullResult a
+  = PullOK a
+  | NotInCache
+  | PullError String
+  deriving (Eq, Ord, Show)
+
+-- |
+-- The result of a tentative push to the remote cache
+data PushResult
+  = PushOK
+  | PushError String
+  deriving (Eq, Ord, Show)
+
+data AliasResult
+ = AliasOK
+ | TargetNotInCache
+ | AliasError String
+
+-- |
+-- A simple mechanism for remote-caching.
+--
+-- Provides a way to push a path to the cache and pull it back.
+--
+-- No assumption is made on the availability of a store path. In particular,
+-- pushing a path to the cache doesn't mean that we can pull it back.
+class Monad m => Cacher m a where
+  push ::
+       a
+    -> ContentHash -- ^ "Primary" key: hash of the content
+    -> Maybe ContentHash -- ^ "Secondary" key: hash of the dependencies
+    -> Path Abs Dir -- ^ Path to the content
+    -> m PushResult
+  pull :: a -> ContentHash -> Path Abs Dir -> m (PullResult ())
+
+-- |
+-- Push the path as an archive to the remote cache
+pushAsArchive ::
+     MonadIO m
+  => (ContentHash -> ContentHash -> m (Either String ())) -- ^ How to create the aliases
+  -> (ContentHash -> ByteString -> m PushResult) -- ^ How to push the content
+  -> ContentHash -- ^ Primary key
+  -> Maybe ContentHash -- ^ Secondary key
+  -> Path Abs Dir
+  -> m PushResult
+pushAsArchive alias pushArchive primaryKey mSecondaryKey path = do
+  archive <- liftIO $ Tar.write <$> Tar.pack (toFilePath path) ["."]
+  pushArchive primaryKey archive >>= \case
+    PushError e -> pure $ PushError e
+    res ->
+      case mSecondaryKey of
+        Just secondaryKey ->
+          alias primaryKey secondaryKey >>= \case
+          Left err -> pure $ PushError err
+          Right () -> pure res
+        Nothing -> pure res
+
+pullAsArchive ::
+     MonadIO m
+  => (ContentHash -> m (PullResult ByteString))
+  -> ContentHash
+  -> Path Abs Dir
+  -> m (PullResult ())
+pullAsArchive pullArchive hash path =
+  pullArchive hash >>= \case
+    PullOK archive -> do
+      liftIO $ Tar.unpack (toFilePath path) $ Tar.read archive
+      pure $ PullOK ()
+    NotInCache -> pure NotInCache
+    PullError e -> pure $ PullError e
+
+-- |
+-- A dummy remote cache implementation which does nothing
+data NoCache = NoCache
+
+instance Monad m => Cacher m NoCache where
+  pull _ _ _ = pure NotInCache
+  push _ _ _ _ = pure PushOK
+
+-- |
+-- An in-memory cache, for testing purposes
+data MemoryCache = MemoryCache (MVar (Map ContentHash ByteString))
+instance MonadIO m => Cacher m MemoryCache where
+  pull (MemoryCache cacheVar) = pullAsArchive $ \hash -> do
+    cacheMap <- liftIO $ readMVar cacheVar
+    case Map.lookup hash cacheMap of
+      Nothing -> pure NotInCache
+      Just x  -> pure (PullOK x)
+  push (MemoryCache cacheVar) = pushAsArchive alias $ \hash content -> do
+    liftIO $ modifyMVar_
+      cacheVar
+      (\cacheMap -> pure $ Map.insert hash content cacheMap)
+    pure PushOK
+    where
+      alias from to = liftIO $ Right <$> modifyMVar_ cacheVar
+        (\cacheMap -> pure $ Map.insert to (cacheMap Map.! from) cacheMap)
+
+memoryCache :: MonadIO m => m MemoryCache
+memoryCache = liftIO $ MemoryCache <$> newMVar mempty
+
+-- |
+-- If 'a' is a 'Cacher' then 'Maybe a' is a cacher such that 'Just x' behavies
+-- like 'x' and 'Nothing' doesn't cache anything
+instance Cacher m a => Cacher m (Maybe a) where
+  pull (Just x) = pull x
+  pull Nothing  = pull NoCache
+
+  push (Just x) = push x
+  push Nothing  = push NoCache
diff --git a/test/CAS/ContentStore.hs b/test/CAS/ContentStore.hs
new file mode 100644
--- /dev/null
+++ b/test/CAS/ContentStore.hs
@@ -0,0 +1,472 @@
+{-# OPTIONS_GHC -fno-warn-unused-do-bind #-}
+{-# LANGUAGE LambdaCase        #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes       #-}
+{-# LANGUAGE TypeApplications  #-}
+
+module CAS.ContentStore
+  ( tests
+  ) where
+
+import           Control.Concurrent.Async
+import           Control.Exception.Safe          (tryAny)
+import           Data.CAS.ContentHashable        (contentHash)
+import           Data.CAS.ContentStore           (ContentStore)
+import qualified Data.CAS.ContentStore           as ContentStore
+import qualified Data.CAS.RemoteCache            as Remote
+import           Control.Monad                   (void)
+import           Data.Maybe                      (catMaybes)
+import qualified Data.Set                        as Set
+import           Path
+import           Path.IO
+import           System.Posix.Files
+import           Test.Tasty
+import           Test.Tasty.HUnit
+
+tests :: TestTree
+tests = testGroup "Content Store"
+
+  [ testCase "initialise fresh store" $
+    withTmpDir $ \dir -> do
+      let root = dir </> [reldir|store|]
+      ContentStore.withStore root $ \_ ->
+        doesDirExist @IO root
+          @? "store root exists"
+
+  , testCase "initialise existing store" $
+    withTmpDir $ \dir -> do
+      let root = dir </> [reldir|store|]
+      createDir root
+      ContentStore.withStore root $ \_ ->
+        doesDirExist @IO root
+          @? "store root exists"
+
+  , testCase "store is not writable" $
+    withEmptyStore $ \store -> do
+      let root = ContentStore.root store
+      isNotWritable root
+        @? "store not writable"
+
+  , testCase "subtree stages" $
+    withEmptyStore $ \store -> do
+      hash <- contentHash ("test" :: String)
+
+      missing <- ContentStore.query store hash
+      missing @?= ContentStore.Missing ()
+      missing' <- ContentStore.lookup store hash
+      missing' @?= ContentStore.Missing ()
+
+      subtree <- ContentStore.markPending store hash
+      let dir = subtree </> [reldir|dir|]
+          file = dir </> [relfile|file|]
+          expectedContent = "Hello World"
+      pending <- ContentStore.query store hash
+      pending @?= ContentStore.Pending ()
+      pending' <- ContentStore.lookup store hash
+      pending' @?= ContentStore.Pending ()
+      doesDirExist @IO subtree
+        @? "pending subtree exists"
+      isWritable subtree
+        @? "pending subtree is writable"
+      createDir dir
+      writeFile (fromAbsFile file) expectedContent
+      do
+        content <- readFile (fromAbsFile file)
+        content @?= expectedContent
+
+      item <- ContentStore.markComplete store hash
+      let itemDir = ContentStore.itemPath store item
+          file' = itemDir </> [relfile|dir/file|]
+      complete <- ContentStore.query store hash
+      complete @?= ContentStore.Complete ()
+      complete' <- ContentStore.lookup store hash
+      complete' @?= ContentStore.Complete item
+      doesDirExist @IO itemDir
+        @? "complete subtree exists"
+      isNotWritable itemDir
+        @? "complete subtree is not writable"
+      isNotWritable file'
+        @? "complete file is not writable"
+      do
+        content <- readFile (fromAbsFile file')
+        content @?= expectedContent
+
+  , testCase "await construction" $
+    Remote.memoryCache >>= \myCache ->
+    withEmptyStore $ \store -> do
+      hash <- contentHash ("test" :: String)
+
+      ContentStore.constructOrAsync store myCache hash >>= \case
+        ContentStore.Pending _ ->
+          assertFailure "missing already under construction"
+        ContentStore.Complete _ ->
+          assertFailure "missing already complete"
+        ContentStore.Missing _ ->
+          return ()
+
+      a <- ContentStore.constructOrAsync store Remote.NoCache hash >>= \case
+        ContentStore.Missing _ -> do
+          assertFailure "under construction still missing"
+          undefined
+        ContentStore.Complete _ -> do
+          assertFailure "under construction already complete"
+          undefined
+        ContentStore.Pending a ->
+          return a
+
+      b <- ContentStore.lookupOrWait store hash >>= \case
+        ContentStore.Missing _ -> do
+          assertFailure "under construction still missing"
+          undefined
+        ContentStore.Complete _ -> do
+          assertFailure "under construction already complete"
+          undefined
+        ContentStore.Pending b ->
+          return b
+
+      item <- ContentStore.markComplete store hash
+
+      item' <- wait a
+      item' @?= ContentStore.Completed item
+
+      item'' <- wait b
+      item'' @?= ContentStore.Completed item
+
+      ContentStore.constructOrAsync store Remote.NoCache hash >>= \case
+        ContentStore.Missing _ -> do
+          assertFailure "complete still missing"
+        ContentStore.Pending _ -> do
+          assertFailure "complete still under construction"
+        ContentStore.Complete _ -> do
+          return ()
+
+  , testCase "await failure" $
+    withEmptyStore $ \store -> do
+      hash <- contentHash ("test" :: String)
+
+      ContentStore.constructOrAsync store Remote.NoCache hash >>= \case
+        ContentStore.Pending _ ->
+          assertFailure "missing already under construction"
+        ContentStore.Complete _ ->
+          assertFailure "missing already complete"
+        ContentStore.Missing _ ->
+          return ()
+
+      a <- ContentStore.constructOrAsync store Remote.NoCache hash >>= \case
+        ContentStore.Missing _ -> do
+          assertFailure "under construction still missing"
+          undefined
+        ContentStore.Complete _ -> do
+          assertFailure "under construction already complete"
+          undefined
+        ContentStore.Pending a ->
+          return a
+
+      b <- ContentStore.lookupOrWait store hash >>= \case
+        ContentStore.Missing _ -> do
+          assertFailure "under construction still missing"
+          undefined
+        ContentStore.Complete _ -> do
+          assertFailure "under construction already complete"
+          undefined
+        ContentStore.Pending b ->
+          return b
+
+      ContentStore.removeFailed store hash
+
+      item' <- wait a
+      item' @?= ContentStore.Failed
+
+      item'' <- wait b
+      item'' @?= ContentStore.Failed
+
+      ContentStore.constructOrAsync store Remote.NoCache hash >>= \case
+        ContentStore.Pending _ -> do
+          assertFailure "failed still under construction"
+        ContentStore.Complete _ -> do
+          assertFailure "failed already complete"
+        ContentStore.Missing _ -> do
+          return ()
+
+  , testCase "construct if missing" $
+    withEmptyStore $ \store -> do
+      hash <- contentHash ("test" :: String)
+      let file = [relfile|file|]
+          expectedContent = "Hello World"
+
+      ContentStore.constructIfMissing store Remote.NoCache hash >>= \case
+        ContentStore.Pending () ->
+          assertFailure "missing already under construction"
+        ContentStore.Complete _ ->
+          assertFailure "missing already complete"
+        ContentStore.Missing subtree -> do
+          isWritable subtree
+            @? "under construction not writable"
+          writeFile (fromAbsFile $ subtree </> file) expectedContent
+
+      ContentStore.constructIfMissing store Remote.NoCache hash >>= \case
+        ContentStore.Missing _ ->
+          assertFailure "under construction still missing"
+        ContentStore.Complete _ ->
+          assertFailure "under construction already complete"
+        ContentStore.Pending () ->
+          void $ ContentStore.markComplete store hash
+
+      ContentStore.constructIfMissing store Remote.NoCache hash >>= \case
+        ContentStore.Missing _ ->
+          assertFailure "complete still missing"
+        ContentStore.Pending () ->
+          assertFailure "complete still under construction"
+        ContentStore.Complete item -> do
+          let subtree = ContentStore.itemPath store item
+          isNotWritable (subtree </> file)
+            @? "complete still writable"
+          content <- readFile (fromAbsFile $ subtree </> file)
+          content @?= expectedContent
+
+  , testCase "remove failed" $
+    withEmptyStore $ \store -> do
+      hash <- contentHash ("test" :: String)
+      subtree <- ContentStore.markPending store hash
+      ContentStore.removeFailed store hash
+      not <$> doesDirExist @IO subtree
+        @? "subtree was removed"
+
+  , testCase "forcibly remove" $
+    withEmptyStore $ \store -> do
+      hash <- contentHash ("test" :: String)
+      subtree <- ContentStore.markPending store hash
+
+      ContentStore.removeForcibly store hash
+      not <$> doesDirExist @IO subtree
+        @? "remove under construction"
+
+      ContentStore.removeForcibly store hash
+      not <$> doesDirExist @IO subtree
+        @? "remove missing"
+
+      subtree' <- ContentStore.markPending store hash
+      void $ ContentStore.markComplete store hash
+      ContentStore.removeForcibly store hash
+      not <$> doesDirExist @IO subtree'
+        @? "remove complete"
+
+  , testCase "subtree state is persisted" $
+    withTmpDir $ \dir -> do
+      let root = dir </> [reldir|store|]
+      hash <- contentHash ("test" :: String)
+
+      do
+        ContentStore.withStore root $ \store ->
+          void $ ContentStore.markPending store hash
+
+      -- Imagine the process terminates and the store is closed
+
+      do
+        ContentStore.withStore root $ \store -> do
+          underConstruction <- ContentStore.query store hash
+          underConstruction @?= ContentStore.Pending ()
+          void $ ContentStore.markComplete store hash
+
+      -- Imagine the process terminates and the store is closed
+
+      do
+        ContentStore.withStore root $ \store -> do
+          complete <- ContentStore.query store hash
+          complete @?= ContentStore.Complete ()
+
+  , testCase "mark complete before under construction fails" $
+    withEmptyStore $ \store -> do
+      hash <- contentHash ("test" :: String)
+      ContentStore.markComplete store hash
+        `shouldFail` "complete before under construction"
+
+  , testCase "mark complete after complete fails" $
+    withEmptyStore $ \store -> do
+      hash <- contentHash ("test" :: String)
+      void $ ContentStore.markPending store hash
+      void $ ContentStore.markComplete store hash
+      ContentStore.markComplete store hash
+        `shouldFail` "complete after complete"
+
+  , testCase "mark under construction after under construction fails" $
+    withEmptyStore $ \store -> do
+      hash <- contentHash ("test" :: String)
+      void $ ContentStore.markPending store hash
+      void $ ContentStore.markPending store hash
+        `shouldFail` "under construction after under construction"
+
+  , testCase "mark under construction after complete fails" $
+    withEmptyStore $ \store -> do
+      hash <- contentHash ("test" :: String)
+      void $ ContentStore.markPending store hash
+      void $ ContentStore.markComplete store hash
+      void $ ContentStore.markPending store hash
+        `shouldFail` "under construction after complete"
+
+  , testCase "remove missing fails" $
+    withEmptyStore $ \store -> do
+      hash <- contentHash ("test" :: String)
+      ContentStore.removeFailed store hash
+        `shouldFail` "remove non existent"
+
+  , testCase "remove complete fails" $
+    withEmptyStore $ \store -> do
+      hash <- contentHash ("test" :: String)
+      void $ ContentStore.markPending store hash
+      void $ ContentStore.markComplete store hash
+      ContentStore.removeFailed store hash
+        `shouldFail` "remove complete"
+
+  , testCase "list store contents" $
+    withEmptyStore $ \store -> do
+      [a, b, c, d] <- mapM contentHash ["a", "b", "c", "d" :: String]
+      void $ mapM (ContentStore.markPending store) [a, b, c, d]
+      mapM_ (ContentStore.markComplete store) [a, b]
+
+      (pendings, completes, items) <- ContentStore.listAll store
+      let all' = pendings ++ completes
+      Set.fromList all' @?= Set.fromList [a, b, c, d]
+      Set.size (Set.fromList all') @?= 4
+
+      underContsruction <- ContentStore.listPending store
+      Set.fromList underContsruction @?= Set.fromList [c, d]
+
+      complete <- ContentStore.listComplete store
+      Set.fromList complete @?= Set.fromList [a, b]
+
+      items' <- catMaybes <$> mapM (ContentStore.waitUntilComplete store) [a, b]
+      Set.fromList items @?= Set.fromList items'
+
+  , testCase "store aliases" $
+    withEmptyStore $ \store -> do
+      let fp = [relfile|test|]
+          contentA = "itemA"
+          contentB = "itemB"
+      itemA <- do
+        hash <- contentHash ("a" :: String)
+        dir <- ContentStore.markPending store hash
+        writeFile (fromAbsFile $ dir </> fp) contentA
+        ContentStore.markComplete store hash
+      itemB <- do
+        hash <- contentHash ("b" :: String)
+        dir <- ContentStore.markPending store hash
+        writeFile (fromAbsFile $ dir </> fp) contentB
+        ContentStore.markComplete store hash
+      let aliasA = ContentStore.Alias "aliasA"
+          aliasB = ContentStore.Alias "aliasB"
+
+      do
+        r <- ContentStore.lookupAlias store aliasA
+        r @?= Nothing
+
+      ContentStore.assignAlias store aliasA itemA
+      ContentStore.assignAlias store aliasB itemB
+      do
+        r <- ContentStore.lookupAlias store aliasA
+        r @?= Just itemA
+      do
+        r <- ContentStore.lookupAlias store aliasB
+        r @?= Just itemB
+
+      ContentStore.assignAlias store aliasB itemA
+      do
+        r <- ContentStore.lookupAlias store aliasA
+        r @?= Just itemA
+      do
+        r <- ContentStore.lookupAlias store aliasB
+        r @?= Just itemA
+
+      ContentStore.removeAlias store aliasA
+      ContentStore.removeAlias store aliasB
+      do
+        r <- ContentStore.lookupAlias store aliasA
+        r @?= Nothing
+      do
+        r <- ContentStore.lookupAlias store aliasB
+        r @?= Nothing
+
+    , testCase "Remote caching (constructOrAsync)" $ do
+      cacher <- Remote.memoryCache
+      hash <- contentHash ("test" :: String)
+      let file = [relfile|file|]
+          expectedContent = "Hello World"
+
+      -- Populate the remote cache
+      withEmptyStore $ \store -> do
+        ContentStore.constructOrAsync store cacher hash >>= \case
+          ContentStore.Pending _ ->
+            assertFailure "missing already under construction"
+          ContentStore.Complete _ ->
+            assertFailure "missing already complete"
+          ContentStore.Missing subtree -> do
+            isWritable subtree @? "under construction not writable"
+            writeFile (fromAbsFile $ subtree </> file) expectedContent
+            Remote.push cacher hash Nothing subtree
+
+      -- Expects having the item in cache
+      withEmptyStore $ \store -> do
+        ContentStore.constructOrAsync store cacher hash >>= \case
+          ContentStore.Pending _ ->
+            assertFailure "missing already under construction"
+          ContentStore.Complete _ -> pure ()
+          ContentStore.Missing _ -> assertFailure "Not found in the cache"
+
+    , testCase "Remote caching (withConstructIfMissing)" $ do
+      cacher <- Remote.memoryCache
+      hash <- contentHash ("test" :: String)
+      let file = [relfile|file|]
+          expectedContent = "Hello World"
+          doWrite subtree = do
+            isWritable subtree @? "under construction not writable"
+            writeFile (fromAbsFile $ subtree </> file) expectedContent
+            return $ Right ()
+      
+      -- Populates the remote cache
+      withEmptyStore $ \store -> do
+        ContentStore.withConstructIfMissing store cacher hash doWrite >>= \case
+          ContentStore.Missing _ -> assertFailure "not found in the cache"
+          ContentStore.Pending _ ->
+            assertFailure "missing already under construction"
+            -- ContentStore.waitUntilComplete store hash >>= \case
+            --   Just item -> return ()
+            --   Nothing -> assertFailure "item construction failed"
+          ContentStore.Complete _ -> pure ()
+    
+      -- Expects having the item in the remote cache
+      withEmptyStore $ \store -> do
+        ContentStore.withConstructIfMissing store cacher hash
+          (const $ assertFailure "should not try to write the file a second time") >>= \case
+            ContentStore.Missing _ -> assertFailure "Not found in the cache"
+            ContentStore.Pending _ ->
+              assertFailure "missing already under construction"
+            ContentStore.Complete _ -> pure ()
+
+  ]
+
+shouldFail :: IO a -> String -> IO ()
+shouldFail m msg = tryAny m >>= \case
+  Left _ -> return ()
+  Right _ -> assertFailure msg
+
+withTmpDir :: (Path Abs Dir -> IO a) -> IO a
+withTmpDir = withSystemTempDir "funflow-test"
+
+withEmptyStore :: (ContentStore -> IO a) -> IO a
+withEmptyStore k = withTmpDir $ \dir ->
+  ContentStore.withStore (dir </> [reldir|store|]) k
+
+isNotWritable :: Path Abs t -> IO Bool
+isNotWritable path = do
+  mode <- fileMode <$> getFileStatus (toFilePath path)
+  return $! nullFileMode == (mode `intersectFileModes` writeModes)
+  where
+    writeModes =
+      ownerWriteMode
+      `unionFileModes`
+      groupWriteMode
+      `unionFileModes`
+      otherWriteMode
+
+isWritable :: Path Abs t -> IO Bool
+isWritable = fmap not . isNotWritable
diff --git a/test/Test.hs b/test/Test.hs
new file mode 100644
--- /dev/null
+++ b/test/Test.hs
@@ -0,0 +1,10 @@
+import qualified CAS.ContentStore
+import           Test.Tasty
+
+main :: IO ()
+main = defaultMain tests
+
+tests :: TestTree
+tests = testGroup "Unit Tests"
+  [ CAS.ContentStore.tests
+  ]
