diff --git a/ChangeLog.md b/ChangeLog.md
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -2,6 +2,9 @@
 
 `attoparsec-framer` uses [PVP Versioning][1].
 
+## 0.1.0.1 -- 2023-07-19
+
+* Adjust upper bound for ByteString to allow use of 0.12.0.0
 
 ## 0.1.0.0 -- 2023-03-01
 
diff --git a/attoparsec-framer.cabal b/attoparsec-framer.cabal
--- a/attoparsec-framer.cabal
+++ b/attoparsec-framer.cabal
@@ -1,11 +1,11 @@
 cabal-version:      3.0
 name:               attoparsec-framer
-version:            0.1.0.0
-synopsis:           Use Attoparsec to parse framed protocol bytestreams
+version:            0.1.0.1
+synopsis:           Use Attoparsec to parse framed protocol byte streams
 description:
   A library that simplifies the use of
   [Attoparsec](https://hackage.haskell.org/package/attoparsec) when processing
-  framed protocol bytestreams.
+  framed protocol byte streams.
 
   As well as reading the haddocks, please take a look at a
   [demo server](https://github.com/adetokunbo/attoparsec-framer/blob/main/toy/Server.hs)
@@ -36,7 +36,7 @@
   build-depends:
     , attoparsec  >=0.14.4   && <0.15
     , base        >=4.10     && <5
-    , bytestring  >=0.10.8.2 && <0.12.0.0
+    , bytestring  >=0.10.8.2 && <0.12.1.0
     , exceptions  >= 0.10.4  && < 0.11
     , text        >=1.2.3    && <2.2
 
@@ -55,7 +55,7 @@
     , attoparsec-framer
     , exceptions
     , base               >=4.10     && <5
-    , bytestring         >=0.10.8.2 && <0.12.0.0
+    , bytestring         >=0.10.8.2 && <0.12.1.0
     , network            >=3.1.0    && <4
     , network-run        >=0.2.2    && <0.3
     , text               >=1.2.3    && <2.2
@@ -78,7 +78,7 @@
     , attoparsec-binary  >=0.2      && <0.3
     , attoparsec-framer
     , base               >=4.10     && <5
-    , bytestring         >=0.10.8.2 && <0.12.0.0
+    , bytestring         >=0.10.8.2 && <0.12.1.0
     , network            >=3.1.0    && <4
     , network-run        >=0.2.2    && <0.3
     , text               >=1.2.3    && <2.2
diff --git a/src/Data/Attoparsec/Framer.hs b/src/Data/Attoparsec/Framer.hs
--- a/src/Data/Attoparsec/Framer.hs
+++ b/src/Data/Attoparsec/Framer.hs
@@ -11,22 +11,21 @@
 
 Provides the 'Framer' data type that combines an @Attoparsec 'A.Parser'@ with a
 a few additional combinators that allow the parser to be used to process frames
-from the framed byte streams commonly used in network protocol implementations.
+of the framed byte streams commonly used in network protocol implementations.
 
 A @'Framer'@ specifies how the processing function @'runFramer'@ should
 parse a byte stream.
 
 Minimally, a @Framer@ specifies
 
-* An @'A.Parser'@, used to extract frames from the byte stream
-* a @'FrameHandler'@ responsible using the parsed frames
-* the bytestream source, represented a 'ByteSource'
-
+* a @'A.Parser'@, used to extract frames from the byte stream
+* a @'FrameHandler'@ responsible for using the parsed frames
+* the byte stream source, represented by a 'ByteSource'
 
-@'runFramer'@ the 'FrameHandler' is invoked repeatedly; on each
-invocation it returns a 'Progression', which indicates if processing should
-continue. This makes it possible to terminate for the 'FrameHandler' to signal
-that frame processing should terminate.
+@'runFramer'@ reads chunks from the @ByteSource@, parses these into frames and
+invokes the 'FrameHandler'. Each invocation returns a 'Progression', which
+indicates if processing should continue. This allows the 'FrameHandler' to
+trigger termination of 'runFramer'.
 -}
 module Data.Attoparsec.Framer (
   -- * Framer
@@ -71,7 +70,7 @@
 type FrameHandler m frame = frame -> m Progression
 
 
--- | A byte stream from which chunks are to be repeatedly retrieved.
+-- | A byte stream from which chunks are to be retrieved.
 type ByteSource m = Word32 -> m ByteString
 
 
@@ -83,7 +82,7 @@
   deriving (Eq, Show)
 
 
--- | Use 'A.Parser' to parse a stream of @frames@ from a bytestream
+-- | Uses a 'A.Parser' to parse a stream of @frames@ from a byte stream
 data Framer m frame = Framer
   { framerChunkSize :: !Word32
   , frameByteSource :: !(ByteSource m)
@@ -94,8 +93,8 @@
   }
 
 
-{- | Construct @'Framer'@ that will handle @frames@ repeatedly until a returned
- @'Progression'@ stops it.
+{- | Construct a @'Framer'@ that will handle @frames@ repeatedly until the
+@FrameHandler@ returns a @'Progression'@ that stops it.
 -}
 mkFramer' ::
   MonadThrow m =>
@@ -114,13 +113,16 @@
     }
 
 
--- | Construct @'Framer'@ that loops continuously.
+-- | Construct a @'Framer'@ that loops continuously.
 mkFramer ::
   MonadThrow m =>
-  A.Parser a ->
-  (a -> m ()) ->
-  (Word32 -> m ByteString) ->
-  Framer m a
+  -- | parses frames from the byte stream
+  A.Parser frame ->
+  -- | handles parsed frames
+  (frame -> m ()) ->
+  -- | obtains the next chunk from the byte stream
+  ByteSource m ->
+  Framer m frame
 mkFramer parser onFrame fetchBytes =
   let onFrameContinue x = do
         onFrame x
@@ -131,7 +133,7 @@
 -- | Repeatedly parse and handle frames until the configured @FrameHandler@ ends handling.
 runFramer ::
   MonadThrow m =>
-  Framer m a ->
+  Framer m frame ->
   m ()
 runFramer f =
   let Framer
@@ -147,13 +149,19 @@
 
 {- | Parse and handle a single frame.
 
-The result is tuple of the outstanding unparsed bytes from the bytestream if
-any, and a value indicating if the bytestream has terminated.
+The result is a tuple: (Maybe @unparsed@, @terminated@)
+
+where
+
+@unparsed@ are outstanding bytes fetched from the @ByteSource@ and
+@terminated@ is @True@ if the @ByteSource@ has no further input.
 -}
 runOneFrame ::
   MonadThrow m =>
+  -- | the unparsed bytes from an earlier invocation, if any
   Maybe ByteString ->
-  Framer m a ->
+  -- | the 'Framer' used to parse the @frame@
+  Framer m frame ->
   m ((Maybe ByteString), Bool)
 runOneFrame restMb f =
   let Framer
@@ -255,9 +263,9 @@
 On failures, @'runFramer'@ throws @'Exception's@ using @'MonadThrow'@ rather
 than using an @Either@ or @MonadError@
 
-This is because it is intended to be used to parse framed protocol byte streams;
-where parsing or connection errors here are typically not recoverable. In haskell
-non-recoverable failures are better modelled using @Exceptions@.
+This is because its intended use is for parsing framed protocol byte streams;
+where parsing or connection errors are typically not recoverable. In
+haskell non-recoverable failures are better modelled using @Exceptions@.
 
 Although it throws 'NoMoreInput' or 'BrokenFrame' when appropriate, it provides
 hooks to override these when constructing a 'Framer'.
diff --git a/src/Data/Attoparsec/Framer/Testing.hs b/src/Data/Attoparsec/Framer/Testing.hs
--- a/src/Data/Attoparsec/Framer/Testing.hs
+++ b/src/Data/Attoparsec/Framer/Testing.hs
@@ -1,4 +1,5 @@
 {-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE ScopedTypeVariables #-}
 {-# OPTIONS_HADDOCK prune not-home #-}
 
@@ -15,13 +16,18 @@
   -- * testing combinators
   parsesFromFramerOk,
   chunksOfN,
+  linkedSrcAndSink,
+  linkedSrcAndSink',
 ) where
 
 import Control.Exception (catch)
+import Control.Monad (when)
 import qualified Data.Attoparsec.ByteString as A
 import Data.Attoparsec.Framer
 import Data.ByteString (ByteString)
 import qualified Data.ByteString as BS
+import Data.ByteString.Builder (byteStringHex, toLazyByteString)
+import qualified Data.ByteString.Lazy.Char8 as C8
 import Data.IORef (
   IORef,
   modifyIORef',
@@ -70,3 +76,53 @@
     Just (x : xs) -> do
       writeIORef chunkStore $ Just xs
       pure x
+
+
+{- | A @'ByteSource'@ linked to a byte sink.
+
+Provides a @ByteSource@ and @byte sink@ that emulate a responding endpoint.
+
+The @responses@ are consumed each time the byte sink is invoked.
+
+Whenever the sink is invoked, the head of the provided responses is removed
+and starts to be returned in chunks by the @ByteSource@,
+-}
+linkedSrcAndSink :: [ByteString] -> IO (ByteSource IO, (ByteString -> IO ()))
+linkedSrcAndSink responses = do
+  refSrc <- newIORef Nothing
+  refSink <- newIORef responses
+  pure (ioRefByteSource refSrc, ioRefByteSink False refSink refSrc)
+
+
+-- | Like 'linkedSrcAndSink', but prints the src and sink to output as debug
+linkedSrcAndSink' :: [ByteString] -> IO (ByteSource IO, (ByteString -> IO ()))
+linkedSrcAndSink' responses = do
+  refSrc <- newIORef Nothing
+  refSink <- newIORef responses
+  pure (ioRefByteSource refSrc, ioRefByteSink True refSink refSrc)
+
+
+ioRefByteSource :: IORef (Maybe ByteString) -> ByteSource IO
+ioRefByteSource refSrc size = do
+  readIORef refSrc >>= \case
+    Nothing -> pure BS.empty
+    Just src -> do
+      let taken = BS.take (fromIntegral size) src
+          rest = BS.drop (fromIntegral size) src
+          stored = if BS.null taken then Nothing else Just rest
+      writeIORef refSrc stored
+      pure taken
+
+
+ioRefByteSink :: Bool -> IORef [ByteString] -> IORef (Maybe ByteString) -> ByteString -> IO ()
+ioRefByteSink debug refResponses refSrc _ignored = do
+  let asHex = toLazyByteString . byteStringHex
+  when debug $ C8.putStrLn $ "bytesink got: " <> (asHex _ignored)
+  readIORef refResponses >>= \case
+    [] -> do
+      when debug $ C8.putStrLn "bytesource has nothing"
+      writeIORef refSrc Nothing
+    (x : xs) -> do
+      when debug $ C8.putStrLn $ "bytesink will reply with: " <> (asHex x)
+      writeIORef refSrc $ Just x
+      writeIORef refResponses $ xs
