diff --git a/src/_zkapauthorizer/_plugin.py b/src/_zkapauthorizer/_plugin.py
index e83f3166663e3a9a9e849be57dbc77f458257d3c..8b16a7751eec14dc61e1751101839724440ea346 100644
--- a/src/_zkapauthorizer/_plugin.py
+++ b/src/_zkapauthorizer/_plugin.py
@@ -17,6 +17,14 @@ The Twisted plugin that glues the Zero-Knowledge Access Pass system into
 Tahoe-LAFS.
 """
 
+from weakref import (
+    WeakValueDictionary,
+)
+
+from functools import (
+    partial,
+)
+
 import attr
 
 from zope.interface import (
@@ -37,14 +45,18 @@ from .api import (
     ZKAPAuthorizerStorageClient,
 )
 
-from ._storage_server import (
-    TOKEN_LENGTH,
+from .model import (
+    VoucherStore,
 )
 
 from .resource import (
     from_configuration as resource_from_configuration,
 )
 
+from .controller import (
+    DummyRedeemer,
+)
+
 @implementer(IAnnounceableStorageServer)
 @attr.s
 class AnnounceableStorageServer(object):
@@ -52,14 +64,37 @@ class AnnounceableStorageServer(object):
     storage_server = attr.ib()
 
 
-
+@attr.s
 @implementer(IFoolscapStoragePlugin)
 class ZKAPAuthorizer(object):
     """
     A storage plugin which provides a token-based access control mechanism on
     top of the Tahoe-LAFS built-in storage server interface.
+
+    :ivar WeakValueDictionary _stores: A mapping from node directories to this
+        plugin's database connections for those nodes.  The existence of any
+        kind of attribute to reference database connections (not so much the
+        fact that it is a WeakValueDictionary; if it were just a weakref the
+        same would be true) probably reflects an error in the interface which
+        forces different methods to use instance state to share a database
+        connection.
     """
-    name = u"privatestorageio-zkapauthz-v1"
+    name = attr.ib(default=u"privatestorageio-zkapauthz-v1")
+    _stores = attr.ib(default=attr.Factory(WeakValueDictionary))
+
+    def _get_store(self, node_config):
+        """
+        :return VoucherStore: The database for the given node.  At most one
+            connection is made to the database per ``ZKAPAuthorizer`` instance.
+        """
+        key =  node_config.get_config_path()
+        try:
+            s = self._stores[key]
+        except KeyError:
+            s = VoucherStore.from_node_config(node_config)
+            self._stores[key] = s
+        return s
+
 
     def get_storage_server(self, configuration, get_anonymous_storage_server):
         announcement = {}
@@ -75,14 +110,24 @@ class ZKAPAuthorizer(object):
         )
 
 
-    def get_storage_client(self, configuration, announcement, get_rref):
-        return succeed(
-            ZKAPAuthorizerStorageClient(
-                get_rref,
-                lambda: [b"x" * TOKEN_LENGTH],
-            )
+    def get_storage_client(self, node_config, announcement, get_rref):
+        """
+        Create an ``IStorageClient`` that submits ZKAPs with certain requests in
+        order to authorize them.  The ZKAPs are extracted from the database
+        managed by this plugin in the node directory that goes along with
+        ``node_config``.
+        """
+        return ZKAPAuthorizerStorageClient(
+            get_rref,
+            # TODO: Make the caller figure out the correct number of
+            # passes to extract.
+            partial(self._get_store(node_config).extract_passes, 1),
         )
 
 
     def get_client_resource(self, node_config):
-        return resource_from_configuration(node_config)
+        return resource_from_configuration(
+            node_config,
+            store=self._get_store(node_config),
+            redeemer=DummyRedeemer(),
+        )
diff --git a/src/_zkapauthorizer/_storage_client.py b/src/_zkapauthorizer/_storage_client.py
index 7615bbaf1b2fb08a8f9509e596bacedffc9d67dc..e0dc41ddca2693016c17785a9dc4b655b7f2b585 100644
--- a/src/_zkapauthorizer/_storage_client.py
+++ b/src/_zkapauthorizer/_storage_client.py
@@ -49,16 +49,27 @@ class ZKAPAuthorizerStorageClient(object):
         valid ``RemoteReference`` corresponding to the server-side object for
         this scheme.
 
-    :ivar _get_tokens: A no-argument callable which retrieves some tokens
+    :ivar _get_passes: A no-argument callable which retrieves some passes
         which can be used to authorize an operation.
     """
     _get_rref = attr.ib()
-    _get_tokens = attr.ib()
+    _get_passes = attr.ib()
 
     @property
     def _rref(self):
         return self._get_rref()
 
+    def _get_encoded_passes(self):
+        """
+        :return: A list of passes from ``_get_passes`` encoded into their
+            ``bytes`` representation.
+        """
+        return list(
+            t.text.encode("ascii")
+            for t
+            in self._get_passes()
+        )
+
     def get_version(self):
         return self._rref.callRemote(
             "get_version",
@@ -75,7 +86,7 @@ class ZKAPAuthorizerStorageClient(object):
     ):
         return self._rref.callRemote(
             "allocate_buckets",
-            self._get_tokens(),
+            self._get_encoded_passes(),
             storage_index,
             renew_secret,
             cancel_secret,
@@ -101,7 +112,7 @@ class ZKAPAuthorizerStorageClient(object):
     ):
         return self._rref.callRemote(
             "add_lease",
-            self._get_tokens(),
+            self._get_encoded_passes(),
             storage_index,
             renew_secret,
             cancel_secret,
@@ -114,7 +125,7 @@ class ZKAPAuthorizerStorageClient(object):
     ):
         return self._rref.callRemote(
             "renew_lease",
-            self._get_tokens(),
+            self._get_encoded_passes(),
             storage_index,
             renew_secret,
         )
@@ -143,7 +154,7 @@ class ZKAPAuthorizerStorageClient(object):
     ):
         return self._rref.callRemote(
             "slot_testv_and_readv_and_writev",
-            self._get_tokens(),
+            self._get_encoded_passes(),
             storage_index,
             secrets,
             tw_vectors,
diff --git a/src/_zkapauthorizer/_storage_server.py b/src/_zkapauthorizer/_storage_server.py
index e917100e209e0cf9877fcf0e519623e2e1208ff3..3f9aec7c698a18913c45c86a1b8924111a3ab110 100644
--- a/src/_zkapauthorizer/_storage_server.py
+++ b/src/_zkapauthorizer/_storage_server.py
@@ -20,6 +20,10 @@ This is the server part of a storage access protocol.  The client part is
 implemented in ``_storage_client.py``.
 """
 
+from __future__ import (
+    absolute_import,
+)
+
 import attr
 from attr.validators import (
     provides,
@@ -29,127 +33,20 @@ from zope.interface import (
     implementer_only,
 )
 
-from foolscap.constraint import (
-    ByteStringConstraint,
-)
 from foolscap.api import (
-    ListOf,
     Referenceable,
 )
 from foolscap.ipb import (
     IReferenceable,
     IRemotelyCallable,
 )
-from foolscap.remoteinterface import (
-    RemoteMethodSchema,
-    RemoteInterface,
-)
-
 from allmydata.interfaces import (
     RIStorageServer,
 )
 
-# The Foolscap convention seems to be to try to constrain inputs to valid
-# values.  So we'll try to limit the number of tokens a client can supply.
-# Foolscap may be moving away from this so we may eventually drop this as
-# well.  Though it may still make sense on a non-Foolscap protocol (eg HTTP)
-# which Tahoe-LAFS may eventually support.
-#
-# In any case, for now, pick some fairly arbitrary value.  I am deliberately
-# picking a small number here and expect to have to raise.  However, ideally,
-# a client could accomplish a lot with a few tokens while also not wasting a
-# lot of value.
-MAXIMUM_TOKENS_PER_CALL = 10
-
-# This is the length of a serialized PrivacyPass pass (there's a lot of
-# confusion between "tokens" and "passes" here, sadly).
-TOKEN_LENGTH = 97
-
-# Take those values and turn them into the appropriate Foolscap constraint
-# objects.  Foolscap seems to have a convention of representing these as
-# CamelCase module-level values so I replicate that here.
-Token = ByteStringConstraint(maxLength=TOKEN_LENGTH, minLength=TOKEN_LENGTH)
-TokenList = ListOf(Token, maxLength=MAXIMUM_TOKENS_PER_CALL)
-
-
-def add_tokens(schema):
-    """
-    Add a ``tokens`` parameter to the given method schema.
-
-    :param foolscap.remoteinterface.RemoteMethodSchema schema: An existing
-        method schema to modify.
-
-    :return foolscap.remoteinterface.RemoteMethodSchema: A schema like
-        ``schema`` but with one additional required argument.
-    """
-    return add_arguments(schema, [(b"tokens", TokenList)])
-
-
-def add_arguments(schema, kwargs):
-    """
-    Create a new schema like ``schema`` but with the arguments given by
-    ``kwargs`` prepended to the signature.
-
-    :param foolscap.remoteinterface.RemoteMethodSchema schema: The existing
-        schema.
-
-    :param list[(bytes, foolscap.IConstraint)] kwargs: The arguments to
-        prepend to the signature of ``schema``.
-
-    :return foolscap.remoteinterface.RemoteMethodSchema: The new schema
-        object.
-    """
-    new_kwargs = dict(schema.argConstraints)
-    new_kwargs.update(kwargs)
-    modified_schema = RemoteMethodSchema(**new_kwargs)
-    # Initialized from **new_kwargs, RemoteMethodSchema.argumentNames is in
-    # some arbitrary, probably-incorrect order.  This breaks user code which
-    # tries to use positional arguments.  Put them back in the order they were
-    # in originally (in the input ``schema``), prepended with the newly added
-    # arguments.
-    modified_schema.argumentNames = (
-        # The new arguments
-        list(argName for (argName, _) in kwargs) +
-        # The original arguments in the original order
-        schema.argumentNames
-    )
-    return modified_schema
-
-
-
-class RITokenAuthorizedStorageServer(RemoteInterface):
-    """
-    An object which can store and retrieve shares, subject to token-based
-    authorization.
-
-    This is much the same as ``allmydata.interfaces.RIStorageServer`` but
-    several of its methods take an additional ``tokens`` parameter.  Clients
-    are expected to supply suitable tokens and only after the tokens have been
-    validated is service provided.
-    """
-    __remote_name__ = (
-        "RITokenAuthorizedStorageServer.tahoe.privatestorage.io"
-    )
-
-    get_version = RIStorageServer["get_version"]
-
-    allocate_buckets = add_tokens(RIStorageServer["allocate_buckets"])
-
-    add_lease = add_tokens(RIStorageServer["add_lease"])
-
-    renew_lease = add_tokens(RIStorageServer["renew_lease"])
-
-    get_buckets = RIStorageServer["get_buckets"]
-
-    slot_readv = RIStorageServer["slot_readv"]
-
-    slot_testv_and_readv_and_writev = add_tokens(
-        RIStorageServer["slot_testv_and_readv_and_writev"],
-    )
-
-    advise_corrupt_share = RIStorageServer["advise_corrupt_share"]
-
-
+from .foolscap import (
+    RITokenAuthorizedStorageServer,
+)
 
 @implementer_only(RITokenAuthorizedStorageServer, IReferenceable, IRemotelyCallable)
 # It would be great to use `frozen=True` (value-based hashing) instead of
@@ -220,7 +117,14 @@ class ZKAPAuthorizerStorageServer(Referenceable):
         """
         return self._original.remote_advise_corrupt_share(*a, **kw)
 
-    def remote_slot_testv_and_readv_and_writev(self, tokens, *a, **kw):
+    def remote_slot_testv_and_readv_and_writev(
+            self,
+            tokens,
+            storage_index,
+            secrets,
+            tw_vectors,
+            r_vector,
+    ):
         """
         Pass through after a token check to ensure clients can only allocate
         storage for mutable shares if they present valid tokens.
@@ -235,8 +139,13 @@ class ZKAPAuthorizerStorageServer(Referenceable):
         # about (and don't expose over the network): renew_leases.  We always
         # pass False for this because we want to manage leases completely
         # separately from writes.
-        kw["renew_leases"] = False
-        return self._original.slot_testv_and_readv_and_writev(*a, **kw)
+        return self._original.slot_testv_and_readv_and_writev(
+            storage_index,
+            secrets,
+            tw_vectors,
+            r_vector,
+            renew_leases=False,
+        )
 
     def remote_slot_readv(self, *a, **kw):
         """
diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py
index 7e3302d7953be1cc1e462f7caab63253a4f0b4c7..c0d5536009d07976a85e74109cd3ec5347d5e302 100644
--- a/src/_zkapauthorizer/controller.py
+++ b/src/_zkapauthorizer/controller.py
@@ -17,11 +17,172 @@ This module implements controllers (in the MVC sense) for the web interface
 for the client side of the storage plugin.
 """
 
+from functools import (
+    partial,
+)
+
 import attr
 
+from zope.interface import (
+    Interface,
+    implementer,
+)
+
+from twisted.internet.defer import (
+    Deferred,
+    succeed,
+)
+
+from .foolscap import (
+    TOKEN_LENGTH,
+)
+from .model import (
+    Pass,
+    RandomToken,
+)
+
+
+class IRedeemer(Interface):
+    """
+    An ``IRedeemer`` can exchange a voucher for one or more passes.
+    """
+    def random_tokens_for_voucher(voucher, count):
+        """
+        Generate a number of random tokens to use in the redemption process for
+        the given voucher.
+
+        :param Voucher voucher: The voucher the tokens will be associated
+            with.
+
+        :param int count: The number of random tokens to generate.
+
+        :return list[RandomToken]: The generated tokens.  Random tokens must
+            be unique over the lifetime of the Tahoe-LAFS node where this
+            plugin is being used but the same tokens *may* be generated for
+            the same voucher.  The tokens must be kept secret to preserve the
+            anonymity property of the system.
+        """
+
+    def redeem(voucher, random_tokens):
+        """
+        Redeem a voucher for passes.
+
+        Implementations of this method do not need to be fault tolerant.  If a
+        redemption attempt is interrupted before it completes, it is the
+        caller's responsibility to call this method again with the same
+        arguments.
+
+        :param Voucher voucher: The voucher to redeem.
+
+        :param list[RandomToken] random_tokens: The random tokens to use in
+            the redemption process.
+
+        :return: A ``Deferred`` which fires with a list of ``Pass`` instances
+            on successful redemption or which fails with
+            ``TransientRedemptionError`` on any error which may be resolved by
+            simply trying again later or which fails with
+            ``PermanentRedemptionError`` on any error which is definitive and
+            final.
+        """
+
+
+@implementer(IRedeemer)
+class NonRedeemer(object):
+    """
+    A ``NonRedeemer`` never tries to redeem vouchers for ZKAPs.
+    """
+    def random_tokens_for_voucher(self, voucher, count):
+        # It doesn't matter because we're never going to try to redeem them.
+        return list(
+            RandomToken(u"{}-{}".format(voucher, n))
+            for n
+            in range(count)
+        )
+
+    def redeem(self, voucher, random_tokens):
+        # Don't try to redeem them.
+        return Deferred()
+
+
+@implementer(IRedeemer)
+@attr.s
+class DummyRedeemer(object):
+    """
+    A ``DummyRedeemer`` pretends to redeem vouchers for ZKAPs.  Instead of
+    really redeeming them, it makes up some fake ZKAPs and pretends those are
+    the result.
+    """
+    def random_tokens_for_voucher(self, voucher, count):
+        """
+        Generate some number of random tokens to submit along with a voucher for
+        redemption.
+        """
+        # Dummy token generation.
+        return list(
+            RandomToken(u"{}-{}".format(voucher, n))
+            for n
+            in range(count)
+        )
+
+    def redeem(self, voucher, random_tokens):
+        """
+        :return: An already-fired ``Deferred`` that has a list of ``Pass``
+            instances wrapping meaningless values.
+        """
+        return succeed(
+            list(
+                Pass((u"pass-" + token.token_value).zfill(TOKEN_LENGTH))
+                for token
+                in random_tokens
+            ),
+        )
+
+
 @attr.s
 class PaymentController(object):
+    """
+    The ``PaymentController`` coordinates the process of turning a voucher
+    into a collection of ZKAPs:
+
+      1. A voucher to be consumed is handed to the controller.
+         Once a voucher is handed over to the controller the controller takes all responsibility for it.
+
+      2. The controller tells the data store to remember the voucher.
+         The data store provides durability for the voucher which represents an investment (ie, a purchase) on the part of the client.
+
+      3. The controller hands the voucher and some random tokens to a redeemer.
+         In the future, this step will need to be retried in the case of failures.
+
+      4. When the voucher has been redeemed for passes, the controller hands them to the data store with the voucher.
+        The data store marks the voucher as redeemed and stores the passes for use by the storage client.
+    """
     store = attr.ib()
+    redeemer = attr.ib()
 
     def redeem(self, voucher):
-        self.store.add(voucher)
+        # Pre-generate the random tokens to use when redeeming the voucher.
+        # These are persisted with the voucher so the redemption can be made
+        # idempotent.  We don't want to lose the value if we fail after the
+        # server deems the voucher redeemed but before we persist the result.
+        # With a stable set of tokens, we can re-submit them and the server
+        # can re-sign them without fear of issuing excess passes.  Whether the
+        # server signs a given set of random tokens once or many times, the
+        # number of passes that can be constructed is still only the size of
+        # the set of random tokens.
+        tokens = self.redeemer.random_tokens_for_voucher(voucher, 100)
+
+        # Persist the voucher and tokens so they're available if we fail.
+        self.store.add(voucher, tokens)
+
+        # Ask the redeemer to do the real task of redemption.
+        d = self.redeemer.redeem(voucher, tokens)
+        d.addCallback(
+            partial(self._redeemSuccess, voucher),
+        )
+
+    def _redeemSuccess(self, voucher, passes):
+        """
+        Update the database state to reflect that a voucher was redeemed and to
+        store the resulting passes.
+        """
+        self.store.insert_passes_for_voucher(voucher, passes)
diff --git a/src/_zkapauthorizer/foolscap.py b/src/_zkapauthorizer/foolscap.py
new file mode 100644
index 0000000000000000000000000000000000000000..224614dc7649ee80d3d2bec04eacee90ce749112
--- /dev/null
+++ b/src/_zkapauthorizer/foolscap.py
@@ -0,0 +1,118 @@
+from __future__ import (
+    absolute_import,
+)
+
+from foolscap.constraint import (
+    ByteStringConstraint,
+)
+from foolscap.api import (
+    ListOf,
+)
+from foolscap.remoteinterface import (
+    RemoteMethodSchema,
+    RemoteInterface,
+)
+
+from allmydata.interfaces import (
+    RIStorageServer,
+)
+
+# The Foolscap convention seems to be to try to constrain inputs to valid
+# values.  So we'll try to limit the number of tokens a client can supply.
+# Foolscap may be moving away from this so we may eventually drop this as
+# well.  Though it may still make sense on a non-Foolscap protocol (eg HTTP)
+# which Tahoe-LAFS may eventually support.
+#
+# In any case, for now, pick some fairly arbitrary value.  I am deliberately
+# picking a small number here and expect to have to raise.  However, ideally,
+# a client could accomplish a lot with a few tokens while also not wasting a
+# lot of value.
+MAXIMUM_TOKENS_PER_CALL = 10
+
+# This is the length of a serialized PrivacyPass pass (there's a lot of
+# confusion between "tokens" and "passes" here, sadly).
+TOKEN_LENGTH = 97
+
+# Take those values and turn them into the appropriate Foolscap constraint
+# objects.  Foolscap seems to have a convention of representing these as
+# CamelCase module-level values so I replicate that here.
+Token = ByteStringConstraint(maxLength=TOKEN_LENGTH, minLength=TOKEN_LENGTH)
+TokenList = ListOf(Token, maxLength=MAXIMUM_TOKENS_PER_CALL)
+
+
+def add_tokens(schema):
+    """
+    Add a ``tokens`` parameter to the given method schema.
+
+    :param foolscap.remoteinterface.RemoteMethodSchema schema: An existing
+        method schema to modify.
+
+    :return foolscap.remoteinterface.RemoteMethodSchema: A schema like
+        ``schema`` but with one additional required argument.
+    """
+    return add_arguments(schema, [(b"tokens", TokenList)])
+
+
+def add_arguments(schema, kwargs):
+    """
+    Create a new schema like ``schema`` but with the arguments given by
+    ``kwargs`` prepended to the signature.
+
+    :param foolscap.remoteinterface.RemoteMethodSchema schema: The existing
+        schema.
+
+    :param list[(bytes, foolscap.IConstraint)] kwargs: The arguments to
+        prepend to the signature of ``schema``.
+
+    :return foolscap.remoteinterface.RemoteMethodSchema: The new schema
+        object.
+    """
+    new_kwargs = dict(schema.argConstraints)
+    new_kwargs.update(kwargs)
+    modified_schema = RemoteMethodSchema(**new_kwargs)
+    # Initialized from **new_kwargs, RemoteMethodSchema.argumentNames is in
+    # some arbitrary, probably-incorrect order.  This breaks user code which
+    # tries to use positional arguments.  Put them back in the order they were
+    # in originally (in the input ``schema``), prepended with the newly added
+    # arguments.
+    modified_schema.argumentNames = (
+        # The new arguments
+        list(argName for (argName, _) in kwargs) +
+        # The original arguments in the original order
+        schema.argumentNames
+    )
+    return modified_schema
+
+
+
+class RITokenAuthorizedStorageServer(RemoteInterface):
+    """
+    An object which can store and retrieve shares, subject to token-based
+    authorization.
+
+    This is much the same as ``allmydata.interfaces.RIStorageServer`` but
+    several of its methods take an additional ``tokens`` parameter.  Clients
+    are expected to supply suitable tokens and only after the tokens have been
+    validated is service provided.
+    """
+    __remote_name__ = (
+        "RITokenAuthorizedStorageServer.tahoe.privatestorage.io"
+    )
+
+    get_version = RIStorageServer["get_version"]
+
+    allocate_buckets = add_tokens(RIStorageServer["allocate_buckets"])
+
+    add_lease = add_tokens(RIStorageServer["add_lease"])
+
+    renew_lease = add_tokens(RIStorageServer["renew_lease"])
+
+    get_buckets = RIStorageServer["get_buckets"]
+
+    slot_readv = RIStorageServer["slot_readv"]
+
+    slot_testv_and_readv_and_writev = add_tokens(
+        RIStorageServer["slot_testv_and_readv_and_writev"],
+    )
+
+    advise_corrupt_share = RIStorageServer["advise_corrupt_share"]
diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py
index 58357cb237a36e9d1ac300529ccb0d9bdad0c03b..c2e56063489167c5e41ace8f3526751213e0a4ef 100644
--- a/src/_zkapauthorizer/model.py
+++ b/src/_zkapauthorizer/model.py
@@ -85,6 +85,11 @@ def open_and_initialize(path, required_schema_version, connect=None):
     except OperationalError as e:
         raise StoreOpenError(e)
 
+    # Enforcement of foreign key constraints is off by default.  It must be
+    # enabled on a per-connection basis.  This is a helpful feature to ensure
+    # consistency so we want it enforced and we use it in our schema.
+    conn.execute("PRAGMA foreign_keys = ON")
+
     with conn:
         cursor = conn.cursor()
         cursor.execute(
@@ -112,11 +117,32 @@ def open_and_initialize(path, required_schema_version, connect=None):
             """
             CREATE TABLE IF NOT EXISTS [vouchers] (
                 [number] text,
+                [redeemed] num DEFAULT 0,
 
                 PRIMARY KEY([number])
             )
             """,
         )
+        cursor.execute(
+            """
+            CREATE TABLE IF NOT EXISTS [tokens] (
+                [text] text, -- The random string that defines the token.
+                [voucher] text, -- Reference to the voucher these tokens go with.
+
+                PRIMARY KEY([text])
+                FOREIGN KEY([voucher]) REFERENCES [vouchers]([number])
+            )
+            """,
+        )
+        cursor.execute(
+            """
+            CREATE TABLE IF NOT EXISTS [passes] (
+                [text] text, -- The string that defines the pass.
+
+                PRIMARY KEY([text])
+            )
+            """,
+        )
     return conn
 
 
@@ -143,11 +169,21 @@ class VoucherStore(object):
     :ivar allmydata.node._Config node_config: The Tahoe-LAFS node configuration object for
         the node that owns the persisted vouchers.
     """
-    database_path = attr.ib(type=FilePath)
+    database_path = attr.ib(validator=attr.validators.instance_of(FilePath))
     _connection = attr.ib()
 
     @classmethod
     def from_node_config(cls, node_config, connect=None):
+        """
+        Create or open the ``VoucherStore`` for a given node.
+
+        :param allmydata.node._Config node_config: The Tahoe-LAFS
+            configuration object for the node for which we want to open a
+            store.
+
+        :param connect: An alternate database connection function.  This is
+            primarily for the purposes of the test suite.
+        """
         db_path = FilePath(node_config.get_private_path(CONFIG_DB_NAME))
         conn = open_and_initialize(
             db_path,
@@ -161,10 +197,15 @@ class VoucherStore(object):
 
     @with_cursor
     def get(self, cursor, voucher):
+        """
+        :param unicode voucher: The text value of a voucher to retrieve.
+
+        :return Voucher: The voucher object that matches the given value.
+        """
         cursor.execute(
             """
             SELECT
-                ([number])
+                [number], [redeemed]
             FROM
                 [vouchers]
             WHERE
@@ -175,36 +216,150 @@ class VoucherStore(object):
         refs = cursor.fetchall()
         if len(refs) == 0:
             raise KeyError(voucher)
-        return Voucher(refs[0][0])
+        return Voucher(refs[0][0], bool(refs[0][1]))
 
     @with_cursor
-    def add(self, cursor, voucher):
+    def add(self, cursor, voucher, tokens):
+        """
+        Add a new voucher and associated random tokens to the database.  If a
+        voucher with the given text value is already present, do nothing.
+
+        :param unicode voucher: The text value of a voucher to add.
+
+        :param list[RandomToken]: The tokens to add alongside the voucher.
+        """
         cursor.execute(
             """
-            INSERT OR IGNORE INTO [vouchers] VALUES (?)
+            INSERT OR IGNORE INTO [vouchers] ([number]) VALUES (?)
             """,
             (voucher,)
         )
+        if cursor.rowcount:
+            # Something was inserted.  Insert the tokens, too.  It's okay to
+            # drop the tokens in the other case.  They've never been used.
+            # What's *already* in the database, on the other hand, may already
+            # have been submitted in a redeem attempt and must not change.
+            cursor.executemany(
+                """
+                INSERT INTO [tokens] ([voucher], [text]) VALUES (?, ?)
+                """,
+                list(
+                    (voucher, token.token_value)
+                    for token
+                    in tokens
+                ),
+            )
 
     @with_cursor
     def list(self, cursor):
+        """
+        Get all known vouchers.
+
+        :return list[Voucher]: All vouchers known to the store.
+        """
         cursor.execute(
             """
-            SELECT ([number]) FROM [vouchers]
+            SELECT [number], [redeemed] FROM [vouchers]
             """,
         )
         refs = cursor.fetchall()
 
         return list(
-            Voucher(number)
-            for (number,)
+            Voucher(number, bool(redeemed))
+            for (number, redeemed)
             in refs
         )
 
+    @with_cursor
+    def insert_passes_for_voucher(self, cursor, voucher, passes):
+        """
+        Store some passes.
+
+        :param unicode voucher: The voucher associated with the passes.  This
+            voucher will be marked as redeemed to indicate it has fulfilled
+            its purpose and has no further use for us.
+
+        :param list[Pass] passes: The passes to store.
+        """
+        cursor.executemany(
+            """
+            INSERT INTO [passes] VALUES (?)
+            """,
+            list((p.text,) for p in passes),
+        )
+        cursor.execute(
+            """
+            UPDATE [vouchers] SET [redeemed] = 1 WHERE [number] = ?
+            """,
+            (voucher,),
+        )
+
+    @with_cursor
+    def extract_passes(self, cursor, count):
+        """
+        Remove and return some passes.
+
+        :param int count: The maximum number of passes to remove and return.
+            If fewer passes than this are available, only as many as are
+            available are returned.
+
+        :return list[Pass]: The removed passes.
+        """
+        cursor.execute(
+            """
+            CREATE TEMPORARY TABLE [extracting-passes]
+            AS
+            SELECT [text] FROM [passes] LIMIT ?
+            """,
+            (count,),
+        )
+        cursor.execute(
+            """
+            DELETE FROM [passes] WHERE [text] IN [extracting-passes]
+            """,
+        )
+        cursor.execute(
+            """
+            SELECT [text] FROM [extracting-passes]
+            """,
+        )
+        texts = cursor.fetchall()
+        cursor.execute(
+            """
+            DROP TABLE [extracting-passes]
+            """,
+        )
+        return list(
+            Pass(t)
+            for (t,)
+            in texts
+        )
+
+
+@attr.s(frozen=True)
+class Pass(object):
+    """
+    A ``Pass`` instance completely represents a single Zero-Knowledge Access Pass.
+
+    :ivar unicode text: The text value of the pass.  This can be sent to a
+        service provider one time to anonymously prove a prior voucher
+        redemption.  If it is sent more than once the service provider may
+        choose to reject it and the anonymity property is compromised.  Pass
+        text should be kept secret.  If pass text is divulged to third-parties
+        the anonymity property may be compromised.
+    """
+    text = attr.ib(validator=attr.validators.instance_of(unicode))
+
+
+@attr.s(frozen=True)
+class RandomToken(object):
+    token_value = attr.ib(validator=attr.validators.instance_of(unicode))
+
 
 @attr.s
 class Voucher(object):
     number = attr.ib()
+    redeemed = attr.ib(default=False, validator=attr.validators.instance_of(bool))
 
     @classmethod
     def from_json(cls, json):
diff --git a/src/_zkapauthorizer/resource.py b/src/_zkapauthorizer/resource.py
index e13b81b2260ded02c90aa496eab06158ef452f10..7f78a0b41415370ebe9ca4f18e9fdb1b12da0233 100644
--- a/src/_zkapauthorizer/resource.py
+++ b/src/_zkapauthorizer/resource.py
@@ -37,14 +37,12 @@ from ._base64 import (
     urlsafe_b64decode,
 )
 
-from .model import (
-    VoucherStore,
-)
 from .controller import (
     PaymentController,
+    NonRedeemer,
 )
 
-def from_configuration(node_config, store=None):
+def from_configuration(node_config, store, redeemer=None):
     """
     Instantiate the plugin root resource using data from its configuration
     section in the Tahoe-LAFS configuration file::
@@ -57,15 +55,17 @@ def from_configuration(node_config, store=None):
         This is also used to read and write files in the private storage area
         of the node's persistent state location.
 
-    :param VoucherStore store: The store to use.  If ``None`` a sensible one
-        is constructed.
+    :param VoucherStore store: The store to use.
+
+    :param IRedeemer redeemer: The voucher redeemer to use.  If ``None`` a
+        sensible one is constructed.
 
     :return IResource: The root of the resource hierarchy presented by the
         client side of the plugin.
     """
-    if store is None:
-        store = VoucherStore.from_node_config(node_config)
-    controller = PaymentController(store)
+    if redeemer is None:
+        redeemer = NonRedeemer()
+    controller = PaymentController(store, redeemer)
     root = Resource()
     root.putChild(
         b"voucher",
@@ -97,12 +97,12 @@ class _VoucherCollection(Resource):
         try:
             payload = loads(request.content.read())
         except Exception:
-            return bad_request().render(request)
+            return bad_request(u"json request body required").render(request)
         if payload.keys() != [u"voucher"]:
-            return bad_request().render(request)
+            return bad_request(u"request object must have exactly one key: 'voucher'").render(request)
         voucher = payload[u"voucher"]
         if not is_syntactic_voucher(voucher):
-            return bad_request().render(request)
+            return bad_request(u"submitted voucher is syntactically invalid").render(request)
 
         self._controller.redeem(voucher)
         return b""
@@ -170,11 +170,11 @@ class VoucherView(Resource):
         return self._voucher.to_json()
 
 
-def bad_request():
+def bad_request(reason=u"Bad Request"):
     """
     :return IResource: A resource which can be rendered to produce a **BAD
         REQUEST** response.
     """
     return ErrorPage(
-        BAD_REQUEST, b"Bad Request", b"Bad Request",
+        BAD_REQUEST, b"Bad Request", reason.encode("utf-8"),
     )
diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py
index e615137fd615201776e7ccfa46ba1d908a8f883b..8f6e74ed6c4a71cff35f75630b7b26fb3232c543 100644
--- a/src/_zkapauthorizer/tests/strategies.py
+++ b/src/_zkapauthorizer/tests/strategies.py
@@ -52,6 +52,11 @@ from allmydata.client import (
     config_from_string,
 )
 
+from ..model import (
+    Pass,
+    RandomToken,
+)
+
 
 def _merge_dictionaries(dictionaries):
     result = {}
@@ -175,6 +180,34 @@ def vouchers():
     )
 
 
+def random_tokens():
+    """
+    Build random tokens as unicode strings.
+    """
+    return binary(
+        min_size=32,
+        max_size=32,
+    ).map(
+        urlsafe_b64encode,
+    ).map(
+        lambda token: RandomToken(token.decode("ascii")),
+    )
+
+
+def zkaps():
+    """
+    Build random ZKAPs as ``Pass` instances.
+    """
+    return binary(
+        min_size=32,
+        max_size=32,
+    ).map(
+        urlsafe_b64encode,
+    ).map(
+        lambda zkap: Pass(zkap.decode("ascii")),
+    )
+
+
 def request_paths():
     """
     Build lists of unicode strings that represent the path component of an
diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py
index dad75bb63b094f71201f99a8dfdbf1cee3888b13..203e44ca3ca557788daefd667fbbea3dc73de4e5 100644
--- a/src/_zkapauthorizer/tests/test_client_resource.py
+++ b/src/_zkapauthorizer/tests/test_client_resource.py
@@ -92,6 +92,7 @@ from treq.testing import (
 )
 
 from ..model import (
+    Voucher,
     VoucherStore,
     memory_connect,
 )
@@ -390,10 +391,7 @@ class VoucherTests(TestCase):
                     AfterPreprocessing(
                         json_content,
                         succeeded(
-                            Equals({
-                                u"version": 1,
-                                u"number": voucher,
-                            }),
+                            Equals(Voucher(voucher).marshal()),
                         ),
                     ),
                 ),
@@ -449,7 +447,7 @@ class VoucherTests(TestCase):
                         succeeded(
                             Equals({
                                 u"vouchers": list(
-                                    {u"version": 1, u"number": voucher}
+                                    Voucher(voucher).marshal()
                                     for voucher
                                     in vouchers
                                 ),
diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py
new file mode 100644
index 0000000000000000000000000000000000000000..2969a01187eb56dfa3cec435846f4d021c40f7a6
--- /dev/null
+++ b/src/_zkapauthorizer/tests/test_controller.py
@@ -0,0 +1,100 @@
+# Copyright 2019 PrivateStorage.io, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Tests for ``_zkapauthorizer.controller``.
+"""
+
+from testtools import (
+    TestCase,
+)
+from testtools.matchers import (
+    Equals,
+)
+
+from fixtures import (
+    TempDir,
+)
+
+from hypothesis import (
+    given,
+)
+
+from ..controller import (
+    NonRedeemer,
+    DummyRedeemer,
+    PaymentController,
+)
+
+from ..model import (
+    memory_connect,
+    VoucherStore,
+)
+
+from .strategies import (
+    tahoe_configs,
+    vouchers,
+)
+
+class PaymentControllerTests(TestCase):
+    """
+    Tests for ``PaymentController``.
+    """
+    @given(tahoe_configs(), vouchers())
+    def test_not_redeemed_while_redeeming(self, get_config, voucher):
+        """
+        A ``Voucher`` is not marked redeemed before ``IRedeemer.redeem``
+        completes.
+        """
+        tempdir = self.useFixture(TempDir())
+        store = VoucherStore.from_node_config(
+            get_config(
+                tempdir.join(b"node"),
+                b"tub.port",
+            ),
+            connect=memory_connect,
+        )
+        controller = PaymentController(
+            store,
+            NonRedeemer(),
+        )
+        controller.redeem(voucher)
+
+        persisted_voucher = store.get(voucher)
+        self.assertThat(
+            persisted_voucher.redeemed,
+            Equals(False),
+        )
+
+    @given(tahoe_configs(), vouchers())
+    def test_redeemed_after_redeeming(self, get_config, voucher):
+        tempdir = self.useFixture(TempDir())
+        store = VoucherStore.from_node_config(
+            get_config(
+                tempdir.join(b"node"),
+                b"tub.port",
+            ),
+            connect=memory_connect,
+        )
+        controller = PaymentController(
+            store,
+            DummyRedeemer(),
+        )
+        controller.redeem(voucher)
+
+        persisted_voucher = store.get(voucher)
+        self.assertThat(
+            persisted_voucher.redeemed,
+            Equals(True),
+        )
diff --git a/src/_zkapauthorizer/tests/test_model.py b/src/_zkapauthorizer/tests/test_model.py
index 7732e5e06f40460bf1acf00665ab1e4501298afa..1a6ac3cf944dc53b3b6ad554ea85addd170b9aaa 100644
--- a/src/_zkapauthorizer/tests/test_model.py
+++ b/src/_zkapauthorizer/tests/test_model.py
@@ -64,6 +64,8 @@ from ..model import (
 from .strategies import (
     tahoe_configs,
     vouchers,
+    random_tokens,
+    zkaps,
 )
 
 
@@ -104,8 +106,8 @@ class VoucherStoreTests(TestCase):
             raises(KeyError),
         )
 
-    @given(tahoe_configs(), vouchers())
-    def test_add(self, get_config, voucher):
+    @given(tahoe_configs(), vouchers(), lists(random_tokens(), unique=True))
+    def test_add(self, get_config, voucher, tokens):
         """
         ``VoucherStore.get`` returns a ``Voucher`` representing a voucher
         previously added to the store with ``VoucherStore.add``.
@@ -116,16 +118,17 @@ class VoucherStoreTests(TestCase):
             config,
             memory_connect,
         )
-        store.add(voucher)
+        store.add(voucher, tokens)
         self.assertThat(
             store.get(voucher),
             MatchesStructure(
                 number=Equals(voucher),
+                redeemed=Equals(False),
             ),
         )
 
-    @given(tahoe_configs(), vouchers())
-    def test_add_idempotent(self, get_config, voucher):
+    @given(tahoe_configs(), vouchers(), lists(random_tokens(), unique=True))
+    def test_add_idempotent(self, get_config, voucher, tokens):
         """
         More than one call to ``VoucherStore.add`` with the same argument results
         in the same state as a single call.
@@ -136,8 +139,8 @@ class VoucherStoreTests(TestCase):
             config,
             memory_connect,
         )
-        store.add(voucher)
-        store.add(voucher)
+        store.add(voucher, tokens)
+        store.add(voucher, [])
         self.assertThat(
             store.get(voucher),
             MatchesStructure(
@@ -146,7 +149,7 @@ class VoucherStoreTests(TestCase):
         )
 
 
-    @given(tahoe_configs(), lists(vouchers()))
+    @given(tahoe_configs(), lists(vouchers(), unique=True))
     def test_list(self, get_config, vouchers):
         """
         ``VoucherStore.list`` returns a ``list`` containing a ``Voucher`` object
@@ -161,17 +164,17 @@ class VoucherStoreTests(TestCase):
         )
 
         for voucher in vouchers:
-            store.add(voucher)
+            store.add(voucher, [])
 
         self.assertThat(
             store.list(),
-            AfterPreprocessing(
-                lambda refs: set(ref.number for ref in refs),
-                Equals(set(vouchers)),
-            ),
+            Equals(list(
+                Voucher(number)
+                for number
+                in vouchers
+            )),
         )
 
-
     @given(tahoe_configs())
     def test_uncreateable_store_directory(self, get_config):
         """
@@ -250,3 +253,43 @@ class VoucherTests(TestCase):
             Voucher.from_json(ref.to_json()),
             Equals(ref),
         )
+
+
+class ZKAPStoreTests(TestCase):
+    """
+    Tests for ZKAP-related functionality of ``VoucherStore``.
+    """
+    @given(tahoe_configs(), vouchers(), lists(zkaps(), unique=True))
+    def test_zkaps_round_trip(self, get_config, voucher_value, passes):
+        """
+        ZKAPs that are added to the store can later be retrieved.
+        """
+        tempdir = self.useFixture(TempDir())
+        config = get_config(tempdir.join(b"node"), b"tub.port")
+        store = VoucherStore.from_node_config(
+            config,
+            memory_connect,
+        )
+        store.insert_passes_for_voucher(voucher_value, passes)
+        retrieved_passes = store.extract_passes(len(passes))
+        self.expectThat(passes, Equals(retrieved_passes))
+
+        # After extraction, the passes are no longer available.
+        more_passes = store.extract_passes(1)
+        self.expectThat([], Equals(more_passes))
+
+    @given(tahoe_configs(), vouchers(), random_tokens(), zkaps())
+    def test_mark_vouchers_redeemed(self, get_config, voucher_value, token, one_pass):
+        """
+        The voucher for ZKAPs that are added to the store are marked as redeemed.
+        """
+        tempdir = self.useFixture(TempDir())
+        config = get_config(tempdir.join(b"node"), b"tub.port")
+        store = VoucherStore.from_node_config(
+            config,
+            memory_connect,
+        )
+        store.add(voucher_value, [token])
+        store.insert_passes_for_voucher(voucher_value, [one_pass])
+        loaded_voucher = store.get(voucher_value)
+        self.assertThat(loaded_voucher.redeemed, Equals(True))
diff --git a/src/_zkapauthorizer/tests/test_plugin.py b/src/_zkapauthorizer/tests/test_plugin.py
index 72c017ace8af653dfac8198446e3aa98753265ed..2587904e68bf78928d8b4344a653de7db4d4a4cd 100644
--- a/src/_zkapauthorizer/tests/test_plugin.py
+++ b/src/_zkapauthorizer/tests/test_plugin.py
@@ -31,6 +31,7 @@ from testtools.matchers import (
     Always,
     Contains,
     AfterPreprocessing,
+    Equals,
 )
 from testtools.twistedsupport import (
     succeeded,
@@ -47,6 +48,9 @@ from foolscap.ipb import (
     IReferenceable,
     IRemotelyCallable,
 )
+from foolscap.referenceable import (
+    LocalReferenceable,
+)
 
 from allmydata.interfaces import (
     IFoolscapStoragePlugin,
@@ -68,10 +72,19 @@ from twisted.plugins.zkapauthorizer import (
     storage_server,
 )
 
+from ..model import (
+    VoucherStore,
+)
+
 from .strategies import (
     tahoe_configs,
     configurations,
     announcements,
+    vouchers,
+    random_tokens,
+    zkaps,
+    storage_indexes,
+    lease_renew_secrets,
 )
 from .matchers import (
     Provides,
@@ -88,7 +101,7 @@ def get_anonymous_storage_server():
 
 
 def get_rref():
-    return None
+    return LocalReferenceable(None)
 
 
 class PluginTests(TestCase):
@@ -214,21 +227,82 @@ class ClientPluginTests(TestCase):
     Tests for the plugin's implementation of
     ``IFoolscapStoragePlugin.get_storage_client``.
     """
-    @given(configurations(), announcements())
-    def test_interface(self, configuration, announcement):
+    @given(tahoe_configs(), announcements())
+    def test_interface(self, get_config, announcement):
         """
-        ``get_storage_client`` returns a ``Deferred`` that fires with an object
-        which provides ``IStorageServer``.
+        ``get_storage_client`` returns an object which provides
+        ``IStorageServer``.
         """
-        storage_client_deferred = storage_server.get_storage_client(
-            configuration,
+        tempdir = self.useFixture(TempDir())
+        node_config = get_config(
+            tempdir.join(b"node"),
+            b"tub.port",
+        )
+
+        storage_client = storage_server.get_storage_client(
+            node_config,
+            announcement,
+            get_rref,
+        )
+
+        self.assertThat(
+            storage_client,
+            Provides([IStorageServer]),
+        )
+
+
+    @given(
+        tahoe_configs(),
+        announcements(),
+        vouchers(),
+        random_tokens(),
+        zkaps(),
+        storage_indexes(),
+        lease_renew_secrets(),
+    )
+    def test_passes_extracted(
+            self,
+            get_config,
+            announcement,
+            voucher,
+            token,
+            zkap,
+            storage_index,
+            renew_secret,
+    ):
+        """
+        The ``ZKAPAuthorizerStorageServer`` returned by ``get_storage_client``
+        extracts passes from the plugin database.
+        """
+        tempdir = self.useFixture(TempDir())
+        node_config = get_config(
+            tempdir.join(b"node"),
+            b"tub.port",
+        )
+
+        store = VoucherStore.from_node_config(node_config)
+        store.add(voucher, [token])
+        store.insert_passes_for_voucher(voucher, [zkap])
+
+        storage_client = storage_server.get_storage_client(
+            node_config,
             announcement,
             get_rref,
         )
 
+        # This is hooked up to a garbage reference.  We don't care about its
+        # _result_, anyway, right now.
+        d = storage_client.renew_lease(
+            storage_index,
+            renew_secret,
+        )
+        d.addBoth(lambda ignored: None)
+
+        # There should be no passes left to extract.
+        remaining = store.extract_passes(1)
         self.assertThat(
-            storage_client_deferred,
-            succeeded(Provides([IStorageServer])),
+            remaining,
+            Equals([]),
         )
 
 
diff --git a/src/_zkapauthorizer/tests/test_storage_protocol.py b/src/_zkapauthorizer/tests/test_storage_protocol.py
index 202d87e52d056bf9cabe58e481dfda4bdef0a54c..51cac393c395cdd3dc520aacc864dc81028e6d3b 100644
--- a/src/_zkapauthorizer/tests/test_storage_protocol.py
+++ b/src/_zkapauthorizer/tests/test_storage_protocol.py
@@ -29,7 +29,8 @@ from testtools import (
 from testtools.matchers import (
     Equals,
     HasLength,
-    Always,
+    IsInstance,
+    AfterPreprocessing,
 )
 from testtools.twistedsupport import (
     succeeded,
@@ -84,9 +85,12 @@ from ..api import (
     ZKAPAuthorizerStorageServer,
     ZKAPAuthorizerStorageClient,
 )
-from .._storage_server import (
+from ..foolscap import (
     TOKEN_LENGTH,
 )
+from ..model import (
+    Pass,
+)
 
 class AnonymousStorageServer(Fixture):
     """
@@ -156,8 +160,8 @@ class ShareTests(TestCase):
         self.canary = LocalReferenceable(None)
         self.anonymous_storage_server = self.useFixture(AnonymousStorageServer()).storage_server
 
-        def get_tokens():
-            return [b"x" * TOKEN_LENGTH]
+        def get_passes():
+            return [Pass(u"x" * TOKEN_LENGTH)]
 
         self.server = ZKAPAuthorizerStorageServer(
             self.anonymous_storage_server,
@@ -165,7 +169,7 @@ class ShareTests(TestCase):
         self.local_remote_server = LocalRemote(self.server)
         self.client = ZKAPAuthorizerStorageClient(
             get_rref=lambda: self.local_remote_server,
-            get_tokens=get_tokens,
+            get_passes=get_passes,
         )
 
     def test_get_version(self):
@@ -509,7 +513,7 @@ class ShareTests(TestCase):
         d = self.client._rref.callRemote(
             "slot_testv_and_readv_and_writev",
             # tokens
-            self.client._get_tokens(),
+            self.client._get_encoded_passes(),
             # storage_index
             storage_index,
             # secrets
@@ -526,11 +530,15 @@ class ShareTests(TestCase):
             True,
         )
 
-        # The operation should fail.  I'm not that concerned with how just
-        # yet.
+        # The operation should fail.
         self.expectThat(
             d,
-            failed(Always()),
+            failed(
+                AfterPreprocessing(
+                    lambda f: f.value,
+                    IsInstance(TypeError),
+                ),
+            ),
         )
 
         # There should be no shares at the given storage index.
diff --git a/tahoe-lafs.nix b/tahoe-lafs.nix
index fd34d16edbefc955cb27e03e3cd117d82fd28621..f9460c420f2e79a5d4daa43736aa7e8c9d95d9a4 100644
--- a/tahoe-lafs.nix
+++ b/tahoe-lafs.nix
@@ -11,9 +11,9 @@ buildPythonPackage rec {
     owner = "LeastAuthority";
     repo = "tahoe-lafs";
     # HEAD of an integration branch for all of the storage plugin stuff.  Last
-    # updated August 15th 2019.
-    rev = "b35a8908f4096ccae35da78b0e7dde96d6cf1667";
-    sha256 = "0n289hzx2s1jvspmpz2c5iwl0dvnfc8qbiqfmpbl88ymrjp7p6rr";
+    # updated August 20th 2019.
+    rev = "ba2f31f5f3719c7cf9f621852571e89ab445bf61";
+    sha256 = "02c3zghx1951zw1912c2qf9s7n41wsbz8ld5700myak69nvkh0gs";
   };
 
   postPatch = ''
diff --git a/zkapauthorizer.nix b/zkapauthorizer.nix
index 282bdc8036b1aee7a1792c88343220e4cf66d90c..8265f890df9f900dcdea5b87d159ae117ceaf33d 100644
--- a/zkapauthorizer.nix
+++ b/zkapauthorizer.nix
@@ -8,6 +8,8 @@ buildPythonPackage rec {
   name = "${pname}-${version}";
   src = ./.;
 
+  outputs = [ "out" "doc" ];
+
   depsBuildBuild = [
     sphinx
     circleci-cli
@@ -30,7 +32,15 @@ buildPythonPackage rec {
   ];
 
   checkPhase = ''
-    ${pyflakes}/bin/pyflakes src/_zkapauthorizer
-    python -m coverage run --source _zkapauthorizer,twisted.plugins.zkapauthorizer --module twisted.trial _zkapauthorizer
+    runHook preCheck
+    "${pyflakes}/bin/pyflakes" src/_zkapauthorizer
+    python -m coverage run --branch --source _zkapauthorizer,twisted.plugins.zkapauthorizer --module twisted.trial _zkapauthorizer
+    runHook postCheck
+  '';
+
+  postCheck = ''
+    python -m coverage html
+    mkdir -p "$doc/share/doc/${name}"
+    cp -vr .coverage htmlcov "$doc/share/doc/${name}"
   '';
 }