Repository: learningequality/morango (language: python)

Issue:
Add new ids argument to cleanupsyncs command
## Summary
- Adds `ids` argument to cleanup management command so we can initiate targeted cleanup of incomplete SoUD syncs
- Migrates buildkite to pipeline.yml (resolves: https://buildkite.com/learningequality/morango/builds/154#d38eb321-f1f9-4d70-b93a-34efb48d0fb3)

## TODO

- [X] Have tests been written for the new code?
- [ ] Has documentation been written/updated?
- [ ] New dependencies (if any) added to requirements file

## Reviewer guidance
- Run management command with `--ids` arg



Discussion / hints:

Fix patch:
diff --git a/.buildkite/build.sh b/.buildkite/build.sh
new file mode 100755
index 00000000..b6dc792f
--- /dev/null
+++ b/.buildkite/build.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+mkdir -p dist
+make dist
+buildkite-agent artifact upload 'dist/*.whl'
+buildkite-agent artifact upload 'dist/*.tar.gz'
diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml
new file mode 100644
index 00000000..a30126a9
--- /dev/null
+++ b/.buildkite/pipeline.yml
@@ -0,0 +1,10 @@
+steps:
+  - label: Install build packages
+    command:
+     - pip install setuptools wheel
+
+  - wait
+
+  - label: Build python package
+    command:
+     - .buildkite/build.sh
diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml
index f818538d..80969c8b 100644
--- a/.github/workflows/tox.yml
+++ b/.github/workflows/tox.yml
@@ -116,3 +116,27 @@ jobs:
                   key: ${{ runner.os }}-tox-py${{ matrix.python-version }}-${{ hashFiles('setup.py', 'requirements/*.txt') }}
             - name: Test with tox
               run: tox -e postgres
+    windows:
+        name: Python unit tests on Windows Server
+        needs: pre_job
+        runs-on: windows-latest
+        strategy:
+            max-parallel: 5
+            matrix:
+                python-version: [3.6]
+
+        steps:
+            - uses: actions/checkout@v2
+            - name: Set up Python ${{ matrix.python-version }}
+              if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
+              uses: actions/setup-python@v2
+              with:
+                  python-version: ${{ matrix.python-version }}
+            - name: Install tox
+              if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
+              run: |
+                  python -m pip install --upgrade pip
+                  pip install tox
+            - name: Test with tox
+              if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
+              run: tox -e windows
diff --git a/.gitignore b/.gitignore
index e034b961..6e708dab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -96,3 +96,4 @@ Pipfile
 .idea
 
 tests/testapp/testapp.db
+tests/testapp/testapp2.db
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 668fe954..24e6dad9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,38 @@
 
 List of the most important changes for each release.
 
+## 0.6.5
+- Sets queuing limit of 100k combined FSICs between client and server
+- Fixes SQL expression tree error when there are many FSICs, up to 100k limit
+- Adds additional `ids` argument to `cleanupsyncs` management command
+
+## 0.6.4
+- Fixes issue with `assert` statement removal during python optimization
+
+## 0.6.3
+
+- Fixes issue handling database counters which caused repeat syncing of unchanged data
+
+## 0.6.2
+
+- Fixes slow performance due to excessive use of `sleep`
+
+## 0.6.1
+
+- Fix to set counters on `TransferSession` *after* serialization
+- Fix capabilities request header as it should be prefixed with `HTTP`
+- Fix issues with flow of transfer operations
+- Logging and error handling improvements
+
+
+## 0.6.0
+
+- Track the `TransferSession` that last modified a `Store` record
+- Add state attributes to `TransferSession` for persisting its stage and status during a sync
+- Update the timestamp of the last activity for a `SyncSession` and `TransferSession` during a sync
+- Add support for resuming a sync
+- Add support for configuring custom handling of transfer operations
+- Add support for handling transfer operations asynchronously
 
 ## 0.5.6
 
diff --git a/Makefile b/Makefile
index 369c81aa..0d0cf046 100644
--- a/Makefile
+++ b/Makefile
@@ -56,6 +56,9 @@ clean-test: ## remove test and coverage artifacts
 lint:
 	flake8 morango
 
+migrations:
+	python tests/testapp/manage.py makemigrations
+
 test:
 	pytest tests/testapp/tests/
 
diff --git a/docs/architecture/index.rst b/docs/architecture/index.rst
index fb631e69..37220651 100644
--- a/docs/architecture/index.rst
+++ b/docs/architecture/index.rst
@@ -130,3 +130,16 @@ It should be cautioned that there is currently no mechanism for revoking certifi
 In Kolibri, on the ``FacilityDataset`` model, we generate the certificate as a function of the ``calculate_source_id`` method. Note that we currently set the ID of the certificate to be the same as the ID of the facility model. This allows queries on the certificate hierarchy tree to find certificates that are associated with the facility.
 
 There's flexibility in the application layer for determining the validity of a root certificate, and it's specified on a per-profile basis. For the ``facilitydata`` profile, Kolibri leverages its ``auth`` models for this.
+
+
+Session controller, contexts, and operations
+--------------------------------------------
+
+.. image:: ./session-controller.png
+
+A unidirectional sync has several stages: ``INITIALIZING``, ``SERIALIZING``, ``QUEUING``, ``TRANSFERRING``, ``DEQUEUING``, ``DESERIALIZING``, and ``CLEANUP``. Each stage requires callable objects, referred to here simply as *operations*. Operations handle the necessary operational aspects of the transfer for each stage. The ``SessionController`` class establishes an internal API for invoking those operations through a Chain-of-responsibility software design pattern. Provided with a *context*, either a ``LocalSessionContext`` or a ``NetworkSessionContext``, the controller will iterate through each incomplete stage and invoke the operations for stage, passing along the context object. An operation isn't required to handle the context, which is analogous to a request object, but can defer responsibility to the next operation in the stage's list of operations by returning ``False``. At least one operation must handle the context, which is communicated by returning a ``transfer_statuses`` constant of either ``PENDING``, ``STARTED``, ``ERRORED``, or ``COMPLETED``.
+
+.. image:: ./session-controller-seq.png
+
+The list of operations for each stage are configured through Django settings. The configuration key for each stage follows the pattern ``MORANGO_%STAGE%_OPERATIONS``, so the list/tuple of operations for the ``QUEUING`` stage access the ``MORANGO_QUEUING_OPERATIONS`` configuration value. Built-in operations implement a callable ``BaseOperation`` class by overriding a ``handle`` method. The ``BaseOperation`` class supports raising an ``AssertionError`` to defer responsibility to the next operation.
+
diff --git a/docs/architecture/session-controller-seq.png b/docs/architecture/session-controller-seq.png
new file mode 100644
index 00000000..ecd54117
Binary files /dev/null and b/docs/architecture/session-controller-seq.png differ
diff --git a/docs/architecture/session-controller.png b/docs/architecture/session-controller.png
new file mode 100644
index 00000000..8f3e6834
Binary files /dev/null and b/docs/architecture/session-controller.png differ
diff --git a/morango/__init__.py b/morango/__init__.py
index ef5f19c8..f8dfb72e 100644
--- a/morango/__init__.py
+++ b/morango/__init__.py
@@ -3,4 +3,4 @@
 from __future__ import unicode_literals
 
 default_app_config = "morango.apps.MorangoConfig"
-__version__ = "0.5.6"
+__version__ = "0.6.5"
diff --git a/morango/api/permissions.py b/morango/api/permissions.py
index bd65e695..2ec315e3 100644
--- a/morango/api/permissions.py
+++ b/morango/api/permissions.py
@@ -1,6 +1,5 @@
 import json
 
-from django.conf import settings
 from django.contrib.auth import authenticate
 from django.contrib.auth import get_user_model
 from rest_framework import authentication
@@ -8,6 +7,7 @@
 from rest_framework import permissions
 
 from morango.models.core import TransferSession
+from morango.utils import SETTINGS
 
 
 class BasicMultiArgumentAuthentication(authentication.BasicAuthentication):
@@ -33,7 +33,7 @@ def authenticate_credentials(self, userargs, password, request=None):
                 credentials[key] = val
 
         # authenticate the user via Django's auth backends
-        user = authenticate(**credentials)
+        user = authenticate(request=request, **credentials)
 
         if user is None:
             raise exceptions.AuthenticationFailed("Invalid credentials.")
@@ -78,57 +78,13 @@ class CertificatePushPermissions(permissions.BasePermission):
     message = "Server does not allow certificate pushing."
 
     def has_permission(self, request, view):
-        if getattr(settings, "ALLOW_CERTIFICATE_PUSHING", False):
+        if SETTINGS.ALLOW_CERTIFICATE_PUSHING:
             return True
         return False
 
 
-class NoncePermissions(permissions.BasePermission):
-    def has_permission(self, request, view):
-
-        if request.method != "POST":
-            return False
-
-        return True
-
-
-class SyncSessionPermissions(permissions.BasePermission):
-    def has_permission(self, request, view):
-
-        if request.method == "DELETE":
-            return True
-
-        if request.method == "POST":
-            return (
-                True
-            )  # we'll be doing some additional permission checks in the viewset
-
-        return False
-
-
-class TransferSessionPermissions(permissions.BasePermission):
-    def has_permission(self, request, view):
-
-        if request.method == "DELETE":
-            return True
-
-        if request.method == "POST":
-            return (
-                True
-            )  # we'll be doing some additional permission checks in the viewset
-
-        if request.method == "PATCH":
-            return True
-
-        return False
-
-
 class BufferPermissions(permissions.BasePermission):
     def has_permission(self, request, view):
-
-        if request.method == "POST":
-            return True
-
         if request.method == "GET":
             sesh_id = request.query_params.get("transfer_session_id")
             if not sesh_id:
@@ -139,4 +95,4 @@ def has_permission(self, request, view):
                 return False
             return True
 
-        return False
+        return request.method in ("POST",)
diff --git a/morango/api/serializers.py b/morango/api/serializers.py
index 4fe0695a..a23cc2d5 100644
--- a/morango/api/serializers.py
+++ b/morango/api/serializers.py
@@ -75,16 +75,8 @@ class Meta:
             "client_instance",
             "server_instance",
         )
-        read_only_fields = (
-            "start_timestamp",
-            "last_activity_timestamp",
-            "active",
-            "client_certificate",
-            "connection_kind",
-            "client_ip",
-            "server_ip",
-            "client_instance",
-        )
+        # serializer is not used for creates, so these fields are not allowed updates
+        read_only_fields = fields
 
 
 class TransferSessionSerializer(serializers.ModelSerializer):
@@ -99,11 +91,28 @@ class Meta:
             "push",
             "records_transferred",
             "records_total",
+            "bytes_received",
+            "bytes_sent",
+            "sync_session",
+            "server_fsic",
+            "client_fsic",
+            "transfer_stage",
+            "transfer_stage_status",
+        )
+        # serializer is not used for creates, so these fields are not allowed updates
+        read_only_fields = (
+            "id",
+            "start_timestamp",
+            "last_activity_timestamp",
+            "active",
+            "filter",
+            "push",
             "sync_session",
             "server_fsic",
             "client_fsic",
+            "transfer_stage",
+            "transfer_stage_status",
         )
-        read_only_fields = ("start_timestamp", "last_activity_timestamp", "active")
 
 
 class InstanceIDSerializer(serializers.ModelSerializer):
diff --git a/morango/api/viewsets.py b/morango/api/viewsets.py
index ade3e22c..71fa39a7 100644
--- a/morango/api/viewsets.py
+++ b/morango/api/viewsets.py
@@ -1,8 +1,8 @@
 import json
 import platform
 import uuid
+import logging
 
-from django.conf import settings
 from django.core.exceptions import ValidationError
 from django.utils import timezone
 from ipware.ip import get_ip
@@ -14,23 +14,25 @@
 from rest_framework.parsers import JSONParser
 
 import morango
-from . import permissions
-from . import serializers
-from .. import errors
-from ..models import certificates
-from ..models import core
+from morango import errors
+from morango.api import permissions
+from morango.api import serializers
+from morango.constants import transfer_stages
+from morango.constants import transfer_statuses
+from morango.constants.capabilities import ASYNC_OPERATIONS
 from morango.constants.capabilities import GZIP_BUFFER_POST
+from morango.models import certificates
 from morango.models.core import Buffer
-from morango.models.core import DatabaseMaxCounter
+from morango.models.core import Certificate
 from morango.models.core import InstanceIDModel
-from morango.models.core import RecordMaxCounterBuffer
+from morango.models.core import SyncSession
+from morango.models.core import TransferSession
 from morango.models.fields.crypto import SharedKey
-from morango.sync.operations import _dequeue_into_store
-from morango.sync.operations import _queue_into_buffer
-from morango.sync.operations import _serialize_into_store
-from morango.sync.operations import OperationLogger
-from morango.sync.utils import validate_and_create_buffer_data
+from morango.sync.context import LocalSessionContext
+from morango.sync.controller import SessionController
 from morango.utils import CAPABILITIES
+from morango.utils import _assert
+from morango.utils import parse_capabilities_from_server_request
 
 
 if GZIP_BUFFER_POST in CAPABILITIES:
@@ -41,6 +43,23 @@
     parsers = (JSONParser,)
 
 
+def controller_signal_logger(context=None):
+    _assert(context is not None, "Missing context")
+
+    if context.stage_status == transfer_statuses.PENDING:
+        logging.info("Starting stage '{}'".format(context.stage))
+    elif context.stage_status == transfer_statuses.STARTED:
+        logging.info("Stage '{}' is in progress".format(context.stage))
+    elif context.stage_status == transfer_statuses.COMPLETED:
+        logging.info("Completed stage '{}'".format(context.stage))
+    elif context.stage_status == transfer_statuses.ERRORED:
+        logging.info("Encountered error during stage '{}'".format(context.stage))
+
+
+session_controller = SessionController.build()
+session_controller.signals.connect(controller_signal_logger)
+
+
 class CertificateChainViewSet(viewsets.ViewSet):
     permissions = (permissions.CertificatePushPermissions,)
 
@@ -51,7 +70,7 @@ def create(self, request):
 
         # verify the rest of the cert chain
         try:
-            core.Certificate.save_certificate_chain(cert_chain)
+            Certificate.save_certificate_chain(cert_chain)
         except (AssertionError, errors.MorangoCertificateError) as e:
             return response.Response(
                 "Saving certificate chain has failed: {}".format(str(e)),
@@ -59,7 +78,7 @@ def create(self, request):
             )
 
         # create an in-memory instance of the cert from the serialized data and signature
-        certificate = core.Certificate.deserialize(
+        certificate = Certificate.deserialize(
             client_cert["serialized"], client_cert["signature"]
         )
 
@@ -105,7 +124,12 @@ def create(self, request):
         )
 
 
-class CertificateViewSet(viewsets.ModelViewSet):
+class CertificateViewSet(
+    viewsets.mixins.CreateModelMixin,
+    viewsets.mixins.RetrieveModelMixin,
+    viewsets.mixins.ListModelMixin,
+    viewsets.GenericViewSet,
+):
     permission_classes = (permissions.CertificatePermissions,)
     serializer_class = serializers.CertificateSerializer
     authentication_classes = (permissions.BasicMultiArgumentAuthentication,)
@@ -117,7 +141,7 @@ def create(self, request):
         if serialized_cert.is_valid():
 
             # inflate the provided data into an actual in-memory certificate
-            certificate = core.Certificate(**serialized_cert.validated_data)
+            certificate = Certificate(**serialized_cert.validated_data)
 
             # add a salt, ID and signature to the certificate
             certificate.salt = uuid.uuid4().hex
@@ -162,7 +186,7 @@ def get_queryset(self):
 
         params = self.request.query_params
 
-        base_queryset = core.Certificate.objects
+        base_queryset = Certificate.objects
 
         # filter by profile, if requested
         if "profile" in params:
@@ -184,7 +208,7 @@ def get_queryset(self):
                 )
                 return target_cert.get_ancestors(include_self=True)
 
-        except core.Certificate.DoesNotExist:
+        except Certificate.DoesNotExist:
             # if the target_cert can't be found, just return an empty queryset
             return base_queryset.none()
 
@@ -192,8 +216,7 @@ def get_queryset(self):
         return base_queryset.exclude(_private_key=None)
 
 
-class NonceViewSet(viewsets.ModelViewSet):
-    permission_classes = (permissions.NoncePermissions,)
+class NonceViewSet(viewsets.mixins.CreateModelMixin, viewsets.GenericViewSet):
     serializer_class = serializers.NonceSerializer
 
     def create(self, request):
@@ -204,17 +227,20 @@ def create(self, request):
         )
 
 
-class SyncSessionViewSet(viewsets.ModelViewSet):
-    permission_classes = (permissions.SyncSessionPermissions,)
+class SyncSessionViewSet(
+    viewsets.mixins.DestroyModelMixin,
+    viewsets.mixins.RetrieveModelMixin,
+    viewsets.GenericViewSet,
+):
     serializer_class = serializers.SyncSessionSerializer
 
     def create(self, request):
 
-        instance_id, _ = core.InstanceIDModel.get_or_create_current_instance()
+        instance_id, _ = InstanceIDModel.get_or_create_current_instance()
 
         # verify and save the certificate chain to our cert store
         try:
-            core.Certificate.save_certificate_chain(
+            Certificate.save_certificate_chain(
                 request.data.get("certificate_chain"),
                 expected_last_id=request.data.get("client_certificate_id"),
             )
@@ -225,13 +251,13 @@ def create(self, request):
 
         # attempt to load the requested certificates
         try:
-            server_cert = core.Certificate.objects.get(
+            server_cert = Certificate.objects.get(
                 id=request.data.get("server_certificate_id")
             )
-            client_cert = core.Certificate.objects.get(
+            client_cert = Certificate.objects.get(
                 id=request.data.get("client_certificate_id")
             )
-        except core.Certificate.DoesNotExist:
+        except Certificate.DoesNotExist:
             return response.Response(
                 "Requested certificate does not exist!",
                 status=status.HTTP_400_BAD_REQUEST,
@@ -281,7 +307,7 @@ def create(self, request):
             ),
         }
 
-        syncsession = core.SyncSession(**data)
+        syncsession = SyncSession(**data)
         syncsession.full_clean()
         syncsession.save()
 
@@ -297,21 +323,25 @@ def perform_destroy(self, syncsession):
         syncsession.save()
 
     def get_queryset(self):
-        return core.SyncSession.objects.filter(active=True)
+        return SyncSession.objects.filter(active=True)
 
 
-class TransferSessionViewSet(viewsets.ModelViewSet):
-    permission_classes = (permissions.TransferSessionPermissions,)
+class TransferSessionViewSet(
+    viewsets.mixins.RetrieveModelMixin,
+    viewsets.mixins.UpdateModelMixin,
+    viewsets.mixins.DestroyModelMixin,
+    viewsets.GenericViewSet,
+):
     serializer_class = serializers.TransferSessionSerializer
 
     def create(self, request):  # noqa: C901
 
         # attempt to load the requested syncsession
         try:
-            syncsession = core.SyncSession.objects.filter(active=True).get(
+            syncsession = SyncSession.objects.filter(active=True).get(
                 id=request.data.get("sync_session_id")
             )
-        except core.SyncSession.DoesNotExist:
+        except SyncSession.DoesNotExist:
             return response.Response(
                 "Requested syncsession does not exist or is no longer active!",
                 status=status.HTTP_400_BAD_REQUEST,
@@ -338,83 +368,97 @@ def create(self, request):  # noqa: C901
         if scope_error_msg:
             return response.Response(scope_error_msg, status=status.HTTP_403_FORBIDDEN)
 
-        # build the data to be used for creating the transfersession
-        data = {
-            "id": request.data.get("id"),
-            "start_timestamp": timezone.now(),
-            "last_activity_timestamp": timezone.now(),
-            "active": True,
-            "filter": requested_filter,
-            "push": is_a_push,
-            "records_total": request.data.get("records_total") if is_a_push else None,
-            "sync_session": syncsession,
-            "client_fsic": request.data.get("client_fsic") or "{}",
-            "server_fsic": "{}",
-        }
-
-        transfersession = core.TransferSession(**data)
-        transfersession.full_clean()
-        transfersession.save()
-
-        # must update database max counters before calculating fsics
-        if not is_a_push:
-
-            if getattr(settings, "MORANGO_SERIALIZE_BEFORE_QUEUING", True):
-                with OperationLogger("Serializing records", "Serialization complete"):
-                    _serialize_into_store(
-                        transfersession.sync_session.profile, filter=requested_filter
-                    )
+        context = LocalSessionContext(
+            request=request,
+            sync_session=syncsession,
+            sync_filter=requested_filter,
+            is_push=is_a_push,
+        )
 
-        transfersession.server_fsic = json.dumps(
-            DatabaseMaxCounter.calculate_filter_max_counters(requested_filter)
+        # If both client and ourselves allow async, we just return accepted status, and the client
+        # should PATCH the transfer_session to the appropriate stage. If not async, we wait until
+        # queuing is complete
+        to_stage = (
+            transfer_stages.INITIALIZING
+            if self.async_allowed()
+            else transfer_stages.QUEUING
+        )
+        result = session_controller.proceed_to_and_wait_for(
+            to_stage, context=context, max_interval=2
         )
-        transfersession.save()
 
-        if not is_a_push:
+        if result == transfer_statuses.ERRORED:
+            if context.error:
+                raise context.error
 
-            # queue records to get ready for pulling
-            with OperationLogger("Queueing records into buffer", "Queueing complete"):
-                _queue_into_buffer(transfersession)
-            # update records_total on transfer session object
-            records_total = Buffer.objects.filter(
-                transfer_session=transfersession
-            ).count()
-            transfersession.records_total = records_total
-            transfersession.save()
+            return response.Response(
+                "Failed to initialize session",
+                status=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            )
+
+        if result == transfer_statuses.COMPLETED:
+            response_status = status.HTTP_201_CREATED
+        else:
+            response_status = status.HTTP_202_ACCEPTED
 
         return response.Response(
-            serializers.TransferSessionSerializer(transfersession).data,
-            status=status.HTTP_201_CREATED,
+            self.get_serializer(context.transfer_session).data,
+            status=response_status,
         )
 
-    def perform_destroy(self, transfersession):
-        if transfersession.push:
-            # if no records were transferred, we can safely skip the next steps
-            if (
-                transfersession.records_total is not None
-                and transfersession.records_total > 0
-            ):
-                # dequeue into store and then delete records
-                with OperationLogger(
-                    "Dequeuing records into store", "Dequeuing complete"
-                ):
-                    _dequeue_into_store(transfersession)
-                # update database max counters but use latest fsics on server
-                DatabaseMaxCounter.update_fsics(
-                    json.loads(transfersession.client_fsic),
-                    certificates.Filter(transfersession.filter),
+    def update(self, request, *args, **kwargs):
+        if not kwargs.get("partial", False):
+            return response.Response(
+                "Only PATCH updates allowed", status=status.HTTP_405_METHOD_NOT_ALLOWED
+            )
+
+        update_stage = request.data.pop("transfer_stage", None)
+        if update_stage is not None:
+            # if client is trying to update `transfer_stage`, then we use the controller to proceed
+            # to the stage, but wait for completion if both do not support async
+            context = LocalSessionContext(
+                request=request,
+                transfer_session=self.get_object(),
+            )
+            # special case for transferring, not to wait since it's a chunked process
+            if self.async_allowed() or update_stage == transfer_stages.TRANSFERRING:
+                session_controller.proceed_to(update_stage, context=context)
+            else:
+                session_controller.proceed_to_and_wait_for(
+                    update_stage, context=context, max_interval=2
                 )
+
+        return super(TransferSessionViewSet, self).update(request, *args, **kwargs)
+
+    def perform_destroy(self, transfer_session):
+        context = LocalSessionContext(
+            request=self.request,
+            transfer_session=transfer_session,
+        )
+        if self.async_allowed():
+            session_controller.proceed_to(transfer_stages.CLEANUP, context=context)
         else:
-            # if pull, then delete records that were queued
-            Buffer.objects.filter(transfer_session=transfersession).delete()
-            RecordMaxCounterBuffer.objects.filter(
-                transfer_session=transfersession
-            ).delete()
-        transfersession.active = False
-        transfersession.save()
+            result = session_controller.proceed_to_and_wait_for(
+                transfer_stages.CLEANUP, context=context, max_interval=2
+            )
+            # raise an error for synchronous, if status is false
+            if result == transfer_statuses.ERRORED:
+                if context.error:
+                    raise context.error
+                else:
+                    raise RuntimeError("Cleanup failed")
 
     def get_queryset(self):
-        return core.TransferSession.objects.filter(active=True)
+        return TransferSession.objects.filter(active=True)
+
+    def async_allowed(self):
+        """
+        :return: A boolean if async ops are allowed by client and self
+        """
+        client_capabilities = parse_capabilities_from_server_request(self.request)
+        return (
+            ASYNC_OPERATIONS in client_capabilities and ASYNC_OPERATIONS in CAPABILITIES
+        )
 
 
 class BufferViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
@@ -426,9 +470,7 @@ class BufferViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
     def create(self, request):
         data = request.data if isinstance(request.data, list) else [request.data]
         # ensure the transfer session allows pushes, and is same across records
-        transfer_session = core.TransferSession.objects.get(
-            id=data[0]["transfer_session"]
-        )
+        transfer_session = TransferSession.objects.get(id=data[0]["transfer_session"])
         if not transfer_session.push:
             return response.Response(
                 "Specified TransferSession does not allow pushes.",
@@ -439,15 +481,27 @@ def create(self, request):
                 "All pushed records must be associated with the same TransferSession.",
                 status=status.HTTP_403_FORBIDDEN,
             )
-        validate_and_create_buffer_data(data, transfer_session)
 
-        return response.Response(status=status.HTTP_201_CREATED)
+        context = LocalSessionContext(
+            request=request, transfer_session=transfer_session
+        )
+        result = session_controller.proceed_to(
+            transfer_stages.TRANSFERRING, context=context
+        )
 
-    def get_queryset(self):
+        if result == transfer_statuses.ERRORED:
+            if context.error:
+                raise context.error
+            else:
+                response_status = status.HTTP_500_INTERNAL_SERVER_ERROR
+        else:
+            response_status = status.HTTP_201_CREATED
 
-        session_id = self.request.query_params["transfer_session_id"]
+        return response.Response(status=response_status)
 
-        return core.Buffer.objects.filter(transfer_session_id=session_id)
+    def get_queryset(self):
+        session_id = self.request.query_params["transfer_session_id"]
+        return Buffer.objects.filter(transfer_session_id=session_id)
 
 
 class MorangoInfoViewSet(viewsets.ViewSet):
diff --git a/morango/apps.py b/morango/apps.py
index 9c5489fb..2d525e96 100644
--- a/morango/apps.py
+++ b/morango/apps.py
@@ -2,6 +2,7 @@
 
 from django.apps import AppConfig
 
+from morango.registry import session_middleware
 from morango.registry import syncable_models
 
 
@@ -14,3 +15,6 @@ def ready(self):
 
         # populate syncable model registry by profile
         syncable_models.populate()
+
+        # populate session controller middleware from settings
+        session_middleware.populate()
diff --git a/morango/constants/capabilities.py b/morango/constants/capabilities.py
index 68bd8a70..ad2a906f 100644
--- a/morango/constants/capabilities.py
+++ b/morango/constants/capabilities.py
@@ -1,2 +1,3 @@
 GZIP_BUFFER_POST = "GZIP_BUFFER_POST"
 ALLOW_CERTIFICATE_PUSHING = "ALLOW_CERTIFICATE_PUSHING"
+ASYNC_OPERATIONS = "ASYNC_OPERATIONS"
diff --git a/morango/constants/settings.py b/morango/constants/settings.py
new file mode 100644
index 00000000..968cd04b
--- /dev/null
+++ b/morango/constants/settings.py
@@ -0,0 +1,42 @@
+ALLOW_CERTIFICATE_PUSHING = False
+MORANGO_SERIALIZE_BEFORE_QUEUING = True
+MORANGO_DESERIALIZE_AFTER_DEQUEUING = True
+MORANGO_DISALLOW_ASYNC_OPERATIONS = False
+MORANGO_INITIALIZE_OPERATIONS = (
+    "morango.sync.operations:InitializeOperation",
+    "morango.sync.operations:LegacyNetworkInitializeOperation",
+    "morango.sync.operations:NetworkInitializeOperation",
+)
+MORANGO_SERIALIZE_OPERATIONS = (
+    "morango.sync.operations:SerializeOperation",
+    "morango.sync.operations:LegacyNetworkSerializeOperation",
+    "morango.sync.operations:NetworkSerializeOperation",
+)
+MORANGO_QUEUE_OPERATIONS = (
+    "morango.sync.operations:ProducerQueueOperation",
+    "morango.sync.operations:ReceiverQueueOperation",
+    "morango.sync.operations:LegacyNetworkQueueOperation",
+    "morango.sync.operations:NetworkQueueOperation",
+)
+MORANGO_TRANSFERRING_OPERATIONS = (
+    "morango.sync.operations:PullProducerOperation",
+    "morango.sync.operations:PushReceiverOperation",
+    "morango.sync.operations:NetworkPushTransferOperation",
+    "morango.sync.operations:NetworkPullTransferOperation",
+)
+MORANGO_DEQUEUE_OPERATIONS = (
+    "morango.sync.operations:ProducerDequeueOperation",
+    "morango.sync.operations:ReceiverDequeueOperation",
+    "morango.sync.operations:LegacyDequeueOperation",
+    "morango.sync.operations:NetworkDequeueOperation",
+)
+MORANGO_DESERIALIZE_OPERATIONS = (
+    "morango.sync.operations:ProducerDeserializeOperation",
+    "morango.sync.operations:ReceiverDeserializeOperation",
+    "morango.sync.operations:LegacyNetworkDeserializeOperation",
+    "morango.sync.operations:NetworkDeserializeOperation",
+)
+MORANGO_CLEANUP_OPERATIONS = (
+    "morango.sync.operations:CleanupOperation",
+    "morango.sync.operations:NetworkCleanupOperation",
+)
diff --git a/morango/constants/transfer_stages.py b/morango/constants/transfer_stages.py
new file mode 100644
index 00000000..a93c69ee
--- /dev/null
+++ b/morango/constants/transfer_stages.py
@@ -0,0 +1,62 @@
+"""
+This module contains constants representing the possible stages of a transfer session.
+"""
+from django.utils.translation import ugettext_lazy as _
+
+INITIALIZING = "initializing"
+SERIALIZING = "serializing"
+QUEUING = "queuing"
+TRANSFERRING = "transferring"
+DEQUEUING = "dequeuing"
+DESERIALIZING = "deserializing"
+CLEANUP = "cleanup"
+
+CHOICES = (
+    (INITIALIZING, _("Initializing")),
+    (SERIALIZING, _("Serializing")),
+    (QUEUING, _("Queuing")),
+    (TRANSFERRING, _("Transferring")),
+    (DEQUEUING, _("Dequeuing")),
+    (DESERIALIZING, _("Deserializing")),
+    (CLEANUP, _("Cleanup")),
+)
+
+ALL = {stage for stage, _ in CHOICES}
+
+PRECEDENCE = {
+    INITIALIZING: 10,
+    SERIALIZING: 20,
+    QUEUING: 30,
+    TRANSFERRING: 40,
+    DEQUEUING: 50,
+    DESERIALIZING: 60,
+    CLEANUP: 70,
+}
+
+
+def precedence(stage_key):
+    """
+    :param stage_key: The stage constant
+    """
+    try:
+        return PRECEDENCE[stage_key]
+    except KeyError:
+        return None
+
+
+class stage(str):
+    """
+    Modeled after celery's status utilities
+    """
+
+    def __gt__(self, other):
+        return precedence(self) > precedence(other)
+
+    def __ge__(self, other):
+        return precedence(self) >= precedence(other)
+
+    def __lt__(self, other):
+        return precedence(self) < precedence(other)
+
+    def __le__(self, other):
+        return precedence(self) <= precedence(other)
diff --git a/morango/constants/transfer_statuses.py b/morango/constants/transfer_statuses.py
new file mode 100644
index 00000000..ee896c87
--- /dev/null
+++ b/morango/constants/transfer_statuses.py
@@ -0,0 +1,25 @@
+"""
+This module contains constants representing the possible statuses of a transfer session stage.
+"""
+from django.utils.translation import ugettext_lazy as _
+
+PENDING = "pending"
+STARTED = "started"
+COMPLETED = "completed"
+ERRORED = "errored"
+
+CHOICES = (
+    (PENDING, _("Pending")),
+    (STARTED, _("Started")),
+    (COMPLETED, _("Completed")),
+    (ERRORED, _("Errored")),
+)
+
+ALL = {stage for stage, _ in CHOICES}
+
+IN_PROGRESS_STATES = (
+    PENDING,
+    STARTED,
+)
+
+FINISHED_STATES = (COMPLETED, ERRORED)
diff --git a/morango/errors.py b/morango/errors.py
index c9094a55..5c220fa5 100644
--- a/morango/errors.py
+++ b/morango/errors.py
@@ -52,3 +52,15 @@ class NonceExpired(MorangoNonceError):
 
 class MorangoServerDoesNotAllowNewCertPush(MorangoError):
     pass
+
+
+class MorangoResumeSyncError(MorangoError):
+    pass
+
+
+class MorangoContextUpdateError(MorangoError):
+    pass
+
+
+class MorangoLimitExceeded(MorangoError):
+    pass
diff --git a/morango/management/commands/cleanupsyncs.py b/morango/management/commands/cleanupsyncs.py
index 989be271..01c61b4a 100644
--- a/morango/management/commands/cleanupsyncs.py
+++ b/morango/management/commands/cleanupsyncs.py
@@ -1,7 +1,6 @@
 import datetime
 
 from django.core.management.base import BaseCommand
-from django.db import connection
 from django.db import transaction
 from django.utils import timezone
 
@@ -12,6 +11,12 @@ class Command(BaseCommand):
     help = "Closes and cleans up the data for any incomplete sync sessions older than a certain number of hours."
 
     def add_arguments(self, parser):
+        parser.add_argument(
+            "--ids",
+            type=lambda ids: ids.split(","),
+            default=None,
+            help="Comma separated list of SyncSession IDs to filter against"
+        )
         parser.add_argument(
             "--expiration",
             action="store",
@@ -29,6 +34,11 @@ def handle(self, *args, **options):
         oldsessions = TransferSession.objects.filter(
             last_activity_timestamp__lt=cutoff, active=True
         )
+
+        # if ids arg was passed, filter down sessions to only those IDs if included by expiration filter
+        if options["ids"]:
+            oldsessions = oldsessions.filter(sync_session_id__in=options["ids"])
+
         sesscount = oldsessions.count()
 
         # loop over the stale sessions one by one to close them out
@@ -45,15 +55,7 @@ def handle(self, *args, **options):
 
             # delete buffer data and mark session as inactive
             with transaction.atomic():
-                with connection.cursor() as cursor:
-                    cursor.execute(
-                        "DELETE FROM morango_buffer WHERE transfer_session_id = %s",
-                        (sess.id,),
-                    )
-                    cursor.execute(
-                        "DELETE FROM morango_recordmaxcounterbuffer WHERE transfer_session_id = %s",
-                        (sess.id,),
-                    )
+                sess.delete_buffers()
                 sess.active = False
                 sess.save()
                 sess.sync_session.active = False
diff --git a/morango/migrations/0017_store_last_transfer_session_id.py b/morango/migrations/0017_store_last_transfer_session_id.py
new file mode 100644
index 00000000..adef790d
--- /dev/null
+++ b/morango/migrations/0017_store_last_transfer_session_id.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.28 on 2021-06-25 23:13
+from __future__ import unicode_literals
+
+from django.db import migrations
+import morango.models.fields.uuids
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('morango', '0016_store_deserialization_error'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='store',
+            name='last_transfer_session_id',
+            field=morango.models.fields.uuids.UUIDField(blank=True, db_index=True, default=None, null=True),
+        ),
+    ]
diff --git a/morango/migrations/0018_auto_20210714_2216.py b/morango/migrations/0018_auto_20210714_2216.py
new file mode 100644
index 00000000..5736a6f2
--- /dev/null
+++ b/morango/migrations/0018_auto_20210714_2216.py
@@ -0,0 +1,51 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.29 on 2021-07-14 22:16
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("morango", "0017_store_last_transfer_session_id"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="syncsession",
+            name="process_id",
+            field=models.IntegerField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name="transfersession",
+            name="transfer_stage",
+            field=models.CharField(
+                blank=True,
+                choices=[
+                    ("initializing", "Initializing"),
+                    ("serializing", "Serializing"),
+                    ("queuing", "Queuing"),
+                    ("transferring", "Transferring"),
+                    ("dequeuing", "Dequeuing"),
+                    ("deserializing", "Deserializing"),
+                    ("cleanup", "Cleanup"),
+                ],
+                max_length=20,
+            ),
+        ),
+        migrations.AddField(
+            model_name="transfersession",
+            name="transfer_stage_status",
+            field=models.CharField(
+                blank=True,
+                choices=[
+                    ("pending", "Pending"),
+                    ("started", "Started"),
+                    ("completed", "Completed"),
+                    ("errored", "Errored"),
+                ],
+                max_length=20,
+            ),
+        ),
+    ]
diff --git a/morango/models/certificates.py b/morango/models/certificates.py
index 2548e23b..c915db69 100644
--- a/morango/models/certificates.py
+++ b/morango/models/certificates.py
@@ -27,6 +27,7 @@
 from morango.errors import CertificateSignatureInvalid
 from morango.errors import NonceDoesNotExist
 from morango.errors import NonceExpired
+from morango.utils import _assert
 
 
 @python_2_unicode_compatible
@@ -87,9 +88,10 @@ def generate_root_certificate(cls, scope_def_id, **extra_scope_params):
         cert.scope_version = scope_def.version
         cert.profile = scope_def.profile
         primary_scope_param_key = scope_def.primary_scope_param_key
-        assert (
-            primary_scope_param_key
-        ), "Root cert can only be created for ScopeDefinition that has primary_scope_param_key defined"
+        _assert(
+            primary_scope_param_key,
+            "Root cert can only be created for ScopeDefinition that has primary_scope_param_key defined",
+        )
 
         # generate a key and extract the public key component
         cert.private_key = Key()
@@ -207,11 +209,11 @@ def save_certificate_chain(cls, cert_chain, expected_last_id=None):
         cert = cls.deserialize(cert_data["serialized"], cert_data["signature"])
 
         # verify the id of the cert matches the id of the outer serialized data
-        assert cert_data["id"] == cert.id
+        _assert(cert_data["id"] == cert.id, "Serialized ID does not match")
 
         # check that the expected ID matches, if specified
         if expected_last_id:
-            assert cert.id == expected_last_id
+            _assert(cert.id == expected_last_id, "ID does not match expected value")
 
         # if cert already exists locally, it's already been verified, so no need to continue
         # (this also means we have the full cert chain for it, given the `parent` relations)
@@ -224,9 +226,10 @@ def save_certificate_chain(cls, cert_chain, expected_last_id=None):
         if len(cert_chain) > 1:
             cls.save_certificate_chain(cert_chain[:-1], expected_last_id=cert.parent_id)
         else:
-            assert (
-                not cert.parent_id
-            ), "First cert in chain must be a root cert (no parent)"
+            _assert(
+                not cert.parent_id,
+                "First cert in chain must be a root cert (no parent)",
+            )
 
         # ensure the certificate checks out (now that we know its parent, if any, is saved)
         cert.check_certificate()
@@ -237,9 +240,9 @@ def save_certificate_chain(cls, cert_chain, expected_last_id=None):
         return cert
 
     def sign(self, value):
-        assert (
-            self.private_key
-        ), "Can only sign using certificates that have private keys"
+        _assert(
+            self.private_key, "Can only sign using certificates that have private keys"
+        )
         return self.private_key.sign(value)
 
     def verify(self, value, signature):
@@ -350,6 +353,8 @@ def __le__(self, other):
         return self.is_subset_of(other)
 
     def __eq__(self, other):
+        if other is None:
+            return False
         for partition in self._filter_tuple:
             if partition not in other._filter_tuple:
                 return False
diff --git a/morango/models/core.py b/morango/models/core.py
index ccef6bc8..a52a9dfe 100644
--- a/morango/models/core.py
+++ b/morango/models/core.py
@@ -19,18 +19,21 @@
 from django.utils import six
 from django.utils import timezone
 
-from .. import proquint
-from ..registry import syncable_models
-from .certificates import Certificate
-from .certificates import Filter
-from .fields.uuids import sha2_uuid
-from .fields.uuids import UUIDField
-from .fields.uuids import UUIDModelMixin
-from .manager import SyncableModelManager
-from .morango_mptt import MorangoMPTTModel
-from .utils import get_0_4_system_parameters
-from .utils import get_0_5_system_id
-from .utils import get_0_5_mac_address
+from morango import proquint
+from morango.registry import syncable_models
+from morango.models.certificates import Certificate
+from morango.models.certificates import Filter
+from morango.models.fields.uuids import sha2_uuid
+from morango.models.fields.uuids import UUIDField
+from morango.models.fields.uuids import UUIDModelMixin
+from morango.models.manager import SyncableModelManager
+from morango.models.morango_mptt import MorangoMPTTModel
+from morango.models.utils import get_0_4_system_parameters
+from morango.models.utils import get_0_5_system_id
+from morango.models.utils import get_0_5_mac_address
+from morango.constants import transfer_stages
+from morango.constants import transfer_statuses
+from morango.utils import _assert
 
 logger = logging.getLogger(__name__)
 
@@ -119,7 +122,6 @@ class InstanceIDModel(models.Model):
     def get_or_create_current_instance(cls, clear_cache=False):
         """Get the instance model corresponding to the current system, or create a new
         one if the system is new or its properties have changed (e.g. new MAC address)."""
-
         if clear_cache:
             cls._cached_instance = None
 
@@ -157,9 +159,9 @@ def get_or_create_current_instance(cls, clear_cache=False):
             kwargs["current"] = True
 
             # ensure we only ever have 1 current instance ID
-            InstanceIDModel.objects.filter(current=True).exclude(id=kwargs["id"]).update(
-                current=False
-            )
+            InstanceIDModel.objects.filter(current=True).exclude(
+                id=kwargs["id"]
+            ).update(current=False)
             # create the model, or get existing if one already exists with this ID
             instance, created = InstanceIDModel.objects.update_or_create(
                 id=kwargs["id"], defaults=kwargs
@@ -226,10 +228,13 @@ class SyncSession(models.Model):
     # used to store other data we may need to know about this sync session
     extra_fields = models.TextField(default="{}")
 
+    # system process ID for ensuring same sync session does not run in parallel
+    process_id = models.IntegerField(blank=True, null=True)
+
 
 class TransferSession(models.Model):
     """
-    ``TransferSession`` holds metatada that is related to a specific transfer (push/pull) session
+    ``TransferSession`` holds metadata that is related to a specific transfer (push/pull) session
     between 2 morango instances.
     """
 
@@ -258,9 +263,70 @@ class TransferSession(models.Model):
     client_fsic = models.TextField(blank=True, default="{}")
     server_fsic = models.TextField(blank=True, default="{}")
 
+    # stages and stage status of transfer session
+    transfer_stage = models.CharField(
+        max_length=20, choices=transfer_stages.CHOICES, blank=True
+    )
+    transfer_stage_status = models.CharField(
+        max_length=20, choices=transfer_statuses.CHOICES, blank=True
+    )
+
+    @property
+    def pull(self):
+        """Getter for `not push` condition, which adds complexity in conditional statements"""
+        return not self.push
+
     def get_filter(self):
         return Filter(self.filter)
 
+    def update_state(self, stage=None, stage_status=None):
+        """
+        :type stage: morango.constants.transfer_stages.*|None
+        :type stage_status: morango.constants.transfer_statuses.*|None
+        """
+        if stage is not None:
+            if self.transfer_stage and transfer_stages.stage(
+                self.transfer_stage
+            ) > transfer_stages.stage(stage):
+                raise ValueError(
+                    "Update stage is behind current stage | current={}, new={}".format(
+                        self.transfer_stage, stage
+                    )
+                )
+            self.transfer_stage = stage
+        if stage_status is not None:
+            self.transfer_stage_status = stage_status
+        if stage is not None or stage_status is not None:
+            self.last_activity_timestamp = timezone.now()
+            self.save()
+            self.sync_session.last_activity_timestamp = timezone.now()
+            self.sync_session.save()
+
+    def delete_buffers(self):
+        """
+        Deletes `Buffer` and `RecordMaxCounterBuffer` model records by executing SQL directly
+        against the database for better performance
+        """
+        with connection.cursor() as cursor:
+            cursor.execute(
+                "DELETE FROM morango_buffer WHERE transfer_session_id = %s",
+                (self.id,),
+            )
+            cursor.execute(
+                "DELETE FROM morango_recordmaxcounterbuffer WHERE transfer_session_id = %s",
+                (self.id,),
+            )
+
+    def get_touched_record_ids_for_model(self, model):
+        if isinstance(model, SyncableModel) or (
+            isinstance(model, six.class_types) and issubclass(model, SyncableModel)
+        ):
+            model = model.morango_model_name
+        _assert(isinstance(model, six.string_types), "Model must resolve to string")
+        return Store.objects.filter(
+            model_name=model, last_transfer_session_id=self.id
+        ).values_list("id", flat=True)
+
 
 class DeletedModels(models.Model):
     """
@@ -343,6 +409,10 @@ class Store(AbstractStore):
     dirty_bit = models.BooleanField(default=False)
     deserialization_error = models.TextField(blank=True)
 
+    last_transfer_session_id = UUIDField(
+        blank=True, null=True, default=None, db_index=True
+    )
+
     objects = StoreManager()
 
     def _deserialize_store_model(self, fk_cache):  # noqa: C901
@@ -576,9 +646,13 @@ def delete(
         self, using=None, keep_parents=False, hard_delete=False, *args, **kwargs
     ):
         using = using or router.db_for_write(self.__class__, instance=self)
-        assert self._get_pk_val() is not None, (
+        _assert(
+            self._get_pk_val() is not None,
             "%s object can't be deleted because its %s attribute is set to None."
-            % (self._meta.object_name, self._meta.pk.attname)
+            % (
+                self._meta.object_name,
+                self._meta.pk.attname,
+            ),
         )
         collector = Collector(using=using)
         collector.collect([self], keep_parents=keep_parents)
diff --git a/morango/models/fields/uuids.py b/morango/models/fields/uuids.py
index 7c06956b..d268fc47 100644
--- a/morango/models/fields/uuids.py
+++ b/morango/models/fields/uuids.py
@@ -2,6 +2,7 @@
 import uuid
 
 from django.db import models
+from morango.utils import _assert
 
 
 def sha2_uuid(*args):
@@ -96,9 +97,10 @@ def calculate_uuid(self):
             return uuid.uuid4().hex
 
         # if we got this far, uuid_input_fields should be a tuple
-        assert isinstance(
-            self.uuid_input_fields, tuple
-        ), "'uuid_input_fields' must either be a tuple or the string 'RANDOM'"
+        _assert(
+            isinstance(self.uuid_input_fields, tuple),
+            "'uuid_input_fields' must either be a tuple or the string 'RANDOM'",
+        )
 
         # calculate the input to the UUID function
         hashable_input_vals = []
diff --git a/morango/registry.py b/morango/registry.py
index adf2aab8..7f797b00 100644
--- a/morango/registry.py
+++ b/morango/registry.py
@@ -3,14 +3,18 @@
 This class is registered at app load time for morango in `apps.py`.
 """
 import sys
+import inspect
 from collections import OrderedDict
+from importlib import import_module
 
 from django.db.models.fields.related import ForeignKey
 from django.utils import six
 
+from morango.constants import transfer_stages
 from morango.errors import InvalidMorangoModelConfiguration
 from morango.errors import ModelRegistryNotReady
 from morango.errors import UnsupportedFieldType
+from morango.utils import SETTINGS
 
 
 def _get_foreign_key_classes(m):
@@ -185,3 +189,76 @@ def populate(self):  # noqa: C901
 
 
 syncable_models = SyncableModelRegistry()
+
+
+class SessionMiddlewareOperations(list):
+    """
+    Iterable list class that holds and initializes a list of transfer operations as configured
+    through Morango settings, and associate the group with a transfer stage by `related_stage`
+    """
+    __slots__ = ("related_stage",)
+
+    def __init__(self, related_stage):
+        super(SessionMiddlewareOperations, self).__init__()
+        self.related_stage = related_stage
+
+    def populate(self, callable_imports):
+        """
+        Middleware are executed in the same order that they're populated, through settings,
+        so order is important!
+
+        :param callable_imports: A list of strings referencing `BaseOperation` classes
+        :type callable_imports: str[]
+        """
+        for callable_import in callable_imports:
+            callable_module, callable_name = callable_import.rsplit(":", 1)
+            middleware_callable = getattr(import_module(callable_module), callable_name)
+            if inspect.isclass(middleware_callable):
+                self.append(middleware_callable())
+            else:
+                self.append(middleware_callable)
+
+    def __call__(self, context):
+        # As middleware list, we expect that one of the operations should handle the request context
+        # so executing the middleware loops through each of the operations and executes them until
+        # a non-false value is returned. At least one of the operations must "handle" it by
+        # returning the non-false value, otherwise the middleware has failed to handle the operation
+        # for the related transfer stage
+        for operation in self:
+            result = operation(context)
+            # operation tells us it has "handled" the context by returning result that is not False
+            if result is not False:
+                return result
+        else:
+            raise NotImplementedError(
+                "Operation for {} stage has no middleware".format(self.related_stage)
+            )
+
+
+STAGE_TO_SETTINGS = {
+    transfer_stages.INITIALIZING: "MORANGO_INITIALIZE_OPERATIONS",
+    transfer_stages.SERIALIZING: "MORANGO_SERIALIZE_OPERATIONS",
+    transfer_stages.QUEUING: "MORANGO_QUEUE_OPERATIONS",
+    transfer_stages.TRANSFERRING: "MORANGO_TRANSFERRING_OPERATIONS",
+    transfer_stages.DEQUEUING: "MORANGO_DEQUEUE_OPERATIONS",
+    transfer_stages.DESERIALIZING: "MORANGO_DESERIALIZE_OPERATIONS",
+    transfer_stages.CLEANUP: "MORANGO_CLEANUP_OPERATIONS",
+}
+
+
+class SessionMiddlewareRegistry(list):
+    """Middleware registry is a list of middleware configurable through settings"""
+
+    def populate(self):
+        # sort dict items according to stage precedence
+        sorted_stage_map = sorted(
+            STAGE_TO_SETTINGS.items(), key=lambda s: transfer_stages.precedence(s[0])
+        )
+        # add middleware operations groups in order of stage precedence
+        for stage, setting in sorted_stage_map:
+            transfer_middleware = SessionMiddlewareOperations(stage)
+            transfer_middleware.populate(getattr(SETTINGS, setting))
+            self.append(transfer_middleware)
+
+
+session_middleware = SessionMiddlewareRegistry()
diff --git a/morango/sync/backends/postgres.py b/morango/sync/backends/postgres.py
index 87332056..f858d025 100644
--- a/morango/sync/backends/postgres.py
+++ b/morango/sync/backends/postgres.py
@@ -96,10 +96,11 @@ def _dequeuing_merge_conflict_rmcb(self, cursor, transfersession_id):
     def _dequeuing_merge_conflict_buffer(self, cursor, current_id, transfersession_id):
         # transfer buffer serialized into conflicting store
         merge_conflict_store = """UPDATE {store} as store SET (serialized, deleted, last_saved_instance, last_saved_counter, hard_deleted, model_name,
-                                                        profile, partition, source_id, conflicting_serialized_data, dirty_bit, _self_ref_fk, deserialization_error)
+                                                        profile, partition, source_id, conflicting_serialized_data, dirty_bit, _self_ref_fk, deserialization_error, last_transfer_session_id)
                                             = (CASE buffer.hard_deleted WHEN TRUE THEN '' ELSE store.serialized END, store.deleted OR buffer.deleted, '{current_instance_id}',
                                                    {current_instance_counter}, store.hard_deleted, store.model_name, store.profile, store.partition, store.source_id,
-                                                   CASE buffer.hard_deleted WHEN TRUE THEN '' ELSE buffer.serialized || '\n' || store.conflicting_serialized_data END, TRUE, store._self_ref_fk, '')
+                                                   CASE buffer.hard_deleted WHEN TRUE THEN '' ELSE buffer.serialized || '\n' || store.conflicting_serialized_data END, TRUE, store._self_ref_fk,
+                                                   '', '{transfer_session_id}')
                                             /*Scope to a single record.*/
                                             FROM {buffer} AS buffer
                                             WHERE store.id = buffer.model_uuid
@@ -175,20 +176,20 @@ def _dequeuing_insert_remaining_buffer(self, cursor, transfersession_id):
             ),
             updated as
             (
-                UPDATE {store} store SET (serialized, deleted, last_saved_instance, last_saved_counter, hard_deleted,
-                                     model_name, profile, partition, source_id, conflicting_serialized_data, dirty_bit, _self_ref_fk, deserialization_error)
+                UPDATE {store} store SET (serialized, deleted, last_saved_instance, last_saved_counter, hard_deleted, model_name, profile,
+                                     partition, source_id, conflicting_serialized_data, dirty_bit, _self_ref_fk, deserialization_error, last_transfer_session_id)
                                     = (nv.serialized, nv.deleted, nv.last_saved_instance, nv.last_saved_counter, nv.hard_deleted,
                                        nv.model_name, nv.profile, nv.partition, nv.source_id, nv.conflicting_serialized_data, TRUE,
-                                       nv._self_ref_fk, '')
+                                       nv._self_ref_fk, '', '{transfer_session_id}')
                 FROM new_values nv
                 WHERE nv.model_uuid = store.id
                 returning store.*
             )
-            INSERT INTO {store}(id, serialized, deleted, last_saved_instance, last_saved_counter, hard_deleted,
-                                model_name, profile, partition, source_id, conflicting_serialized_data, dirty_bit, _self_ref_fk, deserialization_error)
+            INSERT INTO {store}(id, serialized, deleted, last_saved_instance, last_saved_counter, hard_deleted, model_name, profile,
+                                partition, source_id, conflicting_serialized_data, dirty_bit, _self_ref_fk, deserialization_error, last_transfer_session_id)
             SELECT ut.model_uuid, ut.serialized, ut.deleted, ut.last_saved_instance, ut.last_saved_counter, ut.hard_deleted,
                        ut.model_name, ut.profile, ut.partition, ut.source_id, ut.conflicting_serialized_data, TRUE,
-                       ut._self_ref_fk, ''
+                       ut._self_ref_fk, '', '{transfer_session_id}'
             FROM new_values ut
             WHERE ut.model_uuid not in (SELECT id FROM updated)
         """.format(
diff --git a/morango/sync/backends/sqlite.py b/morango/sync/backends/sqlite.py
index ac68e281..7c58249d 100644
--- a/morango/sync/backends/sqlite.py
+++ b/morango/sync/backends/sqlite.py
@@ -69,11 +69,12 @@ def _dequeuing_merge_conflict_rmcb(self, cursor, transfersession_id):
 
     def _dequeuing_merge_conflict_buffer(self, cursor, current_id, transfersession_id):
         # transfer buffer serialized into conflicting store
-        merge_conflict_store = """REPLACE INTO {store} (id, serialized, deleted, last_saved_instance, last_saved_counter, hard_deleted, model_name,
-                                                        profile, partition, source_id, conflicting_serialized_data, dirty_bit, _self_ref_fk, deserialization_error)
+        merge_conflict_store = """REPLACE INTO {store} (id, serialized, deleted, last_saved_instance, last_saved_counter, hard_deleted, model_name, profile, partition,
+                                                        source_id, conflicting_serialized_data, dirty_bit, _self_ref_fk, deserialization_error, last_transfer_session_id)
                                             SELECT store.id, CASE buffer.hard_deleted WHEN 1 THEN '' ELSE store.serialized END, store.deleted OR buffer.deleted, '{current_instance_id}',
                                                    {current_instance_counter}, store.hard_deleted OR buffer.hard_deleted, store.model_name, store.profile, store.partition, store.source_id,
-                                                   CASE buffer.hard_deleted WHEN 1 THEN '' ELSE buffer.serialized || '\n' || store.conflicting_serialized_data END, 1, store._self_ref_fk, ''
+                                                   CASE buffer.hard_deleted WHEN 1 THEN '' ELSE buffer.serialized || '\n' || store.conflicting_serialized_data END, 1, store._self_ref_fk,
+                                                   '', '{transfer_session_id}'
                                             FROM {buffer} AS buffer, {store} AS store
                                             /*Scope to a single record.*/
                                             WHERE store.id = buffer.model_uuid
@@ -122,11 +123,11 @@ def _dequeuing_update_rmcs_last_saved_by(
 
     def _dequeuing_insert_remaining_buffer(self, cursor, transfersession_id):
         # insert remaining records into store
-        insert_remaining_buffer = """REPLACE INTO {store} (id, serialized, deleted, last_saved_instance, last_saved_counter, hard_deleted,
-                                                               model_name, profile, partition, source_id, conflicting_serialized_data, dirty_bit, _self_ref_fk, deserialization_error)
+        insert_remaining_buffer = """REPLACE INTO {store} (id, serialized, deleted, last_saved_instance, last_saved_counter, hard_deleted, model_name, profile, partition,
+                                                           source_id, conflicting_serialized_data, dirty_bit, _self_ref_fk, deserialization_error, last_transfer_session_id)
                                     SELECT buffer.model_uuid, buffer.serialized, buffer.deleted, buffer.last_saved_instance, buffer.last_saved_counter, buffer.hard_deleted,
                                            buffer.model_name, buffer.profile, buffer.partition, buffer.source_id, buffer.conflicting_serialized_data, 1,
-                                           buffer._self_ref_fk, ''
+                                           buffer._self_ref_fk, '', '{transfer_session_id}'
                                     FROM {buffer} AS buffer
                                     WHERE buffer.transfer_session_id = '{transfer_session_id}'
                            """.format(
diff --git a/morango/sync/context.py b/morango/sync/context.py
new file mode 100644
index 00000000..5c30afd8
--- /dev/null
+++ b/morango/sync/context.py
@@ -0,0 +1,319 @@
+from morango.constants import transfer_stages
+from morango.constants import transfer_statuses
+from morango.errors import MorangoContextUpdateError
+from morango.models.certificates import Filter
+from morango.models.core import SyncSession
+from morango.models.core import TransferSession
+from morango.utils import parse_capabilities_from_server_request
+from morango.utils import CAPABILITIES
+
+
+class SessionContext(object):
+    """
+    Class that holds the context of a transfer, for executing transfer ops through the middleware
+    """
+
+    __slots__ = (
+        "sync_session",
+        "transfer_session",
+        "filter",
+        "is_push",
+        "capabilities",
+        "error",
+    )
+
+    def __init__(
+        self,
+        sync_session=None,
+        transfer_session=None,
+        sync_filter=None,
+        is_push=None,
+        capabilities=None,
+    ):
+        """
+        :param sync_session: The sync session instance
+        :type sync_session: SyncSession|None
+        :param transfer_session: The current transfer session that will be operated against
+        :type transfer_session: TransferSession|None
+        :param sync_filter The sync filter to use for the TransferSession
+        :type sync_filter Filter|None
+        :param is_push: A boolean indicating whether or not the transfer is a push or pull
+        :type is_push: bool
+        :param capabilities: Capabilities set that is combined (union) against our own capabilities
+        :type capabilities: set|None
+        """
+        self.sync_session = sync_session
+        self.transfer_session = transfer_session
+        self.filter = sync_filter
+        self.is_push = is_push
+        self.capabilities = set(capabilities or []) & CAPABILITIES
+        self.error = None
+
+        if self.transfer_session:
+            self.sync_session = transfer_session.sync_session or self.sync_session
+            self.is_push = transfer_session.push or self.is_push
+            if transfer_session.filter:
+                self.filter = transfer_session.get_filter()
+
+    def update(
+        self,
+        transfer_session=None,
+        sync_filter=None,
+        is_push=None,
+        stage=None,
+        stage_status=None,
+        capabilities=None,
+        error=None,
+    ):
+        """
+        Updates the context
+        :type transfer_session: TransferSession|None
+        :type sync_filter Filter|None
+        :type is_push: bool
+        :type stage: str|None
+        :type stage_status: str|None
+        :type capabilities: str[]|None
+        :type error: BaseException|None
+        """
+        if transfer_session and self.transfer_session:
+            raise MorangoContextUpdateError("Transfer session already exists")
+        elif (
+            transfer_session
+            and self.sync_session
+            and transfer_session.sync_session_id != self.sync_session.id
+        ):
+            raise MorangoContextUpdateError("Sync session mismatch")
+
+        if sync_filter and self.filter:
+            raise MorangoContextUpdateError("Filter already exists")
+
+        if is_push is not None and self.is_push is not None:
+            raise MorangoContextUpdateError("Push/pull method already exists")
+
+        self.transfer_session = transfer_session or self.transfer_session
+        self.filter = sync_filter or self.filter
+        self.is_push = is_push if is_push is not None else self.is_push
+        self.capabilities = set(capabilities or self.capabilities) & CAPABILITIES
+        self.update_state(stage=stage, stage_status=stage_status)
+        self.error = error or self.error
+
+        # if transfer session was passed in, that takes precedence
+        if transfer_session:
+            self.sync_session = transfer_session.sync_session
+            self.is_push = transfer_session.push
+            if transfer_session.filter:
+                self.filter = transfer_session.get_filter()
+
+    @property
+    def is_pull(self):
+        """
+        :rtype: bool
+        """
+        return not self.is_push
+
+    @property
+    def stage(self):
+        """
+        The stage of the transfer context
+        :return: A transfer_stages.* constant
+        :rtype: str
+        """
+        raise NotImplementedError("Context `stage` getter is missing")
+
+    @property
+    def stage_status(self):
+        """
+        The status of the transfer context's stage
+        :return: A transfer_statuses.* constant
+        :rtype: str
+        """
+        raise NotImplementedError("Context `stage_status` getter is missing")
+
+    def update_state(self, stage=None, stage_status=None):
+        """
+        Updates the stage state
+        :type stage: transfer_stages.*|None
+        :type stage_status: transfer_statuses.*|None
+        """
+        raise NotImplementedError("Context `update_state` method is missing")
+
+    def __getstate__(self):
+        """Return dict of simplified data for serialization"""
+        return dict(
+            sync_session_id=self.sync_session.id if self.sync_session else None,
+            transfer_session_id=(
+                self.transfer_session.id if self.transfer_session else None
+            ),
+            filter=str(self.filter),
+            is_push=self.is_push,
+            stage=self.stage,
+            stage_status=self.stage_status,
+            capabilities=self.capabilities,
+            error=self.error,
+        )
+
+    def __setstate__(self, state):
+        """Re-apply dict state after serialization"""
+        sync_session_id = state.get("sync_session_id", None)
+        if sync_session_id is not None:
+            self.sync_session = SyncSession.objects.get(pk=sync_session_id)
+
+        transfer_session_id = state.get("transfer_session_id", None)
+        if transfer_session_id is not None:
+            self.transfer_session = TransferSession.objects.get(pk=transfer_session_id)
+            if self.sync_session is None:
+                self.sync_session = self.transfer_session.sync_session
+
+        sync_filter = state.get("filter", None)
+        if sync_filter is not None:
+            self.filter = Filter(sync_filter)
+
+        self.is_push = state.get("is_push", None)
+        self.capabilities = state.get("capabilities", None)
+
+        stage = state.get("stage", None)
+        stage_status = state.get("stage_status", None)
+        self.update_state(stage=stage, stage_status=stage_status)
+        self.error = state.get("error", None)
+
+
+class LocalSessionContext(SessionContext):
+    """
+    Class that holds the context for operating on a transfer locally
+    """
+
+    __slots__ = (
+        "request",
+        "is_server",
+    )
+
+    def __init__(self, request=None, **kwargs):
+        """
+        :param request: If acting as the server, it should pass in the request, but the request
+            is not serialized into context. See `is_server` prop for determining if request was
+            passed in.
+        :type request: django.http.request.HttpRequest
+        """
+        capabilities = kwargs.pop("capabilities", [])
+        if request is not None:
+            capabilities = parse_capabilities_from_server_request(request)
+
+        super(LocalSessionContext, self).__init__(capabilities=capabilities, **kwargs)
+        self.request = request
+        self.is_server = request is not None
+
+    @property
+    def _has_transfer_session(self):
+        """
+        :rtype: bool
+        """
+        return getattr(self, "transfer_session", None) is not None
+
+    @property
+    def stage(self):
+        """
+        :return: A transfer_stage.* constant
+        """
+        stage = transfer_stages.INITIALIZING
+        if self._has_transfer_session:
+            stage = self.transfer_session.transfer_stage or stage
+        return stage
+
+    @property
+    def stage_status(self):
+        """
+        :return: A transfer_statuses.* constant
+        """
+        stage_status = transfer_statuses.PENDING
+        if self._has_transfer_session:
+            stage_status = self.transfer_session.transfer_stage_status or stage_status
+        return stage_status
+
+    @property
+    def is_receiver(self):
+        """
+        Whether or not the context indicates that the current local instance is receiving data,
+        which means either:
+            - A server context and a push transfer, or
+            - A client context and a pull transfer
+        :return: bool
+        """
+        return self.is_push == self.is_server
+
+    @property
+    def is_producer(self):
+        """
+        The opposite of `is_receiver`, meaning either:
+            - A server context and a pull transfer, or
+            - A client context and a push transfer
+        :return: bool
+        """
+        return not self.is_receiver
+
+    def update_state(self, stage=None, stage_status=None):
+        """
+        Passes through updating state to `TransferSession`, refreshing it from the DB in case it
+        has changed during operation
+
+        :param stage: Target stage for update
+        :param stage_status: Target status for update
+        """
+        if self._has_transfer_session:
+            self.transfer_session.refresh_from_db()
+            self.transfer_session.update_state(stage=stage, stage_status=stage_status)
+
+    def __getstate__(self):
+        """Return dict of simplified data for serialization"""
+        state = super(LocalSessionContext, self).__getstate__()
+        state.update(is_server=self.is_server)
+        return state
+
+    def __setstate__(self, state):
+        """Re-apply dict state after serialization"""
+        self.is_server = state.pop("is_server", False)
+        super(LocalSessionContext, self).__setstate__(state)
+
+
+class NetworkSessionContext(SessionContext):
+    """
+    Class that holds the context for operating on a transfer remotely through network connection
+    """
+
+    __slots__ = ("connection", "_stage", "_stage_status")
+
+    def __init__(self, connection, **kwargs):
+        """
+        :param connection: The sync client connection that allows operations to execute API calls
+            against the remote Morango server instance
+        :type connection: NetworkSyncConnection
+        """
+        self.connection = connection
+        super(NetworkSessionContext, self).__init__(
+            capabilities=self.connection.server_info.get("capabilities", []), **kwargs
+        )
+
+        # since this is network context, keep local reference to state vars
+        self._stage = transfer_stages.INITIALIZING
+        self._stage_status = transfer_statuses.PENDING
+
+    @property
+    def stage(self):
+        """
+        :return: A transfer_stage.* constant
+        """
+        return self._stage
+
+    @property
+    def stage_status(self):
+        """
+        :return: A transfer_statuses.* constant
+        """
+        return self._stage_status
+
+    def update_state(self, stage=None, stage_status=None):
+        """
+        :param stage: Target stage for update
+        :param stage_status: Target status for update
+        """
+        self._stage = stage or self._stage
+        self._stage_status = stage_status or self._stage_status
diff --git a/morango/sync/controller.py b/morango/sync/controller.py
index acce2b76..2c034c3c 100644
--- a/morango/sync/controller.py
+++ b/morango/sync/controller.py
@@ -1,7 +1,18 @@
-from .operations import _deserialize_from_store
-from .operations import _serialize_into_store
-from .operations import OperationLogger
-from .syncsession import NetworkSyncConnection
+import logging
+from time import sleep
+
+from morango.constants import transfer_stages
+from morango.constants import transfer_statuses
+from morango.registry import session_middleware
+
+from morango.sync.operations import _deserialize_from_store
+from morango.sync.operations import _serialize_into_store
+from morango.sync.operations import OperationLogger
+from morango.sync.utils import SyncSignalGroup
+from morango.utils import _assert
+
+
+logger = logging.getLogger(__name__)
 
 
 def _self_referential_fk(klass_model):
@@ -17,7 +28,7 @@ def _self_referential_fk(klass_model):
 
 class MorangoProfileController(object):
     def __init__(self, profile):
-        assert profile, "profile needs to be defined."
+        _assert(profile, "profile needs to be defined.")
         self.profile = profile
 
     def serialize_into_store(self, filter=None):
@@ -34,10 +45,211 @@ def deserialize_from_store(self, skip_erroring=False, filter=None):
         with OperationLogger("Deserializing records", "Deserialization complete"):
             # we first serialize to avoid deserialization merge conflicts
             _serialize_into_store(self.profile, filter=filter)
-            _deserialize_from_store(self.profile, filter=filter, skip_erroring=skip_erroring)
+            _deserialize_from_store(
+                self.profile, filter=filter, skip_erroring=skip_erroring
+            )
 
-    def create_network_connection(self, base_url):
-        return NetworkSyncConnection(base_url=base_url)
+    def create_network_connection(self, base_url, **kwargs):
+        from morango.sync.syncsession import NetworkSyncConnection
+
+        kwargs.update(base_url=base_url)
+        return NetworkSyncConnection(**kwargs)
 
     def create_disk_connection(path):
         raise NotImplementedError("Coming soon...")
+
+
+class SessionControllerSignals(object):
+    __slots__ = transfer_stages.ALL
+
+    def __init__(self):
+        """
+        Initializes signal group for each transfer stage
+        """
+        for stage in transfer_stages.ALL:
+            setattr(self, stage, SyncSignalGroup(context=None))
+
+    def connect(self, handler):
+        """
+        Connects handler to every stage's signal
+        :param handler: callable
+        """
+        for stage in transfer_stages.ALL:
+            signal = getattr(self, stage)
+            signal.connect(handler)
+
+
+class SessionController(object):
+    """
+    Controller class that is used to execute transfer operations, like queuing and serializing,
+    but does so through the middleware registry, which allows customization of how those transfer
+    stage operations are handled
+    """
+
+    __slots__ = (
+        "middleware",
+        "signals",
+        "context",
+    )
+
+    def __init__(self, middleware, signals, context=None):
+        """
+        :type middleware: morango.registry.SessionMiddlewareRegistry|list
+        :type signals: SessionControllerSignals
+        :type context: morango.sync.context.SessionContext|None
+        """
+        self.middleware = middleware
+        self.signals = signals
+        self.context = context
+
+    @classmethod
+    def build(cls, middleware=None, signals=None, context=None):
+        """
+        Factory method that instantiates the `SessionController` with the specified context and
+        the global middleware registry `session_middleware`
+
+        :type middleware: morango.registry.SessionMiddlewareRegistry|list|None
+        :type signals: SessionControllerSignals|none
+        :type context: morango.sync.context.SessionContext|None
+        :return: A new transfer controller
+        :rtype: SessionController
+        """
+        middleware = middleware or session_middleware
+        signals = signals or SessionControllerSignals()
+        return SessionController(middleware, signals, context=context)
+
+    def proceed_to(self, target_stage, context=None):
+        """
+        Calls middleware that operates on stages between the current stage and the `target_stage`.
+        The middleware are called incrementally, but in order to proceed to the next stage, the
+        middleware must return a complete status. If the middleware does not return a complete
+        status, that status is returned indicating that the method call has not reached
+        `target_stage`. Therefore middleware can perform operations asynchronously and this can be
+        repeatedly called to move forward through the transfer stages and their operations
+
+        When invoking the middleware, if the status result is:
+            PENDING: The controller will continue to invoke the middleware again when this method
+                is called repeatedly, until the status result changes
+            STARTED: The controller will not invoke any middleware until the the status changes,
+                and assumes the "started" operation will update the state itself
+            COMPLETED: The controller will proceed to invoke the middleware for the next stage
+            ERRORED: The controller will not invoke any middleware until the the status changes,
+                which would require codified resolution of the error outside of the controller
+
+        :param target_stage: transfer_stage.* - The transfer stage to proceed to
+        :type target_stage: str
+        :param context: Override controller context, or provide it if missing
+        :type context: morango.sync.context.SessionContext|None
+        :return: transfer_status.* - The status of proceeding to that stage
+        :rtype: str
+        """
+        if context is None:
+            context = self.context
+            if context is None:
+                raise ValueError("Controller is missing required context object")
+
+        target_stage = transfer_stages.stage(target_stage)
+
+        # we can't 'proceed' to a stage we've already passed
+        current_stage = transfer_stages.stage(context.stage)
+        if current_stage > target_stage:
+            return transfer_statuses.COMPLETED
+
+        # See comments above, any of these statuses mean a no-op for proceeding
+        if context.stage_status in (
+            transfer_statuses.STARTED,
+            transfer_statuses.ERRORED,
+        ):
+            return context.stage_status
+
+        result = False
+        # inside "session_middleware"
+        for middleware in self.middleware:
+            middleware_stage = transfer_stages.stage(middleware.related_stage)
+
+            # break when we find middleware beyond proceed-to stage
+            if middleware_stage > target_stage:
+                break
+            # execute middleware, up to and including the requested stage
+            elif middleware_stage > current_stage or (
+                context.stage_status == transfer_statuses.PENDING
+                and middleware_stage == current_stage
+            ):
+                # if the result is not completed status, then break because that means we can't
+                # proceed to the next stage (yet)
+                result = self._invoke_middleware(context, middleware)
+                # update local stage variable after completion
+                current_stage = transfer_stages.stage(context.stage)
+                if result != transfer_statuses.COMPLETED:
+                    break
+
+        # since the middleware must handle our request, or throw an unimplemented error, this
+        # should always be a non-False status
+        return result
+
+    def proceed_to_and_wait_for(self, target_stage, context=None, max_interval=5):
+        """
+        Same as `proceed_to` but waits for a finished status to be returned by sleeping between
+        calls to `proceed_to` if status is not complete
+
+        :param target_stage: transfer_stage.* - The transfer stage to proceed to
+        :type target_stage: str
+        :param context: Override controller context, or provide it if missing
+        :type context: morango.sync.context.SessionContext|None
+        :param max_interval: The max time, in seconds, between repeat calls to `.proceed_to`
+        :return: transfer_status.* - The status of proceeding to that stage,
+            which should be `ERRORED` or `COMPLETE`
+        :rtype: str
+        """
+        result = transfer_statuses.PENDING
+        tries = 0
+        while result not in transfer_statuses.FINISHED_STATES:
+            if tries > 0:
+                # exponential backoff up to max_interval
+                sleep(min(0.3 * (2 ** tries - 1), max_interval))
+            result = self.proceed_to(target_stage, context=context)
+            tries += 1
+        return result
+
+    def _invoke_middleware(self, context, middleware):
+        """
+        Invokes middleware, with logging if enabled, and handles updating the transfer session state
+
+        :param context: The context to invoke the middleware with
+        :type context: morango.sync.context.SessionContext
+        :type middleware: morango.registry.SessionMiddlewareOperations
+        :return: transfer_status.* - The result of invoking the middleware
+        :rtype: str
+        """
+        stage = middleware.related_stage
+        signal = getattr(self.signals, stage)
+        at_stage = context.stage == stage
+
+        try:
+            context.update(stage=stage, stage_status=transfer_statuses.PENDING)
+
+            # only fire "started" when we first try to invoke the stage
+            # NOTE: this means that signals.started is not equivalent to transfer_stage.STARTED
+            if not at_stage:
+                signal.started.fire(context=context)
+
+            result = middleware(context)
+
+            # don't update stage result if context's stage was updated during operation
+            if context.stage == stage:
+                context.update(stage_status=result)
+
+            # fire signals based off middleware invocation result; the progress signal if incomplete
+            if result == transfer_statuses.COMPLETED:
+                signal.completed.fire(context=context)
+            else:
+                signal.in_progress.fire(context=context)
+
+            return result
+        except Exception as e:
+            # always log the error itself
+            logger.error(e)
+            context.update(stage_status=transfer_statuses.ERRORED, error=e)
+            # fire completed signal, after context update. handlers can use context to detect error
+            signal.completed.fire(context=context)
+            return transfer_statuses.ERRORED
diff --git a/morango/sync/operations.py b/morango/sync/operations.py
index 9dd4a076..c6a832ae 100644
--- a/morango/sync/operations.py
+++ b/morango/sync/operations.py
@@ -1,8 +1,8 @@
 import functools
 import json
 import logging
+import uuid
 
-from django.conf import settings
 from django.core import exceptions
 from django.core.serializers.json import DjangoJSONEncoder
 from django.db import connection
@@ -12,9 +12,15 @@
 from django.db.models import Q
 from django.db.models import signals
 from django.utils import six
-
-from .backends.utils import load_backend
-from .utils import mute_signals
+from django.utils import timezone
+from rest_framework.exceptions import ValidationError
+
+from morango.api.serializers import BufferSerializer
+from morango.constants.capabilities import ASYNC_OPERATIONS
+from morango.constants import transfer_stages
+from morango.constants import transfer_statuses
+from morango.errors import MorangoResumeSyncError
+from morango.errors import MorangoLimitExceeded
 from morango.models.certificates import Filter
 from morango.models.core import Buffer
 from morango.models.core import DatabaseMaxCounter
@@ -24,7 +30,15 @@
 from morango.models.core import RecordMaxCounter
 from morango.models.core import RecordMaxCounterBuffer
 from morango.models.core import Store
+from morango.models.core import TransferSession
 from morango.registry import syncable_models
+from morango.sync.backends.utils import load_backend
+from morango.sync.context import LocalSessionContext
+from morango.sync.context import NetworkSessionContext
+from morango.sync.utils import mute_signals
+from morango.sync.utils import validate_and_create_buffer_data
+from morango.utils import _assert
+from morango.utils import SETTINGS
 
 
 logger = logging.getLogger(__name__)
@@ -36,10 +50,13 @@
 USING_DB = db_name
 if "postgresql" in transaction.get_connection(USING_DB).vendor:
     USING_DB = db_name + "-serializable"
-    assert (
-        USING_DB in connections
-    ), "Please add a `default-serializable` database connection in your django settings file, \
-                                     which copies all the configuration settings of the `default` db connection"
+    _assert(
+        USING_DB in connections,
+        "Please add a `default-serializable` database connection in your django settings file, \
+            which copies all the configuration settings of the `default` db connection",
+    )
+
+SQL_UNION_MAX = 500
 
 
 class OperationLogger(object):
@@ -153,6 +170,8 @@ def _serialize_into_store(profile, filter=None):
                     # update deleted flags in case it was previously deleted
                     store_model.deleted = False
                     store_model.hard_deleted = False
+                    # clear last_transfer_session_id
+                    store_model.last_transfer_session_id = None
 
                     # update this model
                     store_model.save()
@@ -397,7 +416,6 @@ def _queue_into_buffer(transfersession):
     We use raw sql queries to place data in the buffer and the record max counter buffer, which matches the conditions of the FSIC,
     as well as the partition for the data we are syncing.
     """
-    last_saved_by_conditions = []
     filter_prefixes = Filter(transfersession.filter)
     server_fsic = json.loads(transfersession.server_fsic)
     client_fsic = json.loads(transfersession.client_fsic)
@@ -411,18 +429,7 @@ def _queue_into_buffer(transfersession):
     if not fsics:
         return
 
-    # create condition for all push FSICs where instance_ids are equal, but internal counters are higher than FSICs counters
-    for instance, counter in six.iteritems(fsics):
-        last_saved_by_conditions += [
-            "(last_saved_instance = '{0}' AND last_saved_counter > {1})".format(
-                instance, counter
-            )
-        ]
-    if fsics:
-        last_saved_by_conditions = [
-            _join_with_logical_operator(last_saved_by_conditions, "OR")
-        ]
-
+    profile_condition = ["profile = '{}'".format(transfersession.sync_session.profile)]
     partition_conditions = []
     # create condition for filtering by partitions
     for prefix in filter_prefixes:
@@ -430,47 +437,100 @@ def _queue_into_buffer(transfersession):
     if filter_prefixes:
         partition_conditions = [_join_with_logical_operator(partition_conditions, "OR")]
 
-    # combine conditions
-    fsic_and_partition_conditions = _join_with_logical_operator(
-        last_saved_by_conditions + partition_conditions, "AND"
-    )
+    chunk_size = 200
+    fsics = list(fsics.items())
+    fsics_len = len(fsics)
+    fsics_limit = chunk_size * SQL_UNION_MAX
 
-    # filter by profile
-    where_condition = _join_with_logical_operator(
-        [
-            fsic_and_partition_conditions,
-            "profile = '{}'".format(transfersession.sync_session.profile),
-        ],
-        "AND",
-    )
+    if fsics_len >= fsics_limit:
+        raise MorangoLimitExceeded(
+            "Limit of {limit} instance counters exceeded with {actual}".format(
+                limit=fsics_limit,
+                actual=fsics_len,
+            )
+        )
 
-    # execute raw sql to take all records that match condition, to be put into buffer for transfer
-    with connection.cursor() as cursor:
-        queue_buffer = """INSERT INTO {outgoing_buffer}
-                        (model_uuid, serialized, deleted, last_saved_instance, last_saved_counter, hard_deleted,
-                         model_name, profile, partition, source_id, conflicting_serialized_data, transfer_session_id, _self_ref_fk)
-                        SELECT id, serialized, deleted, last_saved_instance, last_saved_counter, hard_deleted, model_name, profile, partition, source_id, conflicting_serialized_data, '{transfer_session_id}', _self_ref_fk
-                        FROM {store} WHERE {condition}""".format(
-            outgoing_buffer=Buffer._meta.db_table,
-            transfer_session_id=transfersession.id,
-            condition=where_condition,
-            store=Store._meta.db_table,
+    # chunk fsics creating multiple SQL selects which will be unioned before insert
+    i = 0
+    chunk = fsics[:chunk_size]
+    select_buffers = []
+    select_rmc_buffers = []
+
+    while chunk:
+        # create condition for all push FSICs where instance_ids are equal, but internal counters are higher than
+        # FSICs counters
+        last_saved_by_conditions = [
+            "(last_saved_instance = '{0}' AND last_saved_counter > {1})".format(
+                instance, counter
+            )
+            for instance, counter in chunk
+        ]
+        if last_saved_by_conditions:
+            last_saved_by_conditions = [
+                _join_with_logical_operator(last_saved_by_conditions, "OR")
+            ]
+
+        # combine conditions and filter by profile
+        where_condition = _join_with_logical_operator(
+            profile_condition + last_saved_by_conditions + partition_conditions,
+            "AND",
+        )
+
+        # execute raw sql to take all records that match condition, to be put into buffer for transfer
+        select_buffers.append(
+            """SELECT
+                   id, serialized, deleted, last_saved_instance, last_saved_counter, hard_deleted, model_name, profile,
+                   partition, source_id, conflicting_serialized_data,
+                   CAST ('{transfer_session_id}' AS {transfer_session_id_type}), _self_ref_fk
+               FROM {store} WHERE {condition}
+            """.format(
+                transfer_session_id=transfersession.id,
+                transfer_session_id_type=TransferSession._meta.pk.rel_db_type(
+                    connection
+                ),
+                condition=where_condition,
+                store=Store._meta.db_table,
+            )
         )
-        cursor.execute(queue_buffer)
         # take all record max counters that are foreign keyed onto store models, which were queued into the buffer
-        queue_rmc_buffer = """INSERT INTO {outgoing_rmcb}
-                            (instance_id, counter, transfer_session_id, model_uuid)
-                            SELECT instance_id, counter, '{transfer_session_id}', store_model_id
-                            FROM {record_max_counter} AS rmc
-                            INNER JOIN {outgoing_buffer} AS buffer ON rmc.store_model_id = buffer.model_uuid
-                            WHERE buffer.transfer_session_id = '{transfer_session_id}'
-                            """.format(
-            outgoing_rmcb=RecordMaxCounterBuffer._meta.db_table,
-            transfer_session_id=transfersession.id,
-            record_max_counter=RecordMaxCounter._meta.db_table,
-            outgoing_buffer=Buffer._meta.db_table,
+        select_rmc_buffers.append(
+            """SELECT instance_id, counter, CAST ('{transfer_session_id}' AS {transfer_session_id_type}), store_model_id
+               FROM {record_max_counter} AS rmc
+               INNER JOIN {outgoing_buffer} AS buffer ON rmc.store_model_id = buffer.model_uuid
+               WHERE buffer.transfer_session_id = '{transfer_session_id}'
+            """.format(
+                transfer_session_id=transfersession.id,
+                transfer_session_id_type=TransferSession._meta.pk.rel_db_type(
+                    connection
+                ),
+                record_max_counter=RecordMaxCounter._meta.db_table,
+                outgoing_buffer=Buffer._meta.db_table,
+            )
+        )
+        i += chunk_size
+        chunk = fsics[i : i + chunk_size]
+
+    with connection.cursor() as cursor:
+        cursor.execute(
+            """INSERT INTO {outgoing_buffer}
+               (model_uuid, serialized, deleted, last_saved_instance, last_saved_counter,
+               hard_deleted, model_name, profile, partition, source_id, conflicting_serialized_data,
+               transfer_session_id, _self_ref_fk)
+               {select}
+            """.format(
+                outgoing_buffer=Buffer._meta.db_table,
+                select=" UNION ".join(select_buffers),
+            )
+        )
+        cursor.execute(
+            """INSERT INTO {outgoing_rmcb}
+               (instance_id, counter, transfer_session_id, model_uuid)
+               {select}
+            """.format(
+                outgoing_rmcb=RecordMaxCounterBuffer._meta.db_table,
+                select=" UNION ".join(select_rmc_buffers),
+            )
         )
-        cursor.execute(queue_rmc_buffer)
 
 
 @transaction.atomic(using=USING_DB)
@@ -479,7 +539,7 @@ def _dequeue_into_store(transfersession):
     Takes data from the buffers and merges into the store and record max counters.
 
     ALGORITHM: Incrementally insert and delete on a case by case basis to ensure subsequent cases
-    are not effected by previous cases.
+    are not affected by previous cases.
     """
     with connection.cursor() as cursor:
         DBBackend._dequeuing_delete_rmcb_records(cursor, transfersession.id)
@@ -498,8 +558,757 @@ def _dequeue_into_store(transfersession):
         DBBackend._dequeuing_insert_remaining_rmcb(cursor, transfersession.id)
         DBBackend._dequeuing_delete_remaining_rmcb(cursor, transfersession.id)
         DBBackend._dequeuing_delete_remaining_buffer(cursor, transfersession.id)
-    if getattr(settings, "MORANGO_DESERIALIZE_AFTER_DEQUEUING", True):
-        # we first serialize to avoid deserialization merge conflicts
-        filter = transfersession.get_filter()
-        _serialize_into_store(transfersession.sync_session.profile, filter=filter)
-        _deserialize_from_store(transfersession.sync_session.profile, filter=filter)
+
+
+class BaseOperation(object):
+    """
+    Base Operation class which defines operation specific behavior that occurs during a sync
+    """
+
+    __slots__ = ()
+    expects_context = None
+    """Operation will automatically filter contexts based on this type, before calling `handle`"""
+
+    def __call__(self, context):
+        """
+        :type context: morango.sync.controller.SessionContext
+        """
+        debug_msg = "[morango:{}] {} -> {}".format(
+            "pull" if context.is_pull else "push",
+            context.__class__.__name__,
+            self.__class__.__name__,
+        )
+        result = False
+        try:
+            # verify context object matches what the operation expects
+            if self.expects_context is None or isinstance(
+                context, self.expects_context
+            ):
+                logger.debug("{} = ?".format(debug_msg))
+                result = self.handle(context)
+
+            if result is not False and result not in transfer_statuses.ALL:
+                raise NotImplementedError(
+                    "Transfer operation must return False, or a transfer status"
+                )
+        except AssertionError:
+            # if the operation raises an AssertionError, we equate that to returning False, which
+            # means that this operation did not handle it and so other operation instances should
+            # be tried to handle it
+            result = False
+        logger.debug("{} = {}".format(debug_msg, result))
+        return result
+
+    def handle(self, context):
+        """
+        :type context: morango.sync.context.SessionContext
+        :return: transfer_status.* - See `SessionController` for how the return status is handled
+        """
+        raise NotImplementedError("Transfer operation handler not implemented")
+
+    def _assert(self, condition, message="Operation does not handle this condition"):
+        """
+        :param condition: a bool condition, if false will raise assertion error
+        """
+        _assert(condition, message)
+
+
+class LocalOperation(BaseOperation):
+    """
+    Base class for local operations that expect a local context object
+    """
+
+    expects_context = LocalSessionContext
+
+
+class InitializeOperation(LocalOperation):
+    """
+    Operation to initialize the transfer session in the local instance
+    """
+
+    def handle(self, context):
+        """
+        :type context: LocalSessionContext
+        """
+        self._assert(context.transfer_session is None)
+
+        # attributes that we'll use to identify existing sessions. we really only want there to
+        # be one of these at a time
+        data = dict(
+            push=context.is_push,
+            sync_session_id=context.sync_session.id,
+            active=True,
+        )
+
+        # get the most recent transfer session
+        transfer_sessions = TransferSession.objects.order_by("-last_activity_timestamp")
+
+        try:
+            transfer_session = transfer_sessions.get(**data)
+        except TransferSession.DoesNotExist:
+            # build data for creating transfer session
+            data.update(
+                id=uuid.uuid4().hex,
+                filter=str(context.filter),
+                start_timestamp=timezone.now(),
+                last_activity_timestamp=timezone.now(),
+                active=True,
+                transfer_stage=transfer_stages.INITIALIZING,
+            )
+            # if in server context, we'll have request
+            if context.request:
+                data.update(
+                    id=context.request.data.get("id"),
+                    records_total=context.request.data.get("records_total")
+                    if context.is_push
+                    else None,
+                    client_fsic=context.request.data.get("client_fsic") or "{}",
+                )
+            elif context.is_server:
+                raise MorangoResumeSyncError(
+                    "Cannot create transfer session without request as server"
+                )
+
+            # create, validate, and save!
+            transfer_session = TransferSession(**data)
+            transfer_session.full_clean()
+            transfer_session.save()
+
+        # if resuming, this should also update the context such that the next attempted stage
+        # is the next stage to invoke
+        context.update(transfer_session=transfer_session)
+        return transfer_statuses.COMPLETED
+
+
+class SerializeOperation(LocalOperation):
+    """
+    Performs serialization related steps which affect the counters involved in the sync, including
+    serialization of data if enabled through configuration
+    """
+
+    def handle(self, context):
+        """
+        :type context: LocalSessionContext
+        """
+        self._assert(context.sync_session is not None)
+        self._assert(context.filter is not None)
+
+        if context.is_producer and SETTINGS.MORANGO_SERIALIZE_BEFORE_QUEUING:
+            _serialize_into_store(
+                context.sync_session.profile,
+                filter=context.filter,
+            )
+
+        fsic = json.dumps(
+            DatabaseMaxCounter.calculate_filter_max_counters(context.filter)
+        )
+        if context.is_server:
+            context.transfer_session.server_fsic = fsic
+            context.transfer_session.client_fsic = context.request.data.get(
+                "client_fsic", "{}"
+            )
+        else:
+            context.transfer_session.client_fsic = fsic
+        context.transfer_session.save()
+        return transfer_statuses.COMPLETED
+
+
+class ProducerQueueOperation(LocalOperation):
+    """
+    Performs queuing of data for as local instance
+    """
+
+    def handle(self, context):
+        """
+        :type context: LocalSessionContext
+        """
+        self._assert(context.is_producer)
+        self._assert(context.sync_session is not None)
+        self._assert(context.transfer_session is not None)
+
+        _queue_into_buffer(context.transfer_session)
+
+        # update the records_total for client and server transfer session
+        records_total = Buffer.objects.filter(
+            transfer_session=context.transfer_session
+        ).count()
+
+        logger.debug("[morango] Queued {} records".format(records_total))
+        context.transfer_session.records_total = records_total
+        context.transfer_session.save()
+        return transfer_statuses.COMPLETED
+
+
+class ReceiverQueueOperation(LocalOperation):
+    """
+    Receiver of transfer data does not need to queue anything
+    """
+
+    def handle(self, context):
+        """
+        :type context: LocalSessionContext
+        """
+        self._assert(context.is_receiver)
+        # TODO: move updating record counts from request to here instead of viewset serializer
+        return transfer_statuses.COMPLETED
+
+
+class PullProducerOperation(LocalOperation):
+    """
+    Operation that handles the transfer session updates for the server during a pull
+    """
+
+    def handle(self, context):
+        """
+        :type context: LocalSessionContext
+        """
+        self._assert(context.is_pull)
+        self._assert(context.is_producer)
+        self._assert(context.request is not None)
+
+        records_transferred = context.request.data.get(
+            "records_transferred", context.transfer_session.records_transferred
+        )
+
+        if records_transferred == context.transfer_session.records_total:
+            return transfer_statuses.COMPLETED
+        return transfer_statuses.PENDING
+
+
+class PushReceiverOperation(LocalOperation):
+    """
+    Operation that handles the result of a push, as the server, using a local context / session
+    """
+
+    def handle(self, context):
+        """
+        :type context: LocalSessionContext
+        """
+        self._assert(context.is_push)
+        self._assert(context.is_receiver)
+        self._assert(context.request is not None)
+
+        # operation can be invoked even though there's nothing to transfer
+        if context.transfer_session.records_total > 0:
+            data = context.request.data
+            if not isinstance(context.request.data, list):
+                data = [context.request.data]
+
+            validate_and_create_buffer_data(data, context.transfer_session)
+
+        if (
+            context.transfer_session.records_transferred
+            == context.transfer_session.records_total
+        ):
+            return transfer_statuses.COMPLETED
+        return transfer_statuses.PENDING
+
+
+class ProducerDequeueOperation(LocalOperation):
+    """
+    Producers of transfer data do not need to dequeue
+    """
+
+    def handle(self, context):
+        """
+        :type context: LocalSessionContext
+        """
+        self._assert(context.is_producer)
+        return transfer_statuses.COMPLETED
+
+
+class ReceiverDequeueOperation(LocalOperation):
+    """
+    Performs dequeuing of transferred data for receiver contexts
+    """
+
+    def handle(self, context):
+        """
+        :type context: LocalSessionContext
+        """
+        self._assert(context.is_receiver)
+        self._assert(context.transfer_session is not None)
+        self._assert(context.filter is not None)
+
+        # if no records were transferred, we can safely skip
+        records_transferred = context.transfer_session.records_transferred or 0
+        if records_transferred > 0:
+            _dequeue_into_store(context.transfer_session)
+
+        return transfer_statuses.COMPLETED
+
+
+class ProducerDeserializeOperation(LocalOperation):
+    """
+    Producers of transfer data do not need to deserialize
+    """
+
+    def handle(self, context):
+        """
+        :type context: LocalSessionContext
+        """
+        self._assert(context.is_producer)
+        return transfer_statuses.COMPLETED
+
+
+class ReceiverDeserializeOperation(LocalOperation):
+    """
+    Performs deserialization if enabled through configuration and if applicable for local transfer
+    session
+    """
+
+    def handle(self, context):
+        """
+        :type context: LocalSessionContext
+        """
+        self._assert(context.sync_session is not None)
+        self._assert(context.transfer_session is not None)
+        self._assert(context.filter is not None)
+
+        records_transferred = context.transfer_session.records_transferred or 0
+        if SETTINGS.MORANGO_DESERIALIZE_AFTER_DEQUEUING and records_transferred > 0:
+            # we first serialize to avoid deserialization merge conflicts
+            _serialize_into_store(
+                context.sync_session.profile,
+                filter=context.filter,
+            )
+            _deserialize_from_store(
+                context.sync_session.profile,
+                filter=context.filter,
+            )
+
+        # update database max counters but use latest fsics from client/server
+        if context.is_receiver:
+            fsic = (
+                context.transfer_session.client_fsic
+                if context.is_server
+                else context.transfer_session.server_fsic
+            )
+            DatabaseMaxCounter.update_fsics(json.loads(fsic), context.filter)
+
+        return transfer_statuses.COMPLETED
+
+
+class CleanupOperation(LocalOperation):
+    """
+    Marks the local transfer session as inactive, and deletes queued buffer data if applicable
+    """
+
+    def handle(self, context):
+        """
+        :type context: LocalSessionContext
+        """
+        self._assert(context.transfer_session is not None)
+
+        if context.is_producer:
+            context.transfer_session.delete_buffers()
+
+        context.transfer_session.active = False
+        context.transfer_session.save()
+        return transfer_statuses.COMPLETED
+
+
+class NetworkOperation(BaseOperation):
+    expects_context = NetworkSessionContext
+
+    def create_transfer_session(self, context):
+        """
+        :type context: NetworkSessionContext
+        :return: A response dict
+        """
+        return context.connection._create_transfer_session(
+            dict(
+                id=context.transfer_session.id,
+                filter=context.transfer_session.filter,
+                push=context.transfer_session.push,
+                sync_session_id=context.sync_session.id,
+                client_fsic=context.transfer_session.client_fsic,
+            )
+        ).json()
+
+    def get_transfer_session(self, context):
+        """
+        Retrieves remote transfer session
+
+        :type context: NetworkSessionContext
+        :return: A response dict
+        """
+        return context.connection._get_transfer_session(context.transfer_session).json()
+
+    def update_transfer_session(self, context, **data):
+        """
+        Updates remote transfer session
+
+        :type context: NetworkSessionContext
+        :param data: Data to update remote transfer session wiht
+        :return: A response dict
+        """
+        return context.connection._update_transfer_session(
+            data, context.transfer_session
+        ).json()
+
+    def close_transfer_session(self, context):
+        """
+        Closes remote transfer session
+
+        :type context: NetworkSessionContext
+        :return: The Response
+        """
+        return context.connection._close_transfer_session(context.transfer_session)
+
+    def put_buffers(self, context, buffers):
+        """
+        :type context: NetworkSessionContext
+        :param buffers: List of serialized Buffer dicts
+        :return: The response
+        """
+        return context.connection._push_record_chunk(buffers)
+
+    def get_buffers(self, context):
+        """
+        Pulls a single chunk of buffers from the remote server and does some validation
+
+        :type context: NetworkSessionContext
+        :return: A list of dicts, serialized Buffers
+        """
+        response = context.connection._pull_record_chunk(context.transfer_session)
+
+        data = response.json()
+
+        # parse out the results from a paginated set, if needed
+        if isinstance(data, dict) and "results" in data:
+            data = data["results"]
+
+        # no buffers?
+        if len(data) == 0:
+            return data
+
+        # ensure the transfer session allows pulls, and is same across records
+        transfer_session = TransferSession.objects.get(id=data[0]["transfer_session"])
+        if transfer_session.push:
+            raise ValidationError("Specified TransferSession does not allow pulling.")
+
+        if len(set(rec["transfer_session"] for rec in data)) > 1:
+            raise ValidationError(
+                "All pulled records must be associated with the same TransferSession."
+            )
+
+        if context.transfer_session.id != transfer_session.id:
+            raise ValidationError(
+                "Specified TransferSession does not match this SyncClient's current TransferSession."
+            )
+        return data
+
+    def remote_proceed_to(self, context, stage, **kwargs):
+        """
+        Uses server API's to push updates to a remote `TransferSession`, which triggers the
+        controller's `.proceed_to()` for the stage
+
+        :type context: NetworkSessionContext
+        :param stage: A transfer_stage.*
+        :param kwargs: Other kwargs to send
+        :return: A tuple of the remote's status, and the server response JSON
+        """
+        stage = transfer_stages.stage(stage)
+        data = self.get_transfer_session(context)
+        remote_stage = transfer_stages.stage(data.get("transfer_stage"))
+        remote_status = data.get("transfer_stage_status")
+
+        if remote_stage < stage:
+            # if current stage is not yet at `stage`, push it to that stage through update
+            kwargs.update(transfer_stage=stage)
+            data = self.update_transfer_session(context, **kwargs)
+            remote_status = data.get("transfer_stage_status")
+        elif remote_stage > stage:
+            # if past this stage, then we just make sure returned status is completed
+            remote_status = transfer_statuses.COMPLETED
+
+        if not remote_status:
+            raise MorangoResumeSyncError("Remote failed to proceed to {}".format(stage))
+
+        # if still in progress, then we return PENDING which will cause controller
+        # to again call the middleware that contains this operation, and check the server status
+        if remote_status in transfer_statuses.IN_PROGRESS_STATES:
+            remote_status = transfer_statuses.PENDING
+
+        return remote_status, data
+
+
+class LegacyNetworkInitializeOperation(NetworkOperation):
+    """
+    Initializes remote transfer session in backwards compatible way, by expecting that the server
+    will perform serialization and queuing during the create API call
+    """
+
+    def handle(self, context):
+        """
+        :type context: NetworkSessionContext
+        """
+        self._assert(context.transfer_session is not None)
+        self._assert(ASYNC_OPERATIONS not in context.capabilities)
+
+        # if local stage is transferring or beyond, we definitely don't need to initialize
+        local_stage = context.stage
+        if transfer_stages.stage(local_stage) >= transfer_stages.stage(
+            transfer_stages.TRANSFERRING
+        ):
+            return transfer_statuses.COMPLETED
+
+        data = self.create_transfer_session(context)
+        context.transfer_session.server_fsic = data.get("server_fsic") or "{}"
+
+        if context.transfer_session.pull:
+            context.transfer_session.records_total = data.get("records_total", 0)
+
+        context.transfer_session.save()
+        return transfer_statuses.COMPLETED
+
+
+class NetworkInitializeOperation(NetworkOperation):
+    """
+    Performs initialization (create) of transfer session on the remote, and does not expect the
+    server to advance the transfer session beyond initialization
+    """
+
+    def handle(self, context):
+        """
+        :type context: NetworkSessionContext
+        """
+        self._assert(context.transfer_session is not None)
+        self._assert(ASYNC_OPERATIONS in context.capabilities)
+
+        # if local stage is transferring or beyond, we definitely don't need to initialize
+        if context.stage is not None and transfer_stages.stage(
+            context.stage
+        ) < transfer_stages.stage(transfer_stages.TRANSFERRING):
+            self.create_transfer_session(context)
+
+        return transfer_statuses.COMPLETED
+
+
+class NetworkLegacyNoOpMixin(object):
+    """
+    Mixin that handles contexts without ASYNC_OPERATIONS capability, either because the remote
+    is on older version of Morango, or either the client or server has it disabled.
+    """
+
+    def handle(self, context):
+        """
+        :type context: NetworkSessionContext
+        """
+        self._assert(ASYNC_OPERATIONS not in context.capabilities)
+        return transfer_statuses.COMPLETED
+
+
+class LegacyNetworkSerializeOperation(NetworkLegacyNoOpMixin, NetworkOperation):
+    """
+    Without ASYNC_OPERATIONS capability, the server will perform serialization during initialization
+    """
+
+    pass
+
+
+class NetworkSerializeOperation(NetworkOperation):
+    """
+    Performs serialization on the remote by updating the remote's transfer stage
+    """
+
+    def handle(self, context):
+        """
+        :type context: NetworkSessionContext
+        """
+        self._assert(context.transfer_session is not None)
+        self._assert(ASYNC_OPERATIONS in context.capabilities)
+
+        remote_status, data = self.remote_proceed_to(
+            context,
+            transfer_stages.SERIALIZING,
+            client_fsic=context.transfer_session.client_fsic,
+        )
+
+        if remote_status == transfer_statuses.COMPLETED:
+            context.transfer_session.server_fsic = data.get("server_fsic")
+            context.transfer_session.save()
+
+        return remote_status
+
+
+class LegacyNetworkQueueOperation(NetworkLegacyNoOpMixin, NetworkOperation):
+    """
+    Without ASYNC_OPERATIONS capability, the server will perform queuing during initialization
+    """
+
+    pass
+
+
+class NetworkQueueOperation(NetworkOperation):
+    """
+    Performs queuing on the remote by updating the remote's transfer stage
+    """
+
+    def handle(self, context):
+        """
+        :type context: NetworkSessionContext
+        """
+        self._assert(context.transfer_session is not None)
+        self._assert(ASYNC_OPERATIONS in context.capabilities)
+
+        update_kwargs = {}
+        if context.is_push:
+            update_kwargs.update(records_total=context.transfer_session.records_total)
+
+        remote_status, data = self.remote_proceed_to(
+            context, transfer_stages.QUEUING, **update_kwargs
+        )
+
+        if context.is_pull and remote_status == transfer_statuses.COMPLETED:
+            context.transfer_session.records_total = data.get("records_total", 0)
+            context.transfer_session.save()
+
+        return remote_status
+
+
+class NetworkPushTransferOperation(NetworkOperation):
+    def handle(self, context):
+        """
+        :type context: NetworkSessionContext
+        """
+        self._assert(context.transfer_session is not None)
+        self._assert(context.is_push)
+
+        if context.transfer_session.records_total == 0:
+            # since we won't be transferring anything, we can say we're done
+            return transfer_statuses.COMPLETED
+
+        offset = context.transfer_session.records_transferred
+        chunk_size = context.connection.chunk_size
+
+        buffered_records = Buffer.objects.filter(
+            transfer_session=context.transfer_session
+        ).order_by("pk")
+
+        data = BufferSerializer(
+            buffered_records[offset : offset + chunk_size], many=True
+        ).data
+
+        # push buffers chunk to server
+        self.put_buffers(context, data)
+
+        context.transfer_session.records_transferred = min(
+            offset + chunk_size, context.transfer_session.records_total
+        )
+        context.transfer_session.bytes_sent = context.connection.bytes_sent
+        context.transfer_session.bytes_received = context.connection.bytes_received
+        context.transfer_session.save()
+
+        # if we've transferred all records, return a completed status
+        op_status = transfer_statuses.PENDING
+        if (
+            context.transfer_session.records_transferred
+            >= context.transfer_session.records_total
+        ):
+            op_status = transfer_statuses.COMPLETED
+
+        return op_status
+
+
+class NetworkPullTransferOperation(NetworkOperation):
+    def handle(self, context):
+        """
+        :type context: NetworkSessionContext
+        """
+        self._assert(context.transfer_session is not None)
+        self._assert(context.is_pull)
+
+        transfer_session = context.transfer_session
+
+        if transfer_session.records_total > 0:
+            # grab buffers, just one chunk
+            data = self.get_buffers(context)
+
+            validate_and_create_buffer_data(
+                data, transfer_session, connection=context.connection
+            )
+
+        # if we've transferred all records, return a completed status
+        op_status = transfer_statuses.PENDING
+        if transfer_session.records_transferred >= transfer_session.records_total:
+            op_status = transfer_statuses.COMPLETED
+
+        # update the records transferred so client and server are in agreement
+        self.update_transfer_session(
+            context,
+            transfer_stage=transfer_stages.TRANSFERRING,
+            records_transferred=transfer_session.records_transferred,
+            # flip each of these since we're talking about the remote instance
+            bytes_received=transfer_session.bytes_sent,
+            bytes_sent=transfer_session.bytes_received,
+        )
+
+        return op_status
+
+
+class LegacyDequeueOperation(NetworkLegacyNoOpMixin, NetworkOperation):
+    """
+    Without ASYNC_OPERATIONS capability, the server will perform dequeuing during cleanup
+    """
+
+    pass
+
+
+class NetworkDequeueOperation(NetworkOperation):
+    """
+    Performs dequeuing on the remote by updating the remote's transfer stage
+    """
+
+    def handle(self, context):
+        """
+        :type context: NetworkSessionContext
+        """
+        self._assert(ASYNC_OPERATIONS in context.capabilities)
+
+        remote_status, _ = self.remote_proceed_to(context, transfer_stages.DEQUEUING)
+        return remote_status
+
+
+class LegacyNetworkDeserializeOperation(NetworkLegacyNoOpMixin, NetworkOperation):
+    """
+    Without ASYNC_OPERATIONS capability, the server will perform deserialization during cleanup
+    """
+
+    pass
+
+
+class NetworkDeserializeOperation(NetworkOperation):
+    """
+    Performs deserialization on the remote by updating the remote's transfer stage
+    """
+
+    def handle(self, context):
+        """
+        :type context: NetworkSessionContext
+        """
+        self._assert(context.transfer_session is not None)
+        self._assert(ASYNC_OPERATIONS in context.capabilities)
+
+        remote_status, _ = self.remote_proceed_to(
+            context, transfer_stages.DESERIALIZING
+        )
+        return remote_status
+
+
+class NetworkCleanupOperation(NetworkOperation):
+    """
+    "Cleans up" the remote transfer session, which will trigger the LocalCleanupOperation on the
+    server
+    """
+
+    def handle(self, context):
+        """
+        :type context: NetworkSessionContext
+        """
+        response = self.close_transfer_session(context)
+        remote_status = transfer_statuses.COMPLETED
+        if response.status_code < 200 or response.status_code >= 300:
+            remote_status = transfer_statuses.ERRORED
+        return remote_status
diff --git a/morango/sync/session.py b/morango/sync/session.py
index 433a5619..09a04fb5 100644
--- a/morango/sync/session.py
+++ b/morango/sync/session.py
@@ -5,6 +5,8 @@
 from requests.utils import super_len
 from requests.packages.urllib3.util.url import parse_url
 
+from morango.utils import serialize_capabilities_to_client_request
+
 
 logger = logging.getLogger(__name__)
 
@@ -34,6 +36,7 @@ class SessionWrapper(Session):
     bytes_received = 0
 
     def request(self, method, url, **kwargs):
+        response = None
         try:
             response = super(SessionWrapper, self).request(method, url, **kwargs)
 
@@ -53,9 +56,10 @@ def request(self, method, url, **kwargs):
             return response
         except exceptions.RequestException as req_err:
             # we want to log all request errors for debugging purposes
-            response_content = (
-                req_err.response.content if req_err.response else "(no response)"
-            )
+            if response is None:
+                response = req_err.response
+
+            response_content = response.content if response else "(no response)"
             logger.error(
                 "{} Reason: {}".format(req_err.__class__.__name__, response_content)
             )
@@ -66,9 +70,11 @@ def prepare_request(self, request):
         Override request preparer so we can get the prepared content length, for tracking
         transfer sizes
 
-        :param request: requests.Request
+        :type request: requests.Request
         :rtype: requests.PreparedRequest
         """
+        # add header with client's morango capabilities so server has that information
+        serialize_capabilities_to_client_request(request)
         prepped = super(SessionWrapper, self).prepare_request(request)
         parsed_url = parse_url(request.url)
 
diff --git a/morango/sync/syncsession.py b/morango/sync/syncsession.py
index 62392e24..7c428043 100644
--- a/morango/sync/syncsession.py
+++ b/morango/sync/syncsession.py
@@ -1,48 +1,45 @@
 """
 The main module to be used for initiating the synchronization of data between morango instances.
 """
+import os
 import json
 import logging
 import socket
 import uuid
 from io import BytesIO
 
-from django.conf import settings
-from django.core.paginator import Paginator
 from django.utils import timezone
 from django.utils.six import iteritems
+from django.utils.six import raise_from
 from requests.adapters import HTTPAdapter
-from requests.exceptions import Timeout
+from requests.exceptions import HTTPError
 from requests.packages.urllib3.util.retry import Retry
-from rest_framework.exceptions import ValidationError
 from django.utils.six.moves.urllib.parse import urljoin
 from django.utils.six.moves.urllib.parse import urlparse
 
-from .operations import _dequeue_into_store
-from .operations import _queue_into_buffer
-from .operations import _serialize_into_store
 from .session import SessionWrapper
-from .utils import validate_and_create_buffer_data
-from morango.api.serializers import BufferSerializer
 from morango.api.serializers import CertificateSerializer
 from morango.api.serializers import InstanceIDSerializer
 from morango.constants import api_urls
+from morango.constants import transfer_stages
+from morango.constants import transfer_statuses
 from morango.constants.capabilities import ALLOW_CERTIFICATE_PUSHING
 from morango.constants.capabilities import GZIP_BUFFER_POST
 from morango.errors import CertificateSignatureInvalid
 from morango.errors import MorangoError
+from morango.errors import MorangoResumeSyncError
 from morango.errors import MorangoServerDoesNotAllowNewCertPush
 from morango.models.certificates import Certificate
 from morango.models.certificates import Key
-from morango.models.core import Buffer
-from morango.models.core import DatabaseMaxCounter
 from morango.models.core import InstanceIDModel
-from morango.models.core import RecordMaxCounterBuffer
 from morango.models.core import SyncSession
-from morango.models.core import TransferSession
+from morango.sync.controller import SessionController
+from morango.sync.context import LocalSessionContext
+from morango.sync.context import NetworkSessionContext
 from morango.sync.utils import SyncSignal
 from morango.sync.utils import SyncSignalGroup
 from morango.utils import CAPABILITIES
+from morango.utils import pid_exists
 
 if GZIP_BUFFER_POST in CAPABILITIES:
     from gzip import GzipFile
@@ -100,11 +97,31 @@ class Connection(object):
 
 
 class NetworkSyncConnection(Connection):
-    def __init__(self, base_url="", compresslevel=9, retries=7, backoff_factor=0.3):
+    __slots__ = (
+        "base_url",
+        "compresslevel",
+        "session",
+        "server_info",
+        "capabilities",
+        "chunk_size",
+    )
+
+    default_chunk_size = 500
+
+    def __init__(
+        self,
+        base_url="",
+        compresslevel=9,
+        retries=7,
+        backoff_factor=0.3,
+        chunk_size=default_chunk_size,
+    ):
         """
         The underlying network connection with a syncing peer. Any network requests
         (such as certificate querying or syncing related) will be done through this class.
         """
+        if base_url == "":
+            raise AssertionError("Network connection `base_url` cannot be empty")
 
         self.base_url = base_url
         self.compresslevel = compresslevel
@@ -121,6 +138,7 @@ def __init__(self, base_url="", compresslevel=9, retries=7, backoff_factor=0.3):
             urljoin(self.base_url, api_urls.INFO)
         ).json()
         self.capabilities = self.server_info.get("capabilities", [])
+        self.chunk_size = chunk_size
 
     @property
     def bytes_sent(self):
@@ -136,7 +154,23 @@ def urlresolve(self, endpoint, lookup=None):
         url = urljoin(urljoin(self.base_url, endpoint), lookup)
         return url
 
-    def create_sync_session(self, client_cert, server_cert, chunk_size=500):
+    def create_sync_session(self, client_cert, server_cert, chunk_size=None):
+        """
+        Starts a sync session by creating it on the server side and returning a client to use
+        for initiating transfer operations
+
+        :param client_cert: The local certificate to use, already registered with the server
+        :type client_cert: Certificate
+        :param server_cert: The server's certificate that relates to the same profile as local
+        :type server_cert: Certificate
+        :param chunk_size: An optional parameter specifying the size for each transferred chunk
+        :type chunk_size: int
+        :return: A SyncSessionClient instance
+        :rtype: SyncSessionClient
+        """
+        if chunk_size is not None:
+            self.chunk_size = chunk_size
+
         # if server cert does not exist locally, retrieve it from server
         if not Certificate.objects.filter(id=server_cert.id).exists():
             cert_chain_response = self._get_certificate_chain(
@@ -210,9 +244,51 @@ def create_sync_session(self, client_cert, server_cert, chunk_size=500):
                 ).data
             ),
             "server_instance": session_resp.json().get("server_instance") or "{}",
+            "process_id": os.getpid(),
         }
         sync_session = SyncSession.objects.create(**data)
-        return SyncSessionClient(self, sync_session, chunk_size=chunk_size)
+        return SyncSessionClient(self, sync_session)
+
+    def resume_sync_session(self, sync_session_id, chunk_size=None):
+        """
+        Resumes an existing sync session given an ID
+
+        :param sync_session_id: The UUID of the `SyncSession` to resume
+        :param chunk_size: An optional parameter specifying the size for each transferred chunk
+        :return: A SyncSessionClient instance
+        :rtype: SyncSessionClient
+        """
+        if chunk_size is not None:
+            self.chunk_size = chunk_size
+
+        try:
+            sync_session = SyncSession.objects.get(pk=sync_session_id, active=True)
+        except SyncSession.DoesNotExist:
+            raise MorangoResumeSyncError(
+                "Session for ID '{}' not found".format(sync_session_id)
+            )
+
+        # check that process of existing session isn't still running
+        if (
+            sync_session.process_id
+            and sync_session.process_id != os.getpid()
+            and pid_exists(sync_session.process_id)
+        ):
+            raise MorangoResumeSyncError(
+                "Session process '{}' is still running".format(sync_session.process_id)
+            )
+
+        # In order to resume, we need sync sessions on both server and client, otherwise resuming
+        # wouldn't have any benefit
+        try:
+            self._get_sync_session(sync_session)
+        except HTTPError as e:
+            raise_from(MorangoResumeSyncError("Failure resuming sync session"), e)
+
+        # update process id
+        sync_session.process_id = os.getpid()
+        sync_session.save()
+        return SyncSessionClient(self, sync_session)
 
     def close_sync_session(self, sync_session):
         # "delete" sync session on server side
@@ -360,9 +436,19 @@ def _push_certificate_chain(self, data):
     def _create_sync_session(self, data):
         return self.session.post(self.urlresolve(api_urls.SYNCSESSION), json=data)
 
+    def _get_sync_session(self, sync_session):
+        return self.session.get(
+            self.urlresolve(api_urls.SYNCSESSION, lookup=sync_session.id)
+        )
+
     def _create_transfer_session(self, data):
         return self.session.post(self.urlresolve(api_urls.TRANSFERSESSION), json=data)
 
+    def _get_transfer_session(self, transfer_session):
+        return self.session.get(
+            self.urlresolve(api_urls.TRANSFERSESSION, lookup=transfer_session.id)
+        )
+
     def _update_transfer_session(self, data, transfer_session):
         return self.session.patch(
             self.urlresolve(api_urls.TRANSFERSESSION, lookup=transfer_session.id),
@@ -394,10 +480,10 @@ def _push_record_chunk(self, data):
         else:
             return self.session.post(self.urlresolve(api_urls.BUFFER), json=data)
 
-    def _pull_record_chunk(self, chunk_size, transfer_session):
+    def _pull_record_chunk(self, transfer_session):
         # pull records from server for given transfer session
         params = {
-            "limit": chunk_size,
+            "limit": self.chunk_size,
             "offset": transfer_session.records_transferred,
             "transfer_session_id": transfer_session.id,
         }
@@ -421,35 +507,35 @@ class SyncClientSignals(SyncSignal):
 
 
 class SyncSessionClient(object):
-    signals = None
-    """A namespaced attribute clearly separating signal groups"""
-
-    def __init__(self, sync_connection, sync_session, chunk_size=500):
+    __slots__ = (
+        "sync_connection",
+        "sync_session",
+        "signals",
+        "controller",
+    )
+
+    def __init__(self, sync_connection, sync_session, controller=None):
         """
         :param sync_connection: NetworkSyncConnection
         :param sync_session: SyncSession
-        :param chunk_size: int
+        :param controller: SessionController
         """
         self.sync_connection = sync_connection
         self.sync_session = sync_session
-        self.chunk_size = chunk_size
         self.signals = SyncClientSignals()
+        self.controller = controller or SessionController.build()
 
     def get_pull_client(self):
         """
         returns ``PullClient``
         """
-        return PullClient(
-            self.sync_connection, self.sync_session, chunk_size=self.chunk_size
-        )
+        return PullClient(self.sync_connection, self.sync_session, self.controller)
 
     def get_push_client(self):
         """
         returns ``PushClient``
         """
-        return PushClient(
-            self.sync_connection, self.sync_session, chunk_size=self.chunk_size
-        )
+        return PushClient(self.sync_connection, self.sync_session, self.controller)
 
     def initiate_pull(self, sync_filter):
         """
@@ -480,302 +566,144 @@ def close_sync_session(self):
         self.sync_connection.close()
 
 
-class BaseSyncClient(object):
+class TransferClient(object):
     """
     Base class for handling common operations for initiating syncing and other related operations.
     """
 
-    signals = None
-    """A namespaced attribute clearly separating signal groups"""
-
-    def __init__(self, sync_connection, sync_session, chunk_size=500):
+    __slots__ = (
+        "sync_connection",
+        "sync_session",
+        "controller",
+        "current_transfer_session",
+        "signals",
+        "local_context",
+        "remote_context",
+    )
+
+    def __init__(self, sync_connection, sync_session, controller):
         """
         :param sync_connection: NetworkSyncConnection
         :param sync_session: SyncSession
-        :param chunk_size: int
+        :param controller: SessionController
         """
         self.sync_connection = sync_connection
         self.sync_session = sync_session
-        self.chunk_size = chunk_size
+        self.controller = controller
         self.current_transfer_session = None
         self.signals = SyncClientSignals()
 
-    def initialize(self, sync_filter):
-        """
-        :param sync_filter: Filter
-        """
-        raise NotImplementedError("Abstract method")
-
-    def run(self):
-        raise NotImplementedError("Abstract method")
-
-    def finalize(self, allow_server_timeout=False):
-        raise NotImplementedError("Abstract method")
-
-    def _has_records(self):
-        # not initialized
-        if not self.current_transfer_session:
-            raise MorangoError("Client has not been initialized")
-
-        records_total = self.current_transfer_session.records_total
-        return records_total is not None and records_total > 0
-
-    def _initialize_server_transfer_session(self):
-        return self.sync_connection._create_transfer_session(
-            dict(
-                id=self.current_transfer_session.id,
-                filter=self.current_transfer_session.filter,
-                push=self.current_transfer_session.push,
-                sync_session_id=self.sync_session.id,
-                client_fsic=self.current_transfer_session.client_fsic,
-            )
-        )
-
-    def _close_server_transfer_session(self):
-        return self.sync_connection._close_transfer_session(
-            self.current_transfer_session
-        )
-
-    def _create_transfer_session(self, sync_filter, **data):
-        # build data for creating transfer session on server side
-        data.update(
-            id=uuid.uuid4().hex,
-            filter=str(sync_filter),
-            sync_session_id=self.sync_session.id,
-            last_activity_timestamp=timezone.now(),
-            client_fsic=json.dumps(
-                DatabaseMaxCounter.calculate_filter_max_counters(sync_filter)
-            ),
-        )
-
-        self.current_transfer_session = TransferSession.objects.create(**data)
-        self.signals.session.started.fire(
-            transfer_session=self.current_transfer_session
+        # TODO: come up with strategy to use only one context here
+        self.local_context = LocalSessionContext(sync_session=sync_session)
+        self.remote_context = NetworkSessionContext(
+            sync_connection, sync_session=sync_session
         )
 
-        # save transfersession locally before creating transfersession server side
-        self.current_transfer_session.save()
-
-        # create transfer session on server side
-        transfer_resp = self._initialize_server_transfer_session()
-        self.current_transfer_session.server_fsic = (
-            transfer_resp.json().get("server_fsic") or "{}"
-        )
-        self.current_transfer_session.save()
-
-    def _close_transfer_session(self, allow_server_timeout=False):
-        """
-        :param allow_server_timeout: Ignores timeout errors during remote close of transfer session
-        """
-        try:
-            # "delete" transfer session on server side
-            self._close_server_transfer_session()
-        except Timeout as e:
-            # re-raise error if we weren't told to ignore it
-            if not allow_server_timeout:
-                raise e
-            else:
-                logger.warning(
-                    "Request to close server's transfer session has timed out"
+    def proceed_to_and_wait_for(self, stage):
+        contexts = (self.local_context, self.remote_context)
+        for context in contexts:
+            max_interval = 1 if context is self.local_context else 5
+            result = self.controller.proceed_to_and_wait_for(
+                stage, context=context, max_interval=max_interval
+            )
+            if result == transfer_statuses.ERRORED:
+                raise_from(
+                    MorangoError("Stage `{}` failed".format(stage)),
+                    context.error,
                 )
 
-        # "delete" our own local transfer session
-        self.current_transfer_session.active = False
-        self.current_transfer_session.save()
-        self.signals.session.completed.fire(
-            transfer_session=self.current_transfer_session
-        )
-        self.current_transfer_session = None
-
-
-class PushClient(BaseSyncClient):
-    """
-    Sync client for pushing to a server
-    """
-
     def initialize(self, sync_filter):
         """
         :param sync_filter: Filter
         """
-        # before pushing or creating the transfer session, we want to serialize the most recent
-        # data and update database max counters
-        if getattr(settings, "MORANGO_SERIALIZE_BEFORE_QUEUING", True):
-            _serialize_into_store(
-                self.sync_session.profile,
-                filter=sync_filter,
-            )
+        # set filter on controller
+        self.local_context.update(sync_filter=sync_filter)
+        self.remote_context.update(sync_filter=sync_filter)
 
-        self._create_transfer_session(sync_filter, push=True)
+        # initialize the transfer session locally
+        status = self.controller.proceed_to_and_wait_for(
+            transfer_stages.INITIALIZING, context=self.local_context, max_interval=1
+        )
+        if status == transfer_statuses.ERRORED:
+            raise_from(
+                MorangoError("Failed to initialize transfer session"),
+                self.local_context.error,
+            )
 
-        with self.signals.queuing.send(transfer_session=self.current_transfer_session):
-            _queue_into_buffer(self.current_transfer_session)
+        # copy the transfer session to local state and update remote controller context
+        self.current_transfer_session = self.local_context.transfer_session
+        self.remote_context.update(transfer_session=self.current_transfer_session)
 
-        # update the records_total for client and server transfer session
-        records_total = Buffer.objects.filter(
+        self.signals.session.started.fire(
             transfer_session=self.current_transfer_session
-        ).count()
-
-        self.current_transfer_session.records_total = records_total
-        self.current_transfer_session.save()
-
-        self.sync_connection._update_transfer_session(
-            {"records_total": records_total}, self.current_transfer_session
         )
 
-    def run(self):
-        if not self._has_records():
-            return
+        # backwards compatibility for the queuing signal as it included both serialization
+        # and queuing originally
+        with self.signals.queuing.send(transfer_session=self.current_transfer_session):
+            # proceeding to serialization on remote will trigger initialization as well
+            self.proceed_to_and_wait_for(transfer_stages.SERIALIZING)
+            self.proceed_to_and_wait_for(transfer_stages.QUEUING)
 
+    def run(self):
         with self.signals.transferring.send(
             transfer_session=self.current_transfer_session
         ) as status:
-            self._push_records(callback=status.in_progress.fire)
+            self._transfer(callback=status.in_progress.fire)
 
-    def finalize(self, allow_server_timeout=False):
+    def finalize(self):
         # if not initialized, we don't need to finalize
         if not self.current_transfer_session:
             return
 
-        # upon successful completion of pushing records, proceed to delete buffered records
-        Buffer.objects.filter(transfer_session=self.current_transfer_session).delete()
-        RecordMaxCounterBuffer.objects.filter(
-            transfer_session=self.current_transfer_session
-        ).delete()
-
-        # makes a remote call to the server to close the transfer session, which dequeues the
-        # data remotely
         with self.signals.dequeuing.send(
             transfer_session=self.current_transfer_session
         ):
-            self._close_transfer_session(allow_server_timeout=allow_server_timeout)
+            self.proceed_to_and_wait_for(transfer_stages.DESERIALIZING)
 
-    def _push_records(self, callback=None):
-        # paginate buffered records so we do not load them all into memory
-        buffered_records = Buffer.objects.filter(
+        self.proceed_to_and_wait_for(transfer_stages.CLEANUP)
+        self.signals.session.completed.fire(
             transfer_session=self.current_transfer_session
-        ).order_by("pk")
-        buffered_pages = Paginator(buffered_records, self.chunk_size)
+        )
+        self.current_transfer_session = None
 
-        for count in buffered_pages.page_range:
-            # serialize and send records to server
-            serialized_recs = BufferSerializer(
-                buffered_pages.page(count).object_list, many=True
-            )
-            self.sync_connection._push_record_chunk(serialized_recs.data)
-            # update records_transferred upon successful request
-            self.current_transfer_session.records_transferred = min(
-                self.current_transfer_session.records_transferred + self.chunk_size,
-                self.current_transfer_session.records_total,
+    def _transfer(self, callback=None):
+        result = transfer_statuses.PENDING
+
+        while result not in transfer_statuses.FINISHED_STATES:
+            result = self.controller.proceed_to(
+                transfer_stages.TRANSFERRING, context=self.remote_context
             )
-            self.current_transfer_session.bytes_sent = self.sync_connection.bytes_sent
-            self.current_transfer_session.bytes_received = (
-                self.sync_connection.bytes_received
+            self.local_context.update(
+                stage=transfer_stages.TRANSFERRING, stage_status=result
             )
-            self.current_transfer_session.save()
-
             if callback is not None:
                 callback()
 
-
-class PullClient(BaseSyncClient):
-    """
-    Sync class to pull from server
-    """
-
-    sync_filter = None
-
-    def initialize(self, sync_filter):
-        """
-        :param sync_filter: Filter
-        """
-        self.sync_filter = sync_filter
-        self._create_transfer_session(sync_filter, push=False)
-
-    def run(self):
-        if not self._has_records():
-            return
-
-        with self.signals.transferring.send(
-            transfer_session=self.current_transfer_session
-        ) as status:
-            self._pull_records(callback=status.in_progress.fire)
-
-    def finalize(self, allow_server_timeout=False):
-        # if not initialized, we don't need to finalize
-        if not self.current_transfer_session:
-            return
-
-        if self._has_records():
-            with self.signals.dequeuing.send(
-                transfer_session=self.current_transfer_session
-            ):
-                _dequeue_into_store(self.current_transfer_session)
-
-            # update database max counters but use latest fsics on client
-            DatabaseMaxCounter.update_fsics(
-                json.loads(self.current_transfer_session.server_fsic), self.sync_filter
+        if result == transfer_statuses.ERRORED:
+            raise_from(
+                MorangoError("Failure occurred during transfer"),
+                self.remote_context.error,
             )
 
-        self._close_transfer_session(allow_server_timeout=allow_server_timeout)
-
-    def _initialize_server_transfer_session(self):
-        # this makes a remote call to the server, which triggers queuing for our pull session
-        with self.signals.queuing.send(transfer_session=self.current_transfer_session):
-            response = super(PullClient, self)._initialize_server_transfer_session()
-
-        self.current_transfer_session.records_total = response.json().get(
-            "records_total"
-        )
-        return response
-
-    def _pull_records(self, callback=None):
-        while (
-            self.current_transfer_session.records_transferred
-            < self.current_transfer_session.records_total
-        ):
-            buffers_resp = self.sync_connection._pull_record_chunk(
-                self.chunk_size, self.current_transfer_session
-            )
 
-            # load the returned data from JSON
-            data = buffers_resp.json()
-
-            # parse out the results from a paginated set, if needed
-            if isinstance(data, dict) and "results" in data:
-                data = data["results"]
-
-            # ensure the transfer session allows pulls, and is same across records
-            transfer_session = TransferSession.objects.get(
-                id=data[0]["transfer_session"]
-            )
-            if transfer_session.push:
-                raise ValidationError(
-                    "Specified TransferSession does not allow pulling."
-                )
+class PushClient(TransferClient):
+    """
+    Sync client for pushing to a server
+    """
 
-            if len(set(rec["transfer_session"] for rec in data)) > 1:
-                raise ValidationError(
-                    "All pulled records must be associated with the same TransferSession."
-                )
+    def __init__(self, *args, **kwargs):
+        super(PushClient, self).__init__(*args, **kwargs)
+        self.local_context.update(is_push=True)
+        self.remote_context.update(is_push=True)
 
-            if self.current_transfer_session.id != transfer_session.id:
-                raise ValidationError(
-                    "Specified TransferSession does not match this SyncClient's current TransferSession."
-                )
 
-            validate_and_create_buffer_data(
-                data, self.current_transfer_session, connection=self.sync_connection
-            )
-            # update the records transferred so client and server are in agreement
-            self.sync_connection._update_transfer_session(
-                {
-                    "records_transferred": self.current_transfer_session.records_transferred,
-                    # flip each of these since we're talking about the remote instance
-                    "bytes_received": self.current_transfer_session.bytes_sent,
-                    "bytes_sent": self.current_transfer_session.bytes_received,
-                },
-                self.current_transfer_session,
-            )
+class PullClient(TransferClient):
+    """
+    Sync class to pull from server
+    """
 
-            if callback is not None:
-                callback()
+    def __init__(self, *args, **kwargs):
+        super(PullClient, self).__init__(*args, **kwargs)
+        self.local_context.update(is_push=False)
+        self.remote_context.update(is_push=False)
diff --git a/morango/sync/utils.py b/morango/sync/utils.py
index 9e9a14c6..89081b09 100644
--- a/morango/sync/utils.py
+++ b/morango/sync/utils.py
@@ -70,7 +70,9 @@ def wrapper(*args, **kwargs):
         return wrapper
 
 
-def validate_and_create_buffer_data(data, transfer_session, connection=None):  # noqa: C901
+def validate_and_create_buffer_data(  # noqa: C901
+    data, transfer_session, connection=None
+):
     data = copy.deepcopy(data)
     rmcb_list = []
     buffer_list = []
@@ -89,11 +91,9 @@ def validate_and_create_buffer_data(data, transfer_session, connection=None):  #
         )
         if expected_model_uuid != record["model_uuid"]:
             raise ValidationError(
-                {
-                    "model_uuid": "Does not match results of calling {}.compute_namespaced_id".format(
-                        Model.__class__.__name__
-                    )
-                }
+                "Does not match results of calling {}.compute_namespaced_id".format(
+                    Model.__class__.__name__
+                )
             )
 
         # ensure the profile is marked onto the buffer record
@@ -102,30 +102,24 @@ def validate_and_create_buffer_data(data, transfer_session, connection=None):  #
         # ensure the partition is within the transfer session's filter
         if not transfer_session.get_filter().contains_partition(record["partition"]):
             raise ValidationError(
-                {
-                    "partition": "Partition {} is not contained within filter for TransferSession ({})".format(
-                        record["partition"], transfer_session.filter
-                    )
-                }
+                "Partition {} is not contained within filter for TransferSession ({})".format(
+                    record["partition"], transfer_session.filter
+                )
             )
 
         # ensure that all nested RMCB models are properly associated with this record and transfer session
         for rmcb in record.pop("rmcb_list"):
             if rmcb["transfer_session"] != transfer_session.id:
                 raise ValidationError(
-                    {
-                        "rmcb_list": "Transfer session on RMCB ({}) does not match Buffer's TransferSession ({})".format(
-                            rmcb["transfer_session"], transfer_session
-                        )
-                    }
+                    "Transfer session on RMCB ({}) does not match Buffer's TransferSession ({})".format(
+                        rmcb["transfer_session"], transfer_session
+                    )
                 )
             if rmcb["model_uuid"] != record["model_uuid"]:
                 raise ValidationError(
-                    {
-                        "rmcb_list": "Model UUID on RMCB ({}) does not match Buffer's Model UUID ({})".format(
-                            rmcb["model_uuid"], record["model_uuid"]
-                        )
-                    }
+                    "Model UUID on RMCB ({}) does not match Buffer's Model UUID ({})".format(
+                        rmcb["model_uuid"], record["model_uuid"]
+                    )
                 )
             rmcb["transfer_session_id"] = rmcb.pop("transfer_session")
             rmcb_list += [RecordMaxCounterBuffer(**rmcb)]
@@ -152,6 +146,8 @@ class SyncSignal(object):
     Helper class for firing signals from the sync client
     """
 
+    __slots__ = ("_handlers", "_defaults")
+
     def __init__(self, **kwargs_defaults):
         """
         Default keys/values that the signal consumer can depend on being present.
diff --git a/morango/utils.py b/morango/utils.py
index 087dcb81..ca3144f7 100644
--- a/morango/utils.py
+++ b/morango/utils.py
@@ -1,7 +1,27 @@
+import os
+
 from django.conf import settings
 
+from morango.constants import settings as default_settings
 from morango.constants.capabilities import ALLOW_CERTIFICATE_PUSHING
 from morango.constants.capabilities import GZIP_BUFFER_POST
+from morango.constants.capabilities import ASYNC_OPERATIONS
+
+
+class Settings(object):
+    """
+    Maps the settings from constants file with their defaults to this class which allows access
+    via attributes similar to Django settings
+    """
+
+    __slots__ = [key for key in dir(default_settings) if not key.startswith("__")]
+
+    def __getattribute__(self, key):
+        """Coalesces settings with the defaults"""
+        return getattr(settings, key, getattr(default_settings, key, None))
+
+
+SETTINGS = Settings()
 
 
 def get_capabilities():
@@ -14,9 +34,79 @@ def get_capabilities():
     except ImportError:
         pass
 
-    if getattr(settings, "ALLOW_CERTIFICATE_PUSHING", False):
+    if SETTINGS.ALLOW_CERTIFICATE_PUSHING:
         capabilities.add(ALLOW_CERTIFICATE_PUSHING)
+
+    # Middleware async operation capabilities are standard in 0.6.0 and above
+    if not SETTINGS.MORANGO_DISALLOW_ASYNC_OPERATIONS:
+        capabilities.add(ASYNC_OPERATIONS)
+
     return capabilities
 
 
 CAPABILITIES = get_capabilities()
+CAPABILITIES_CLIENT_HEADER = "X-Morango-Capabilities"
+CAPABILITIES_SERVER_HEADER = "HTTP_{}".format(
+    CAPABILITIES_CLIENT_HEADER.upper().replace("-", "_")
+)
+
+
+def serialize_capabilities_to_client_request(request):
+    """
+    :param request: The client request sending to another Morango server
+    :type request: requests.Request
+    """
+    request.headers[CAPABILITIES_CLIENT_HEADER] = " ".join(CAPABILITIES)
+
+
+def parse_capabilities_from_server_request(request):
+    """
+    :param request: The request object received from a Morango client
+    :type request: django.http.request.HttpRequest
+    :return: A set of capabilities
+    """
+    return set(request.META.get(CAPABILITIES_SERVER_HEADER, "").split(" "))
+
+
+def _posix_pid_exists(pid):
+    """Check whether PID exists in the current process table."""
+    import errno
+
+    if pid < 0:
+        return False
+    try:
+        # Send signal 0, this is harmless
+        os.kill(pid, 0)
+    except OSError as e:
+        return e.errno == errno.EPERM
+    else:
+        return True
+
+
+def _windows_pid_exists(pid):
+    import ctypes
+
+    kernel32 = ctypes.windll.kernel32
+    SYNCHRONIZE = 0x100000
+
+    process = kernel32.OpenProcess(SYNCHRONIZE, 0, pid)
+    if process != 0:
+        kernel32.CloseHandle(process)
+        return True
+    else:
+        return False
+
+
+if os.name == "posix":
+    pid_exists = _posix_pid_exists
+else:
+    pid_exists = _windows_pid_exists
+
+
+def _assert(condition, message):
+    """
+    :param condition: A bool condition that if false will raise an AssertionError
+    :param message: assertion error detail message
+    """
+    if not condition:
+        raise AssertionError(message)
diff --git a/tox.ini b/tox.ini
index add645fa..73c83fe8 100644
--- a/tox.ini
+++ b/tox.ini
@@ -2,6 +2,7 @@
 envlist =
   py{2.7,3.4,3.5,3.6,3.7,3.8,3.9}-cryptography{1.2,1.8,2.3}
   postgres
+  windows
 
 [testenv]
 
@@ -9,7 +10,7 @@ whitelist_externals=
   sh
 
 setenv =
-  PYTHONPATH = {toxinidir}:{toxinidir}/tests/testapp
+  PYTHONPATH = {toxinidir}{:}{toxinidir}/tests/testapp
 
 basepython =
   py2.7: python2.7
@@ -20,6 +21,7 @@ basepython =
   py3.8: python3.8
   py3.9: python3.9
   postgres: python3.5
+  windows: python3.6
 
 deps =
   -r{toxinidir}/requirements/test.txt
@@ -29,7 +31,7 @@ deps =
 
 commands =
   sh -c '! tests/testapp/manage.py makemigrations --dry-run --exit --noinput'
-  py.test {posargs:--cov=morango --color=no}
+  python -O -m pytest {posargs:--cov=morango --color=no}
 
 [testenv:postgres]
 deps =
@@ -39,4 +41,12 @@ setenv =
   PYTHONPATH = {toxinidir}:{toxinidir}/tests/testapp
   DJANGO_SETTINGS_MODULE = testapp.postgres_settings
 commands =
-  py.test {posargs:--cov=morango --color=no}
+  python -O -m pytest  {posargs:--cov=morango --color=no}
+
+[testenv:windows]
+deps =
+  -r{toxinidir}/requirements/test.txt
+setenv =
+    PYTHONPATH = {toxinidir}{:}{toxinidir}/tests/testapp
+commands =
+  python -O -m pytest {posargs:--cov=morango --color=no} -m windows


Test patch:
diff --git a/tests/testapp/testapp/db.py b/tests/testapp/testapp/db.py
new file mode 100644
index 00000000..40d1abe4
--- /dev/null
+++ b/tests/testapp/testapp/db.py
@@ -0,0 +1,19 @@
+import os
+
+
+class TestingRouter(object):
+    """
+    A router to control all database operations on models in the
+    test application.
+    """
+    def db_for_read(self, *args, **kwargs):
+        return os.environ.get("MORANGO_TEST_DATABASE", "default")
+
+    def db_for_write(self, *args, **kwargs):
+        return os.environ.get("MORANGO_TEST_DATABASE", "default")
+
+    def allow_relation(self, *args, **kwargs):
+        return True
+
+    def allow_migrate(self, *args, **kwargs):
+        return True
diff --git a/tests/testapp/testapp/postgres_settings.py b/tests/testapp/testapp/postgres_settings.py
index d06d077b..960ffa13 100644
--- a/tests/testapp/testapp/postgres_settings.py
+++ b/tests/testapp/testapp/postgres_settings.py
@@ -39,3 +39,5 @@
         }
     }
 }
+
+MORANGO_TEST_POSTGRESQL = True
diff --git a/tests/testapp/testapp/settings.py b/tests/testapp/testapp/settings.py
index 0a056c11..e18c4037 100644
--- a/tests/testapp/testapp/settings.py
+++ b/tests/testapp/testapp/settings.py
@@ -80,11 +80,22 @@
 DATABASES = {
     'default': {
         'ENGINE': 'django.db.backends.sqlite3',
-        'NAME': os.path.join(BASE_DIR, 'testapp.db'),
+        'NAME': os.path.join(BASE_DIR, "testapp.db"),
+    },
+    'default2': {
+        'ENGINE': 'django.db.backends.sqlite3',
+        'NAME': os.path.join(BASE_DIR, "testapp2.db"),
     }
 }
 
 
+DATABASE_ROUTERS = [
+    "testapp.db.TestingRouter",
+]
+
+MORANGO_TEST_POSTGRESQL = False
+
+
 # Password validation
 # https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators
 
@@ -123,4 +134,4 @@
 
 STATIC_URL = '/static/'
 
-AUTH_USER_MODEL = 'facility_profile.MyUser'
\ No newline at end of file
+AUTH_USER_MODEL = 'facility_profile.MyUser'
diff --git a/tests/testapp/tests/helpers.py b/tests/testapp/tests/helpers.py
index 97907733..46603e54 100644
--- a/tests/testapp/tests/helpers.py
+++ b/tests/testapp/tests/helpers.py
@@ -2,9 +2,11 @@
 Helper functions for use across syncing related functionality.
 """
 import uuid
+import json
 
 import factory
 import mock
+from django.test.testcases import LiveServerTestCase
 from django.core.serializers.json import DjangoJSONEncoder
 from django.utils import timezone
 from facility_profile.models import Facility
@@ -13,6 +15,7 @@
 from facility_profile.models import SummaryLog
 from test.support import EnvironmentVarGuard
 
+from morango.api.serializers import BufferSerializer
 from morango.models.core import AbstractStore
 from morango.models.core import Buffer
 from morango.models.core import DatabaseIDModel
@@ -22,8 +25,12 @@
 from morango.models.core import Store
 from morango.models.core import SyncSession
 from morango.models.core import TransferSession
+from morango.sync.context import SessionContext
 from morango.sync.controller import MorangoProfileController
-from morango.sync.syncsession import BaseSyncClient
+from morango.sync.controller import SessionController
+from morango.sync.syncsession import NetworkSyncConnection
+from morango.sync.syncsession import TransferClient
+from morango.sync.syncsession import SyncSessionClient
 
 
 class FacilityFactory(factory.DjangoModelFactory):
@@ -74,8 +81,10 @@ def create_dummy_store_data():
     ]  # counter is at 0
 
     # create controllers for app/store/buffer operations
+    conn = mock.Mock(spec='morango.sync.syncsession.NetworkSyncConnection')
+    conn.server_info = dict(capabilities=[])
     data["mc"] = MorangoProfileController("facilitydata")
-    data["sc"] = BaseSyncClient(None, "host")
+    data["sc"] = TransferClient(conn, "host", SessionController.build())
     session = SyncSession.objects.create(
         id=uuid.uuid4().hex,
         profile="facilitydata",
@@ -338,3 +347,98 @@ def create_buffer_and_store_dummy_data(transfer_session_id):
     )
 
     return data
+
+
+class BaseClientTestCase(LiveServerTestCase):
+    profile = "facilitydata"
+
+    def setUp(self):
+        super(BaseClientTestCase, self).setUp()
+        DatabaseIDModel.objects.create()
+        self.session = SyncSession.objects.create(
+            id=uuid.uuid4().hex,
+            profile=self.profile,
+            last_activity_timestamp=timezone.now(),
+        )
+        self.conn = NetworkSyncConnection(base_url=self.live_server_url)
+        self.conn.chunk_size = 3
+        self.transfer_session = None
+        self.client = self.build_client()
+        self.instance = InstanceIDModel.get_or_create_current_instance(clear_cache=True)[0]
+
+    def build_client(self, client_class=SyncSessionClient, controller=None):
+        client = client_class(self.conn, self.session, controller)
+        self.transferring_mock = mock.Mock()
+        client.signals.transferring.connect(self.transferring_mock)
+        return client
+
+    def build_buffer_items(self, transfer_session, **kwargs):
+        data = {
+            "profile": kwargs.get("profile", self.profile),
+            "serialized": kwargs.get("serialized", '{"test": 99}'),
+            "deleted": kwargs.get("deleted", False),
+            "last_saved_instance": kwargs.get("last_saved_instance", uuid.uuid4().hex),
+            "last_saved_counter": kwargs.get("last_saved_counter", 179),
+            "partition": kwargs.get("partition", "partition"),
+            "source_id": kwargs.get("source_id", uuid.uuid4().hex),
+            "model_name": kwargs.get("model_name", "contentsummarylog"),
+            "conflicting_serialized_data": kwargs.get(
+                "conflicting_serialized_data", ""
+            ),
+            "model_uuid": kwargs.get("model_uuid", None),
+            "transfer_session": transfer_session,
+        }
+
+        for i in range(self.conn.chunk_size):
+            data["source_id"] = uuid.uuid4().hex
+            data["model_uuid"] = SummaryLog.compute_namespaced_id(
+                data["partition"], data["source_id"], data["model_name"]
+            )
+            Buffer.objects.create(**data)
+
+        buffered_items = Buffer.objects.filter(
+            transfer_session=transfer_session
+        )
+        serialized_records = BufferSerializer(buffered_items, many=True)
+        return json.dumps(serialized_records.data)
+
+
+class BaseTransferClientTestCase(BaseClientTestCase):
+    def build_client(self, client_class=TransferClient, controller=None, update_context=False):
+        if not self.transfer_session:
+            self.transfer_session = TransferSession.objects.create(
+                id=uuid.uuid4().hex,
+                sync_session=self.session,
+                filter="partition",
+                push=True,
+                last_activity_timestamp=timezone.now(),
+                records_total=3,
+            )
+
+        client = super(BaseTransferClientTestCase, self).build_client(client_class=client_class, controller=controller)
+        client.current_transfer_session = self.transfer_session
+        if client.local_context.is_push is None:
+            client.local_context.update(is_push=self.transfer_session.push)
+            client.remote_context.update(is_push=self.transfer_session.push)
+        if update_context:
+            client.local_context.update(transfer_session=self.transfer_session)
+            client.remote_context.update(transfer_session=self.transfer_session)
+        return client
+
+
+class TestSessionContext(SessionContext):
+    __test__ = False
+    _stage = None
+    _stage_status = None
+
+    @property
+    def stage(self):
+        return self._stage
+
+    @property
+    def stage_status(self):
+        return self._stage_status
+
+    def update_state(self, stage=None, stage_status=None):
+        self._stage = stage or self._stage
+        self._stage_status = stage_status or self._stage_status
diff --git a/tests/testapp/tests/integration/__init__.py b/tests/testapp/tests/integration/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/testapp/tests/test_morango_mptt.py b/tests/testapp/tests/integration/test_morango_mptt.py
similarity index 100%
rename from tests/testapp/tests/test_morango_mptt.py
rename to tests/testapp/tests/integration/test_morango_mptt.py
diff --git a/tests/testapp/tests/test_serialization.py b/tests/testapp/tests/integration/test_serialization.py
similarity index 100%
rename from tests/testapp/tests/test_serialization.py
rename to tests/testapp/tests/integration/test_serialization.py
diff --git a/tests/testapp/tests/test_signals.py b/tests/testapp/tests/integration/test_signals.py
similarity index 100%
rename from tests/testapp/tests/test_signals.py
rename to tests/testapp/tests/integration/test_signals.py
diff --git a/tests/testapp/tests/test_syncing_models.py b/tests/testapp/tests/integration/test_syncing_models.py
similarity index 100%
rename from tests/testapp/tests/test_syncing_models.py
rename to tests/testapp/tests/integration/test_syncing_models.py
diff --git a/tests/testapp/tests/integration/test_syncsession.py b/tests/testapp/tests/integration/test_syncsession.py
new file mode 100644
index 00000000..146fd2b7
--- /dev/null
+++ b/tests/testapp/tests/integration/test_syncsession.py
@@ -0,0 +1,316 @@
+import json
+import contextlib
+import pytest
+import mock
+
+from django.conf import settings
+from django.db import connections
+from django.test.testcases import LiveServerTestCase
+from facility_profile.models import SummaryLog
+from facility_profile.models import InteractionLog
+from facility_profile.models import MyUser
+from requests.exceptions import Timeout
+from test.support import EnvironmentVarGuard
+
+from morango.errors import MorangoError
+from morango.models.certificates import Certificate
+from morango.models.certificates import Filter
+from morango.models.certificates import Key
+from morango.models.certificates import ScopeDefinition
+from morango.models.core import Buffer
+from morango.models.core import InstanceIDModel
+from morango.models.core import TransferSession
+from morango.sync.controller import MorangoProfileController
+
+
+SECOND_TEST_DATABASE = "default2"
+SECOND_SYSTEM_ID = "default2"
+
+
+@contextlib.contextmanager
+def second_environment():
+    with EnvironmentVarGuard() as env:
+        env["MORANGO_TEST_DATABASE"] = SECOND_TEST_DATABASE
+        env["MORANGO_SYSTEM_ID"] = SECOND_SYSTEM_ID
+        instance2, _ = InstanceIDModel.get_or_create_current_instance(clear_cache=True)
+        yield
+    instance1, _ = InstanceIDModel.get_or_create_current_instance(clear_cache=True)
+    assert instance1.id != instance2.id
+
+
+@pytest.mark.skipif(
+    getattr(settings, "MORANGO_TEST_POSTGRESQL", False), reason="Not supported"
+)
+class PushPullClientTestCase(LiveServerTestCase):
+    multi_db = True
+    profile = "facilitydata"
+
+    def setUp(self):
+        super(PushPullClientTestCase, self).setUp()
+        self.profile_controller = MorangoProfileController(self.profile)
+        self.conn = self.profile_controller.create_network_connection(
+            self.live_server_url
+        )
+        self.conn.chunk_size = 3
+
+        self.remote_user = self._setUpServer()
+        self.filter = Filter("{}:user".format(self.remote_user.id))
+        self.client = self._setUpClient(self.remote_user.id)
+        self.session = self.client.sync_session
+        self.last_session_activity = self.session.last_activity_timestamp
+        self.last_transfer_activity = None
+
+        self.local_user = MyUser.objects.create(
+            id=self.remote_user.id, username="bob", is_superuser=True
+        )
+
+    def _setUpCertScopes(self):
+        root_scope = ScopeDefinition.objects.create(
+            id="root_scope",
+            profile=self.profile,
+            version=1,
+            primary_scope_param_key="user",
+            description="Root cert for ${user}.",
+            read_filter_template="",
+            write_filter_template="",
+            read_write_filter_template="${user}",
+        )
+
+        subset_scope = ScopeDefinition.objects.create(
+            id="subset_scope",
+            profile=self.profile,
+            version=1,
+            primary_scope_param_key="",
+            description="Subset cert under ${user} for :${sub}.",
+            read_filter_template="${user}",
+            write_filter_template="${user}:${sub}",
+            read_write_filter_template="",
+        )
+        return root_scope, subset_scope
+
+    def _setUpServer(self):
+        with second_environment():
+            root_scope, subset_scope = self._setUpCertScopes()
+            root_cert = Certificate.generate_root_certificate(root_scope.id)
+
+            remote_user = MyUser.objects.create(
+                id=root_cert.id, username="bob", is_superuser=True
+            )
+            remote_user.set_password("password")
+            remote_user.save()
+
+            subset_cert = Certificate(
+                parent=root_cert,
+                profile=self.profile,
+                scope_definition=subset_scope,
+                scope_version=subset_scope.version,
+                scope_params=json.dumps({"user": remote_user.id, "sub": "user"}),
+                private_key=Key(),
+            )
+            root_cert.sign_certificate(subset_cert)
+            subset_cert.save()
+        return remote_user
+
+    def _setUpClient(self, primary_partition):
+        root_scope, subset_scope = self._setUpCertScopes()
+
+        server_certs = self.conn.get_remote_certificates(
+            primary_partition, root_scope.id
+        )
+        server_cert = server_certs[0]
+        client_cert = self.conn.certificate_signing_request(
+            server_cert,
+            subset_scope.id,
+            {"user": primary_partition, "sub": "user"},
+            userargs="bob",
+            password="password",
+        )
+        return self.conn.create_sync_session(client_cert, server_cert)
+
+    @classmethod
+    def _create_server_thread(cls, connections_override):
+        # override default to point to second environment database
+        connections_override["default"] = connections["default2"]
+        return super(PushPullClientTestCase, cls)._create_server_thread(
+            connections_override
+        )
+
+    def assertLastActivityUpdate(self, transfer_session=None):
+        """A signal callable that asserts `last_activity_timestamp`s are updated"""
+        if self.last_transfer_activity is not None:
+            self.assertLess(
+                self.last_transfer_activity, transfer_session.last_activity_timestamp
+            )
+            self.assertLess(
+                self.last_session_activity,
+                transfer_session.sync_session.last_activity_timestamp,
+            )
+        self.last_transfer_activity = transfer_session.last_activity_timestamp
+        self.last_session_activity = (
+            transfer_session.sync_session.last_activity_timestamp
+        )
+
+    def test_push(self):
+        for _ in range(5):
+            SummaryLog.objects.create(user=self.local_user)
+            InteractionLog.objects.create(user=self.local_user)
+
+        with second_environment():
+            self.assertEqual(
+                0, SummaryLog.objects.filter(user=self.remote_user).count()
+            )
+            self.assertEqual(
+                0, InteractionLog.objects.filter(user=self.remote_user).count()
+            )
+
+        client = self.client.get_push_client()
+        client.signals.queuing.completed.connect(self.assertLastActivityUpdate)
+        client.signals.transferring.in_progress.connect(self.assertLastActivityUpdate)
+        client.signals.dequeuing.completed.connect(self.assertLastActivityUpdate)
+
+        self.assertEqual(0, TransferSession.objects.filter(active=True).count())
+        client.initialize(self.filter)
+        self.assertEqual(1, TransferSession.objects.filter(active=True).count())
+        transfer_session = client.local_context.transfer_session
+        self.assertNotEqual(0, transfer_session.records_total)
+        self.assertEqual(0, transfer_session.records_transferred)
+        self.assertLessEqual(
+            1, Buffer.objects.filter(transfer_session=transfer_session).count()
+        )
+        client.run()
+        self.assertNotEqual(0, transfer_session.records_transferred)
+        client.finalize()
+        self.assertEqual(
+            0, Buffer.objects.filter(transfer_session=transfer_session).count()
+        )
+        self.assertEqual(0, TransferSession.objects.filter(active=True).count())
+
+        with second_environment():
+            self.assertEqual(
+                5, SummaryLog.objects.filter(user=self.remote_user).count()
+            )
+            self.assertEqual(
+                5, InteractionLog.objects.filter(user=self.remote_user).count()
+            )
+
+    def test_pull(self):
+        with second_environment():
+            for _ in range(5):
+                SummaryLog.objects.create(user=self.remote_user)
+                InteractionLog.objects.create(user=self.remote_user)
+
+        self.assertEqual(0, SummaryLog.objects.filter(user=self.local_user).count())
+        self.assertEqual(0, InteractionLog.objects.filter(user=self.local_user).count())
+
+        client = self.client.get_pull_client()
+        client.signals.queuing.completed.connect(self.assertLastActivityUpdate)
+        client.signals.transferring.in_progress.connect(self.assertLastActivityUpdate)
+        client.signals.dequeuing.completed.connect(self.assertLastActivityUpdate)
+
+        self.assertEqual(0, TransferSession.objects.filter(active=True).count())
+        client.initialize(self.filter)
+        self.assertEqual(1, TransferSession.objects.filter(active=True).count())
+        transfer_session = client.local_context.transfer_session
+        self.assertNotEqual(0, transfer_session.records_total)
+        self.assertEqual(0, transfer_session.records_transferred)
+        client.run()
+        self.assertNotEqual(0, transfer_session.records_transferred)
+        self.assertLessEqual(
+            1, Buffer.objects.filter(transfer_session=transfer_session).count()
+        )
+        client.finalize()
+        self.assertEqual(
+            0, Buffer.objects.filter(transfer_session=transfer_session).count()
+        )
+        self.assertEqual(0, TransferSession.objects.filter(active=True).count())
+
+        self.assertEqual(5, SummaryLog.objects.filter(user=self.local_user).count())
+        self.assertEqual(5, InteractionLog.objects.filter(user=self.local_user).count())
+
+    def test_full_flow_and_repeat(self):
+        with second_environment():
+            for _ in range(5):
+                SummaryLog.objects.create(user=self.remote_user)
+                InteractionLog.objects.create(user=self.remote_user)
+
+        self.assertEqual(0, SummaryLog.objects.filter(user=self.local_user).count())
+        self.assertEqual(0, InteractionLog.objects.filter(user=self.local_user).count())
+
+        # first pull
+        pull_client = self.client.get_pull_client()
+        pull_client.initialize(self.filter)
+        transfer_session = pull_client.local_context.transfer_session
+        self.assertNotEqual(0, transfer_session.records_total)
+        self.assertEqual(0, transfer_session.records_transferred)
+        pull_client.run()
+        self.assertNotEqual(0, transfer_session.records_transferred)
+        pull_client.finalize()
+
+        # sanity check pull worked
+        self.assertEqual(5, SummaryLog.objects.filter(user=self.local_user).count())
+        self.assertEqual(5, InteractionLog.objects.filter(user=self.local_user).count())
+
+        # now do a push after pull, but nothing to actually transfer
+        push_client = self.client.get_push_client()
+        push_client.initialize(self.filter)
+        transfer_session = push_client.local_context.transfer_session
+        self.assertEqual(0, transfer_session.records_total)
+        self.assertEqual(0, transfer_session.records_transferred)
+        push_client.run()
+        self.assertEqual(0, transfer_session.records_transferred)
+        push_client.finalize()
+
+        # second pass for pull, only do initialize to make sure nothing gets queued for sync
+        second_pull_client = self.client.get_pull_client()
+        second_pull_client.initialize(self.filter)
+        transfer_session = second_pull_client.local_context.transfer_session
+        self.assertEqual(0, transfer_session.records_total)
+
+    def test_resume(self):
+        # create data
+        for _ in range(5):
+            SummaryLog.objects.create(user=self.local_user)
+            InteractionLog.objects.create(user=self.local_user)
+
+        with second_environment():
+            self.assertEqual(0, SummaryLog.objects.filter(user=self.remote_user).count())
+            self.assertEqual(0, InteractionLog.objects.filter(user=self.remote_user).count())
+
+        # use client to start a sync
+        client = self.client.get_push_client()
+        self.assertEqual(0, TransferSession.objects.filter(active=True).count())
+        client.initialize(self.filter)
+        self.assertEqual(1, TransferSession.objects.filter(active=True).count())
+        transfer_session = client.local_context.transfer_session
+        self.assertNotEqual(0, transfer_session.records_total)
+        self.assertEqual(0, transfer_session.records_transferred)
+        self.assertLessEqual(1, Buffer.objects.filter(transfer_session=transfer_session).count())
+
+        # simulate timeout
+        with mock.patch("morango.sync.operations.NetworkOperation.put_buffers") as mock_put_buffers:
+            mock_put_buffers.side_effect = Timeout("Network disconnected")
+            with self.assertRaises(MorangoError):
+                client.run()
+
+        self.assertEqual(0, transfer_session.records_transferred)
+
+        # get resume client and retry
+        resume_client = self.conn.resume_sync_session(client.sync_session.id).get_push_client()
+        self.assertEqual(1, TransferSession.objects.filter(active=True).count())
+        resume_client.initialize(self.filter)
+        self.assertEqual(1, TransferSession.objects.filter(active=True).count())
+        transfer_session = resume_client.local_context.transfer_session
+        self.assertNotEqual(0, transfer_session.records_total)
+        self.assertEqual(0, transfer_session.records_transferred)
+        self.assertLessEqual(1, Buffer.objects.filter(transfer_session=transfer_session).count())
+        self.assertEqual(0, transfer_session.records_transferred)
+        resume_client.run()
+        self.assertNotEqual(0, transfer_session.records_transferred)
+
+        resume_client.finalize()
+        self.assertEqual(0, Buffer.objects.filter(transfer_session=transfer_session).count())
+        self.assertEqual(0, TransferSession.objects.filter(active=True).count())
+
+        with second_environment():
+            self.assertEqual(5, SummaryLog.objects.filter(user=self.remote_user).count())
+            self.assertEqual(5, InteractionLog.objects.filter(user=self.remote_user).count())
diff --git a/tests/testapp/tests/models/__init__.py b/tests/testapp/tests/models/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/testapp/tests/test_certificates.py b/tests/testapp/tests/models/test_certificates.py
similarity index 100%
rename from tests/testapp/tests/test_certificates.py
rename to tests/testapp/tests/models/test_certificates.py
diff --git a/tests/testapp/tests/models/test_core.py b/tests/testapp/tests/models/test_core.py
new file mode 100644
index 00000000..b2fc5e07
--- /dev/null
+++ b/tests/testapp/tests/models/test_core.py
@@ -0,0 +1,242 @@
+import factory
+import uuid
+from django.test import TestCase
+from django.utils import timezone
+from django.utils.six import iteritems
+
+from facility_profile.models import MyUser
+
+from morango.constants import transfer_stages
+from morango.constants import transfer_statuses
+from morango.sync.controller import MorangoProfileController
+from morango.models.certificates import Filter
+from morango.models.core import DatabaseMaxCounter
+from morango.models.core import TransferSession
+from morango.models.core import SyncSession
+from morango.models.core import Store
+
+
+class DatabaseMaxCounterFactory(factory.DjangoModelFactory):
+    class Meta:
+        model = DatabaseMaxCounter
+
+
+class FilterMaxCounterTestCase(TestCase):
+    def setUp(self):
+        self.instance_a = "a" * 32
+        self.prefix_a = "AAA"
+        self.user_prefix_a = "AAA:user_id:joe"
+
+        self.instance_b = "b" * 32
+        self.prefix_b = "BBB"
+        self.user_prefix_b = "BBB:user_id:rick"
+        self.user2_prefix_b = "BBB:user_id:emily"
+
+        # instance A dmc
+        DatabaseMaxCounterFactory(
+            instance_id=self.instance_a, partition=self.prefix_a, counter=15
+        )
+        DatabaseMaxCounterFactory(
+            instance_id=self.instance_a, partition=self.user_prefix_a, counter=20
+        )
+        DatabaseMaxCounterFactory(
+            instance_id=self.instance_a, partition=self.user2_prefix_b, counter=17
+        )
+
+        # instance B dmc
+        DatabaseMaxCounterFactory(
+            instance_id=self.instance_b, partition=self.user_prefix_a, counter=10
+        )
+        DatabaseMaxCounterFactory(
+            instance_id=self.instance_b, partition=self.prefix_b, counter=12
+        )
+        DatabaseMaxCounterFactory(
+            instance_id=self.instance_b, partition=self.user_prefix_b, counter=5
+        )
+        DatabaseMaxCounterFactory(
+            instance_id=self.instance_b, partition=self.user2_prefix_b, counter=2
+        )
+
+    def test_filter_not_in_dmc(self):
+        fmcs = DatabaseMaxCounter.calculate_filter_max_counters(Filter("ZZZ"))
+        self.assertEqual(fmcs, {})
+
+    def test_instances_for_one_partition_but_not_other(self):
+        fmcs = DatabaseMaxCounter.calculate_filter_max_counters(
+            Filter(self.user_prefix_a + "\n" + self.user_prefix_b)
+        )
+        self.assertEqual(fmcs[self.instance_b], 10)
+
+    def test_insufficient_instances_for_all_partitions(self):
+        user_with_prefix = self.prefix_b + "user_id:richard"
+        fmcs = DatabaseMaxCounter.calculate_filter_max_counters(
+            Filter(self.prefix_a + "\n" + user_with_prefix)
+        )
+        self.assertFalse(fmcs)
+
+    def test_single_partition_with_all_instances(self):
+        fmcs = DatabaseMaxCounter.calculate_filter_max_counters(
+            Filter(self.user_prefix_a)
+        )
+        self.assertEqual(fmcs[self.instance_a], 20)
+        self.assertEqual(fmcs[self.instance_b], 10)
+
+    def test_all_partitions_have_all_instances(self):
+        fmcs = DatabaseMaxCounter.calculate_filter_max_counters(
+            Filter(self.user_prefix_a + "\n" + self.user2_prefix_b)
+        )
+        self.assertEqual(fmcs[self.instance_a], 17)
+        self.assertEqual(fmcs[self.instance_b], 10)
+
+
+class DatabaseMaxCounterUpdateCalculation(TestCase):
+    def setUp(self):
+        self.filter = "filter"
+
+    def test_update_all_fsics(self):
+        client_fsic = {"a" * 32: 2, "b" * 32: 2, "c" * 32: 2}
+        server_fsic = {"a" * 32: 1, "b" * 32: 1, "c" * 32: 1}
+        self.assertFalse(DatabaseMaxCounter.objects.filter(counter=2).exists())
+        for instance_id, counter in iteritems(server_fsic):
+            DatabaseMaxCounter.objects.create(
+                instance_id=instance_id, counter=counter, partition=self.filter
+            )
+        DatabaseMaxCounter.update_fsics(client_fsic, Filter(self.filter))
+        self.assertTrue(DatabaseMaxCounter.objects.filter(counter=2).exists())
+        self.assertFalse(DatabaseMaxCounter.objects.filter(counter=1).exists())
+
+    def test_update_some_fsics(self):
+        client_fsic = {"a" * 32: 1, "e" * 32: 2, "c" * 32: 1}
+        server_fsic = {"a" * 32: 2, "b" * 32: 1, "c" * 32: 2}
+        self.assertFalse(
+            DatabaseMaxCounter.objects.filter(instance_id="e" * 32).exists()
+        )
+        for instance_id, counter in iteritems(server_fsic):
+            DatabaseMaxCounter.objects.create(
+                instance_id=instance_id, counter=counter, partition=self.filter
+            )
+        DatabaseMaxCounter.update_fsics(client_fsic, Filter(self.filter))
+        self.assertTrue(
+            DatabaseMaxCounter.objects.filter(instance_id="e" * 32).exists()
+        )
+
+    def test_no_fsics_get_updated(self):
+        client_fsic = {"a" * 32: 1, "b" * 32: 1, "c" * 32: 1}
+        server_fsic = {"a" * 32: 2, "b" * 32: 2, "c" * 32: 2}
+        self.assertFalse(DatabaseMaxCounter.objects.filter(counter=1).exists())
+        for instance_id, counter in iteritems(server_fsic):
+            DatabaseMaxCounter.objects.create(
+                instance_id=instance_id, counter=counter, partition=self.filter
+            )
+        DatabaseMaxCounter.update_fsics(client_fsic, Filter(self.filter))
+        self.assertFalse(DatabaseMaxCounter.objects.filter(counter=1).exists())
+
+
+class TransferSessionTestCase(TestCase):
+    def setUp(self):
+        super(TransferSessionTestCase, self).setUp()
+        self.sync_session = SyncSession.objects.create(
+            id=uuid.uuid4().hex,
+            profile="facilitydata",
+            last_activity_timestamp=timezone.now(),
+        )
+        self.instance = TransferSession.objects.create(
+            id=uuid.uuid4().hex,
+            sync_session=self.sync_session,
+            push=True,
+            last_activity_timestamp=timezone.now(),
+        )
+
+    def test_update_state(self):
+        self.assertEqual("", self.instance.transfer_stage)
+        self.assertEqual("", self.instance.transfer_stage_status)
+        previous_activity = self.instance.last_activity_timestamp
+        previous_sync_activity = self.sync_session.last_activity_timestamp
+
+        self.instance.update_state(
+            stage=transfer_stages.QUEUING, stage_status=transfer_statuses.PENDING
+        )
+
+        self.assertEqual(transfer_stages.QUEUING, self.instance.transfer_stage)
+        self.assertEqual(transfer_statuses.PENDING, self.instance.transfer_stage_status)
+        self.assertLess(previous_activity, self.instance.last_activity_timestamp)
+        self.assertLess(
+            previous_sync_activity, self.sync_session.last_activity_timestamp
+        )
+
+    def test_update_state__only_stage(self):
+        self.assertEqual("", self.instance.transfer_stage)
+        self.assertEqual("", self.instance.transfer_stage_status)
+        previous_activity = self.instance.last_activity_timestamp
+        previous_sync_activity = self.sync_session.last_activity_timestamp
+
+        self.instance.update_state(stage=transfer_stages.QUEUING)
+
+        self.assertEqual(transfer_stages.QUEUING, self.instance.transfer_stage)
+        self.assertEqual("", self.instance.transfer_stage_status)
+        self.assertLess(previous_activity, self.instance.last_activity_timestamp)
+        self.assertLess(
+            previous_sync_activity, self.sync_session.last_activity_timestamp
+        )
+
+    def test_update_state__only_status(self):
+        self.assertEqual("", self.instance.transfer_stage)
+        self.assertEqual("", self.instance.transfer_stage_status)
+        previous_activity = self.instance.last_activity_timestamp
+        previous_sync_activity = self.sync_session.last_activity_timestamp
+
+        self.instance.update_state(stage_status=transfer_statuses.PENDING)
+
+        self.assertEqual("", self.instance.transfer_stage)
+        self.assertEqual(transfer_statuses.PENDING, self.instance.transfer_stage_status)
+        self.assertLess(previous_activity, self.instance.last_activity_timestamp)
+        self.assertLess(
+            previous_sync_activity, self.sync_session.last_activity_timestamp
+        )
+
+    def test_update_state__none(self):
+        self.assertEqual("", self.instance.transfer_stage)
+        self.assertEqual("", self.instance.transfer_stage_status)
+        previous_activity = self.instance.last_activity_timestamp
+        previous_sync_activity = self.sync_session.last_activity_timestamp
+
+        self.instance.update_state()
+
+        self.assertEqual("", self.instance.transfer_stage)
+        self.assertEqual("", self.instance.transfer_stage_status)
+        self.assertEqual(previous_activity, self.instance.last_activity_timestamp)
+        self.assertEqual(
+            previous_sync_activity, self.sync_session.last_activity_timestamp
+        )
+
+
+class TransferSessionAndStoreTestCase(TestCase):
+    def setUp(self):
+        super(TransferSessionAndStoreTestCase, self).setUp()
+        self.sync_session = SyncSession.objects.create(
+            id=uuid.uuid4().hex,
+            profile="facilitydata",
+            last_activity_timestamp=timezone.now(),
+        )
+        self.instance = TransferSession.objects.create(
+            id=uuid.uuid4().hex,
+            sync_session=self.sync_session,
+            push=True,
+            last_activity_timestamp=timezone.now(),
+        )
+        self.controller = MorangoProfileController(MyUser.morango_profile)
+        self.user = MyUser(username="tester")
+        self.user.save()
+        self.controller.serialize_into_store()
+        stores = Store.objects.filter(model_name=MyUser.morango_model_name)
+        self.assertEqual(1, stores.count())
+        stores.update(last_transfer_session_id=self.instance.id)
+
+    def test_get_touched_record_ids_for_model__instance(self):
+        self.assertEqual([self.user.id], list(self.instance.get_touched_record_ids_for_model(self.user)))
+
+    def test_get_touched_record_ids_for_model__class(self):
+        self.assertEqual([self.user.id], list(self.instance.get_touched_record_ids_for_model(MyUser)))
+
+    def test_get_touched_record_ids_for_model__string(self):
+        self.assertEqual([self.user.id], list(self.instance.get_touched_record_ids_for_model(MyUser.morango_model_name)))
diff --git a/tests/testapp/tests/sync/__init__.py b/tests/testapp/tests/sync/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/testapp/tests/sync/test_context.py b/tests/testapp/tests/sync/test_context.py
new file mode 100644
index 00000000..e5882a67
--- /dev/null
+++ b/tests/testapp/tests/sync/test_context.py
@@ -0,0 +1,251 @@
+import mock
+import pickle
+from django.test import SimpleTestCase
+from django.test import TestCase
+
+from ..helpers import create_dummy_store_data
+from ..helpers import TestSessionContext
+from morango.constants import capabilities
+from morango.constants import transfer_stages
+from morango.constants import transfer_statuses
+from morango.errors import MorangoContextUpdateError
+from morango.models.certificates import Filter
+from morango.models.core import SyncSession
+from morango.models.core import TransferSession
+from morango.sync.context import SessionContext
+from morango.sync.context import LocalSessionContext
+from morango.sync.context import NetworkSessionContext
+
+
+class SessionContextTestCase(SimpleTestCase):
+    def test_init__nothing(self):
+        context = TestSessionContext()
+        context.update_state(
+            stage=transfer_stages.TRANSFERRING, stage_status=transfer_statuses.PENDING
+        )
+        self.assertEqual(transfer_stages.TRANSFERRING, context.stage)
+        self.assertEqual(transfer_statuses.PENDING, context.stage_status)
+
+    def test_init__capabilities__no_match(self):
+        context = TestSessionContext(capabilities=["testing"])
+        self.assertNotIn("testing", context.capabilities)
+
+    @mock.patch("morango.sync.context.CAPABILITIES", {"testing"})
+    def test_init__capabilities(self):
+        context = TestSessionContext(capabilities=["testing"])
+        self.assertIn("testing", context.capabilities)
+
+    def test_init__no_transfer_session(self):
+        sync_session = mock.Mock(spec=SyncSession)
+        sync_filter = mock.Mock(spec=Filter)
+
+        context = TestSessionContext(sync_session=sync_session, sync_filter=sync_filter, is_push=True)
+        self.assertEqual(sync_session, context.sync_session)
+        self.assertEqual(sync_filter, context.filter)
+        self.assertTrue(context.is_push)
+        self.assertFalse(context.is_pull)
+
+    def test_init__with_transfer_session(self):
+        sync_session = mock.Mock(spec=SyncSession)
+        sync_filter = Filter("before_filter")
+        transfer_session = mock.Mock(
+            spec=TransferSession,
+            sync_session=sync_session,
+            push=False,
+            filter="after_filter",
+            transfer_stage=transfer_stages.TRANSFERRING,
+            transfer_stage_status=transfer_statuses.STARTED,
+        )
+        transfer_session.get_filter.return_value = Filter(transfer_session.filter)
+
+        context = TestSessionContext(transfer_session=transfer_session, sync_filter=sync_filter)
+        self.assertEqual(transfer_session, context.transfer_session)
+        self.assertEqual(sync_session, context.sync_session)
+        self.assertEqual("after_filter", str(context.filter))
+        self.assertFalse(context.is_push)
+        self.assertTrue(context.is_pull)
+
+    def test_init__with_transfer_session__no_filter(self):
+        sync_session = mock.Mock(spec=SyncSession)
+        sync_filter = Filter("before_filter")
+        transfer_session = mock.Mock(
+            spec=TransferSession,
+            sync_session=sync_session,
+            push=False,
+            filter=None,
+            transfer_stage=transfer_stages.TRANSFERRING,
+            transfer_stage_status=transfer_statuses.STARTED,
+        )
+        self.assertIsNone(transfer_session.filter)
+
+        context = TestSessionContext(transfer_session=transfer_session, sync_filter=sync_filter)
+        self.assertEqual(transfer_session, context.transfer_session)
+        self.assertEqual(sync_session, context.sync_session)
+        self.assertEqual("before_filter", str(context.filter))
+        self.assertFalse(context.is_push)
+        self.assertTrue(context.is_pull)
+
+    def test_update__no_overwrite__transfer_session(self):
+        sync_session = mock.Mock(spec=SyncSession)
+        sync_filter = mock.Mock(spec=Filter)
+        transfer_session = mock.Mock(
+            spec=TransferSession,
+            sync_session=sync_session,
+            push=False,
+            transfer_stage=transfer_stages.TRANSFERRING,
+            transfer_stage_status=transfer_statuses.STARTED,
+        )
+        transfer_session.get_filter.return_value = sync_filter
+
+        context = SessionContext(transfer_session=transfer_session)
+        with self.assertRaises(MorangoContextUpdateError):
+            context.update(transfer_session=transfer_session)
+
+    def test_update__no_overwrite__filter(self):
+        sync_filter = mock.Mock(spec=Filter)
+        context = SessionContext(sync_filter=sync_filter)
+
+        with self.assertRaises(MorangoContextUpdateError):
+            context.update(sync_filter=sync_filter)
+
+    def test_update__no_overwrite__push(self):
+        context = SessionContext(is_push=True)
+
+        with self.assertRaises(MorangoContextUpdateError):
+            context.update(is_push=False)
+
+    @mock.patch("morango.sync.context.CAPABILITIES", {"testing"})
+    def test_update__basic(self):
+        context = TestSessionContext()
+
+        sync_filter = mock.Mock(spec=Filter)
+        context.update(
+            sync_filter=sync_filter,
+            is_push=True,
+            stage=transfer_stages.TRANSFERRING,
+            stage_status=transfer_statuses.STARTED,
+            capabilities={"testing"}
+        )
+        self.assertEqual(sync_filter, context.filter)
+        self.assertTrue(context.is_push)
+        self.assertFalse(context.is_pull)
+        self.assertEqual(transfer_stages.TRANSFERRING, context.stage)
+        self.assertEqual(transfer_statuses.STARTED, context.stage_status)
+
+    @mock.patch("morango.sync.context.CAPABILITIES", {"testing"})
+    def test_update__with_transfer_session(self):
+        context = TestSessionContext(
+            capabilities={"testing"}
+        )
+
+        sync_session = mock.Mock(spec=SyncSession)
+        sync_filter = mock.Mock(spec=Filter)
+        transfer_session = mock.Mock(
+            spec=TransferSession,
+            sync_session=sync_session,
+            push=True,
+        )
+        transfer_session.get_filter.return_value = sync_filter
+        context.update(transfer_session=transfer_session)
+        self.assertEqual(sync_filter, context.filter)
+        self.assertTrue(context.is_push)
+        self.assertFalse(context.is_pull)
+
+
+class LocalSessionContextTestCase(SimpleTestCase):
+    @mock.patch("morango.sync.context.CAPABILITIES", {"testing"})
+    @mock.patch("morango.sync.context.parse_capabilities_from_server_request")
+    def test_init(self, mock_parse_capabilities):
+        mock_parse_capabilities.return_value = {"testing"}
+        request = mock.Mock(spec="django.http.request.HttpRequest")
+        context = LocalSessionContext(request=request)
+
+        self.assertEqual(request, context.request)
+        self.assertTrue(context.is_server)
+        self.assertIn("testing", context.capabilities)
+
+    def test_update(self):
+        transfer_session = mock.Mock(
+            spec=TransferSession,
+            transfer_stage=transfer_stages.TRANSFERRING,
+            transfer_stage_status=transfer_statuses.STARTED,
+        )
+        context = LocalSessionContext()
+        self.assertNotEqual(transfer_stages.TRANSFERRING, context.stage)
+        self.assertNotEqual(transfer_statuses.STARTED, context.stage_status)
+
+        context.update(transfer_session=transfer_session)
+        self.assertEqual(transfer_stages.TRANSFERRING, context.stage)
+        self.assertEqual(transfer_statuses.STARTED, context.stage_status)
+
+        context.update(stage=transfer_stages.CLEANUP, stage_status=transfer_statuses.PENDING)
+        transfer_session.refresh_from_db.assert_called()
+        transfer_session.update_state.assert_called_with(
+            stage=transfer_stages.CLEANUP, stage_status=transfer_statuses.PENDING
+        )
+
+
+class NetworkSessionContextTestCase(SimpleTestCase):
+    @mock.patch("morango.sync.context.CAPABILITIES", {"testing"})
+    def test_init(self):
+        conn = mock.Mock(
+            spec="morango.sync.syncsession.NetworkSyncConnection",
+            server_info=mock.Mock(),
+        )
+        conn.server_info.get.return_value = {"testing"}
+        context = NetworkSessionContext(conn)
+        self.assertEqual(conn, context.connection)
+        self.assertIn("testing", context.capabilities)
+
+
+class ContextPicklingTestCase(TestCase):
+    def test_basic(self):
+        data = create_dummy_store_data()
+        transfer_session = data["sc"].current_transfer_session
+        transfer_session.filter = "abc123"
+        transfer_session.save()
+
+        context = TestSessionContext(transfer_session=transfer_session)
+        context.update(error=NotImplementedError("This is a test"))
+        pickled_context = pickle.dumps(context)
+        unpickled_context = pickle.loads(pickled_context)
+        self.assertIsNotNone(context.transfer_session)
+        self.assertEqual(context.filter, unpickled_context.filter)
+        self.assertEqual(context.is_push, unpickled_context.is_push)
+        self.assertEqual(context.stage, unpickled_context.stage)
+        self.assertEqual(context.stage_status, unpickled_context.stage_status)
+        self.assertEqual(context.capabilities, unpickled_context.capabilities)
+        self.assertIsInstance(unpickled_context.error, NotImplementedError)
+        self.assertEqual(str(context.error), str(unpickled_context.error))
+
+    @mock.patch("morango.sync.context.parse_capabilities_from_server_request")
+    def test_local(self, mock_parse_capabilities):
+        request = mock.Mock(spec="django.http.request.HttpRequest")
+        mock_parse_capabilities.return_value = {}
+
+        context = LocalSessionContext(request=request)
+        pickled_context = pickle.dumps(context)
+        unpickled_context = pickle.loads(pickled_context)
+        self.assertEqual(context.is_push, unpickled_context.is_push)
+        self.assertEqual(context.stage, unpickled_context.stage)
+        self.assertEqual(context.stage_status, unpickled_context.stage_status)
+        self.assertEqual(context.capabilities, unpickled_context.capabilities)
+        self.assertEqual(context.is_server, unpickled_context.is_server)
+
+    @mock.patch("morango.sync.context.parse_capabilities_from_server_request")
+    def test_network(self, mock_parse_capabilities):
+        conn = mock.Mock(spec="morango.sync.syncsession.NetworkSyncConnection",
+                         server_info=dict(capabilities={}))
+        mock_parse_capabilities.return_value = {}
+
+        context = NetworkSessionContext(conn)
+        context.update_state(
+            stage=transfer_stages.TRANSFERRING, stage_status=transfer_statuses.PENDING
+        )
+        pickled_context = pickle.dumps(context)
+        unpickled_context = pickle.loads(pickled_context)
+        self.assertEqual(context.is_push, unpickled_context.is_push)
+        self.assertEqual(context.stage, unpickled_context.stage)
+        self.assertEqual(context.stage_status, unpickled_context.stage_status)
+        self.assertEqual(context.capabilities, unpickled_context.capabilities)
+
diff --git a/tests/testapp/tests/test_controller_methods.py b/tests/testapp/tests/sync/test_controller.py
similarity index 81%
rename from tests/testapp/tests/test_controller_methods.py
rename to tests/testapp/tests/sync/test_controller.py
index 4fda8904..9f318ef3 100644
--- a/tests/testapp/tests/test_controller_methods.py
+++ b/tests/testapp/tests/sync/test_controller.py
@@ -1,15 +1,20 @@
 import json
 import uuid
+import contextlib
 
 import factory
 import mock
+from django.test import SimpleTestCase
 from django.test import TestCase
 from facility_profile.models import Facility
 from facility_profile.models import MyUser
 from facility_profile.models import SummaryLog
 from test.support import EnvironmentVarGuard
 
-from .helpers import serialized_facility_factory
+from ..helpers import serialized_facility_factory
+from ..helpers import TestSessionContext
+from morango.constants import transfer_stages
+from morango.constants import transfer_statuses
 from morango.models.certificates import Filter
 from morango.models.core import DeletedModels
 from morango.models.core import HardDeletedModels
@@ -18,6 +23,7 @@
 from morango.models.core import Store
 from morango.sync.controller import _self_referential_fk
 from morango.sync.controller import MorangoProfileController
+from morango.sync.controller import SessionController
 
 
 class FacilityModelFactory(factory.DjangoModelFactory):
@@ -626,4 +632,117 @@ def test_deserialization_of_model_with_missing_foreignkey_referent(self):
 
         new_log.refresh_from_db()
         self.assertTrue(new_log.dirty_bit)
-        self.assertIn("exist", new_log.deserialization_error)
\ No newline at end of file
+        self.assertIn("exist", new_log.deserialization_error)
+
+
+class SessionControllerTestCase(SimpleTestCase):
+    def setUp(self):
+        super(SessionControllerTestCase, self).setUp()
+        self.middleware = [
+            mock.Mock(related_stage=stage)
+            for stage, _ in transfer_stages.CHOICES
+        ]
+        self.context = TestSessionContext()
+        self.controller = SessionController.build(middleware=self.middleware, context=self.context)
+
+    @contextlib.contextmanager
+    def _mock_method(self, method):
+        with mock.patch('morango.sync.controller.SessionController.{}'.format(method)) as invoke:
+            yield invoke
+            invoke.reset_mock()
+
+    def test_proceed_to__passed_stage(self):
+        self.context.update(stage=transfer_stages.CLEANUP)
+        result = self.controller.proceed_to(transfer_stages.TRANSFERRING)
+        self.assertEqual(transfer_statuses.COMPLETED, result)
+
+    def test_proceed_to__in_progress(self):
+        self.context.update(stage=transfer_stages.TRANSFERRING, stage_status=transfer_statuses.STARTED)
+        result = self.controller.proceed_to(transfer_stages.TRANSFERRING)
+        self.assertEqual(transfer_statuses.STARTED, result)
+
+    def test_proceed_to__errored(self):
+        self.context.update(stage=transfer_stages.TRANSFERRING, stage_status=transfer_statuses.ERRORED)
+        result = self.controller.proceed_to(transfer_stages.TRANSFERRING)
+        self.assertEqual(transfer_statuses.ERRORED, result)
+
+    def test_proceed_to__executes_middleware__incrementally(self):
+        self.context.update(stage=transfer_stages.SERIALIZING, stage_status=transfer_statuses.COMPLETED)
+        with self._mock_method('_invoke_middleware') as mock_invoke:
+            mock_invoke.return_value = transfer_statuses.STARTED
+            result = self.controller.proceed_to(transfer_stages.QUEUING)
+            self.assertEqual(transfer_statuses.STARTED, result)
+            self.assertEqual(1, len(mock_invoke.call_args_list))
+            call = mock_invoke.call_args[0]
+            self.assertEqual(self.context, call[0])
+            self.assertEqual(transfer_stages.QUEUING, call[1].related_stage)
+            mock_invoke.reset_mock()
+
+    def test_proceed_to__executes_middleware__all(self):
+        self.context.update(stage=transfer_stages.SERIALIZING, stage_status=transfer_statuses.COMPLETED)
+        with self._mock_method('_invoke_middleware') as invoke:
+            invoke.return_value = transfer_statuses.COMPLETED
+            result = self.controller.proceed_to(transfer_stages.CLEANUP)
+            self.assertEqual(transfer_statuses.COMPLETED, result)
+            self.assertEqual(5, len(invoke.call_args_list))
+
+    def test_proceed_to__resuming_fast_forward(self):
+        self.context.update(stage=transfer_stages.INITIALIZING, stage_status=transfer_statuses.PENDING)
+        expected_stages = (
+            transfer_stages.INITIALIZING,
+            transfer_stages.DESERIALIZING,
+            transfer_stages.CLEANUP,
+        )
+
+        def invoke(context, middleware):
+            self.assertIn(context.stage, expected_stages)
+            if context.stage == transfer_stages.INITIALIZING:
+                context.update(stage=transfer_stages.DESERIALIZING, stage_status=transfer_statuses.PENDING)
+            return transfer_statuses.COMPLETED
+
+        with self._mock_method('_invoke_middleware') as mock_invoke:
+            mock_invoke.side_effect = invoke
+            result = self.controller.proceed_to(transfer_stages.CLEANUP)
+            self.assertEqual(transfer_statuses.COMPLETED, result)
+            self.assertEqual(3, len(mock_invoke.call_args_list))
+
+    def test_proceed_to_and_wait_for(self):
+        with self._mock_method('proceed_to') as proceed_to:
+            proceed_to.side_effect = [
+                transfer_statuses.PENDING,
+                transfer_statuses.PENDING,
+                transfer_statuses.COMPLETED
+            ]
+            result = self.controller.proceed_to_and_wait_for(transfer_stages.CLEANUP, max_interval=0.1)
+            self.assertEqual(result, transfer_statuses.COMPLETED)
+
+    def test_proceed_to_and_wait_for__errored(self):
+        with self._mock_method('proceed_to') as proceed_to:
+            proceed_to.side_effect = [
+                transfer_statuses.PENDING,
+                transfer_statuses.ERRORED
+            ]
+            result = self.controller.proceed_to_and_wait_for(transfer_stages.CLEANUP, max_interval=0.1)
+            self.assertEqual(result, transfer_statuses.ERRORED)
+
+    def test_invoke_middleware(self):
+        context = TestSessionContext()
+        self.controller.context = context
+        handler = mock.Mock()
+        self.controller.signals.connect(handler)
+
+        middleware = self.middleware[0]
+        middleware.return_value = transfer_statuses.STARTED
+
+        with mock.patch.object(TestSessionContext, "update_state", wraps=context.update_state) as m:
+            result = self.controller._invoke_middleware(context, middleware)
+            self.assertEqual(result, transfer_statuses.STARTED)
+
+            context_update_calls = m.call_args_list
+            self.assertEqual(2, len(context_update_calls))
+            self.assertEqual(mock.call(stage=middleware.related_stage, stage_status=transfer_statuses.PENDING), context_update_calls[0])
+            self.assertEqual(mock.call(stage=None, stage_status=transfer_statuses.STARTED), context_update_calls[1])
+
+        self.assertEqual(2, len(handler.call_args_list))
+        self.assertEqual(mock.call(context=context), handler.call_args_list[0])
+        self.assertEqual(mock.call(context=context), handler.call_args_list[1])
diff --git a/tests/testapp/tests/test_syncsession_methods.py b/tests/testapp/tests/sync/test_operations.py
similarity index 57%
rename from tests/testapp/tests/test_syncsession_methods.py
rename to tests/testapp/tests/sync/test_operations.py
index 8e1a103e..fb0ad477 100644
--- a/tests/testapp/tests/test_syncsession_methods.py
+++ b/tests/testapp/tests/sync/test_operations.py
@@ -2,15 +2,18 @@
 import uuid
 
 import factory
-from django.conf import settings
 from django.db import connection
 from django.test import TestCase
+from django.test import override_settings
 from django.utils import timezone
 from facility_profile.models import Facility
 import mock
+import pytest
 
-from .helpers import create_buffer_and_store_dummy_data
-from .helpers import create_dummy_store_data
+from ..helpers import create_buffer_and_store_dummy_data
+from ..helpers import create_dummy_store_data
+from morango.constants import transfer_statuses
+from morango.errors import MorangoLimitExceeded
 from morango.models.core import Buffer
 from morango.models.core import DatabaseIDModel
 from morango.models.core import InstanceIDModel
@@ -20,13 +23,19 @@
 from morango.models.core import SyncSession
 from morango.models.core import TransferSession
 from morango.sync.backends.utils import load_backend
+from morango.sync.context import LocalSessionContext
 from morango.sync.controller import MorangoProfileController
+from morango.sync.controller import SessionController
 from morango.sync.operations import _dequeue_into_store
 from morango.sync.operations import _queue_into_buffer
-from morango.sync.syncsession import BaseSyncClient
-from morango.sync.syncsession import SyncClientSignals
-from morango.sync.syncsession import SyncSignal
-from morango.sync.syncsession import SyncSignalGroup
+from morango.sync.operations import CleanupOperation
+from morango.sync.operations import ReceiverDequeueOperation
+from morango.sync.operations import ProducerDequeueOperation
+from morango.sync.operations import ReceiverDeserializeOperation
+from morango.sync.operations import InitializeOperation
+from morango.sync.operations import ProducerQueueOperation
+from morango.sync.operations import ReceiverQueueOperation
+from morango.sync.syncsession import TransferClient
 
 DBBackend = load_backend(connection).SQLWrapper()
 
@@ -38,10 +47,21 @@ class Meta:
     name = factory.Sequence(lambda n: "Fac %d" % n)
 
 
+@override_settings(MORANGO_SERIALIZE_BEFORE_QUEUING=False)
 class QueueStoreIntoBufferTestCase(TestCase):
     def setUp(self):
-        settings.MORANGO_SERIALIZE_BEFORE_QUEUING = False
+        super(QueueStoreIntoBufferTestCase, self).setUp()
         self.data = create_dummy_store_data()
+        self.transfer_session = self.data["sc"].current_transfer_session
+        # self.transfer_session.filter = "abc123"
+        self.context = mock.Mock(
+            spec=LocalSessionContext,
+            transfer_session=self.transfer_session,
+            sync_session=self.transfer_session.sync_session,
+            filter=self.transfer_session.get_filter(),
+            is_push=self.transfer_session.push,
+            is_server=False,
+        )
 
     def assertRecordsBuffered(self, records):
         buffer_ids = Buffer.objects.values_list("model_uuid", flat=True)
@@ -61,17 +81,61 @@ def assertRecordsNotBuffered(self, records):
 
     def test_all_fsics(self):
         fsics = {self.data["group1_id"].id: 1, self.data["group2_id"].id: 1}
-        self.data["sc"].current_transfer_session.client_fsic = json.dumps(fsics)
-        _queue_into_buffer(self.data["sc"].current_transfer_session)
+        self.transfer_session.client_fsic = json.dumps(fsics)
+        _queue_into_buffer(self.transfer_session)
+        # ensure all store and buffer records are buffered
+        self.assertRecordsBuffered(self.data["group1_c1"])
+        self.assertRecordsBuffered(self.data["group1_c2"])
+        self.assertRecordsBuffered(self.data["group2_c1"])
+
+    def test_very_many_fsics(self):
+        """
+        Regression test against 'Expression tree is too large (maximum depth 1000)' error with many fsics
+        """
+        fsics = {self.data["group1_id"].id: 1, self.data["group2_id"].id: 1}
+        fsics.update({
+            uuid.uuid4().hex: i
+            for i in range(20000)
+        })
+        self.transfer_session.client_fsic = json.dumps(fsics)
+        _queue_into_buffer(self.transfer_session)
+        # ensure all store and buffer records are buffered
+        self.assertRecordsBuffered(self.data["group1_c1"])
+        self.assertRecordsBuffered(self.data["group1_c2"])
+        self.assertRecordsBuffered(self.data["group2_c1"])
+
+    @pytest.mark.skip("Takes 30+ seconds, manual run only")
+    def test_very_very_many_fsics(self):
+        """
+        Regression test against 'Expression tree is too large (maximum depth 1000)' error with many fsics
+        Maximum supported value: 99,999
+        """
+        fsics = {self.data["group1_id"].id: 1, self.data["group2_id"].id: 1}
+        fsics.update({
+            uuid.uuid4().hex: i
+            for i in range(99999)
+        })
+        self.transfer_session.client_fsic = json.dumps(fsics)
+        _queue_into_buffer(self.transfer_session)
         # ensure all store and buffer records are buffered
         self.assertRecordsBuffered(self.data["group1_c1"])
         self.assertRecordsBuffered(self.data["group1_c2"])
         self.assertRecordsBuffered(self.data["group2_c1"])
 
+    def test_too_many_fsics(self):
+        fsics = {self.data["group1_id"].id: 1, self.data["group2_id"].id: 1}
+        fsics.update({
+            uuid.uuid4().hex: i
+            for i in range(100000)
+        })
+        self.transfer_session.client_fsic = json.dumps(fsics)
+        with self.assertRaises(MorangoLimitExceeded):
+            _queue_into_buffer(self.transfer_session)
+
     def test_fsic_specific_id(self):
         fsics = {self.data["group2_id"].id: 1}
-        self.data["sc"].current_transfer_session.client_fsic = json.dumps(fsics)
-        _queue_into_buffer(self.data["sc"].current_transfer_session)
+        self.transfer_session.client_fsic = json.dumps(fsics)
+        _queue_into_buffer(self.transfer_session)
         # ensure only records modified with 2nd instance id are buffered
         self.assertRecordsNotBuffered(self.data["group1_c1"])
         self.assertRecordsNotBuffered(self.data["group1_c2"])
@@ -80,10 +144,10 @@ def test_fsic_specific_id(self):
     def test_fsic_counters(self):
         counter = InstanceIDModel.objects.get(id=self.data["group1_id"].id).counter
         fsics = {self.data["group1_id"].id: counter - 1}
-        self.data["sc"].current_transfer_session.client_fsic = json.dumps(fsics)
+        self.transfer_session.client_fsic = json.dumps(fsics)
         fsics[self.data["group1_id"].id] = 0
-        self.data["sc"].current_transfer_session.server_fsic = json.dumps(fsics)
-        _queue_into_buffer(self.data["sc"].current_transfer_session)
+        self.transfer_session.server_fsic = json.dumps(fsics)
+        _queue_into_buffer(self.transfer_session)
         # ensure only records with updated 1st instance id are buffered
         self.assertRecordsBuffered(self.data["group1_c1"])
         self.assertRecordsBuffered(self.data["group1_c2"])
@@ -91,9 +155,9 @@ def test_fsic_counters(self):
 
     def test_fsic_counters_too_high(self):
         fsics = {self.data["group1_id"].id: 100, self.data["group2_id"].id: 100}
-        self.data["sc"].current_transfer_session.client_fsic = json.dumps(fsics)
-        self.data["sc"].current_transfer_session.server_fsic = json.dumps(fsics)
-        _queue_into_buffer(self.data["sc"].current_transfer_session)
+        self.transfer_session.client_fsic = json.dumps(fsics)
+        self.transfer_session.server_fsic = json.dumps(fsics)
+        _queue_into_buffer(self.transfer_session)
         # ensure no records are buffered
         self.assertFalse(Buffer.objects.all())
         self.assertFalse(RecordMaxCounterBuffer.objects.all())
@@ -103,9 +167,9 @@ def test_partition_filter_buffering(self):
         filter_prefixes = "{}:user:summary\n{}:user:interaction".format(
             self.data["user3"].id, self.data["user3"].id
         )
-        self.data["sc"].current_transfer_session.filter = filter_prefixes
-        self.data["sc"].current_transfer_session.client_fsic = json.dumps(fsics)
-        _queue_into_buffer(self.data["sc"].current_transfer_session)
+        self.transfer_session.filter = filter_prefixes
+        self.transfer_session.client_fsic = json.dumps(fsics)
+        _queue_into_buffer(self.transfer_session)
         # ensure records with different partition values are buffered
         self.assertRecordsNotBuffered([self.data["user2"]])
         self.assertRecordsBuffered(self.data["user3_sumlogs"])
@@ -114,9 +178,9 @@ def test_partition_filter_buffering(self):
     def test_partition_prefix_buffering(self):
         fsics = {self.data["group2_id"].id: 1}
         filter_prefixes = "{}".format(self.data["user2"].id)
-        self.data["sc"].current_transfer_session.filter = filter_prefixes
-        self.data["sc"].current_transfer_session.client_fsic = json.dumps(fsics)
-        _queue_into_buffer(self.data["sc"].current_transfer_session)
+        self.transfer_session.filter = filter_prefixes
+        self.transfer_session.client_fsic = json.dumps(fsics)
+        _queue_into_buffer(self.transfer_session)
         # ensure only records with user2 partition are buffered
         self.assertRecordsBuffered([self.data["user2"]])
         self.assertRecordsBuffered(self.data["user2_sumlogs"])
@@ -126,9 +190,9 @@ def test_partition_prefix_buffering(self):
     def test_partition_and_fsic_buffering(self):
         filter_prefixes = "{}:user:summary".format(self.data["user1"].id)
         fsics = {self.data["group1_id"].id: 1}
-        self.data["sc"].current_transfer_session.filter = filter_prefixes
-        self.data["sc"].current_transfer_session.client_fsic = json.dumps(fsics)
-        _queue_into_buffer(self.data["sc"].current_transfer_session)
+        self.transfer_session.filter = filter_prefixes
+        self.transfer_session.client_fsic = json.dumps(fsics)
+        _queue_into_buffer(self.transfer_session)
         # ensure records updated with 1st instance id and summarylog partition are buffered
         self.assertRecordsBuffered(self.data["user1_sumlogs"])
         self.assertRecordsNotBuffered(self.data["user2_sumlogs"])
@@ -137,23 +201,89 @@ def test_partition_and_fsic_buffering(self):
     def test_valid_fsic_but_invalid_partition(self):
         filter_prefixes = "{}:user:summary".format(self.data["user1"].id)
         fsics = {self.data["group2_id"].id: 1}
-        self.data["sc"].current_transfer_session.filter = filter_prefixes
-        self.data["sc"].current_transfer_session.client_fsic = json.dumps(fsics)
-        _queue_into_buffer(self.data["sc"].current_transfer_session)
+        self.transfer_session.filter = filter_prefixes
+        self.transfer_session.client_fsic = json.dumps(fsics)
+        _queue_into_buffer(self.transfer_session)
         # ensure that record with valid fsic but invalid partition is not buffered
         self.assertRecordsNotBuffered([self.data["user4"]])
 
+    def test_local_initialize_operation__server(self):
+        self.transfer_session.active = False
+        self.transfer_session.save()
+        self.context.transfer_session = None
+        id = uuid.uuid4().hex
+        client_fsic = '{"abc123": 456}'
+        records_total = 10
+        self.context.request.data.get.side_effect = [
+            id,
+            records_total,
+            client_fsic,
+        ]
+        self.context.filter = [self.transfer_session.get_filter()]
+        operation = InitializeOperation()
+        self.assertEqual(transfer_statuses.COMPLETED, operation.handle(self.context))
+        self.context.update.assert_called_once()
+        transfer_session = self.context.update.call_args_list[0][1].get("transfer_session")
+        self.assertEqual(id, transfer_session.id)
+        self.assertEqual(records_total, transfer_session.records_total)
+        self.assertEqual(client_fsic, transfer_session.client_fsic)
+
+    def test_local_initialize_operation__not_server(self):
+        self.context.transfer_session = None
+        self.context.request = None
+        self.context.is_server = False
+        self.context.filter = [self.transfer_session.get_filter()]
+        operation = InitializeOperation()
+        self.assertEqual(transfer_statuses.COMPLETED, operation.handle(self.context))
+        self.context.update.assert_called_once()
+
+    def test_local_initialize_operation__resume(self):
+        self.context.transfer_session = None
+        operation = InitializeOperation()
+        self.assertEqual(transfer_statuses.COMPLETED, operation.handle(self.context))
+        self.context.update.assert_called_once_with(transfer_session=self.transfer_session)
+
+    def test_local_queue_operation(self):
+        fsics = {self.data["group1_id"].id: 1, self.data["group2_id"].id: 1}
+        self.transfer_session.client_fsic = json.dumps(fsics)
+
+        self.assertEqual(0, self.transfer_session.records_total or 0)
+        operation = ProducerQueueOperation()
+        self.assertEqual(transfer_statuses.COMPLETED, operation.handle(self.context))
+        self.assertNotEqual(0, self.transfer_session.records_total)
+
+        # ensure all store and buffer records are buffered
+        self.assertRecordsBuffered(self.data["group1_c1"])
+        self.assertRecordsBuffered(self.data["group1_c2"])
+        self.assertRecordsBuffered(self.data["group2_c1"])
+
+    @mock.patch("morango.sync.operations._queue_into_buffer")
+    def test_local_queue_operation__noop(self, mock_queue):
+        fsics = {self.data["group1_id"].id: 1, self.data["group2_id"].id: 1}
+        self.transfer_session.client_fsic = json.dumps(fsics)
+
+        # as server, for push, operation should not queue into buffer
+        self.context.is_push = True
+        self.context.is_server = True
+
+        operation = ReceiverQueueOperation()
+        self.assertEqual(transfer_statuses.COMPLETED, operation.handle(self.context))
+        mock_queue.assert_not_called()
+
 
-class BufferIntoStoreTestCase(TestCase):
+@override_settings(MORANGO_DESERIALIZE_AFTER_DEQUEUING=False)
+class DequeueBufferIntoStoreTestCase(TestCase):
     def setUp(self):
-        settings.MORANGO_DESERIALIZE_AFTER_DEQUEUING = False
+        super(DequeueBufferIntoStoreTestCase, self).setUp()
         self.data = {}
         DatabaseIDModel.objects.create()
         (self.current_id, _) = InstanceIDModel.get_or_create_current_instance()
 
         # create controllers for app/store/buffer operations
+        conn = mock.Mock(spec="morango.sync.syncsession.NetworkSyncConnection")
+        conn.server_info = dict(capabilities=[])
         self.data["mc"] = MorangoProfileController("facilitydata")
-        self.data["sc"] = BaseSyncClient(None, "host")
+        self.data["sc"] = TransferClient(conn, "host", SessionController.build())
         session = SyncSession.objects.create(
             id=uuid.uuid4().hex, profile="", last_activity_timestamp=timezone.now()
         )
@@ -163,11 +293,42 @@ def setUp(self):
             push=True,
             last_activity_timestamp=timezone.now(),
         )
+        self.transfer_session = self.data["sc"].current_transfer_session
         self.data.update(
             create_buffer_and_store_dummy_data(
                 self.data["sc"].current_transfer_session.id
             )
         )
+        self.context = mock.Mock(
+            spec=LocalSessionContext,
+            transfer_session=self.transfer_session,
+            sync_session=self.transfer_session.sync_session,
+            is_server=True,
+        )
+
+    def assert_store_records_tagged_with_last_session(self, store_ids):
+        session_id = self.data["sc"].current_transfer_session.id
+        for store_id in store_ids:
+            assert Store.objects.get(id=store_id).last_transfer_session_id == session_id
+
+    def assert_store_records_not_tagged_with_last_session(self, store_ids):
+        session_id = self.data["sc"].current_transfer_session.id
+        for store_id in store_ids:
+            try:
+                assert Store.objects.get(id=store_id).last_transfer_session_id != session_id
+            except Store.DoesNotExist:
+                pass
+
+    def test_dequeuing_sets_last_session(self):
+        store_ids = [self.data[key] for key in ["model2", "model3", "model4", "model5", "model7"]]
+        self.assert_store_records_not_tagged_with_last_session(store_ids)
+        _dequeue_into_store(self.data["sc"].current_transfer_session)
+        # this one is a reverse fast forward, so it doesn't modify the store record and shouldn't be tagged
+        self.assert_store_records_not_tagged_with_last_session([self.data["model1"]])
+        self.assert_store_records_tagged_with_last_session(store_ids)
+        tagged_actual = set(self.data["sc"].current_transfer_session.get_touched_record_ids_for_model("facility"))
+        tagged_expected = set(store_ids)
+        assert tagged_actual == tagged_expected
 
     def test_dequeuing_delete_rmcb_records(self):
         for i in self.data["model1_rmcb_ids"]:
@@ -177,9 +338,7 @@ def test_dequeuing_delete_rmcb_records(self):
                 ).exists()
             )
         with connection.cursor() as cursor:
-            DBBackend._dequeuing_delete_rmcb_records(
-                cursor, self.data["sc"].current_transfer_session.id
-            )
+            DBBackend._dequeuing_delete_rmcb_records(cursor, self.transfer_session.id)
         for i in self.data["model1_rmcb_ids"]:
             self.assertFalse(
                 RecordMaxCounterBuffer.objects.filter(
@@ -198,7 +357,7 @@ def test_dequeuing_delete_buffered_records(self):
         self.assertTrue(Buffer.objects.filter(model_uuid=self.data["model1"]).exists())
         with connection.cursor() as cursor:
             DBBackend._dequeuing_delete_buffered_records(
-                cursor, self.data["sc"].current_transfer_session.id
+                cursor, self.transfer_session.id
             )
         self.assertFalse(Buffer.objects.filter(model_uuid=self.data["model1"]).exists())
         # ensure other records were not deleted
@@ -215,9 +374,7 @@ def test_dequeuing_merge_conflict_rmcb_greater_than_rmc(self):
         self.assertNotEqual(rmc.counter, rmcb.counter)
         self.assertGreaterEqual(rmcb.counter, rmc.counter)
         with connection.cursor() as cursor:
-            DBBackend._dequeuing_merge_conflict_rmcb(
-                cursor, self.data["sc"].current_transfer_session.id
-            )
+            DBBackend._dequeuing_merge_conflict_rmcb(cursor, self.transfer_session.id)
         rmc = RecordMaxCounter.objects.get(
             instance_id=self.data["model2_rmc_ids"][0],
             store_model_id=self.data["model2"],
@@ -238,9 +395,7 @@ def test_dequeuing_merge_conflict_rmcb_less_than_rmc(self):
         self.assertNotEqual(rmc.counter, rmcb.counter)
         self.assertGreaterEqual(rmc.counter, rmcb.counter)
         with connection.cursor() as cursor:
-            DBBackend._dequeuing_merge_conflict_rmcb(
-                cursor, self.data["sc"].current_transfer_session.id
-            )
+            DBBackend._dequeuing_merge_conflict_rmcb(cursor, self.transfer_session.id)
         rmc = RecordMaxCounter.objects.get(
             instance_id=self.data["model5_rmc_ids"][0],
             store_model_id=self.data["model5"],
@@ -259,7 +414,7 @@ def test_dequeuing_merge_conflict_buffer_rmcb_greater_than_rmc(self):
         with connection.cursor() as cursor:
             current_id = InstanceIDModel.get_current_instance_and_increment_counter()
             DBBackend._dequeuing_merge_conflict_buffer(
-                cursor, current_id, self.data["sc"].current_transfer_session.id
+                cursor, current_id, self.transfer_session.id
             )
         store = Store.objects.get(id=self.data["model2"])
         self.assertEqual(store.last_saved_instance, current_id.id)
@@ -274,7 +429,7 @@ def test_dequeuing_merge_conflict_buffer_rmcb_less_rmc(self):
         with connection.cursor() as cursor:
             current_id = InstanceIDModel.get_current_instance_and_increment_counter()
             DBBackend._dequeuing_merge_conflict_buffer(
-                cursor, current_id, self.data["sc"].current_transfer_session.id
+                cursor, current_id, self.transfer_session.id
             )
         store = Store.objects.get(id=self.data["model5"])
         self.assertEqual(store.last_saved_instance, current_id.id)
@@ -288,7 +443,7 @@ def test_dequeuing_merge_conflict_hard_delete(self):
         with connection.cursor() as cursor:
             current_id = InstanceIDModel.get_current_instance_and_increment_counter()
             DBBackend._dequeuing_merge_conflict_buffer(
-                cursor, current_id, self.data["sc"].current_transfer_session.id
+                cursor, current_id, self.transfer_session.id
             )
         store.refresh_from_db()
         self.assertEqual(store.serialized, "")
@@ -301,7 +456,7 @@ def test_dequeuing_update_rmcs_last_saved_by(self):
         with connection.cursor() as cursor:
             current_id = InstanceIDModel.get_current_instance_and_increment_counter()
             DBBackend._dequeuing_update_rmcs_last_saved_by(
-                cursor, current_id, self.data["sc"].current_transfer_session.id
+                cursor, current_id, self.transfer_session.id
             )
         self.assertTrue(
             RecordMaxCounter.objects.filter(instance_id=current_id.id).exists()
@@ -310,9 +465,7 @@ def test_dequeuing_update_rmcs_last_saved_by(self):
     def test_dequeuing_delete_mc_buffer(self):
         self.assertTrue(Buffer.objects.filter(model_uuid=self.data["model2"]).exists())
         with connection.cursor() as cursor:
-            DBBackend._dequeuing_delete_mc_buffer(
-                cursor, self.data["sc"].current_transfer_session.id
-            )
+            DBBackend._dequeuing_delete_mc_buffer(cursor, self.transfer_session.id)
         self.assertFalse(Buffer.objects.filter(model_uuid=self.data["model2"]).exists())
         # ensure other records were not deleted
         self.assertTrue(Buffer.objects.filter(model_uuid=self.data["model3"]).exists())
@@ -325,9 +478,7 @@ def test_dequeuing_delete_mc_rmcb(self):
             ).exists()
         )
         with connection.cursor() as cursor:
-            DBBackend._dequeuing_delete_mc_rmcb(
-                cursor, self.data["sc"].current_transfer_session.id
-            )
+            DBBackend._dequeuing_delete_mc_rmcb(cursor, self.transfer_session.id)
         self.assertFalse(
             RecordMaxCounterBuffer.objects.filter(
                 model_uuid=self.data["model2"],
@@ -355,7 +506,7 @@ def test_dequeuing_insert_remaining_buffer(self):
         self.assertFalse(Store.objects.filter(id=self.data["model4"]).exists())
         with connection.cursor() as cursor:
             DBBackend._dequeuing_insert_remaining_buffer(
-                cursor, self.data["sc"].current_transfer_session.id
+                cursor, self.transfer_session.id
             )
         self.assertEqual(Store.objects.get(id=self.data["model3"]).serialized, "buffer")
         self.assertTrue(Store.objects.filter(id=self.data["model4"]).exists())
@@ -369,11 +520,9 @@ def test_dequeuing_insert_remaining_rmcb(self):
             )
         with connection.cursor() as cursor:
             DBBackend._dequeuing_insert_remaining_buffer(
-                cursor, self.data["sc"].current_transfer_session.id
-            )
-            DBBackend._dequeuing_insert_remaining_rmcb(
-                cursor, self.data["sc"].current_transfer_session.id
+                cursor, self.transfer_session.id
             )
+            DBBackend._dequeuing_insert_remaining_rmcb(cursor, self.transfer_session.id)
         for i in self.data["model4_rmcb_ids"]:
             self.assertTrue(
                 RecordMaxCounter.objects.filter(
@@ -384,37 +533,31 @@ def test_dequeuing_insert_remaining_rmcb(self):
     def test_dequeuing_delete_remaining_rmcb(self):
         self.assertTrue(
             RecordMaxCounterBuffer.objects.filter(
-                transfer_session_id=self.data["sc"].current_transfer_session.id
+                transfer_session_id=self.transfer_session.id
             ).exists()
         )
         with connection.cursor() as cursor:
-            DBBackend._dequeuing_delete_remaining_rmcb(
-                cursor, self.data["sc"].current_transfer_session.id
-            )
+            DBBackend._dequeuing_delete_remaining_rmcb(cursor, self.transfer_session.id)
         self.assertFalse(
             RecordMaxCounterBuffer.objects.filter(
-                transfer_session_id=self.data["sc"].current_transfer_session.id
+                transfer_session_id=self.transfer_session.id
             ).exists()
         )
 
     def test_dequeuing_delete_remaining_buffer(self):
         self.assertTrue(
-            Buffer.objects.filter(
-                transfer_session_id=self.data["sc"].current_transfer_session.id
-            ).exists()
+            Buffer.objects.filter(transfer_session_id=self.transfer_session.id).exists()
         )
         with connection.cursor() as cursor:
             DBBackend._dequeuing_delete_remaining_buffer(
-                cursor, self.data["sc"].current_transfer_session.id
+                cursor, self.transfer_session.id
             )
         self.assertFalse(
-            Buffer.objects.filter(
-                transfer_session_id=self.data["sc"].current_transfer_session.id
-            ).exists()
+            Buffer.objects.filter(transfer_session_id=self.transfer_session.id).exists()
         )
 
     def test_dequeue_into_store(self):
-        _dequeue_into_store(self.data["sc"].current_transfer_session)
+        _dequeue_into_store(self.transfer_session)
         # ensure a record with different transfer session id is not affected
         self.assertTrue(
             Buffer.objects.filter(transfer_session_id=self.data["tfs_id"]).exists()
@@ -488,100 +631,61 @@ def test_dequeue_into_store(self):
 
         # ensure all buffer and rmcb records were deleted for this transfer session id
         self.assertFalse(
-            Buffer.objects.filter(
-                transfer_session_id=self.data["sc"].current_transfer_session.id
-            ).exists()
+            Buffer.objects.filter(transfer_session_id=self.transfer_session.id).exists()
         )
         self.assertFalse(
             RecordMaxCounterBuffer.objects.filter(
-                transfer_session_id=self.data["sc"].current_transfer_session.id
+                transfer_session_id=self.transfer_session.id
             ).exists()
         )
 
-
-class SyncSignalTestCase(TestCase):
-    def test_defaults(self):
-        signaler = SyncSignal(this_is_a_default=True)
-        handler = mock.Mock()
-        signaler.connect(handler)
-        signaler.fire()
-
-        handler.assert_called_once_with(this_is_a_default=True)
-
-    def test_fire_with_kwargs(self):
-        signaler = SyncSignal(my_key="abc")
-        handler = mock.Mock()
-        signaler.connect(handler)
-        signaler.fire(my_key="123", not_default=True)
-
-        handler.assert_called_once_with(my_key="123", not_default=True)
-
-
-class SyncSignalGroupTestCase(TestCase):
-    def test_started_defaults(self):
-        signaler = SyncSignalGroup(this_is_a_default=True)
-        handler = mock.Mock()
-        signaler.connect(handler)
-
-        signaler.fire()
-        handler.assert_called_with(this_is_a_default=True)
-
-        signaler.started.fire(this_is_a_default=False)
-        handler.assert_called_with(this_is_a_default=False)
-
-    def test_in_progress_defaults(self):
-        signaler = SyncSignalGroup(this_is_a_default=True)
-        handler = mock.Mock()
-        signaler.connect(handler)
-
-        signaler.fire()
-        handler.assert_called_with(this_is_a_default=True)
-
-        signaler.in_progress.fire(this_is_a_default=False)
-        handler.assert_called_with(this_is_a_default=False)
-
-    def test_completed_defaults(self):
-        signaler = SyncSignalGroup(this_is_a_default=True)
-        handler = mock.Mock()
-        signaler.connect(handler)
-
-        signaler.fire()
-        handler.assert_called_with(this_is_a_default=True)
-
-        signaler.completed.fire(this_is_a_default=False)
-        handler.assert_called_with(this_is_a_default=False)
-
-    def test_send(self):
-        signaler = SyncSignalGroup(this_is_a_default=True)
-
-        start_handler = mock.Mock()
-        signaler.started.connect(start_handler)
-        in_progress_handler = mock.Mock()
-        signaler.in_progress.connect(in_progress_handler)
-        completed_handler = mock.Mock()
-        signaler.completed.connect(completed_handler)
-
-        with signaler.send(other="A") as status:
-            start_handler.assert_called_once_with(this_is_a_default=True, other="A")
-            status.in_progress.fire(this_is_a_default=False, other="B")
-            in_progress_handler.assert_called_once_with(
-                this_is_a_default=False, other="B"
-            )
-            completed_handler.assert_not_called()
-
-        completed_handler.assert_called_once_with(this_is_a_default=True, other="A")
-
-
-class SyncClientSignalsTestCase(TestCase):
-    def test_separation(self):
-        handler1 = mock.Mock()
-        signals1 = SyncClientSignals()
-        signals1.session.connect(handler1)
-
-        handler2 = mock.Mock()
-        signals2 = SyncClientSignals()
-        signals2.session.connect(handler2)
-
-        signals1.session.fire()
-        handler1.assert_called_once_with(transfer_session=None)
-        handler2.assert_not_called()
+    def test_local_dequeue_operation(self):
+        self.transfer_session.records_transferred = 1
+        self.context.filter = [self.transfer_session.filter]
+        operation = ReceiverDequeueOperation()
+        self.assertEqual(transfer_statuses.COMPLETED, operation.handle(self.context))
+        self.assertFalse(
+            Buffer.objects.filter(transfer_session_id=self.transfer_session.id).exists()
+        )
+
+    @mock.patch("morango.sync.operations._dequeue_into_store")
+    def test_local_dequeue_operation__noop(self, mock_dequeue):
+        self.context.is_server = False
+        operation = ProducerDequeueOperation()
+        self.assertEqual(transfer_statuses.COMPLETED, operation.handle(self.context))
+        mock_dequeue.assert_not_called()
+
+    @mock.patch("morango.sync.operations._dequeue_into_store")
+    def test_local_dequeue_operation__noop__nothing_transferred(self, mock_dequeue):
+        self.transfer_session.records_transferred = 0
+        operation = ReceiverDequeueOperation()
+        self.assertEqual(transfer_statuses.COMPLETED, operation.handle(self.context))
+        mock_dequeue.assert_not_called()
+
+    @mock.patch("morango.sync.operations.DatabaseMaxCounter.update_fsics")
+    def test_local_deserialize_operation(self, mock_update_fsics):
+        self.transfer_session.records_transferred = 1
+        self.context.filter = [self.transfer_session.filter]
+        operation = ReceiverDeserializeOperation()
+        self.assertEqual(transfer_statuses.COMPLETED, operation.handle(self.context))
+        mock_update_fsics.assert_called()
+
+    def test_local_cleanup(self):
+        self.context.is_server = False
+        self.context.is_push = True
+        operation = CleanupOperation()
+        self.assertTrue(self.transfer_session.active)
+        self.assertTrue(
+            Buffer.objects.filter(transfer_session_id=self.transfer_session.id).exists()
+        )
+        self.assertTrue(
+            RecordMaxCounterBuffer.objects.filter(transfer_session_id=self.transfer_session.id).exists()
+        )
+        self.assertEqual(transfer_statuses.COMPLETED, operation.handle(self.context))
+        self.assertFalse(self.transfer_session.active)
+        self.assertFalse(
+            Buffer.objects.filter(transfer_session_id=self.transfer_session.id).exists()
+        )
+        self.assertFalse(
+            RecordMaxCounterBuffer.objects.filter(transfer_session_id=self.transfer_session.id).exists()
+        )
diff --git a/tests/testapp/tests/sync/test_session.py b/tests/testapp/tests/sync/test_session.py
new file mode 100644
index 00000000..9e96dfcf
--- /dev/null
+++ b/tests/testapp/tests/sync/test_session.py
@@ -0,0 +1,80 @@
+import mock
+from django.test import TestCase
+from requests.exceptions import HTTPError
+from requests.exceptions import RequestException
+
+
+from morango.sync.session import _length_of_headers
+from morango.sync.session import SessionWrapper
+
+
+class SessionWrapperTestCase(TestCase):
+    @mock.patch("morango.sync.session.Session.request")
+    def test_request(self, mocked_super_request):
+        headers = {"Content-Length": 1024}
+        expected = mocked_super_request.return_value = mock.Mock(
+            headers=headers, raise_for_status=mock.Mock(), status_code=200, reason="OK"
+        )
+
+        wrapper = SessionWrapper()
+        actual = wrapper.request("GET", "test_url", is_test=True)
+        mocked_super_request.assert_called_once_with("GET", "test_url", is_test=True)
+        self.assertEqual(expected, actual)
+
+        head_length = len("HTTP/1.1 200 OK") + _length_of_headers(headers)
+        self.assertEqual(wrapper.bytes_received, 1024 + head_length)
+
+    @mock.patch("morango.sync.session.logger")
+    @mock.patch("morango.sync.session.Session.request")
+    def test_request__not_ok(self, mocked_super_request, mocked_logger):
+        raise_for_status = mock.Mock()
+        expected = mocked_super_request.return_value = mock.Mock(
+            headers={"Content-Length": 1024},
+            raise_for_status=raise_for_status,
+            content="Connection timeout",
+        )
+
+        raise_for_status.side_effect = HTTPError(response=expected)
+
+        wrapper = SessionWrapper()
+
+        with self.assertRaises(HTTPError):
+            wrapper.request("GET", "test_url", is_test=True)
+
+        mocked_super_request.assert_called_once_with("GET", "test_url", is_test=True)
+        mocked_logger.error.assert_called_once_with(
+            "HTTPError Reason: Connection timeout"
+        )
+
+    @mock.patch("morango.sync.session.logger")
+    @mock.patch("morango.sync.session.Session.request")
+    def test_request__really_not_ok(self, mocked_super_request, mocked_logger):
+        mocked_super_request.side_effect = RequestException()
+
+        wrapper = SessionWrapper()
+
+        with self.assertRaises(RequestException):
+            wrapper.request("GET", "test_url", is_test=True)
+
+        mocked_super_request.assert_called_once_with("GET", "test_url", is_test=True)
+        mocked_logger.error.assert_called_once_with(
+            "RequestException Reason: (no response)"
+        )
+
+    @mock.patch("morango.sync.session.Session.prepare_request")
+    def test_prepare_request(self, mocked_super_prepare_request):
+        headers = {"Content-Length": 256}
+        expected = mocked_super_prepare_request.return_value = mock.Mock(
+            headers=headers,
+        )
+
+        request = mock.Mock(url="http://test_app/path/to/resource", method="GET", headers={})
+        wrapper = SessionWrapper()
+        actual = wrapper.prepare_request(request)
+        mocked_super_prepare_request.assert_called_once_with(request)
+
+        self.assertEqual(expected, actual)
+        head_length = len("GET /path/to/resource HTTP/1.1") + _length_of_headers(
+            headers
+        )
+        self.assertEqual(wrapper.bytes_sent, 256 + head_length)
diff --git a/tests/testapp/tests/sync/test_syncsession.py b/tests/testapp/tests/sync/test_syncsession.py
new file mode 100644
index 00000000..44625979
--- /dev/null
+++ b/tests/testapp/tests/sync/test_syncsession.py
@@ -0,0 +1,460 @@
+import json
+import uuid
+
+import mock
+from django.test.testcases import LiveServerTestCase
+from django.test.utils import override_settings
+from requests.exceptions import HTTPError
+
+from ..helpers import BaseClientTestCase
+from ..helpers import BaseTransferClientTestCase
+from morango.api.serializers import CertificateSerializer
+from morango.constants import transfer_stages
+from morango.constants import transfer_statuses
+from morango.constants.capabilities import ALLOW_CERTIFICATE_PUSHING
+from morango.errors import CertificateSignatureInvalid
+from morango.errors import MorangoServerDoesNotAllowNewCertPush
+from morango.errors import MorangoResumeSyncError
+from morango.errors import MorangoError
+from morango.models.certificates import Certificate
+from morango.models.certificates import Filter
+from morango.models.certificates import Key
+from morango.models.certificates import ScopeDefinition
+from morango.models.core import SyncSession
+from morango.models.fields.crypto import SharedKey
+from morango.sync.controller import MorangoProfileController
+from morango.sync.context import LocalSessionContext
+from morango.sync.context import NetworkSessionContext
+from morango.sync.session import SessionWrapper
+from morango.sync.syncsession import NetworkSyncConnection
+from morango.sync.syncsession import TransferClient
+from morango.sync.syncsession import PullClient
+from morango.sync.syncsession import PushClient
+from morango.sync.syncsession import SyncSessionClient
+
+
+def mock_patch_decorator(func):
+    def wrapper(*args, **kwargs):
+        mock_object = mock.Mock(
+            status_code=200,
+            content=b"""{"id": "abc"}""",
+            data={"signature": "sig", "client_fsic": "{}", "server_fsic": "{}"},
+        )
+        mock_object.json.return_value = {}
+        with mock.patch.object(SessionWrapper, "request", return_value=mock_object):
+            with mock.patch.object(Certificate, "verify", return_value=True):
+                return func(*args, **kwargs)
+
+    return wrapper
+
+
+class NetworkSyncConnectionTestCase(LiveServerTestCase):
+    def setUp(self):
+        super(NetworkSyncConnectionTestCase, self).setUp()
+        self.profile = "facilitydata"
+
+        self.root_scope_def = ScopeDefinition.objects.create(
+            id="rootcert",
+            profile=self.profile,
+            version=1,
+            primary_scope_param_key="mainpartition",
+            description="Root cert for ${mainpartition}.",
+            read_filter_template="",
+            write_filter_template="",
+            read_write_filter_template="${mainpartition}",
+        )
+
+        self.subset_scope_def = ScopeDefinition.objects.create(
+            id="subcert",
+            profile=self.profile,
+            version=1,
+            primary_scope_param_key="",
+            description="Subset cert under ${mainpartition} for ${subpartition}.",
+            read_filter_template="${mainpartition}",
+            write_filter_template="${mainpartition}:${subpartition}",
+            read_write_filter_template="",
+        )
+
+        self.root_cert = Certificate.generate_root_certificate(self.root_scope_def.id)
+
+        self.subset_cert = Certificate(
+            parent=self.root_cert,
+            profile=self.profile,
+            scope_definition=self.subset_scope_def,
+            scope_version=self.subset_scope_def.version,
+            scope_params=json.dumps(
+                {"mainpartition": self.root_cert.id, "subpartition": "abracadabra"}
+            ),
+            private_key=Key(),
+        )
+        self.root_cert.sign_certificate(self.subset_cert)
+        self.subset_cert.save()
+
+        self.unsaved_cert = Certificate(
+            parent=self.root_cert,
+            profile=self.profile,
+            scope_definition=self.subset_scope_def,
+            scope_version=self.subset_scope_def.version,
+            scope_params=json.dumps(
+                {"mainpartition": self.root_cert.id, "subpartition": "other"}
+            ),
+            public_key=Key(),
+        )
+        self.root_cert.sign_certificate(self.unsaved_cert)
+
+        self.controller = MorangoProfileController("facilitydata")
+        self.network_connection = self.controller.create_network_connection(
+            self.live_server_url
+        )
+        self.key = SharedKey.get_or_create_shared_key()
+
+    @mock.patch.object(SyncSession.objects, "create", return_value=None)
+    def test_creating_sync_session_successful(self, mock_object):
+        self.assertEqual(SyncSession.objects.filter(active=True).count(), 0)
+        self.network_connection.create_sync_session(self.subset_cert, self.root_cert)
+        self.assertEqual(SyncSession.objects.filter(active=True).count(), 1)
+
+    @mock.patch.object(NetworkSyncConnection, "_create_sync_session")
+    @mock.patch.object(Certificate, "verify", return_value=False)
+    def test_creating_sync_session_cert_fails_to_verify(self, mock_verify, mock_create):
+        mock_create.return_value.json.return_value = {}
+        with self.assertRaises(CertificateSignatureInvalid):
+            self.network_connection.create_sync_session(
+                self.subset_cert, self.root_cert
+            )
+
+    def test_get_remote_certs(self):
+        certs = self.subset_cert.get_ancestors(include_self=True)
+        remote_certs = self.network_connection.get_remote_certificates(
+            self.root_cert.id
+        )
+        self.assertSetEqual(set(certs), set(remote_certs))
+
+    @mock.patch.object(SessionWrapper, "request")
+    def test_csr(self, mock_request):
+        # mock a "signed" cert being returned by server
+        cert_serialized = json.dumps(CertificateSerializer(self.subset_cert).data)
+        mock_request.return_value.json.return_value = json.loads(cert_serialized)
+        self.subset_cert.delete()
+
+        # we only want to make sure the "signed" cert is saved
+        with mock.patch.object(
+            Key,
+            "get_private_key_string",
+            return_value=self.subset_cert.private_key.get_private_key_string(),
+        ):
+            self.network_connection.certificate_signing_request(self.root_cert, "", "")
+        self.assertTrue(
+            Certificate.objects.filter(id=json.loads(cert_serialized)["id"]).exists()
+        )
+
+    @override_settings(ALLOW_CERTIFICATE_PUSHING=True)
+    def test_push_signed_client_certificate_chain(self):
+        self.network_connection.capabilities = [ALLOW_CERTIFICATE_PUSHING]
+        cert = self.network_connection.push_signed_client_certificate_chain(
+            self.root_cert,
+            self.subset_scope_def.id,
+            {"mainpartition": self.root_cert.id, "subpartition": "abracadabra"},
+        )
+        self.assertEqual(cert.private_key, None)
+        self.assertTrue(Certificate.objects.filter(id=cert.id).exists())
+
+    @override_settings(ALLOW_CERTIFICATE_PUSHING=True)
+    def test_push_signed_client_certificate_chain_publickey_error(self):
+        self.network_connection.capabilities = [ALLOW_CERTIFICATE_PUSHING]
+        with mock.patch.object(NetworkSyncConnection, "_get_public_key"):
+            NetworkSyncConnection._get_public_key.return_value.json.return_value = [
+                {"public_key": Key().get_public_key_string()}
+            ]
+            with self.assertRaises(HTTPError) as e:
+                self.network_connection.push_signed_client_certificate_chain(
+                    self.root_cert,
+                    self.subset_scope_def.id,
+                    {"mainpartition": self.root_cert.id, "subpartition": "abracadabra"},
+                )
+            self.assertEqual(e.exception.response.status_code, 400)
+
+    @override_settings(ALLOW_CERTIFICATE_PUSHING=True)
+    def test_push_signed_client_certificate_chain_bad_cert(self):
+        self.network_connection.capabilities = [ALLOW_CERTIFICATE_PUSHING]
+        with self.assertRaises(HTTPError) as e:
+            self.network_connection.push_signed_client_certificate_chain(
+                self.root_cert, self.subset_scope_def.id, {"bad": "scope_params"}
+            )
+        self.assertEqual(e.exception.response.status_code, 400)
+
+    @override_settings(ALLOW_CERTIFICATE_PUSHING=True)
+    @mock.patch.object(NetworkSyncConnection, "_get_nonce")
+    def test_push_signed_client_certificate_chain_nonce_error(self, mock_nonce):
+        self.network_connection.capabilities = [ALLOW_CERTIFICATE_PUSHING]
+        mock_nonce.return_value.json.return_value = {"id": uuid.uuid4().hex}
+        with self.assertRaises(HTTPError) as e:
+            self.network_connection.push_signed_client_certificate_chain(
+                self.root_cert,
+                self.subset_scope_def.id,
+                {"mainpartition": self.root_cert.id, "subpartition": "abracadabra"},
+            )
+        self.assertEqual(e.exception.response.status_code, 403)
+
+    def test_push_signed_client_certificate_chain_not_allowed(self):
+        with self.assertRaises(MorangoServerDoesNotAllowNewCertPush) as e:
+            self.network_connection.push_signed_client_certificate_chain(
+                self.root_cert,
+                self.subset_scope_def.id,
+                {"mainpartition": self.root_cert.id, "subpartition": "abracadabra"},
+            )
+            self.assertEqual(e.exception.response.status_code, 403)
+
+    def test_get_cert_chain(self):
+        response = self.network_connection._get_certificate_chain(
+            params={"ancestors_of": self.subset_cert.id}
+        )
+        data = response.json()
+        self.assertEqual(len(data), Certificate.objects.count())
+        self.assertEqual(data[0]["id"], self.root_cert.id)
+        self.assertEqual(data[1]["id"], self.subset_cert.id)
+
+    @mock.patch.object(SyncSession.objects, "create")
+    def test_close_sync_session(self, mock_create):
+        mock_session = mock.Mock(spec=SyncSession)
+
+        def create(**data):
+            mock_session.id = data.get("id")
+            return mock_session
+
+        mock_create.side_effect = create
+        self.assertEqual(SyncSession.objects.filter(active=True).count(), 0)
+        client = self.network_connection.create_sync_session(
+            self.subset_cert, self.root_cert
+        )
+        self.assertEqual(SyncSession.objects.filter(active=True).count(), 1)
+
+        self.network_connection.close_sync_session(client.sync_session)
+        self.assertEqual(SyncSession.objects.filter(active=True).count(), 0)
+
+    @mock.patch.object(SyncSession.objects, "create")
+    def test_resume_sync_session(self, mock_create):
+        def create(**data):
+            """Trickery to get around same DB being used for both client and server"""
+            return SyncSession.objects.get(pk=data.get("id"))
+
+        mock_create.side_effect = create
+
+        # first create a session
+        client = self.network_connection.create_sync_session(self.subset_cert, self.root_cert)
+        # reset process ID
+        sync_session = client.sync_session
+        sync_session.process_id = None
+        sync_session.save()
+        resume_client = self.network_connection.resume_sync_session(sync_session.id)
+        self.assertEqual(sync_session.id, resume_client.sync_session.id)
+
+    @mock.patch.object(SyncSession.objects, "create")
+    def test_resume_sync_session__still_running(self, mock_create):
+        def create(**data):
+            """Trickery to get around same DB being used for both client and server"""
+            return SyncSession.objects.get(pk=data.get("id"))
+
+        mock_create.side_effect = create
+
+        # first create a session
+        client = self.network_connection.create_sync_session(self.subset_cert, self.root_cert)
+        # reset process ID
+        sync_session = client.sync_session
+        sync_session.process_id = 123000111
+        sync_session.save()
+
+        with mock.patch("morango.sync.syncsession.pid_exists") as mock_pid_exists:
+            mock_pid_exists.return_value = True
+            with mock.patch("morango.sync.syncsession.os.getpid") as mock_getpid:
+                mock_getpid.return_value = 245111222
+                with self.assertRaises(MorangoResumeSyncError):
+                    self.network_connection.resume_sync_session(sync_session.id)
+
+
+class SyncSessionClientTestCase(BaseClientTestCase):
+    def test_get_pull_client(self):
+        client = self.client.get_pull_client()
+        self.assertIsInstance(client, PullClient)
+        self.assertEqual(self.client.sync_connection, client.sync_connection)
+        self.assertEqual(self.client.sync_session, client.sync_session)
+
+    def test_get_push_client(self):
+        client = self.client.get_push_client()
+        self.assertIsInstance(client, PushClient)
+        self.assertEqual(self.client.sync_connection, client.sync_connection)
+        self.assertEqual(self.client.sync_session, client.sync_session)
+
+    @mock.patch("morango.sync.syncsession.PullClient")
+    def test_initiate_pull(self, MockPullClient):
+        """
+        TODO: should eventually be removed as this method is deprecated
+        """
+        mock_pull_client = mock.Mock(spec=PullClient)
+        MockPullClient.return_value = mock_pull_client
+
+        filter = Filter("abc123")
+        self.client.initiate_pull(filter)
+        MockPullClient.assert_called_with(
+            self.conn, self.session, self.client.controller
+        )
+
+        mock_pull_client.initialize.assert_called_once_with(filter)
+        mock_pull_client.run.assert_called_once()
+        mock_pull_client.finalize.assert_called_once()
+
+        self.assertEqual(self.client.signals, mock_pull_client.signals)
+
+    @mock.patch("morango.sync.syncsession.PushClient")
+    def test_initiate_push(self, MockPushClient):
+        """
+        TODO: should eventually be removed as this method is deprecated
+        """
+        mock_pull_client = mock.Mock(spec=PushClient)
+        MockPushClient.return_value = mock_pull_client
+
+        sync_filter = Filter("abc123")
+        self.client.initiate_push(sync_filter)
+        MockPushClient.assert_called_with(
+            self.conn, self.session, self.client.controller
+        )
+
+        mock_pull_client.initialize.assert_called_once_with(sync_filter)
+        mock_pull_client.run.assert_called_once()
+        mock_pull_client.finalize.assert_called_once()
+
+        self.assertEqual(self.client.signals, mock_pull_client.signals)
+
+    def test_close_sync_session(self):
+        """
+        TODO: should eventually be removed as this method is deprecated
+        """
+        conn = mock.Mock(spec=NetworkSyncConnection)
+        client = SyncSessionClient(conn, self.session)
+        client.close_sync_session()
+        conn.close.assert_called_once()
+
+
+class TransferClientTestCase(BaseTransferClientTestCase):
+    def build_client(self, client_class=TransferClient, controller=None, update_context=False):
+        self.controller = controller or mock.Mock(spec="morango.sync.controller.SessionController")()
+        return super(TransferClientTestCase, self).build_client(client_class=client_class, controller=self.controller, update_context=update_context)
+
+    def test_init(self):
+        self.assertIsInstance(self.client, TransferClient)
+        self.assertEqual(self.session, self.client.sync_session)
+        self.assertEqual(self.conn, self.client.sync_connection)
+        self.assertEqual(self.controller, self.client.controller)
+        self.assertIsInstance(self.client.local_context, LocalSessionContext)
+        self.assertEqual(self.session, self.client.local_context.sync_session)
+        self.assertIsInstance(self.client.remote_context, NetworkSessionContext)
+        self.assertEqual(self.session, self.client.remote_context.sync_session)
+        self.assertEqual(self.conn, self.client.remote_context.connection)
+
+    def test_proceed_to_and_wait_for__push(self):
+        mock_proceed = self.controller.proceed_to_and_wait_for
+        mock_proceed.return_value = transfer_statuses.COMPLETED
+        self.client.local_context.is_push = True
+        self.client.proceed_to_and_wait_for(transfer_stages.QUEUING)
+        mock_proceed_calls = mock_proceed.call_args_list
+        self.assertEqual(2, len(mock_proceed_calls))
+        self.assertEqual(transfer_stages.QUEUING, mock_proceed_calls[0][0][0])
+        self.assertEqual(self.client.local_context, mock_proceed_calls[0][1].get("context"))
+        self.assertEqual(transfer_stages.QUEUING, mock_proceed_calls[1][0][0])
+        self.assertEqual(self.client.remote_context, mock_proceed_calls[1][1].get("context"))
+
+    def test_proceed_to_and_wait_for__pull(self):
+        mock_proceed = self.controller.proceed_to_and_wait_for
+        mock_proceed.return_value = transfer_statuses.COMPLETED
+        self.client.local_context.is_push = False
+        self.client.proceed_to_and_wait_for(transfer_stages.QUEUING)
+        mock_proceed_calls = mock_proceed.call_args_list
+        self.assertEqual(2, len(mock_proceed_calls))
+        self.assertEqual(transfer_stages.QUEUING, mock_proceed_calls[0][0][0])
+        self.assertEqual(self.client.local_context, mock_proceed_calls[0][1].get("context"))
+        self.assertEqual(transfer_stages.QUEUING, mock_proceed_calls[1][0][0])
+        self.assertEqual(self.client.remote_context, mock_proceed_calls[1][1].get("context"))
+
+    def test_proceed_to_and_wait_for__error(self):
+        self.client.local_context.is_push = False
+        self.controller.last_error = Exception("Oops")
+        self.controller.proceed_to_and_wait_for.return_value = transfer_statuses.ERRORED
+        with self.assertRaises(MorangoError):
+            self.client.proceed_to_and_wait_for(transfer_stages.QUEUING)
+        self.controller.proceed_to_and_wait_for.assert_called_once_with(
+            transfer_stages.QUEUING, context=self.client.local_context, max_interval=1
+        )
+
+    @mock.patch("morango.sync.syncsession.TransferClient.proceed_to_and_wait_for")
+    def test_initialize(self, mock_proceed):
+        session_started_handler = mock.Mock()
+        self.client.signals.session.started.connect(session_started_handler)
+        queuing_handler = mock.Mock()
+        self.client.signals.session.connect(queuing_handler)
+        self.client.local_context.transfer_session = self.transfer_session
+
+        sync_filter = self.transfer_session.get_filter()
+        self.client.initialize(sync_filter)
+        self.assertEqual(sync_filter, self.client.local_context.filter)
+        self.assertEqual(sync_filter, self.client.remote_context.filter)
+        self.controller.proceed_to_and_wait_for.assert_any_call(
+            transfer_stages.INITIALIZING, context=self.client.local_context, max_interval=1
+        )
+        self.assertEqual(self.transfer_session, self.client.remote_context.transfer_session)
+        session_started_handler.assert_called_once_with(transfer_session=self.transfer_session)
+        mock_proceed.assert_any_call(transfer_stages.QUEUING)
+        queuing_handler.assert_any_call(transfer_session=self.transfer_session)
+
+    @mock.patch("morango.sync.syncsession.TransferClient._transfer")
+    def test_run(self, mock_transfer):
+        mock_start = mock.Mock()
+        mock_end = mock.Mock()
+        self.client.signals.transferring.started.connect(mock_start)
+        self.client.signals.transferring.completed.connect(mock_end)
+        self.client.run()
+        mock_transfer.assert_called_once()
+        mock_start.assert_called_once()
+        mock_end.assert_called_once()
+
+    @mock.patch("morango.sync.syncsession.TransferClient.proceed_to_and_wait_for")
+    def test_finalize(self, mock_proceed):
+        mock_start = mock.Mock()
+        mock_end = mock.Mock()
+        mock_session_end = mock.Mock()
+        self.client.signals.dequeuing.started.connect(mock_start)
+        self.client.signals.dequeuing.completed.connect(mock_end)
+        self.client.signals.session.completed.connect(mock_session_end)
+        self.client.finalize()
+        mock_proceed.assert_any_call(transfer_stages.DESERIALIZING)
+        mock_proceed.assert_any_call(transfer_stages.CLEANUP)
+        mock_start.assert_called_once()
+        mock_end.assert_called_once()
+
+    def test_transfer(self):
+        mock_callback = mock.Mock()
+        self.controller.proceed_to.side_effect = [
+            transfer_statuses.PENDING,
+            transfer_statuses.COMPLETED,
+        ]
+        self.client._transfer(callback=mock_callback)
+        self.controller.proceed_to.assert_any_call(
+            transfer_stages.TRANSFERRING, context=self.client.remote_context
+        )
+        self.assertEqual(2, len(mock_callback.call_args_list))
+
+    def test_transfer__error(self):
+        mock_callback = mock.Mock()
+        self.controller.last_error = Exception("Oops")
+        self.controller.proceed_to.side_effect = [
+            transfer_statuses.PENDING,
+            transfer_statuses.ERRORED,
+        ]
+
+        with self.assertRaises(MorangoError):
+            self.client._transfer(callback=mock_callback)
+        self.controller.proceed_to.assert_any_call(
+            transfer_stages.TRANSFERRING, context=self.client.remote_context
+        )
+        self.assertEqual(2, len(mock_callback.call_args_list))
+
+
diff --git a/tests/testapp/tests/sync/test_utils.py b/tests/testapp/tests/sync/test_utils.py
new file mode 100644
index 00000000..04dca74a
--- /dev/null
+++ b/tests/testapp/tests/sync/test_utils.py
@@ -0,0 +1,78 @@
+import mock
+from django.test import TestCase
+
+from morango.sync.utils import SyncSignal
+from morango.sync.utils import SyncSignalGroup
+
+
+class SyncSignalTestCase(TestCase):
+    def test_defaults(self):
+        signaler = SyncSignal(this_is_a_default=True)
+        handler = mock.Mock()
+        signaler.connect(handler)
+        signaler.fire()
+
+        handler.assert_called_once_with(this_is_a_default=True)
+
+    def test_fire_with_kwargs(self):
+        signaler = SyncSignal(my_key="abc")
+        handler = mock.Mock()
+        signaler.connect(handler)
+        signaler.fire(my_key="123", not_default=True)
+
+        handler.assert_called_once_with(my_key="123", not_default=True)
+
+
+class SyncSignalGroupTestCase(TestCase):
+    def test_started_defaults(self):
+        signaler = SyncSignalGroup(this_is_a_default=True)
+        handler = mock.Mock()
+        signaler.connect(handler)
+
+        signaler.fire()
+        handler.assert_called_with(this_is_a_default=True)
+
+        signaler.started.fire(this_is_a_default=False)
+        handler.assert_called_with(this_is_a_default=False)
+
+    def test_in_progress_defaults(self):
+        signaler = SyncSignalGroup(this_is_a_default=True)
+        handler = mock.Mock()
+        signaler.connect(handler)
+
+        signaler.fire()
+        handler.assert_called_with(this_is_a_default=True)
+
+        signaler.in_progress.fire(this_is_a_default=False)
+        handler.assert_called_with(this_is_a_default=False)
+
+    def test_completed_defaults(self):
+        signaler = SyncSignalGroup(this_is_a_default=True)
+        handler = mock.Mock()
+        signaler.connect(handler)
+
+        signaler.fire()
+        handler.assert_called_with(this_is_a_default=True)
+
+        signaler.completed.fire(this_is_a_default=False)
+        handler.assert_called_with(this_is_a_default=False)
+
+    def test_send(self):
+        signaler = SyncSignalGroup(this_is_a_default=True)
+
+        start_handler = mock.Mock()
+        signaler.started.connect(start_handler)
+        in_progress_handler = mock.Mock()
+        signaler.in_progress.connect(in_progress_handler)
+        completed_handler = mock.Mock()
+        signaler.completed.connect(completed_handler)
+
+        with signaler.send(other="A") as status:
+            start_handler.assert_called_once_with(this_is_a_default=True, other="A")
+            status.in_progress.fire(this_is_a_default=False, other="B")
+            in_progress_handler.assert_called_once_with(
+                this_is_a_default=False, other="B"
+            )
+            completed_handler.assert_not_called()
+
+        completed_handler.assert_called_once_with(this_is_a_default=True, other="A")
diff --git a/tests/testapp/tests/test_api.py b/tests/testapp/tests/test_api.py
index 0b7fcf7a..8256b107 100644
--- a/tests/testapp/tests/test_api.py
+++ b/tests/testapp/tests/test_api.py
@@ -6,6 +6,7 @@
 import mock
 from django.core.urlresolvers import reverse
 from django.test.utils import override_settings
+from django.urls.exceptions import NoReverseMatch
 from django.utils import timezone
 from facility_profile.models import MyUser
 from rest_framework.test import APITestCase as BaseTestCase
@@ -14,6 +15,8 @@
 from morango.api.serializers import BufferSerializer
 from morango.api.serializers import CertificateSerializer
 from morango.api.serializers import InstanceIDSerializer
+from morango.constants import transfer_stages
+from morango.constants import transfer_statuses
 from morango.models.certificates import Certificate
 from morango.models.certificates import Key
 from morango.models.certificates import Nonce
@@ -36,6 +39,8 @@
     class APITestCase(BaseTestCase):
         def assertItemsEqual(self, *args, **kwargs):
             self.assertCountEqual(*args, **kwargs)
+
+
 else:
     from base64 import encodestring as b64encode
 
@@ -44,7 +49,6 @@ class APITestCase(BaseTestCase):
 
 
 class CertificateTestCaseMixin(object):
-
     def setUp(self):
 
         self.user = MyUser(username="user")
@@ -84,14 +88,21 @@ def setUp(self):
             read_write_filter_template="",
         )
 
-        self.root_cert1_with_key = Certificate.generate_root_certificate(self.root_scope_def.id)
+        self.root_cert1_with_key = Certificate.generate_root_certificate(
+            self.root_scope_def.id
+        )
 
         self.subset_cert1_without_key = Certificate(
             parent=self.root_cert1_with_key,
             profile=self.profile,
             scope_definition=self.subset_scope_def,
             scope_version=self.subset_scope_def.version,
-            scope_params=json.dumps({"mainpartition": self.root_cert1_with_key.id, "subpartition": "abracadabra"}),
+            scope_params=json.dumps(
+                {
+                    "mainpartition": self.root_cert1_with_key.id,
+                    "subpartition": "abracadabra",
+                }
+            ),
             private_key=Key(),
         )
         self.root_cert1_with_key.sign_certificate(self.subset_cert1_without_key)
@@ -111,14 +122,21 @@ def setUp(self):
         self.subset_cert1_without_key._private_key = None
         self.subset_cert1_without_key.save()
 
-        self.root_cert2_without_key = Certificate.generate_root_certificate(self.root_scope_def.id)
+        self.root_cert2_without_key = Certificate.generate_root_certificate(
+            self.root_scope_def.id
+        )
 
         self.subset_cert2_with_key = Certificate(
             parent=self.root_cert2_without_key,
             profile=self.profile,
             scope_definition=self.subset_scope_def,
             scope_version=self.subset_scope_def.version,
-            scope_params=json.dumps({"mainpartition": self.root_cert2_without_key.id, "subpartition": "abracadabra"}),
+            scope_params=json.dumps(
+                {
+                    "mainpartition": self.root_cert2_without_key.id,
+                    "subpartition": "abracadabra",
+                }
+            ),
             private_key=Key(),
         )
         self.root_cert2_without_key.sign_certificate(self.subset_cert2_with_key)
@@ -135,10 +153,12 @@ def setUp(self):
             scope_definition=self.root_scope_def,
             scope_version=self.root_scope_def.version,
             profile=self.profile,
-            private_key=Key()
+            private_key=Key(),
         )
         self.unsaved_root_cert.id = self.unsaved_root_cert.calculate_uuid()
-        self.unsaved_root_cert.scope_params = json.dumps({self.root_scope_def.primary_scope_param_key: self.unsaved_root_cert.id})
+        self.unsaved_root_cert.scope_params = json.dumps(
+            {self.root_scope_def.primary_scope_param_key: self.unsaved_root_cert.id}
+        )
         self.unsaved_root_cert.sign_certificate(self.unsaved_root_cert)
 
         # create a child cert
@@ -147,18 +167,22 @@ def setUp(self):
             profile=self.profile,
             scope_definition=self.subset_scope_def,
             scope_version=self.subset_scope_def.version,
-            scope_params=json.dumps({"mainpartition": self.unsaved_root_cert.id, "subpartition": "hooplah"}),
+            scope_params=json.dumps(
+                {"mainpartition": self.unsaved_root_cert.id, "subpartition": "hooplah"}
+            ),
             public_key=self.sharedkey.public_key,
         )
 
     def make_cert_endpoint_request(self, params={}, method="GET"):
         fn = getattr(self.client, method.lower())
-        response = fn(reverse('certificates-list'), params, format='json')
+        response = fn(reverse("certificates-list"), params, format="json")
         data = json.loads(response.content.decode())
         return (response, data)
 
     def perform_basic_authentication(self, user):
-        basic_auth_header = b'Basic ' + b64encode(("username=%s:%s" % (user.username, user.actual_password)).encode())
+        basic_auth_header = b"Basic " + b64encode(
+            ("username=%s:%s" % (user.username, user.actual_password)).encode()
+        )
         self.client.credentials(HTTP_AUTHORIZATION=basic_auth_header)
 
     def create_syncsession(self, client_certificate=None, server_certificate=None):
@@ -170,7 +194,7 @@ def create_syncsession(self, client_certificate=None, server_certificate=None):
             server_certificate = self.root_cert1_with_key
 
         # fetch a nonce value to use in creating the syncsession
-        response = self.client.post(reverse('nonces-list'), {}, format='json')
+        response = self.client.post(reverse("nonces-list"), {}, format="json")
         nonce = json.loads(response.content.decode())["id"]
 
         # prepare the data to send in the syncsession creation request
@@ -179,9 +203,17 @@ def create_syncsession(self, client_certificate=None, server_certificate=None):
             "server_certificate_id": server_certificate.id,
             "client_certificate_id": client_certificate.id,
             "profile": client_certificate.profile,
-            "certificate_chain": json.dumps(CertificateSerializer(client_certificate.get_ancestors(include_self=True), many=True).data),
+            "certificate_chain": json.dumps(
+                CertificateSerializer(
+                    client_certificate.get_ancestors(include_self=True), many=True
+                ).data
+            ),
             "connection_path": "http://127.0.0.1:8000",
-            "instance": json.dumps(InstanceIDSerializer(InstanceIDModel.get_or_create_current_instance()[0]).data),
+            "instance": json.dumps(
+                InstanceIDSerializer(
+                    InstanceIDModel.get_or_create_current_instance()[0]
+                ).data
+            ),
             "nonce": nonce,
         }
 
@@ -189,12 +221,20 @@ def create_syncsession(self, client_certificate=None, server_certificate=None):
         data["signature"] = client_certificate.sign("{nonce}:{id}".format(**data))
 
         # make the API call to create the SyncSession
-        response = self.client.post(reverse('syncsessions-list'), data, format='json')
+        response = self.client.post(reverse("syncsessions-list"), data, format="json")
         self.assertEqual(response.status_code, 201)
 
         return SyncSession.objects.get(id=data["id"])
 
-    def make_transfersession_creation_request(self, filter, push, syncsession=None, expected_status=201, expected_message=None, **kwargs):
+    def make_transfersession_creation_request(
+        self,
+        filter,
+        push,
+        syncsession=None,
+        expected_status=201,
+        expected_message=None,
+        **kwargs
+    ):
 
         if not syncsession:
             syncsession = self.create_syncsession()
@@ -208,12 +248,16 @@ def make_transfersession_creation_request(self, filter, push, syncsession=None,
         }
 
         # make the API call to attempt to create the TransferSession
-        response = self.client.post(reverse('transfersessions-list'), data, format='json')
+        response = self.client.post(
+            reverse("transfersessions-list"), data, format="json"
+        )
         self.assertEqual(response.status_code, expected_status)
 
         if expected_status == 201:
             # check that the syncsession was created
-            transfersession = TransferSession.objects.get(id=json.loads(response.content.decode())["id"])
+            transfersession = TransferSession.objects.get(
+                id=json.loads(response.content.decode())["id"]
+            )
             self.assertTrue(transfersession.active)
         else:
             # check that the syncsession was not created
@@ -226,55 +270,84 @@ def make_transfersession_creation_request(self, filter, push, syncsession=None,
 
 
 class CertificateListingTestCase(CertificateTestCaseMixin, APITestCase):
-
     def test_certificate_filtering_by_primary_partition(self):
 
         # check that only the root cert and leaf cert are returned when they're the ones with a private key
-        _, data = self.make_cert_endpoint_request(params={'primary_partition': self.root_cert1_with_key.id})
+        _, data = self.make_cert_endpoint_request(
+            params={"primary_partition": self.root_cert1_with_key.id}
+        )
         self.assertEqual(len(data), 2)
         self.assertEqual(data[0]["id"], self.root_cert1_with_key.id)
         self.assertEqual(data[1]["id"], self.sub_subset_cert1_with_key.id)
 
         # check that only the subcert is returned when it's the one with a private key
-        _, data = self.make_cert_endpoint_request(params={'primary_partition': self.root_cert2_without_key.id})
+        _, data = self.make_cert_endpoint_request(
+            params={"primary_partition": self.root_cert2_without_key.id}
+        )
         self.assertEqual(len(data), 1)
         self.assertEqual(data[0]["id"], self.subset_cert2_with_key.id)
 
         # check that no certificates are returned when the partition doesn't exist
-        _, data = self.make_cert_endpoint_request(params={'primary_partition': "a" * 32})
+        _, data = self.make_cert_endpoint_request(
+            params={"primary_partition": "a" * 32}
+        )
         self.assertEqual(len(data), 0)
 
         # check that no certificates are returned when profile doesn't match
-        _, data = self.make_cert_endpoint_request(params={'primary_partition': self.root_cert2_without_key.id, "profile": "namelessone"})
+        _, data = self.make_cert_endpoint_request(
+            params={
+                "primary_partition": self.root_cert2_without_key.id,
+                "profile": "namelessone",
+            }
+        )
         self.assertEqual(len(data), 0)
 
         # check that certificates are returned when profile does match
-        _, data = self.make_cert_endpoint_request(params={'primary_partition': self.root_cert2_without_key.id, "profile": self.profile})
+        _, data = self.make_cert_endpoint_request(
+            params={
+                "primary_partition": self.root_cert2_without_key.id,
+                "profile": self.profile,
+            }
+        )
         self.assertEqual(len(data), 1)
 
     def test_certificate_filtering_by_ancestors_of(self):
 
         # check that only the root cert is returned when it's the one we're requesting ancestors of
-        _, data = self.make_cert_endpoint_request(params={'ancestors_of': self.root_cert1_with_key.id})
+        _, data = self.make_cert_endpoint_request(
+            params={"ancestors_of": self.root_cert1_with_key.id}
+        )
         self.assertEqual(len(data), 1)
         self.assertEqual(data[0]["id"], self.root_cert1_with_key.id)
 
         # check that both the subcert and root cert are returned when we request ancestors of subcert
-        _, data = self.make_cert_endpoint_request(params={'ancestors_of': self.subset_cert2_with_key.id})
+        _, data = self.make_cert_endpoint_request(
+            params={"ancestors_of": self.subset_cert2_with_key.id}
+        )
         self.assertEqual(len(data), 2)
         self.assertEqual(data[0]["id"], self.root_cert2_without_key.id)
         self.assertEqual(data[1]["id"], self.subset_cert2_with_key.id)
 
         # check that no certificates are returned when the certificate ID doesn't exist
-        _, data = self.make_cert_endpoint_request(params={'ancestors_of': "a" * 32})
+        _, data = self.make_cert_endpoint_request(params={"ancestors_of": "a" * 32})
         self.assertEqual(len(data), 0)
 
         # check that no certificates are returned when profile doesn't match
-        _, data = self.make_cert_endpoint_request(params={'ancestors_of': self.subset_cert2_with_key.id, "profile": "namelessone"})
+        _, data = self.make_cert_endpoint_request(
+            params={
+                "ancestors_of": self.subset_cert2_with_key.id,
+                "profile": "namelessone",
+            }
+        )
         self.assertEqual(len(data), 0)
 
         # check that certificates are returned when profile does match
-        _, data = self.make_cert_endpoint_request(params={'ancestors_of': self.subset_cert2_with_key.id, "profile": self.profile})
+        _, data = self.make_cert_endpoint_request(
+            params={
+                "ancestors_of": self.subset_cert2_with_key.id,
+                "profile": self.profile,
+            }
+        )
         self.assertEqual(len(data), 2)
 
     def test_certificate_full_list_request(self):
@@ -296,13 +369,14 @@ def test_certificate_full_list_request(self):
 
 
 class CertificateCreationTestCase(CertificateTestCaseMixin, APITestCase):
-
     def make_csr(self, parent, **kwargs):
         key = Key()
         params = {
             "parent": parent.id,
             "profile": kwargs.get("profile", self.profile),
-            "scope_definition": kwargs.get("scope_definition", parent.scope_definition_id),
+            "scope_definition": kwargs.get(
+                "scope_definition", parent.scope_definition_id
+            ),
             "scope_version": kwargs.get("scope_version", parent.scope_version),
             "scope_params": kwargs.get("scope_params", parent.scope_params),
             "public_key": kwargs.get("public_key", key.get_public_key_string()),
@@ -341,8 +415,7 @@ def assert_certificate_creation_fails_with_bad_parameters(self, parent, **params
 
     def test_certificate_creation_fails_for_wrong_profile(self):
         self.assert_certificate_creation_fails_with_bad_parameters(
-            parent=self.root_cert1_with_key,
-            profile="covfefe"
+            parent=self.root_cert1_with_key, profile="covfefe"
         )
 
     def test_certificate_creation_fails_for_parent_with_no_private_key(self):
@@ -366,17 +439,26 @@ def test_certificate_creation_fails_for_invalid_scope_definition_id(self):
     @override_settings(ALLOW_CERTIFICATE_PUSHING=True)
     def test_certificate_chain_pushing(self):
         # attach nonce value to certificate salt
-        response = self.client.post(reverse('nonces-list'), format='json')
-        self.unsaved_subset_cert.salt = response.json()['id']
+        response = self.client.post(reverse("nonces-list"), format="json")
+        self.unsaved_subset_cert.salt = response.json()["id"]
         self.unsaved_subset_cert.id = self.unsaved_subset_cert.calculate_uuid()
         self.unsaved_root_cert.sign_certificate(self.unsaved_subset_cert)
 
         # push cert chain up to server
-        data = json.dumps(CertificateSerializer([self.unsaved_root_cert, self.unsaved_subset_cert], many=True).data)
-        response = self.client.post(reverse('certificatechain-list'), data=data, format='json')
+        data = json.dumps(
+            CertificateSerializer(
+                [self.unsaved_root_cert, self.unsaved_subset_cert], many=True
+            ).data
+        )
+        response = self.client.post(
+            reverse("certificatechain-list"), data=data, format="json"
+        )
         self.assertEqual(response.status_code, 201)
         saved_subset_cert = Certificate.objects.get(id=self.unsaved_subset_cert.id)
-        self.assertEqual(saved_subset_cert.private_key.get_private_key_string(), self.sharedkey.private_key.get_private_key_string())
+        self.assertEqual(
+            saved_subset_cert.private_key.get_private_key_string(),
+            self.sharedkey.private_key.get_private_key_string(),
+        )
 
     @override_settings(ALLOW_CERTIFICATE_PUSHING=True)
     def test_certificate_chain_pushing_fails_for_nonce(self):
@@ -386,8 +468,14 @@ def test_certificate_chain_pushing_fails_for_nonce(self):
         self.unsaved_root_cert.sign_certificate(self.unsaved_subset_cert)
 
         # push cert chain up to server
-        data = json.dumps(CertificateSerializer([self.unsaved_root_cert, self.unsaved_subset_cert], many=True).data)
-        response = self.client.post(reverse('certificatechain-list'), data=data, format='json')
+        data = json.dumps(
+            CertificateSerializer(
+                [self.unsaved_root_cert, self.unsaved_subset_cert], many=True
+            ).data
+        )
+        response = self.client.post(
+            reverse("certificatechain-list"), data=data, format="json"
+        )
         self.assertEqual(response.status_code, 403)
 
     @override_settings(ALLOW_CERTIFICATE_PUSHING=True)
@@ -398,15 +486,20 @@ def test_certificate_chain_pushing_fails_for_public_key(self):
         self.unsaved_root_cert.sign_certificate(self.unsaved_subset_cert)
 
         # push cert chain up to server
-        data = json.dumps(CertificateSerializer([self.unsaved_root_cert, self.unsaved_subset_cert], many=True).data)
-        response = self.client.post(reverse('certificatechain-list'), data=data, format='json')
+        data = json.dumps(
+            CertificateSerializer(
+                [self.unsaved_root_cert, self.unsaved_subset_cert], many=True
+            ).data
+        )
+        response = self.client.post(
+            reverse("certificatechain-list"), data=data, format="json"
+        )
         self.assertEqual(response.status_code, 400)
 
 
 class NonceCreationTestCase(APITestCase):
-
     def test_nonces_can_be_created(self):
-        response = self.client.post(reverse('nonces-list'), {}, format='json')
+        response = self.client.post(reverse("nonces-list"), {}, format="json")
         data = json.loads(response.content.decode())
         self.assertEqual(response.status_code, 201)
         nonces = Nonce.objects.all()
@@ -414,48 +507,63 @@ def test_nonces_can_be_created(self):
         self.assertEqual(nonces[0].id, data["id"])
 
     def test_nonces_list_cannot_be_read(self):
-        response = self.client.get(reverse('nonces-list'), {}, format='json')
-        self.assertEqual(response.status_code, 403)
+        response = self.client.get(reverse("nonces-list"), {}, format="json")
+        self.assertEqual(response.status_code, 405)
         nonces = Nonce.objects.all()
         self.assertEqual(nonces.count(), 0)
 
     def test_nonces_item_cannot_be_read(self):
         # create the nonce
-        response = self.client.post(reverse('nonces-list'), {}, format='json')
+        response = self.client.post(reverse("nonces-list"), {}, format="json")
         data = json.loads(response.content.decode())
         # try to read the nonce
-        response = self.client.get(reverse('nonces-detail', kwargs={"pk": data["id"]}), {}, format='json')
-        self.assertEqual(response.status_code, 403)
+        with self.assertRaises(NoReverseMatch):
+            response = self.client.get(
+                reverse("nonces-detail", kwargs={"pk": data["id"]}), {}, format="json"
+            )
+            self.assertEqual(response.status_code, 403)
 
 
 class SyncSessionEndpointTestCase(CertificateTestCaseMixin, APITestCase):
+    def _get_nonce(self):
+        response = self.client.post(reverse("nonces-list"), {}, format="json")
+        return json.loads(response.content.decode())["id"]
 
     def get_initial_syncsession_data_for_request(self):
-
         # fetch a nonce value to use in creating the syncsession
-        response = self.client.post(reverse('nonces-list'), {}, format='json')
-        nonce = json.loads(response.content.decode())["id"]
+        nonce = self._get_nonce()
 
         # prepare the data to send in the syncsession creation request
         data = {
             "id": uuid.uuid4().hex,
             "server_certificate_id": self.root_cert1_with_key.id,
             "client_certificate_id": self.sub_subset_cert1_with_key.id,
-            "certificate_chain": json.dumps(CertificateSerializer(self.sub_subset_cert1_with_key.get_ancestors(include_self=True), many=True).data),
+            "certificate_chain": json.dumps(
+                CertificateSerializer(
+                    self.sub_subset_cert1_with_key.get_ancestors(include_self=True),
+                    many=True,
+                ).data
+            ),
             "connection_path": "http://127.0.0.1:8000",
-            "instance": json.dumps(InstanceIDSerializer(InstanceIDModel.get_or_create_current_instance()[0]).data),
+            "instance": json.dumps(
+                InstanceIDSerializer(
+                    InstanceIDModel.get_or_create_current_instance()[0]
+                ).data
+            ),
             "nonce": nonce,
         }
 
         # sign the nonce/ID combo to attach to the request
-        data["signature"] = self.sub_subset_cert1_with_key.sign("{nonce}:{id}".format(**data))
+        data["signature"] = self.sub_subset_cert1_with_key.sign(
+            "{nonce}:{id}".format(**data)
+        )
 
         return data
 
     def assertSyncSessionCreationFails(self, data, status_code=403):
 
         # make the API call to attempt to create the SyncSession, and make sure it was denied
-        response = self.client.post(reverse('syncsessions-list'), data, format='json')
+        response = self.client.post(reverse("syncsessions-list"), data, format="json")
         self.assertEqual(response.status_code, status_code)
 
         # check that the syncsession was not created
@@ -471,7 +579,7 @@ def test_syncsession_can_be_created(self):
         self.assertEqual(Certificate.objects.count(), self.original_cert_count - 2)
 
         # make the API call to create the SyncSession
-        response = self.client.post(reverse('syncsessions-list'), data, format='json')
+        response = self.client.post(reverse("syncsessions-list"), data, format="json")
         self.assertEqual(response.status_code, 201)
 
         # check that the cert chain was deserialized
@@ -480,8 +588,12 @@ def test_syncsession_can_be_created(self):
         # check that the syncsession was created
         syncsession = SyncSession.objects.get()
         self.assertEqual(syncsession.id, data["id"])
-        self.assertEqual(syncsession.server_certificate_id, data["server_certificate_id"])
-        self.assertEqual(syncsession.client_certificate_id, data["client_certificate_id"])
+        self.assertEqual(
+            syncsession.server_certificate_id, data["server_certificate_id"]
+        )
+        self.assertEqual(
+            syncsession.client_certificate_id, data["client_certificate_id"]
+        )
         self.assertTrue(syncsession.active)
 
     def test_syncsession_creation_fails_with_bad_signature(self):
@@ -496,7 +608,11 @@ def test_syncsession_creation_fails_with_client_cert_not_matching_cert_chain(sel
 
         data = self.get_initial_syncsession_data_for_request()
 
-        data["certificate_chain"] = json.dumps(CertificateSerializer(self.subset_cert2_with_key.get_ancestors(include_self=True), many=True).data)
+        data["certificate_chain"] = json.dumps(
+            CertificateSerializer(
+                self.subset_cert2_with_key.get_ancestors(include_self=True), many=True
+            ).data
+        )
 
         self.assertSyncSessionCreationFails(data)
 
@@ -504,7 +620,11 @@ def test_syncsession_creation_fails_with_expired_nonce(self):
 
         data = self.get_initial_syncsession_data_for_request()
 
-        Nonce.objects.all().update(timestamp=timezone.datetime(2000, 1, 1, tzinfo=timezone.get_current_timezone()))
+        Nonce.objects.all().update(
+            timestamp=timezone.datetime(
+                2000, 1, 1, tzinfo=timezone.get_current_timezone()
+            )
+        )
 
         self.assertSyncSessionCreationFails(data)
 
@@ -531,7 +651,9 @@ def test_syncsession_can_be_deleted(self):
         syncsession = SyncSession.objects.get()
         self.assertEqual(syncsession.active, True)
 
-        response = self.client.delete(reverse('syncsessions-detail', kwargs={"pk": syncsession.id}), format='json')
+        response = self.client.delete(
+            reverse("syncsessions-detail", kwargs={"pk": syncsession.id}), format="json"
+        )
         self.assertEqual(response.status_code, 204)
 
         # check that the syncsession was "deleted" but not _deleted_
@@ -546,12 +668,22 @@ def test_inactive_syncsession_cannot_be_deleted(self):
         syncsession.active = False
         syncsession.save()
 
-        response = self.client.delete(reverse('syncsessions-detail', kwargs={"pk": syncsession.id}), format='json')
+        response = self.client.delete(
+            reverse("syncsessions-detail", kwargs={"pk": syncsession.id}), format="json"
+        )
         self.assertEqual(response.status_code, 404)
 
+    def test_get_sync_session(self):
+        data = self.get_initial_syncsession_data_for_request()
+        self.client.post(reverse("syncsessions-list"), data, format="json")
+
+        response = self.client.get(
+            reverse("syncsessions-detail", kwargs={"pk": data["id"]})
+        )
+        self.assertEqual(response.status_code, 200)
 
-class TransferSessionEndpointTestCase(CertificateTestCaseMixin, APITestCase):
 
+class TransferSessionEndpointTestCase(CertificateTestCaseMixin, APITestCase):
     def test_transfersession_can_be_created(self):
 
         self.make_transfersession_creation_request(
@@ -562,11 +694,15 @@ def test_transfersession_can_be_created(self):
     def test_transfersession_can_be_created_with_smaller_subset_filter(self):
 
         self.make_transfersession_creation_request(
-            filter=str(self.sub_subset_cert1_with_key.get_scope().read_filter).split()[0],
+            filter=str(self.sub_subset_cert1_with_key.get_scope().read_filter).split()[
+                0
+            ],
             push=False,
         )
 
-    def test_transfersession_creation_fails_for_push_when_filter_not_in_client_write_scope(self):
+    def test_transfersession_creation_fails_for_push_when_filter_not_in_client_write_scope(
+        self,
+    ):
 
         self.make_transfersession_creation_request(
             filter=str(self.root_cert1_with_key.get_scope().read_filter),
@@ -575,7 +711,9 @@ def test_transfersession_creation_fails_for_push_when_filter_not_in_client_write
             expected_message="Client certificate scope does not permit pushing",
         )
 
-    def test_transfersession_creation_fails_for_push_when_filter_not_in_server_read_scope(self):
+    def test_transfersession_creation_fails_for_push_when_filter_not_in_server_read_scope(
+        self,
+    ):
 
         syncsession = self.create_syncsession(
             client_certificate=self.root_cert1_with_key,
@@ -590,7 +728,9 @@ def test_transfersession_creation_fails_for_push_when_filter_not_in_server_read_
             expected_message="Server certificate scope does not permit receiving pushes",
         )
 
-    def test_transfersession_creation_fails_for_pull_when_filter_not_in_client_read_scope(self):
+    def test_transfersession_creation_fails_for_pull_when_filter_not_in_client_read_scope(
+        self,
+    ):
 
         self.make_transfersession_creation_request(
             filter=str(self.root_cert1_with_key.get_scope().read_filter),
@@ -599,7 +739,9 @@ def test_transfersession_creation_fails_for_pull_when_filter_not_in_client_read_
             expected_message="Client certificate scope does not permit pulling",
         )
 
-    def test_transfersession_creation_fails_for_pull_when_filter_not_in_server_write_scope(self):
+    def test_transfersession_creation_fails_for_pull_when_filter_not_in_server_write_scope(
+        self,
+    ):
 
         syncsession = self.create_syncsession(
             client_certificate=self.root_cert1_with_key,
@@ -648,6 +790,9 @@ def test_transfersession_can_be_deleted(self):
         self.test_transfersession_can_be_created()
 
         transfersession = TransferSession.objects.get()
+        transfersession.update_state(
+            stage=transfer_stages.DESERIALIZING, stage_status=transfer_statuses.COMPLETED
+        )
         self.assertEqual(transfersession.active, True)
 
         self._delete_transfer_session(transfersession)
@@ -658,13 +803,18 @@ def test_transfersession_with_null_records_total_can_be_deleted(self):
 
         transfersession = TransferSession.objects.get()
         transfersession.records_total = None
-        transfersession.save()
+        transfersession.update_state(
+            stage=transfer_stages.DESERIALIZING, stage_status=transfer_statuses.COMPLETED
+        )
 
         self._delete_transfer_session(transfersession)
 
     def _delete_transfer_session(self, transfersession):
 
-        response = self.client.delete(reverse('transfersessions-detail', kwargs={"pk": transfersession.id}), format='json')
+        response = self.client.delete(
+            reverse("transfersessions-detail", kwargs={"pk": transfersession.id}),
+            format="json",
+        )
         self.assertEqual(response.status_code, 204)
 
         # check that the transfersession was "deleted"
@@ -681,16 +831,22 @@ def test_inactive_transfersession_cannot_be_deleted(self):
         transfersession.active = False
         transfersession.save()
 
-        response = self.client.delete(reverse('transfersessions-detail', kwargs={"pk": transfersession.id}), format='json')
+        response = self.client.delete(
+            reverse("transfersessions-detail", kwargs={"pk": transfersession.id}),
+            format="json",
+        )
         self.assertEqual(response.status_code, 404)
 
 
 class BufferEndpointTestCase(CertificateTestCaseMixin, APITestCase):
-
     def setUp(self):
         super(BufferEndpointTestCase, self).setUp()
-        self.default_push_filter = str(self.sub_subset_cert1_with_key.get_scope().write_filter)
-        self.default_pull_filter = str(self.sub_subset_cert1_with_key.get_scope().read_filter)
+        self.default_push_filter = str(
+            self.sub_subset_cert1_with_key.get_scope().write_filter
+        )
+        self.default_pull_filter = str(
+            self.sub_subset_cert1_with_key.get_scope().read_filter
+        )
 
     def build_buffer_item(self, **kwargs):
 
@@ -702,12 +858,21 @@ def build_buffer_item(self, **kwargs):
 
         server_cert = kwargs["transfer_session"].sync_session.client_certificate
         push = kwargs["transfer_session"].push
-
-        filt = server_cert.get_scope().write_filter if push else server_cert.get_scope().read_filter
+        records_total = kwargs["transfer_session"].records_total or 0
+        kwargs["transfer_session"].records_total = records_total + 1
+        kwargs["transfer_session"].save()
+
+        filt = (
+            server_cert.get_scope().write_filter
+            if push
+            else server_cert.get_scope().read_filter
+        )
         partition = filt._filter_tuple[0] + ":furthersubpart"
 
         data = {
-            "profile": kwargs.get("profile", kwargs["transfer_session"].sync_session.profile),
+            "profile": kwargs.get(
+                "profile", kwargs["transfer_session"].sync_session.profile
+            ),
             "serialized": kwargs.get("serialized", '{"test": 99}'),
             "deleted": kwargs.get("deleted", False),
             "last_saved_instance": kwargs.get("last_saved_instance", uuid.uuid4().hex),
@@ -715,14 +880,18 @@ def build_buffer_item(self, **kwargs):
             "partition": kwargs.get("partition", partition),
             "source_id": kwargs.get("source_id", uuid.uuid4().hex),
             "model_name": kwargs.get("model_name", "contentsummarylog"),
-            "conflicting_serialized_data": kwargs.get("conflicting_serialized_data", ""),
+            "conflicting_serialized_data": kwargs.get(
+                "conflicting_serialized_data", ""
+            ),
             "model_uuid": kwargs.get("model_uuid", None),
             "transfer_session": kwargs["transfer_session"],
         }
 
         if not data["model_uuid"]:
             Model = syncable_models.get_model(data["profile"], data["model_name"])
-            data["model_uuid"] = Model.compute_namespaced_id(data["partition"], data["source_id"], data["model_name"])
+            data["model_uuid"] = Model.compute_namespaced_id(
+                data["partition"], data["source_id"], data["model_name"]
+            )
 
         buffermodel = Buffer.objects.create(**data)
 
@@ -741,22 +910,20 @@ def make_buffer_post_request(self, buffers, expected_status=201, gzip=False):
 
         # extract that data that is to be posted
         data = serialized_recs.data
-        headers = {
-            'format': 'json'
-        }
+        headers = {"format": "json"}
 
         # gzip the content before sending the request
         if gzip:
             new_data = json.dumps([dict(el) for el in data])
-            data = compress_string(bytes(new_data.encode('utf-8')))
-            headers['content_type'] = 'application/gzip'
-            headers['format'] = None
+            data = compress_string(bytes(new_data.encode("utf-8")))
+            headers["content_type"] = "application/gzip"
+            headers["format"] = None
 
         # delete the records from the DB so we don't conflict when we POST
         Buffer.objects.all().delete()
         RecordMaxCounterBuffer.objects.all().delete()
 
-        response = self.client.post(reverse('buffers-list'), data, **headers)
+        response = self.client.post(reverse("buffers-list"), data, **headers)
         self.assertEqual(response.status_code, expected_status)
         if expected_status == 201:
             # check that the buffer items were created
@@ -767,9 +934,13 @@ def make_buffer_post_request(self, buffers, expected_status=201, gzip=False):
 
     def test_push_valid_gzipped_buffer_chunk(self):
         rec_1 = self.build_buffer_item(push=True, filter=self.default_push_filter)
-        rec_2 = self.build_buffer_item(serialized=u'unicode', transfer_session=rec_1.transfer_session)
+        rec_2 = self.build_buffer_item(
+            serialized=u"unicode", transfer_session=rec_1.transfer_session
+        )
         rec_3 = self.build_buffer_item(transfer_session=rec_1.transfer_session)
-        self.make_buffer_post_request([rec_1, rec_2, rec_3], expected_status=201, gzip=True)
+        self.make_buffer_post_request(
+            [rec_1, rec_2, rec_3], expected_status=201, gzip=True
+        )
 
     def test_push_valid_buffer_chunk(self):
         rec_1 = self.build_buffer_item(push=True, filter=self.default_push_filter)
@@ -779,14 +950,20 @@ def test_push_valid_buffer_chunk(self):
 
     def test_push_with_invalid_model_uuid(self):
         rec_1 = self.build_buffer_item(push=True, filter=self.default_push_filter)
-        rec_2 = self.build_buffer_item(transfer_session=rec_1.transfer_session, model_uuid=uuid.uuid4().hex, model_name='facility')
+        rec_2 = self.build_buffer_item(
+            transfer_session=rec_1.transfer_session,
+            model_uuid=uuid.uuid4().hex,
+            model_name="facility",
+        )
         rec_3 = self.build_buffer_item(transfer_session=rec_1.transfer_session)
         self.make_buffer_post_request([rec_1, rec_2, rec_3], expected_status=400)
 
     def test_push_with_partition_not_in_filter(self):
         rec_1 = self.build_buffer_item(push=True, filter=self.default_push_filter)
         rec_2 = self.build_buffer_item(transfer_session=rec_1.transfer_session)
-        rec_3 = self.build_buffer_item(transfer_session=rec_1.transfer_session, partition=uuid.uuid4().hex)
+        rec_3 = self.build_buffer_item(
+            transfer_session=rec_1.transfer_session, partition=uuid.uuid4().hex
+        )
         self.make_buffer_post_request([rec_1, rec_2, rec_3], expected_status=400)
 
     def test_push_fails_for_pull_transfersession(self):
@@ -813,22 +990,24 @@ def create_records_for_pulling(self, count=3, **kwargs):
             records.append(self.build_buffer_item(transfer_session=transfer_session))
 
         # also make some dummy records so we can make sure they don't get returned
-        records.append(self.build_buffer_item(push=False, filter=self.default_pull_filter))
-        records.append(self.build_buffer_item(transfer_session=records[-1].transfer_session))
+        records.append(
+            self.build_buffer_item(push=False, filter=self.default_pull_filter)
+        )
+        records.append(
+            self.build_buffer_item(transfer_session=records[-1].transfer_session)
+        )
 
         # save all the records to the database
         [rec.save() for rec in records]
 
         return records[0].transfer_session.id
 
-    def make_buffer_get_request(self, expected_status=200, expected_count=None, **get_params):
+    def make_buffer_get_request(
+        self, expected_status=200, expected_count=None, **get_params
+    ):
         """Make a GET request to the buffer endpoint. Warning: Deletes the local buffer instances before validating."""
 
-        response = self.client.get(
-            reverse('buffers-list'),
-            get_params,
-            format='json'
-        )
+        response = self.client.get(reverse("buffers-list"), get_params, format="json")
 
         self.assertEqual(response.status_code, expected_status)
 
@@ -850,14 +1029,23 @@ def make_buffer_get_request(self, expected_status=200, expected_count=None, **ge
             model_uuids = [d["model_uuid"] for d in data]
 
             # delete "local" buffer records to avoid uniqueness constraint failures in validation
-            Buffer.objects.filter(transfer_session_id=t_id, model_uuid__in=model_uuids).delete()
+            Buffer.objects.filter(
+                transfer_session_id=t_id, model_uuid__in=model_uuids
+            ).delete()
 
             # run the validation logic to ensure no errors were returned
-            errors = validate_and_create_buffer_data(data, TransferSession.objects.get(id=t_id))
+            errors = validate_and_create_buffer_data(
+                data, TransferSession.objects.get(id=t_id)
+            )
             self.assertFalse(errors)
 
             # check that the correct number of buffer items were created
-            self.assertEqual(expected_count, Buffer.objects.filter(transfer_session_id=t_id, model_uuid__in=model_uuids).count())
+            self.assertEqual(
+                expected_count,
+                Buffer.objects.filter(
+                    transfer_session_id=t_id, model_uuid__in=model_uuids
+                ).count(),
+            )
 
             # check that the correct number of buffer items was returned
             self.assertEqual(expected_count, len(data))
@@ -937,30 +1125,34 @@ def test_pull_by_page_offset_works(self):
 
 
 class MorangoInfoTestCase(APITestCase):
-
     def setUp(self):
         InstanceIDModel.get_or_create_current_instance()
-        self.m_info = self.client.get(reverse('morangoinfo-detail', kwargs={"pk": 1}), format='json')
+        self.m_info = self.client.get(
+            reverse("morangoinfo-detail", kwargs={"pk": 1}), format="json"
+        )
 
     def test_id_changes_id_hash_changes(self):
-        old_id_hash = self.m_info.data['instance_hash']
+        old_id_hash = self.m_info.data["instance_hash"]
         with EnvironmentVarGuard() as env:
-            env['MORANGO_SYSTEM_ID'] = 'new_sys_id'
+            env["MORANGO_SYSTEM_ID"] = "new_sys_id"
             InstanceIDModel.get_or_create_current_instance(clear_cache=True)
-            m_info = self.client.get(reverse('morangoinfo-detail', kwargs={"pk": 1}), format='json')
-        self.assertNotEqual(m_info.data['instance_hash'], old_id_hash)
+            m_info = self.client.get(
+                reverse("morangoinfo-detail", kwargs={"pk": 1}), format="json"
+            )
+        self.assertNotEqual(m_info.data["instance_hash"], old_id_hash)
 
 
 class PublicKeyTestCase(APITestCase):
-
     def setUp(self):
         self.key = SharedKey.get_or_create_shared_key()
 
     @override_settings(ALLOW_CERTIFICATE_PUSHING=True)
     def test_get_public_key(self):
-        response = self.client.get(reverse('publickey-list'), format='json')
-        self.assertEqual(response.data[0]['public_key'], self.key.public_key.get_public_key_string())
+        response = self.client.get(reverse("publickey-list"), format="json")
+        self.assertEqual(
+            response.data[0]["public_key"], self.key.public_key.get_public_key_string()
+        )
 
     def test_can_not_get_public_key(self):
-        response = self.client.get(reverse('publickey-list'), format='json')
+        response = self.client.get(reverse("publickey-list"), format="json")
         self.assertEqual(response.status_code, 403)
diff --git a/tests/testapp/tests/test_connection.py b/tests/testapp/tests/test_connection.py
deleted file mode 100644
index 201ed4d3..00000000
--- a/tests/testapp/tests/test_connection.py
+++ /dev/null
@@ -1,643 +0,0 @@
-import json
-import uuid
-
-import mock
-from django.test import TestCase
-from django.test.testcases import LiveServerTestCase
-from django.test.utils import override_settings
-from django.utils import timezone
-from facility_profile.models import SummaryLog
-from requests.exceptions import HTTPError
-from requests.exceptions import RequestException
-from requests.exceptions import Timeout
-
-from morango.api.serializers import BufferSerializer
-from morango.api.serializers import CertificateSerializer
-from morango.constants.capabilities import ALLOW_CERTIFICATE_PUSHING
-from morango.errors import CertificateSignatureInvalid
-from morango.errors import MorangoServerDoesNotAllowNewCertPush
-from morango.models.certificates import Certificate
-from morango.models.certificates import Filter
-from morango.models.certificates import Key
-from morango.models.certificates import ScopeDefinition
-from morango.models.core import Buffer
-from morango.models.core import InstanceIDModel
-from morango.models.core import SyncSession
-from morango.models.core import TransferSession
-from morango.models.fields.crypto import SharedKey
-from morango.sync.controller import MorangoProfileController
-from morango.sync.session import _length_of_headers
-from morango.sync.session import SessionWrapper
-from morango.sync.syncsession import NetworkSyncConnection
-from morango.sync.syncsession import BaseSyncClient
-from morango.sync.syncsession import PullClient
-from morango.sync.syncsession import PushClient
-from morango.sync.syncsession import SyncSessionClient
-
-
-def mock_patch_decorator(func):
-    def wrapper(*args, **kwargs):
-        mock_object = mock.Mock(
-            content=b"""{"id": "abc"}""",
-            data={"signature": "sig", "client_fsic": "{}", "server_fsic": "{}"},
-        )
-        mock_object.json.return_value = {}
-        with mock.patch.object(SessionWrapper, "request", return_value=mock_object):
-            with mock.patch.object(Certificate, "verify", return_value=True):
-                return func(*args, **kwargs)
-
-    return wrapper
-
-
-class NetworkSyncConnectionTestCase(LiveServerTestCase):
-    def setUp(self):
-        self.profile = "facilitydata"
-
-        self.root_scope_def = ScopeDefinition.objects.create(
-            id="rootcert",
-            profile=self.profile,
-            version=1,
-            primary_scope_param_key="mainpartition",
-            description="Root cert for ${mainpartition}.",
-            read_filter_template="",
-            write_filter_template="",
-            read_write_filter_template="${mainpartition}",
-        )
-
-        self.subset_scope_def = ScopeDefinition.objects.create(
-            id="subcert",
-            profile=self.profile,
-            version=1,
-            primary_scope_param_key="",
-            description="Subset cert under ${mainpartition} for ${subpartition}.",
-            read_filter_template="${mainpartition}",
-            write_filter_template="${mainpartition}:${subpartition}",
-            read_write_filter_template="",
-        )
-
-        self.root_cert = Certificate.generate_root_certificate(self.root_scope_def.id)
-
-        self.subset_cert = Certificate(
-            parent=self.root_cert,
-            profile=self.profile,
-            scope_definition=self.subset_scope_def,
-            scope_version=self.subset_scope_def.version,
-            scope_params=json.dumps(
-                {"mainpartition": self.root_cert.id, "subpartition": "abracadabra"}
-            ),
-            private_key=Key(),
-        )
-        self.root_cert.sign_certificate(self.subset_cert)
-        self.subset_cert.save()
-
-        self.unsaved_cert = Certificate(
-            parent=self.root_cert,
-            profile=self.profile,
-            scope_definition=self.subset_scope_def,
-            scope_version=self.subset_scope_def.version,
-            scope_params=json.dumps(
-                {"mainpartition": self.root_cert.id, "subpartition": "other"}
-            ),
-            public_key=Key(),
-        )
-        self.root_cert.sign_certificate(self.unsaved_cert)
-
-        self.controller = MorangoProfileController("facilitydata")
-        self.network_connection = self.controller.create_network_connection(
-            self.live_server_url
-        )
-        self.key = SharedKey.get_or_create_shared_key()
-
-    @mock.patch.object(SyncSession.objects, "create", return_value=None)
-    def test_creating_sync_session_successful(self, mock_object):
-        self.assertEqual(SyncSession.objects.filter(active=True).count(), 0)
-        self.network_connection.create_sync_session(self.subset_cert, self.root_cert)
-        self.assertEqual(SyncSession.objects.filter(active=True).count(), 1)
-
-    @mock.patch.object(NetworkSyncConnection, "_create_sync_session")
-    @mock.patch.object(Certificate, "verify", return_value=False)
-    def test_creating_sync_session_cert_fails_to_verify(self, mock_verify, mock_create):
-        mock_create.return_value.json.return_value = {}
-        with self.assertRaises(CertificateSignatureInvalid):
-            self.network_connection.create_sync_session(
-                self.subset_cert, self.root_cert
-            )
-
-    def test_get_remote_certs(self):
-        certs = self.subset_cert.get_ancestors(include_self=True)
-        remote_certs = self.network_connection.get_remote_certificates(
-            self.root_cert.id
-        )
-        self.assertSetEqual(set(certs), set(remote_certs))
-
-    @mock.patch.object(SessionWrapper, "request")
-    def test_csr(self, mock_request):
-        # mock a "signed" cert being returned by server
-        cert_serialized = json.dumps(CertificateSerializer(self.subset_cert).data)
-        mock_request.return_value.json.return_value = json.loads(cert_serialized)
-        self.subset_cert.delete()
-
-        # we only want to make sure the "signed" cert is saved
-        with mock.patch.object(
-            Key,
-            "get_private_key_string",
-            return_value=self.subset_cert.private_key.get_private_key_string(),
-        ):
-            self.network_connection.certificate_signing_request(self.root_cert, "", "")
-        self.assertTrue(
-            Certificate.objects.filter(id=json.loads(cert_serialized)["id"]).exists()
-        )
-
-    @override_settings(ALLOW_CERTIFICATE_PUSHING=True)
-    def test_push_signed_client_certificate_chain(self):
-        self.network_connection.capabilities = [ALLOW_CERTIFICATE_PUSHING]
-        cert = self.network_connection.push_signed_client_certificate_chain(
-            self.root_cert,
-            self.subset_scope_def.id,
-            {"mainpartition": self.root_cert.id, "subpartition": "abracadabra"},
-        )
-        self.assertEqual(cert.private_key, None)
-        self.assertTrue(Certificate.objects.filter(id=cert.id).exists())
-
-    @override_settings(ALLOW_CERTIFICATE_PUSHING=True)
-    def test_push_signed_client_certificate_chain_publickey_error(self):
-        self.network_connection.capabilities = [ALLOW_CERTIFICATE_PUSHING]
-        with mock.patch.object(NetworkSyncConnection, "_get_public_key"):
-            NetworkSyncConnection._get_public_key.return_value.json.return_value = [
-                {"public_key": Key().get_public_key_string()}
-            ]
-            with self.assertRaises(HTTPError) as e:
-                self.network_connection.push_signed_client_certificate_chain(
-                    self.root_cert,
-                    self.subset_scope_def.id,
-                    {"mainpartition": self.root_cert.id, "subpartition": "abracadabra"},
-                )
-            self.assertEqual(e.exception.response.status_code, 400)
-
-    @override_settings(ALLOW_CERTIFICATE_PUSHING=True)
-    def test_push_signed_client_certificate_chain_bad_cert(self):
-        self.network_connection.capabilities = [ALLOW_CERTIFICATE_PUSHING]
-        with self.assertRaises(HTTPError) as e:
-            self.network_connection.push_signed_client_certificate_chain(
-                self.root_cert, self.subset_scope_def.id, {"bad": "scope_params"}
-            )
-        self.assertEqual(e.exception.response.status_code, 400)
-
-    @override_settings(ALLOW_CERTIFICATE_PUSHING=True)
-    @mock.patch.object(NetworkSyncConnection, "_get_nonce")
-    def test_push_signed_client_certificate_chain_nonce_error(self, mock_nonce):
-        self.network_connection.capabilities = [ALLOW_CERTIFICATE_PUSHING]
-        mock_nonce.return_value.json.return_value = {"id": uuid.uuid4().hex}
-        with self.assertRaises(HTTPError) as e:
-            self.network_connection.push_signed_client_certificate_chain(
-                self.root_cert,
-                self.subset_scope_def.id,
-                {"mainpartition": self.root_cert.id, "subpartition": "abracadabra"},
-            )
-        self.assertEqual(e.exception.response.status_code, 403)
-
-    def test_push_signed_client_certificate_chain_not_allowed(self):
-        with self.assertRaises(MorangoServerDoesNotAllowNewCertPush) as e:
-            self.network_connection.push_signed_client_certificate_chain(
-                self.root_cert,
-                self.subset_scope_def.id,
-                {"mainpartition": self.root_cert.id, "subpartition": "abracadabra"},
-            )
-            self.assertEqual(e.exception.response.status_code, 403)
-
-    def test_get_cert_chain(self):
-        response = self.network_connection._get_certificate_chain(
-            params={"ancestors_of": self.subset_cert.id}
-        )
-        data = response.json()
-        self.assertEqual(len(data), Certificate.objects.count())
-        self.assertEqual(data[0]["id"], self.root_cert.id)
-        self.assertEqual(data[1]["id"], self.subset_cert.id)
-
-    @mock.patch.object(SyncSession.objects, "create")
-    def test_close_sync_session(self, mock_create):
-        mock_session = mock.Mock(spec=SyncSession)
-
-        def create(**data):
-            mock_session.id = data.get("id")
-            return mock_session
-
-        mock_create.side_effect = create
-        self.assertEqual(SyncSession.objects.filter(active=True).count(), 0)
-        client = self.network_connection.create_sync_session(
-            self.subset_cert, self.root_cert
-        )
-        self.assertEqual(SyncSession.objects.filter(active=True).count(), 1)
-
-        self.network_connection.close_sync_session(client.sync_session)
-        self.assertEqual(SyncSession.objects.filter(active=True).count(), 0)
-
-
-class SyncClientTestCase(LiveServerTestCase):
-    def setUp(self):
-        self.session = SyncSession.objects.create(
-            id=uuid.uuid4().hex,
-            profile="facilitydata",
-            last_activity_timestamp=timezone.now(),
-        )
-        self.transfer_session = TransferSession.objects.create(
-            id=uuid.uuid4().hex,
-            sync_session=self.session,
-            filter="partition",
-            push=True,
-            last_activity_timestamp=timezone.now(),
-            records_total=3,
-        )
-        self.chunk_size = 3
-        self.conn = NetworkSyncConnection(base_url=self.live_server_url)
-        self.syncclient = self.build_client(BaseSyncClient)
-        InstanceIDModel.get_or_create_current_instance()
-
-    def build_client(self, client_class):
-        client = client_class(self.conn, self.session, self.chunk_size)
-        client.current_transfer_session = self.transfer_session
-        self.transferring_mock = mock.Mock()
-        client.signals.transferring.connect(self.transferring_mock)
-        return client
-
-    def build_buffer_items(self, transfer_session, **kwargs):
-
-        data = {
-            "profile": kwargs.get("profile", "facilitydata"),
-            "serialized": kwargs.get("serialized", '{"test": 99}'),
-            "deleted": kwargs.get("deleted", False),
-            "last_saved_instance": kwargs.get("last_saved_instance", uuid.uuid4().hex),
-            "last_saved_counter": kwargs.get("last_saved_counter", 179),
-            "partition": kwargs.get("partition", "partition"),
-            "source_id": kwargs.get("source_id", uuid.uuid4().hex),
-            "model_name": kwargs.get("model_name", "contentsummarylog"),
-            "conflicting_serialized_data": kwargs.get(
-                "conflicting_serialized_data", ""
-            ),
-            "model_uuid": kwargs.get("model_uuid", None),
-            "transfer_session": transfer_session,
-        }
-
-        for i in range(self.chunk_size):
-            data["source_id"] = uuid.uuid4().hex
-            data["model_uuid"] = SummaryLog.compute_namespaced_id(
-                data["partition"], data["source_id"], data["model_name"]
-            )
-            Buffer.objects.create(**data)
-
-        buffered_items = Buffer.objects.filter(
-            transfer_session=self.syncclient.current_transfer_session
-        )
-        serialized_records = BufferSerializer(buffered_items, many=True)
-        return json.dumps(serialized_records.data)
-
-    def test_get_pull_client(self):
-        session_client = self.build_client(SyncSessionClient)
-        client = session_client.get_pull_client()
-        self.assertIsInstance(client, PullClient)
-        self.assertEqual(session_client.sync_connection, client.sync_connection)
-        self.assertEqual(session_client.sync_session, client.sync_session)
-
-    def test_get_push_client(self):
-        session_client = self.build_client(SyncSessionClient)
-        client = session_client.get_push_client()
-        self.assertIsInstance(client, PushClient)
-        self.assertEqual(session_client.sync_connection, client.sync_connection)
-        self.assertEqual(session_client.sync_session, client.sync_session)
-
-    @mock_patch_decorator
-    def test_push_records(self):
-        client = self.build_client(PushClient)
-        self.build_buffer_items(client.current_transfer_session)
-        self.assertEqual(client.current_transfer_session.records_transferred, 0)
-        with client.signals.transferring.send(
-            transfer_session=client.current_transfer_session
-        ) as in_progress:
-            client._push_records(in_progress.fire)
-        self.assertEqual(
-            client.current_transfer_session.records_transferred, self.chunk_size,
-        )
-        self.assertGreaterEqual(client.current_transfer_session.bytes_received, 150)
-        self.transferring_mock.assert_called_with(
-            transfer_session=client.current_transfer_session
-        )
-
-    @mock_patch_decorator
-    def test_pull_records(self):
-        self.transfer_session.push = False
-        self.transfer_session.save()
-        client = self.build_client(PullClient)
-
-        resp = self.build_buffer_items(client.current_transfer_session)
-        SessionWrapper.request.return_value.json.return_value = json.loads(resp)
-        Buffer.objects.filter(transfer_session=client.current_transfer_session).delete()
-        self.assertEqual(
-            Buffer.objects.filter(
-                transfer_session=client.current_transfer_session
-            ).count(),
-            0,
-        )
-        self.assertEqual(client.current_transfer_session.records_transferred, 0)
-        with client.signals.transferring.send(
-            transfer_session=client.current_transfer_session
-        ) as status:
-            client._pull_records(status.in_progress.fire)
-        self.assertEqual(
-            Buffer.objects.filter(
-                transfer_session=client.current_transfer_session
-            ).count(),
-            self.chunk_size,
-        )
-        self.assertEqual(
-            client.current_transfer_session.records_transferred, self.chunk_size,
-        )
-        self.assertGreaterEqual(client.current_transfer_session.bytes_received, 150)
-        self.transferring_mock.assert_called_with(
-            transfer_session=client.current_transfer_session
-        )
-
-    @mock_patch_decorator
-    def test_create_transfer_session_push(self):
-        self.syncclient.current_transfer_session.active = False
-        self.syncclient.current_transfer_session.save()
-        self.assertEqual(TransferSession.objects.filter(active=True).count(), 0)
-        self.syncclient._create_transfer_session("filter", push=True)
-        self.assertEqual(TransferSession.objects.filter(active=True).count(), 1)
-
-    @mock_patch_decorator
-    def test_close_transfer_session_push(self):
-        self.assertEqual(TransferSession.objects.filter(active=True).count(), 1)
-        self.syncclient._close_transfer_session()
-        self.assertEqual(TransferSession.objects.filter(active=True).count(), 0)
-
-    @mock.patch(
-        "morango.sync.syncsession.BaseSyncClient._close_server_transfer_session"
-    )
-    @mock_patch_decorator
-    def test_close_transfer_session_disallow_timeout(self, mocked_close):
-        mocked_close.side_effect = Timeout()
-
-        self.assertEqual(TransferSession.objects.filter(active=True).count(), 1)
-        with self.assertRaises(Timeout):
-            self.syncclient._close_transfer_session()
-
-        self.assertEqual(TransferSession.objects.filter(active=True).count(), 1)
-
-    @mock.patch(
-        "morango.sync.syncsession.BaseSyncClient._close_server_transfer_session"
-    )
-    @mock_patch_decorator
-    def test_close_transfer_session_allow_timeout(self, mocked_close):
-        mocked_close.side_effect = Timeout()
-
-        self.assertEqual(TransferSession.objects.filter(active=True).count(), 1)
-        self.syncclient._close_transfer_session(allow_server_timeout=True)
-        self.assertEqual(TransferSession.objects.filter(active=True).count(), 0)
-
-    @mock.patch("morango.sync.syncsession.PullClient")
-    def test_initiate_pull(self, MockPullClient):
-        """
-        TODO: should eventually be removed as this method is deprecated
-        """
-        mock_pull_client = mock.Mock(spec=PullClient)
-        MockPullClient.return_value = mock_pull_client
-        client = self.build_client(SyncSessionClient)
-
-        filter = Filter("abc123")
-        client.initiate_pull(filter)
-        MockPullClient.assert_called_with(
-            self.conn, self.session, chunk_size=self.chunk_size
-        )
-
-        mock_pull_client.initialize.assert_called_once_with(filter)
-        mock_pull_client.run.assert_called_once()
-        mock_pull_client.finalize.assert_called_once()
-
-        self.assertEqual(client.signals, mock_pull_client.signals)
-
-    @mock.patch("morango.sync.syncsession.PushClient")
-    def test_initiate_push(self, MockPushClient):
-        """
-        TODO: should eventually be removed as this method is deprecated
-        """
-        mock_pull_client = mock.Mock(spec=PushClient)
-        MockPushClient.return_value = mock_pull_client
-        client = self.build_client(SyncSessionClient)
-
-        sync_filter = Filter("abc123")
-        client.initiate_push(sync_filter)
-        MockPushClient.assert_called_with(
-            self.conn, self.session, chunk_size=self.chunk_size
-        )
-
-        mock_pull_client.initialize.assert_called_once_with(sync_filter)
-        mock_pull_client.run.assert_called_once()
-        mock_pull_client.finalize.assert_called_once()
-
-        self.assertEqual(client.signals, mock_pull_client.signals)
-
-    def test_close_sync_session(self):
-        """
-        TODO: should eventually be removed as this method is deprecated
-        """
-        conn = mock.Mock(spec=NetworkSyncConnection)
-        client = SyncSessionClient(conn, self.session)
-        client.close_sync_session()
-        conn.close.assert_called_once()
-
-    @mock.patch(
-        "morango.sync.syncsession.NetworkSyncConnection._update_transfer_session"
-    )
-    @mock.patch("morango.sync.syncsession._queue_into_buffer")
-    @mock.patch("morango.sync.syncsession.BaseSyncClient._create_transfer_session")
-    @mock.patch("morango.sync.syncsession._serialize_into_store")
-    @mock.patch("morango.sync.syncsession.settings")
-    def test_push_client__initialize(
-        self,
-        mock_settings,
-        mock_serialize,
-        mock_parent_create,
-        mock_queue,
-        mock_transfer_update,
-    ):
-        mock_handler = mock.Mock()
-        client = self.build_client(PushClient)
-        client.signals.queuing.connect(mock_handler)
-        sync_filter = Filter("abc123")
-        setattr(mock_settings, "MORANGO_SERIALIZE_BEFORE_QUEUING", True)
-
-        client.initialize(sync_filter)
-        mock_serialize.assert_called_with(self.session.profile, filter=sync_filter)
-        mock_parent_create.assert_called_with(sync_filter, push=True)
-        mock_queue.assert_called_with(client.current_transfer_session)
-        mock_transfer_update.assert_called_once_with(
-            {"records_total": client.current_transfer_session.records_total},
-            client.current_transfer_session,
-        )
-
-        mock_handler.assert_any_call(transfer_session=client.current_transfer_session)
-
-    @mock.patch("morango.sync.syncsession.PushClient._push_records")
-    def test_push_client__run(self, mock_push):
-        client = self.build_client(PushClient)
-        client.current_transfer_session.records_total = 1
-        client.run()
-
-        mock_push.assert_called_once()
-        self.transferring_mock.assert_called_with(
-            transfer_session=client.current_transfer_session
-        )
-
-    @mock.patch("morango.sync.syncsession.PushClient._push_records")
-    def test_push_client__run__no_records(self, mock_push):
-        client = self.build_client(PushClient)
-        client.current_transfer_session.records_total = 0
-        client.run()
-        mock_push.assert_not_called()
-
-    @mock.patch("morango.sync.syncsession.BaseSyncClient._close_transfer_session")
-    def test_push_client__finalize(self, mock_close):
-        mock_handler = mock.Mock()
-        client = self.build_client(PushClient)
-        client.signals.dequeuing.connect(mock_handler)
-        client.finalize()
-
-        mock_handler.assert_any_call(transfer_session=client.current_transfer_session)
-        mock_close.assert_called_once()
-
-    @mock.patch("morango.sync.syncsession.BaseSyncClient._create_transfer_session")
-    def test_pull_client__initialize(self, mock_parent_create):
-        client = self.build_client(PullClient)
-        sync_filter = Filter("abc123")
-
-        client.initialize(sync_filter)
-        self.assertEqual(sync_filter, client.sync_filter)
-        mock_parent_create.assert_called_with(sync_filter, push=False)
-
-    @mock.patch("morango.sync.syncsession.PullClient._pull_records")
-    def test_pull_client__run(self, mock_pull):
-        client = self.build_client(PullClient)
-        client.current_transfer_session.records_total = 1
-        client.run()
-
-        mock_pull.assert_called_once()
-        self.transferring_mock.assert_called_with(
-            transfer_session=client.current_transfer_session
-        )
-
-    @mock.patch("morango.sync.syncsession.PullClient._pull_records")
-    def test_pull_client__run__no_records(self, mock_pull):
-        client = self.build_client(PullClient)
-        client.current_transfer_session.records_total = 0
-        client.run()
-        mock_pull.assert_not_called()
-
-    @mock.patch("morango.sync.syncsession.BaseSyncClient._close_transfer_session")
-    @mock.patch("morango.sync.syncsession._dequeue_into_store")
-    def test_pull_client__finalize(self, mock_dequeue, mock_close):
-        mock_handler = mock.Mock()
-        client = self.build_client(PullClient)
-        client.signals.dequeuing.connect(mock_handler)
-        client.sync_filter = Filter("abc123")
-        client.current_transfer_session.server_fsic = "{}"
-        client.finalize()
-
-        mock_dequeue.assert_called_with(client.current_transfer_session)
-        mock_handler.assert_any_call(transfer_session=client.current_transfer_session)
-        mock_close.assert_called_once()
-
-    @mock.patch(
-        "morango.sync.syncsession.BaseSyncClient._initialize_server_transfer_session"
-    )
-    def test_pull_client__initialize_server_transfer_session(
-        self, mock_parent_initialize
-    ):
-        mock_handler = mock.Mock()
-        mock_response = mock.Mock()
-
-        client = self.build_client(PullClient)
-        client.signals.queuing.connect(mock_handler)
-
-        mock_parent_initialize.return_value = mock_response
-        mock_response.json.return_value = dict(records_total=101)
-
-        self.assertEqual(mock_response, client._initialize_server_transfer_session())
-        self.assertEqual(client.current_transfer_session.records_total, 101)
-        mock_handler.assert_any_call(transfer_session=client.current_transfer_session)
-
-
-class SessionWrapperTestCase(TestCase):
-    @mock.patch("morango.sync.session.Session.request")
-    def test_request(self, mocked_super_request):
-        headers = {"Content-Length": 1024}
-        expected = mocked_super_request.return_value = mock.Mock(
-            headers=headers, raise_for_status=mock.Mock(), status_code=200, reason="OK"
-        )
-
-        wrapper = SessionWrapper()
-        actual = wrapper.request("GET", "test_url", is_test=True)
-        mocked_super_request.assert_called_once_with("GET", "test_url", is_test=True)
-        self.assertEqual(expected, actual)
-
-        head_length = len("HTTP/1.1 200 OK") + _length_of_headers(headers)
-        self.assertEqual(wrapper.bytes_received, 1024 + head_length)
-
-    @mock.patch("morango.sync.session.logger")
-    @mock.patch("morango.sync.session.Session.request")
-    def test_request__not_ok(self, mocked_super_request, mocked_logger):
-        raise_for_status = mock.Mock()
-        expected = mocked_super_request.return_value = mock.Mock(
-            headers={"Content-Length": 1024},
-            raise_for_status=raise_for_status,
-            content="Connection timeout",
-        )
-
-        raise_for_status.side_effect = HTTPError(response=expected)
-
-        wrapper = SessionWrapper()
-
-        with self.assertRaises(HTTPError):
-            wrapper.request("GET", "test_url", is_test=True)
-
-        mocked_super_request.assert_called_once_with("GET", "test_url", is_test=True)
-        mocked_logger.error.assert_called_once_with(
-            "HTTPError Reason: Connection timeout"
-        )
-
-    @mock.patch("morango.sync.session.logger")
-    @mock.patch("morango.sync.session.Session.request")
-    def test_request__really_not_ok(self, mocked_super_request, mocked_logger):
-        raise_for_status = mock.Mock()
-        mocked_super_request.return_value = mock.Mock(
-            headers={"Content-Length": 1024}, raise_for_status=raise_for_status,
-        )
-
-        raise_for_status.side_effect = RequestException()
-
-        wrapper = SessionWrapper()
-
-        with self.assertRaises(RequestException):
-            wrapper.request("GET", "test_url", is_test=True)
-
-        mocked_super_request.assert_called_once_with("GET", "test_url", is_test=True)
-        mocked_logger.error.assert_called_once_with(
-            "RequestException Reason: (no response)"
-        )
-
-    @mock.patch("morango.sync.session.Session.prepare_request")
-    def test_prepare_request(self, mocked_super_prepare_request):
-        headers = {"Content-Length": 256}
-        expected = mocked_super_prepare_request.return_value = mock.Mock(
-            headers=headers,
-        )
-
-        request = mock.Mock(url="http://test_app/path/to/resource", method="GET")
-        wrapper = SessionWrapper()
-        actual = wrapper.prepare_request(request)
-        mocked_super_prepare_request.assert_called_once_with(request)
-
-        self.assertEqual(expected, actual)
-        head_length = len("GET /path/to/resource HTTP/1.1") + _length_of_headers(
-            headers
-        )
-        self.assertEqual(wrapper.bytes_sent, 256 + head_length)
diff --git a/tests/testapp/tests/test_management_commands.py b/tests/testapp/tests/test_management_commands.py
index 95731603..9f8cde59 100644
--- a/tests/testapp/tests/test_management_commands.py
+++ b/tests/testapp/tests/test_management_commands.py
@@ -6,8 +6,6 @@
 from django.utils import timezone
 
 from .helpers import create_buffer_and_store_dummy_data
-from morango.models.core import Buffer
-from morango.models.core import RecordMaxCounterBuffer
 from morango.models.core import SyncSession
 from morango.models.core import TransferSession
 
@@ -72,3 +70,14 @@ def test_all_sessions_cleared(self):
         call_command("cleanupsyncs", expiration=1)
         assert_session_is_cleared(self.transfersession_old)
         assert_session_is_cleared(self.transfersession_new)
+
+    def test_filtering_sessions_cleared(self):
+        call_command("cleanupsyncs", ids=[self.syncsession_old.id], expiration=0)
+        assert_session_is_cleared(self.transfersession_old)
+        assert_session_is_not_cleared(self.transfersession_new)
+
+    def test_multiple_ids_as_list(self):
+        ids = [self.syncsession_old.id, self.syncsession_new.id]
+        call_command("cleanupsyncs", ids=ids, expiration=0)
+        assert_session_is_cleared(self.transfersession_old)
+        assert_session_is_cleared(self.transfersession_new)
diff --git a/tests/testapp/tests/test_models.py b/tests/testapp/tests/test_models.py
deleted file mode 100644
index 48d41a02..00000000
--- a/tests/testapp/tests/test_models.py
+++ /dev/null
@@ -1,93 +0,0 @@
-import factory
-from django.test import TestCase
-from django.utils.six import iteritems
-
-from morango.models.certificates import Filter
-from morango.models.core import DatabaseMaxCounter
-
-
-class DatabaseMaxCounterFactory(factory.DjangoModelFactory):
-
-    class Meta:
-        model = DatabaseMaxCounter
-
-
-class FilterMaxCounterTestCase(TestCase):
-
-    def setUp(self):
-        self.instance_a = "a" * 32
-        self.prefix_a = "AAA"
-        self.user_prefix_a = "AAA:user_id:joe"
-
-        self.instance_b = "b" * 32
-        self.prefix_b = "BBB"
-        self.user_prefix_b = "BBB:user_id:rick"
-        self.user2_prefix_b = "BBB:user_id:emily"
-
-        # instance A dmc
-        DatabaseMaxCounterFactory(instance_id=self.instance_a, partition=self.prefix_a, counter=15)
-        DatabaseMaxCounterFactory(instance_id=self.instance_a, partition=self.user_prefix_a, counter=20)
-        DatabaseMaxCounterFactory(instance_id=self.instance_a, partition=self.user2_prefix_b, counter=17)
-
-        # instance B dmc
-        DatabaseMaxCounterFactory(instance_id=self.instance_b, partition=self.user_prefix_a, counter=10)
-        DatabaseMaxCounterFactory(instance_id=self.instance_b, partition=self.prefix_b, counter=12)
-        DatabaseMaxCounterFactory(instance_id=self.instance_b, partition=self.user_prefix_b, counter=5)
-        DatabaseMaxCounterFactory(instance_id=self.instance_b, partition=self.user2_prefix_b, counter=2)
-
-    def test_filter_not_in_dmc(self):
-        fmcs = DatabaseMaxCounter.calculate_filter_max_counters(Filter("ZZZ"))
-        self.assertEqual(fmcs, {})
-
-    def test_instances_for_one_partition_but_not_other(self):
-        fmcs = DatabaseMaxCounter.calculate_filter_max_counters(Filter(self.user_prefix_a + "\n" + self.user_prefix_b))
-        self.assertEqual(fmcs[self.instance_b], 10)
-
-    def test_insufficient_instances_for_all_partitions(self):
-        user_with_prefix = self.prefix_b + "user_id:richard"
-        fmcs = DatabaseMaxCounter.calculate_filter_max_counters(Filter(self.prefix_a + "\n" + user_with_prefix))
-        self.assertFalse(fmcs)
-
-    def test_single_partition_with_all_instances(self):
-        fmcs = DatabaseMaxCounter.calculate_filter_max_counters(Filter(self.user_prefix_a))
-        self.assertEqual(fmcs[self.instance_a], 20)
-        self.assertEqual(fmcs[self.instance_b], 10)
-
-    def test_all_partitions_have_all_instances(self):
-        fmcs = DatabaseMaxCounter.calculate_filter_max_counters(Filter(self.user_prefix_a + "\n" + self.user2_prefix_b))
-        self.assertEqual(fmcs[self.instance_a], 17)
-        self.assertEqual(fmcs[self.instance_b], 10)
-
-
-class DatabaseMaxCounterUpdateCalculation(TestCase):
-
-    def setUp(self):
-        self.filter = "filter"
-
-    def test_update_all_fsics(self):
-        client_fsic = {'a' * 32: 2, 'b' * 32: 2, 'c' * 32: 2}
-        server_fsic = {'a' * 32: 1, 'b' * 32: 1, 'c' * 32: 1}
-        self.assertFalse(DatabaseMaxCounter.objects.filter(counter=2).exists())
-        for instance_id, counter in iteritems(server_fsic):
-            DatabaseMaxCounter.objects.create(instance_id=instance_id, counter=counter, partition=self.filter)
-        DatabaseMaxCounter.update_fsics(client_fsic, Filter(self.filter))
-        self.assertTrue(DatabaseMaxCounter.objects.filter(counter=2).exists())
-        self.assertFalse(DatabaseMaxCounter.objects.filter(counter=1).exists())
-
-    def test_update_some_fsics(self):
-        client_fsic = {'a' * 32: 1, 'e' * 32: 2, 'c' * 32: 1}
-        server_fsic = {'a' * 32: 2, 'b' * 32: 1, 'c' * 32: 2}
-        self.assertFalse(DatabaseMaxCounter.objects.filter(instance_id='e' * 32).exists())
-        for instance_id, counter in iteritems(server_fsic):
-            DatabaseMaxCounter.objects.create(instance_id=instance_id, counter=counter, partition=self.filter)
-        DatabaseMaxCounter.update_fsics(client_fsic, Filter(self.filter))
-        self.assertTrue(DatabaseMaxCounter.objects.filter(instance_id='e' * 32).exists())
-
-    def test_no_fsics_get_updated(self):
-        client_fsic = {'a' * 32: 1, 'b' * 32: 1, 'c' * 32: 1}
-        server_fsic = {'a' * 32: 2, 'b' * 32: 2, 'c' * 32: 2}
-        self.assertFalse(DatabaseMaxCounter.objects.filter(counter=1).exists())
-        for instance_id, counter in iteritems(server_fsic):
-            DatabaseMaxCounter.objects.create(instance_id=instance_id, counter=counter, partition=self.filter)
-        DatabaseMaxCounter.update_fsics(client_fsic, Filter(self.filter))
-        self.assertFalse(DatabaseMaxCounter.objects.filter(counter=1).exists())
diff --git a/tests/testapp/tests/test_utils.py b/tests/testapp/tests/test_utils.py
new file mode 100644
index 00000000..0d48225b
--- /dev/null
+++ b/tests/testapp/tests/test_utils.py
@@ -0,0 +1,103 @@
+import os
+from requests import Request
+from django.http.request import HttpRequest
+from django.test.testcases import SimpleTestCase
+import mock
+import pytest
+
+from morango.constants.capabilities import ALLOW_CERTIFICATE_PUSHING
+from morango.constants.capabilities import ASYNC_OPERATIONS
+from morango.constants import transfer_stages
+from morango.utils import SETTINGS
+from morango.utils import CAPABILITIES_CLIENT_HEADER
+from morango.utils import CAPABILITIES_SERVER_HEADER
+from morango.utils import get_capabilities
+from morango.utils import serialize_capabilities_to_client_request
+from morango.utils import parse_capabilities_from_server_request
+from morango.utils import pid_exists
+from morango.utils import _posix_pid_exists
+from morango.utils import _windows_pid_exists
+
+
+class SettingsTestCase(SimpleTestCase):
+
+    def assertLength(self, expected, iterable):
+        self.assertEqual(expected, len(iterable))
+
+    def test_defaults(self):
+        self.assertEqual(SETTINGS.ALLOW_CERTIFICATE_PUSHING, False)
+        self.assertEqual(SETTINGS.MORANGO_SERIALIZE_BEFORE_QUEUING, True)
+        self.assertEqual(SETTINGS.MORANGO_DESERIALIZE_AFTER_DEQUEUING, True)
+        self.assertEqual(SETTINGS.MORANGO_DISALLOW_ASYNC_OPERATIONS, False)
+        self.assertLength(3, SETTINGS.MORANGO_INITIALIZE_OPERATIONS)
+        self.assertLength(3, SETTINGS.MORANGO_SERIALIZE_OPERATIONS)
+        self.assertLength(4, SETTINGS.MORANGO_QUEUE_OPERATIONS)
+        self.assertLength(4, SETTINGS.MORANGO_DEQUEUE_OPERATIONS)
+        self.assertLength(4, SETTINGS.MORANGO_DESERIALIZE_OPERATIONS)
+        self.assertLength(2, SETTINGS.MORANGO_CLEANUP_OPERATIONS)
+
+    def test_overriding(self):
+        with self.settings(ALLOW_CERTIFICATE_PUSHING=True):
+            self.assertEqual(SETTINGS.ALLOW_CERTIFICATE_PUSHING, True)
+
+        with self.settings(MORANGO_INITIALIZE_OPERATIONS=("test",)):
+            self.assertEqual(SETTINGS.MORANGO_INITIALIZE_OPERATIONS, ("test",))
+
+
+class CapabilitiesTestCase(SimpleTestCase):
+    def test_get_capabilities__certs(self):
+        with self.settings(ALLOW_CERTIFICATE_PUSHING=True):
+            self.assertIn(ALLOW_CERTIFICATE_PUSHING, get_capabilities())
+
+        with self.settings(ALLOW_CERTIFICATE_PUSHING=False):
+            self.assertNotIn(ALLOW_CERTIFICATE_PUSHING, get_capabilities())
+
+    def test_get_capabilities__async_ops(self):
+        with self.settings(MORANGO_DISALLOW_ASYNC_OPERATIONS=False):
+            self.assertIn(ASYNC_OPERATIONS, get_capabilities())
+
+        with self.settings(MORANGO_DISALLOW_ASYNC_OPERATIONS=True):
+            self.assertNotIn(ASYNC_OPERATIONS, get_capabilities())
+
+    @mock.patch("morango.utils.CAPABILITIES", ("TEST", "SERIALIZE"))
+    def test_serialize(self):
+        req = Request()
+        serialize_capabilities_to_client_request(req)
+        self.assertIn(CAPABILITIES_CLIENT_HEADER, req.headers)
+        self.assertEqual(req.headers[CAPABILITIES_CLIENT_HEADER], "TEST SERIALIZE")
+
+    def test_parse(self):
+        req = HttpRequest()
+        req.META.update(HTTP_X_MORANGO_CAPABILITIES="TEST PARSE")
+        result = parse_capabilities_from_server_request(req)
+        self.assertEqual({"PARSE", "TEST"}, result)
+
+
+class TransferStageTestCase(SimpleTestCase):
+    def test_stage(self):
+        stage_a = transfer_stages.stage(transfer_stages.INITIALIZING)
+        stage_b = transfer_stages.stage(transfer_stages.CLEANUP)
+        stage_c = transfer_stages.stage(transfer_stages.CLEANUP)
+
+        self.assertTrue(stage_b > stage_a)
+        self.assertFalse(stage_b < stage_a)
+        self.assertFalse(stage_b <= stage_a)
+        self.assertTrue(stage_b <= stage_c)
+        self.assertTrue(stage_b >= stage_c)
+
+
+class ProcessIDExistsTestCase(SimpleTestCase):
+    @pytest.mark.skipif(os.name != "posix", reason="Not POSIX OS")
+    def test_posix(self):
+        self.assertEqual(_posix_pid_exists, pid_exists)
+        pid = os.getpid()
+        self.assertTrue(pid_exists(pid))
+        self.assertFalse(pid_exists(123456789))
+
+    @pytest.mark.windows
+    @pytest.mark.skipif(os.name == "posix", reason="POSIX platform")
+    def test_windows(self):
+        self.assertEqual(_windows_pid_exists, pid_exists)
+        pid = os.getpid()
+        self.assertTrue(pid_exists(pid))
+        self.assertFalse(pid_exists(123456789))