diff --git a/.editorconfig b/.editorconfig
index b1eff40a..e3256545 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -12,6 +12,6 @@ trim_trailing_whitespace = true
 indent_style = space
 indent_size = 4
 
-[*.html]
+[*.{html,inc}]
 indent_style = space
 indent_size = 1
diff --git a/Makefile b/Makefile
index c0343821..284c7a1b 100644
--- a/Makefile
+++ b/Makefile
@@ -16,3 +16,10 @@ lint-fix-unsafe:
 	npx @biomejs/biome check --fix --unsafe
 
 fix: format lint-fix-unsafe
+
+init-dev:
+	dropdb --if-exists pgcommitfest
+	createdb pgcommitfest
+	./manage.py migrate
+	./manage.py loaddata auth_data.json
+	./manage.py loaddata commitfest_data.json
diff --git a/media/commitfest/css/commitfest.css b/media/commitfest/css/commitfest.css
index 72cae0e9..985491fb 100644
--- a/media/commitfest/css/commitfest.css
+++ b/media/commitfest/css/commitfest.css
@@ -91,3 +91,23 @@ div.form-group div.controls input.threadpick-input {
 .search-bar {
     display: inline-block;
 }
+
+.workflow table.table {
+    width: auto;
+}
+
+#keyvalue-table {
+    display: none;
+}
+
+#keyvalue-table.collapse.in {
+    display: table;
+}
+
+#history-table {
+    display: none;
+}
+
+#history-table.collapse.in {
+    display: table;
+}
diff --git a/pgcommitfest/commitfest/fixtures/auth_data.json b/pgcommitfest/commitfest/fixtures/auth_data.json
index 88d8c708..9a6e2f03 100644
--- a/pgcommitfest/commitfest/fixtures/auth_data.json
+++ b/pgcommitfest/commitfest/fixtures/auth_data.json
@@ -88,5 +88,41 @@
         "groups": [],
         "user_permissions": []
     }
+},
+{
+    "model": "auth.user",
+    "pk": 6,
+    "fields": {
+        "password": "",
+        "last_login": null,
+        "is_superuser": false,
+        "username": "prolific-author",
+        "first_name": "Prolific",
+        "last_name": "Author",
+        "email": "",
+        "is_staff": false,
+        "is_active": true,
+        "date_joined": "2025-01-01T00:00:00",
+        "groups": [],
+        "user_permissions": []
+    }
+},
+{
+    "model": "auth.user",
+    "pk": 7,
+    "fields": {
+        "password": "",
+        "last_login": null,
+        "is_superuser": false,
+        "username": "prolific-reviewer",
+        "first_name": "Prolific",
+        "last_name": "Reviewer",
+        "email": "",
+        "is_staff": false,
+        "is_active": true,
+        "date_joined": "2025-01-01T00:00:00",
+        "groups": [],
+        "user_permissions": []
+    }
 }
 ]
diff --git a/pgcommitfest/commitfest/fixtures/commitfest_data.json b/pgcommitfest/commitfest/fixtures/commitfest_data.json
index 6e5b32ff..9317624a 100644
--- a/pgcommitfest/commitfest/fixtures/commitfest_data.json
+++ b/pgcommitfest/commitfest/fixtures/commitfest_data.json
@@ -60,6 +60,16 @@
         "enddate": "2025-05-31"
     }
 },
+{
+    "model": "commitfest.commitfest",
+    "pk": 5,
+    "fields": {
+        "name": "Drafts v18",
+        "status": 5,
+        "startdate": "2024-09-01",
+        "enddate": "2025-08-31"
+    }
+},
 {
     "model": "commitfest.topic",
     "pk": 1,
@@ -237,6 +247,25 @@
         ]
     }
 },
+{
+    "model": "commitfest.patch",
+    "pk": 8,
+    "fields": {
+        "name": "Test DGJ Multi-Author and Reviewer",
+        "topic": 3,
+        "wikilink": "",
+        "gitlink": "",
+        "targetversion": 1,
+        "committer": 4,
+        "created": "2025-02-01T00:00",
+        "modified": "2025-02-01T00:00",
+        "lastmail": null,
+        "authors": [6,3],
+        "reviewers": [7,1],
+        "subscribers": [],
+        "mailthread_set": []
+    }
+},
 {
     "model": "commitfest.patchoncommitfest",
     "pk": 1,
@@ -325,6 +354,17 @@
         "status": 1
     }
 },
+{
+    "model": "commitfest.patchoncommitfest",
+    "pk": 9,
+    "fields": {
+        "patch": 8,
+        "commitfest": 5,
+        "enterdate": "2025-02-01T00:00:00",
+        "leavedate": null,
+        "status": 1
+    }
+},
 {
     "model": "commitfest.patchhistory",
     "pk": 1,
diff --git a/pgcommitfest/commitfest/migrations/0011_add_status_related_constraints.py b/pgcommitfest/commitfest/migrations/0011_add_status_related_constraints.py
new file mode 100644
index 00000000..5291d190
--- /dev/null
+++ b/pgcommitfest/commitfest/migrations/0011_add_status_related_constraints.py
@@ -0,0 +1,42 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("commitfest", "0010_add_failing_since_column"),
+    ]
+    operations = [
+        migrations.RunSQL("""
+CREATE UNIQUE INDEX cf_enforce_maxoneopen_idx
+ON commitfest_commitfest (status)
+WHERE status not in (4);
+"""),
+        migrations.RunSQL("""
+CREATE UNIQUE INDEX poc_enforce_maxoneoutcome_idx
+ON commitfest_patchoncommitfest (patch_id)
+WHERE status not in (5);
+"""),
+        migrations.RunSQL("""
+ALTER TABLE commitfest_patchoncommitfest
+ADD CONSTRAINT status_and_leavedate_correlation
+CHECK ((status IN (4,5,6,7,8)) = (leavedate IS NOT NULL));
+"""),
+        migrations.RunSQL("""
+COMMENT ON COLUMN commitfest_patchoncommitfest.leavedate IS
+$$A leave date is recorded in two situations, both of which
+means this particular patch-cf combination became inactive
+on the corresponding date.  For status 5 the patch was moved
+to some other cf.  For 4,6,7, and 8, this was the final cf.
+$$
+"""),
+        migrations.RunSQL("""
+COMMENT ON TABLE commitfest_patchoncommitfest IS
+$$This is a re-entrant table: patches may become associated
+with a given cf multiple times, resetting the entrydate and clearing
+the leavedate each time.  Non-final statuses never have a leavedate
+while final statuses always do.  The final status of 5 (moved) is
+special in that all but one of the rows a patch has in this table
+must have it as the status.
+$$
+"""),
+    ]
diff --git a/pgcommitfest/commitfest/migrations/0012_add_parked_cf_status.py b/pgcommitfest/commitfest/migrations/0012_add_parked_cf_status.py
new file mode 100644
index 00000000..90759466
--- /dev/null
+++ b/pgcommitfest/commitfest/migrations/0012_add_parked_cf_status.py
@@ -0,0 +1,23 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("commitfest", "0011_add_status_related_constraints"),
+    ]
+    operations = [
+        migrations.AlterField(
+            model_name="commitfest",
+            name="status",
+            field=models.IntegerField(
+                choices=[
+                    (1, "Future"),
+                    (2, "Open"),
+                    (3, "In Progress"),
+                    (4, "Closed"),
+                    (5, "Parked"),
+                ],
+                default=1,
+            ),
+        )
+    ]
diff --git a/pgcommitfest/commitfest/migrations/0013_no_patches_in_future_cfs.py b/pgcommitfest/commitfest/migrations/0013_no_patches_in_future_cfs.py
new file mode 100644
index 00000000..75ed886f
--- /dev/null
+++ b/pgcommitfest/commitfest/migrations/0013_no_patches_in_future_cfs.py
@@ -0,0 +1,57 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("commitfest", "0012_add_parked_cf_status"),
+    ]
+    operations = [
+        migrations.RunSQL("""
+CREATE FUNCTION assert_poc_not_future_for_poc()
+RETURNS TRIGGER AS $$
+DECLARE
+    cfstatus int;
+BEGIN
+    SELECT status INTO cfstatus
+    FROM commitfest_commitfest
+    WHERE id = NEW.commitfest_id;
+
+    IF cfstatus = 1 THEN
+       RAISE EXCEPTION 'Patches cannot exist on future commitfests';
+    END IF;
+
+    RETURN NEW;
+END;
+$$
+LANGUAGE plpgsql;
+
+CREATE FUNCTION assert_poc_not_future_for_cf()
+RETURNS trigger AS $$
+BEGIN
+    -- Trigger checks that we only get called when status is 1
+    PERFORM 1
+    FROM commitfest_patchoncommitfest
+    WHERE commitfest_id = NEW.id
+    LIMIT 1;
+
+    IF FOUND THEN
+       RAISE EXCEPTION 'Cannot change commitfest status to 1, patches exists.';
+    END IF;
+    RETURN NEW;
+END;
+$$
+LANGUAGE plpgsql;
+
+CREATE TRIGGER assert_poc_commitfest_is_not_future
+BEFORE INSERT OR UPDATE ON commitfest_patchoncommitfest
+FOR EACH ROW
+EXECUTE FUNCTION assert_poc_not_future_for_poc();
+
+CREATE TRIGGER assert_poc_commitfest_is_not_future
+-- Newly inserted cfs can't have patches
+BEFORE UPDATE ON commitfest_commitfest
+FOR EACH ROW
+WHEN (NEW.status = 1)
+EXECUTE FUNCTION assert_poc_not_future_for_cf();
+"""),
+    ]
diff --git a/pgcommitfest/commitfest/models.py b/pgcommitfest/commitfest/models.py
index fcd9edb9..8548f61c 100644
--- a/pgcommitfest/commitfest/models.py
+++ b/pgcommitfest/commitfest/models.py
@@ -1,5 +1,6 @@
 from django.contrib.auth.models import User
 from django.db import models
+from django.db.models import Q
 from django.shortcuts import get_object_or_404
 
 from datetime import datetime
@@ -38,17 +39,20 @@ class CommitFest(models.Model):
     STATUS_OPEN = 2
     STATUS_INPROGRESS = 3
     STATUS_CLOSED = 4
+    STATUS_PARKED = 5
     _STATUS_CHOICES = (
         (STATUS_FUTURE, "Future"),
         (STATUS_OPEN, "Open"),
-        (STATUS_INPROGRESS, "In Progress"),
+        (STATUS_INPROGRESS, "Ongoing"),
         (STATUS_CLOSED, "Closed"),
+        (STATUS_PARKED, "Drafts"),
     )
     _STATUS_LABELS = (
         (STATUS_FUTURE, "default"),
         (STATUS_OPEN, "info"),
         (STATUS_INPROGRESS, "success"),
         (STATUS_CLOSED, "danger"),
+        (STATUS_PARKED, "default"),
     )
     name = models.CharField(max_length=100, blank=False, null=False, unique=True)
     status = models.IntegerField(
@@ -63,6 +67,8 @@ def statusstring(self):
 
     @property
     def periodstring(self):
+        # Current Workflow intent is to have all Committfest be time-bounded
+        # but the information is just contextual so we still permit null
         if self.startdate and self.enddate:
             return "{0} - {1}".format(self.startdate, self.enddate)
         return ""
@@ -71,10 +77,22 @@ def periodstring(self):
     def title(self):
         return "Commitfest %s" % self.name
 
+    @property
+    def isclosed(self):
+        return self.status == self.STATUS_CLOSED
+
     @property
     def isopen(self):
         return self.status == self.STATUS_OPEN
 
+    @property
+    def isinprogress(self):
+        return self.status == self.STATUS_INPROGRESS
+
+    @property
+    def isparked(self):
+        return self.status == self.STATUS_PARKED
+
     def __str__(self):
         return self.name
 
@@ -158,12 +176,18 @@ class Patch(models.Model, DiffableModel):
         "reviewers": "reviewers_string",
     }
 
+    # XXX probably should just encourage using PoC.commitfest since most places
+    # dealing with the Patch need the PoC anyway.
     def current_commitfest(self):
-        return self.commitfests.order_by("-startdate").first()
+        return self.current_patch_on_commitfest().commitfest
 
     def current_patch_on_commitfest(self):
-        cf = self.current_commitfest()
-        return get_object_or_404(PatchOnCommitFest, patch=self, commitfest=cf)
+        # The unique partial index poc_enforce_maxoneoutcome_idx stores the PoC
+        # No caching here (inside the instance) since the caller should just need
+        # the PoC once per request.
+        return get_object_or_404(
+            PatchOnCommitFest, Q(patch=self) & ~Q(status=PatchOnCommitFest.STATUS_NEXT)
+        )
 
     # Some accessors
     @property
@@ -229,13 +253,13 @@ class PatchOnCommitFest(models.Model):
     STATUS_RETURNED = 7
     STATUS_WITHDRAWN = 8
     _STATUS_CHOICES = (
-        (STATUS_REVIEW, "Needs review"),
+        (STATUS_REVIEW, "Needs Review"),
         (STATUS_AUTHOR, "Waiting on Author"),
         (STATUS_COMMITTER, "Ready for Committer"),
         (STATUS_COMMITTED, "Committed"),
-        (STATUS_NEXT, "Moved to next CF"),
+        (STATUS_NEXT, "Moved"),
         (STATUS_REJECTED, "Rejected"),
-        (STATUS_RETURNED, "Returned with feedback"),
+        (STATUS_RETURNED, "Returned with Feedback"),
         (STATUS_WITHDRAWN, "Withdrawn"),
     )
     _STATUS_LABELS = (
@@ -273,6 +297,14 @@ def is_closed(self):
     def is_open(self):
         return not self.is_closed
 
+    @property
+    def is_committed(self):
+        return self.status == self.STATUS_COMMITTED
+
+    @property
+    def is_committer(self):
+        return self.status == self.STATUS_COMMITTER
+
     @property
     def statusstring(self):
         return [v for k, v in self._STATUS_CHOICES if k == self.status][0]
@@ -528,3 +560,253 @@ class CfbotTask(models.Model):
     status = models.TextField(choices=STATUS_CHOICES, null=False)
     created = models.DateTimeField(auto_now_add=True)
     modified = models.DateTimeField(auto_now=True)
+
+
+# Workflow provides access to the elements required to support
+# the workflow this application is built for.  These elements exist
+# independent of what the user is presently seeing on their page.
+class Workflow(models.Model):
+    def get_poc_for_patchid_or_404(patchid):
+        return get_object_or_404(
+            Patch.objects.select_related(), pk=patchid
+        ).current_patch_on_commitfest()
+
+    # At most a single Open CommitFest is allowed and this function returns it.
+    def open_cf():
+        cfs = list(CommitFest.objects.filter(status=CommitFest.STATUS_OPEN))
+        return cfs[0] if len(cfs) == 1 else None
+
+    # At most a single In Progress CommitFest is allowed and this function returns it.
+    def inprogress_cf():
+        cfs = list(CommitFest.objects.filter(status=CommitFest.STATUS_INPROGRESS))
+        return cfs[0] if len(cfs) == 1 else None
+
+    # At most a single Parked CommitFest is allowed and this function returns it.
+    def parked_cf():
+        cfs = list(CommitFest.objects.filter(status=CommitFest.STATUS_PARKED))
+        return cfs[0] if len(cfs) == 1 else None
+
+    # Returns whether the user is a committer in general and for this patch
+    # since we retrieve all committers in order to answer these questions
+    # provide that list as a third return value.  Passing None for both user
+    # and patch still returns the list of committers.
+    def isCommitter(user, patch):
+        all_committers = Committer.objects.filter(active=True).order_by(
+            "user__last_name", "user__first_name"
+        )
+        if not user and not patch:
+            return False, False, all_committers
+
+        committer = [c for c in all_committers if c.user == user]
+        if len(committer) == 1:
+            is_committer = True
+            is_this_committer = committer[0] == patch.committer
+        else:
+            is_committer = is_this_committer = False
+        return is_committer, is_this_committer, all_committers
+
+    def getCommitfest(cfid):
+        if cfid is None or cfid == "":
+            return None
+        try:
+            int_cfid = int(cfid)
+            cfs = list(CommitFest.objects.filter(id=int_cfid))
+            if len(cfs) == 1:
+                return cfs[0]
+            else:
+                return None
+        except ValueError:
+            return None
+
+    # Implements a re-entrant Commitfest POC creation procedure.
+    # Returns the new POC object.
+    # Creates history and notifies as a side-effect.
+    def createNewPOC(patch, commitfest, initial_status, by_user):
+        poc, created = PatchOnCommitFest.objects.update_or_create(
+            patch=patch,
+            commitfest=commitfest,
+            defaults=dict(
+                enterdate=datetime.now(),
+                status=initial_status,
+                leavedate=None,
+            ),
+        )
+        poc.patch.set_modified()
+        poc.patch.save()
+        poc.save()
+
+        PatchHistory(
+            patch=poc.patch,
+            by=by_user,
+            what="{} in {}".format(poc.statusstring, commitfest.name),
+        ).save_and_notify()
+
+        return poc
+
+    # The rule surrounding patches is they may only be in one active
+    # commitfest at a time.  The transition function takes a patch
+    # open in one commitfest and associates it, with the same status,
+    # in a new commitfest; then makes it inactive in the original.
+    # Returns the new POC object.
+    # Creates history and notifies as a side-effect.
+    def transitionPatch(poc, target_cf, by_user):
+        Workflow.userCanTransitionPatch(poc, target_cf, by_user)
+
+        existing_status = poc.status
+
+        # History looks cleaner if we've left the existing
+        # commitfest entry before joining the new one.  Plus,
+        # not allowed to change non-current commitfest status
+        # and once the new POC is created it becomes current.
+
+        Workflow.updatePOCStatus(poc, PatchOnCommitFest.STATUS_NEXT, by_user)
+
+        new_poc = Workflow.createNewPOC(poc.patch, target_cf, existing_status, by_user)
+
+        return new_poc
+
+    def userCanTransitionPatch(poc, target_cf, user):
+        # Policies not allowed to be broken by anyone.
+
+        # Prevent changes to non-current commitfest for the patch
+        # Meaning, change status to Moved before/during transitioning
+        if poc.commitfest != poc.patch.current_commitfest():
+            raise Exception("PoC commitfest is not patch's current commitfest.")
+
+        # The UI should be preventing people from trying to perform no-op requests
+        if poc.commitfest.id == target_cf.id:
+            raise Exception("Cannot transition to the same commitfest.")
+
+        # This one is arguable but facilitates treating non-open status as final
+        # A determined staff member can always change the status first.
+        if poc.is_closed:
+            raise Exception("Cannot transition a closed patch.")
+
+        # We trust privileged users to make informed choices
+        if user.is_staff:
+            return
+
+        if target_cf.isclosed:
+            raise Exception("Cannot transition to a closed commitfest.")
+
+        if target_cf.isinprogress:
+            raise Exception("Cannot transition to an in-progress commitfest.")
+
+        # Prevent users from moving closed patches, or moving open ones to
+        # non-open commitfests.  The else clause should be a can't happen.
+        if poc.is_open and target_cf.isopen:
+            pass
+        else:
+            # Default deny policy basis
+            raise Exception("Transition not permitted.")
+
+    def userCanChangePOCStatus(poc, new_status, user):
+        # Policies not allowed to be broken by anyone.
+
+        # Prevent changes to non-current commitfest for the patch
+        # Meaning, change status to Moved before/during transitioning
+        if poc.commitfest != poc.patch.current_commitfest():
+            raise Exception("PoC commitfest is not patch's current commitfest.")
+
+        # The UI should be preventing people from trying to perform no-op requests
+        if poc.status == new_status:
+            raise Exception("Cannot change to the same status.")
+
+        # We want commits to happen from, usually, In Progress commitfests,
+        # or Open ones for exempt patches.  We accept Future ones too just because
+        # they do represent a proper, if non-current, Commitfest.
+        if (
+            poc.commitfest.id == CommitFest.STATUS_PARKED
+            and new_status == PatchOnCommitFest.STATUS_COMMITTED
+        ):
+            raise Exception("Cannot change status to committed in a parked commitfest.")
+
+        # We trust privileged users to make informed choices
+        if user.is_staff:
+            return
+
+        is_committer = Workflow.isCommitter(user, poc.patch)
+
+        # XXX Not sure if we want to tighten this up to is_this_committer
+        # with only the is_staff exemption
+        if new_status == PatchOnCommitFest.STATUS_COMMITTED and not is_committer:
+            raise Exception("Only a committer can set status to committed.")
+
+        if new_status == PatchOnCommitFest.STATUS_REJECTED and not is_committer:
+            raise Exception("Only a committer can set status to rejected.")
+
+        if new_status == PatchOnCommitFest.STATUS_RETURNED and not is_committer:
+            raise Exception("Only a committer can set status to returned.")
+
+        if (
+            new_status == PatchOnCommitFest.STATUS_WITHDRAWN
+            and user not in poc.patch.authors.all()
+        ):
+            raise Exception("Only the author can set status to withdrawn.")
+
+        # Prevent users from modifying closed patches
+        # The else clause should be considered a can't happen
+        if poc.is_open:
+            pass
+        else:
+            raise Exception("Cannot change status of closed patch.")
+
+    # Update the status of a PoC
+    # Returns True if the status was changed, False for a same-status no-op.
+    # Creates history and notifies as a side-effect.
+    def updatePOCStatus(poc, new_status, by_user):
+        # XXX Workflow disallows this no-op but not quite ready to enforce it.
+        if poc.status == new_status:
+            return False
+
+        Workflow.userCanChangePOCStatus(poc, new_status, by_user)
+
+        poc.status = new_status
+        poc.leavedate = datetime.now() if not poc.is_open else None
+        poc.patch.set_modified()
+        poc.patch.save()
+        poc.save()
+        PatchHistory(
+            patch=poc.patch,
+            by=by_user,
+            what="{} in {}".format(
+                poc.statusstring,
+                poc.commitfest.name,
+            ),
+        ).save_and_notify()
+
+        return True
+
+    # Sets the value of the committer for the patch
+    # Returns True if the committer was changed, False for a same-committer no-op.
+    # Creates history and notifies as a side-effect.
+    def setCommitter(poc, committer, by_user):
+        if poc.patch.committer == committer:
+            return False
+
+        prevcommitter = poc.patch.committer
+        poc.patch.committer = committer
+        poc.patch.set_modified()
+        poc.patch.save()
+        poc.save()
+
+        if committer is None:
+            msg = "Removed {} as committer in {}".format(
+                prevcommitter.fullname, poc.commitfest.name
+            )
+        elif prevcommitter is None:
+            msg = "Set {} as committer in {}".format(
+                poc.patch.committer.fullname, poc.commitfest.name
+            )
+        else:
+            msg = "Changed to {} as committer in {}".format(
+                poc.patch.committer.fullname, poc.commitfest.name
+            )
+
+        PatchHistory(
+            patch=poc.patch,
+            by=by_user,
+            what=msg,
+        ).save_and_notify(prevcommitter=prevcommitter)
+
+        return True
diff --git a/pgcommitfest/commitfest/templates/base.html b/pgcommitfest/commitfest/templates/base.html
index c70a7f77..b606d666 100644
--- a/pgcommitfest/commitfest/templates/base.html
+++ b/pgcommitfest/commitfest/templates/base.html
@@ -30,6 +30,9 @@
       <a href="/account/login/?next={{request.path}}">Log in</a>
      {%endif%}
     </li>
+    <li class="pull-right active">
+     <a href="/workflow">Workflow</a>
+    </li>
     {%if header_activity%} <li class="pull-right active"><a href="{{header_activity_link}}">{{header_activity}}</a></li>{%endif%}
    </ul>
 
diff --git a/pgcommitfest/commitfest/templates/patch.html b/pgcommitfest/commitfest/templates/patch.html
index c2e73460..7c2d78d1 100644
--- a/pgcommitfest/commitfest/templates/patch.html
+++ b/pgcommitfest/commitfest/templates/patch.html
@@ -1,205 +1,83 @@
 {%extends "base.html"%}
 {%load commitfest%}
 {%block contents%}
- {%include "patch_commands.inc"%}
- <table class="table table-bordered">
-  <tbody>
-   <tr>
-    <th>ID</th>
-    <td><a href="/patch/{{patch.id}}">{{patch.id}}</a></td>
-   </tr>
-   <tr>
-    <th>Title</th>
-    <td>{{patch.name}}</td>
-   </tr>
-   <tr>
-    <th>CI (CFBot)</th>
-    <td>
-     {%if not cfbot_branch %}
-      <span class="label label-default">Not processed</span></a>
-     {%elif cfbot_branch.needs_rebase_since %}
-      <a href="{{cfbot_branch.apply_url}}">
-       <span class="label label-warning" title="View git apply logs">Needs rebase!</span></a>
-      Needs rebase {% cfsince cfbot_branch.needs_rebase_since %}. {%if cfbot_branch.failing_since and cfbot_branch.failing_since != cfbot_branch.needs_rebase_since %}Failing {% cfsince cfbot_branch.failing_since %}. {%endif%}<br>Additional links previous successfully applied patch (outdated):<br>
-      <a href="https://github.com/postgresql-cfbot/postgresql/compare/cf/{{patch.id}}~1...cf/{{patch.id}}" title="View previous successfully applied patch set on GitHub"><img class="github-logo" src="/media/commitfest/github-mark.svg"/></a>
-      <a href="https://cirrus-ci.com/github/postgresql-cfbot/postgresql/cf%2F{{patch.id}}">
-       <span class="label label-default">Summary</span></a>
-     {%else%}
-      <a href="https://github.com/postgresql-cfbot/postgresql/compare/cf/{{patch.id}}~1...cf/{{patch.id}}" title="View last patch set on GitHub"><img class="github-logo" src="/media/commitfest/github-mark.svg"/></a>
-      <a href="https://cirrus-ci.com/github/postgresql-cfbot/postgresql/cf%2F{{patch.id}}">
-       <span class="label label-default">Summary</span></a>
-      {%for c in cfbot_tasks %}
-       {%if c.status == 'COMPLETED'%}
-        <a href="https://cirrus-ci.com/task/{{c.task_id}}" title="{{c.task_name}}: {{c.status}}"><img src="/media/commitfest/new_success.svg"/></a>
-       {%elif c.status == 'CREATED' or c.status == 'SCHEDULED' %}
-        <a href="https://cirrus-ci.com/task/{{c.task_id}}" title="{{c.task_name}}: {{c.status}}"><img src="/media/commitfest/waiting_to_start.svg"/></a>
-       {%elif c.status == 'EXECUTING' %}
-        <a href="https://cirrus-ci.com/task/{{c.task_id}}" title="{{c.task_name}}: {{c.status}}"><img src="/media/commitfest/running.svg"/></a>
-       {%else %}
-        <a href="https://cirrus-ci.com/task/{{c.task_id}}" title="{{c.task_name}}: {{c.status}}"><img src="/media/commitfest/new_failure.svg"/></a>
-       {%endif%}
-      {%endfor%}
-     {%endif%}
-     {%if cfbot_branch %}
-      <button class="btn btn-default" title="This adds the following to your clipboard (needs to be run in an existing git repo):
-                                             git remote add commitfest https://github.com/postgresql-cfbot/postgresql.git
-                                             git fetch commitfest cf/{{patch.id}}
-                                             git checkout commitfest/cf/{{patch.id}}" onclick="addGitCheckoutToClipboard({{patch.id}})">Copy git checkout commands</button>
-     {%endif%}
-    </a>
-   </td>
-  </tr>
-  <tr>
-   <th>Stats (from CFBot)</th>
-   <td>
-    {%if cfbot_branch and cfbot_branch.commit_id %}
-     {%if cfbot_branch.version %}
-      Patch version: {{ cfbot_branch.version }},
-     {%endif%}
-     Patch count: {{ cfbot_branch.patch_count }},
-     First patch: <span class="additions">+{{ cfbot_branch.first_additions }}</span><span class="deletions">&#8722;{{ cfbot_branch.first_deletions }}</span>,
-     All patches: <span class="additions">+{{ cfbot_branch.all_additions }}</span><span class="deletions">&#8722;{{ cfbot_branch.all_deletions }}</span>
-    {%else%}
-     Unknown
-    {%endif%}
-   </tr>
-   <tr>
-    <th>Topic</th>
-    <td>{{patch.topic}}</td>
-   </tr>
-   <tr>
-    <th>Created</th>
-    <td>{{patch.created}}</td>
-   </tr>
-   <tr>
-    <th style="white-space: nowrap;">Last modified</th>
-    <td>{{patch.modified}} ({% cfwhen patch.modified %})</td>
-   </tr>
-   <tr>
-    <th style="white-space: nowrap;">Latest email</th>
-    <td>{%if patch.lastmail%}{{patch.lastmail}} ({% cfwhen patch.lastmail %}){%endif%}</td>
-   </tr>
-   <tr>
-    <th>Status</th>
-    <td>{%for c in patch_commitfests %}
-     <div style="margin-bottom: 3px;"><a href="/{{c.commitfest.id}}/">{{c.commitfest}}</a>: <span class="label label-{{c.status|patchstatuslabel}}">{{c.statusstring}}</span></div>
-    {%endfor%}
-    </td>
-   </tr>
-   <tr>
-    <th>Target version</th>
-    <td>{%if patch.targetversion%}<span class="label label-default">{{patch.targetversion}}</span>{%endif%}</td>
-   </tr>
-   <tr>
-    <th>Authors</th>
-    <td>{{patch.authors_string}}</td>
-   </tr>
-   <tr>
-    <th>Reviewers</th>
-    <td>{{patch.reviewers_string}}<a href="reviewer/{{is_reviewer|yesno:"remove,become"}}/" class="btn btn-default pull-right">{{is_reviewer|yesno:"Remove from reviewers,Become reviewer"}}</a></td>
-   </tr>
-   <tr>
-    <th>Committer</th>
-    <td>{%if patch.committer%}{{patch.committer.fullname}}{%endif%}
-     {%if is_committer%}<a href="committer/{{is_this_committer|yesno:"remove,become"}}/" class="btn btn-default pull-right">{{is_this_committer|yesno:"Unclaim as committer,Claim as committer"}}</a>{%endif%}
-    </td>
-   </tr>
-   <tr>
-    <th>Links</th>
-    {%if patch.wikilink%}
-     <a href="{{patch.wikilink}}">Wiki</a>{%endif%}{%if patch.gitlink%}
-      <a href="{{patch.gitlink}}">Git</a>
-     {%endif%}</td>
-  </tr>
-  <tr>
-   <th>Emails</th>
-   <td>
-    {%if user.is_authenticated%}
-     <div style="float:right"><button class="btn btn-default" onclick="attachThread({{cf.id}},{{patch.id}})">Attach thread</button></div>
-    {%else%}
-     <div style="float:right"><button class="btn btn-default" onclick="location.href='/account/login/?next=/{{cf.id}}/{{patch.id}}/%3Fattachthreadnow'">Attach thread</button></div>
-    {%endif%}
-    <dl>
-     {%for t in patch.mailthread_set.all%}
-      <dt><a href="https://www.postgresql.org/message-id/flat/{{t.messageid}}">{{t.subject}}</a> <button type="button" class="close close-nofloat" title="Detach this thread" onclick="detachThread({{cf.id}},{{patch.id}},'{{t.messageid}}')">&times;</button></dt>
-      <dd>
-       First at <a href="https://www.postgresql.org/message-id/{{t.messageid}}">{{t.firstmessage}}</a> by {{t.firstauthor|hidemail}}<br/>
-       Latest at <a href="https://www.postgresql.org/message-id/{{t.latestmsgid}}">{{t.latestmessage}}</a> by {{t.latestauthor|hidemail}}<br/>
-       {%for ta in t.mailthreadattachment_set.all%}
-        {%if forloop.first%}
-         Latest attachment (<a href="https://www.postgresql.org/message-id/attachment/{{ta.attachmentid}}/{{ta.filename}}">{{ta.filename}}</a>) at <a href="https://www.postgresql.org/message-id/{{ta.messageid}}">{{ta.date}}</a> from {{ta.author|hidemail}} <button type="button" class="btn btn-default btn-xs" data-toggle="collapse" data-target="#att{{t.pk}}" title="Show all attachments"><i class="glyphicon glyphicon-plus"></i></button>
-         <div id="att{{t.pk}}" class="collapse">
-        {%endif%}
-        &nbsp;&nbsp;&nbsp;&nbsp;Attachment (<a href="https://www.postgresql.org/message-id/attachment/{{ta.attachmentid}}/{{ta.filename}}">{{ta.filename}}</a>) at <a href="https://www.postgresql.org/message-id/{{ta.messageid}}">{{ta.date}}</a> from {{ta.author|hidemail}} (Patch: {{ta.ispatch|yesno:"Yes,No,Pending check"}})<br/>
-        {%if forloop.last%}</div>{%endif%}
-       {%endfor%}
-       <div>
-        {%for a in t.mailthreadannotation_set.all%}
-         {%if forloop.first%}
-          <h4>Annotations</h4>
-          <table class="table table-bordered table-striped table-condensed small">
-           <thead>
-            <tr>
-             <th>When</th>
-             <th>Who</th>
-             <th>Mail</th>
-             <th>Annotation</th>
-            </tr>
-           </thead>
-           <tbody>
-         {%endif%}
-         <tr>
-          <td>{{a.date}}</td>
-          <td style="white-space: nowrap">{{a.user_string}}</td>
-          <td style="white-space: nowrap">From {{a.mailauthor}}<br/>at <a href="https://www.postgresql.org/message-id/{{a.msgid}}">{{a.maildate}}</a></td>
-          <td width="99%">{{a.annotationtext}} <button type="button" class="close" title="Delete this annotation" onclick="deleteAnnotation({{a.id}})">&times;</button></td>
-         </tr>
-         {%if forloop.last%}
-          </body>
-          </table>
-         {%endif%}
-        {%endfor%}
-        {%if user.is_authenticated%}<button class="btn btn-xs btn-default" onclick="addAnnotation({{t.id}})">Add annotation</button>{%endif%}
-       </div>
-      </dd>
+ {%if user.is_staff %}
+  {%include "patch_administrative.inc"%}
+ {%endif%}
+
+ {%if not user.is_authenticated %}
+  <div>
+   Read-Only mode.  <a href="/account/login/?next={{request.path}}">Log in</a> to interact with this patch.
+  </div>
+ {%endif%}
+
+ {%include "patch_workflow.inc"%}
+
+ <div class="workflow">
+  <table class="table table-bordered">
+   <tbody>
+    {%include "patch_tr_email.inc"%}
+    {%include "patch_tr_cfbot.inc"%}
+    <tr>
+     <th>Links</th>
+     <td>
+      {%if patch.wikilink%}
+       <a href="{{patch.wikilink}}">Wiki</a>
+      {%endif%}
+      {%if patch.gitlink%}
+       <a href="{{patch.gitlink}}">Git</a>
+      {%endif%}
+      {%if user.is_authenticated %}
+       <a class="btn btn-default pull-right" href="edit/">Edit</a>
+      {%endif%}
+     </td>
+    </tr>
+    <tr>
+     <th>Status</th>
+     <td>{%for c in patch_commitfests %}
+      <div style="margin-bottom: 3px;">
+       <a href="/{{c.commitfest.id}}/">{{c.commitfest}}</a>:
+       <span class="label label-{{c.status|patchstatuslabel}}">{{c.statusstring}}</span>
+       <span>({{c.enterdate}} to {%if c.leavedate%}{{c.leavedate}}{%else%}present{%endif%})</span>
+      </div>
      {%endfor%}
-    </dl>
-   </td>
-  </tr>
-  <tr>
-   <th>History</th>
-   <td>
-    <div style="max-height: 200px; overflow-y: scroll;">
-     <table class="table table-bordered table-striped table-condensed">
-      <thead>
-       <tr>
-        <th>When</th>
-        <th>Who</th>
-        <th>What</th>
-       </tr>
-      </thead>
-      <tbody>
-       {%for h in patch.history %}
-        <tr>
-         <td style="white-space: nowrap;">{{h.date}}</td>
-         <td style="white-space: nowrap;">{{h.by_string}}</td>
-         <td width="99%">{{h.what}}</td>
-        </tr>
-       {%endfor%}
-      </tbody>
-     </table>
-    </div>
-    {%if user.is_authenticated%}
-     <a href="{{is_subscribed|yesno:"unsubscribe,subscribe"}}/" class="btn btn-default">{{is_subscribed|yesno:"Unsubscribe from patch update emails,Subscribe to patch update emails"}}</a>
-    {%endif%}
-   </td>
-  </tr>
- </tbody>
- </table>
+     </td>
+    </tr>
+   </tbody>
+  </table>
+ </div>
+
+ <div>
+  <button class="btn btn-default" data-toggle="collapse" data-target="#history-table">Toggle History Table</button>
+  {%if user.is_authenticated %}
+   <button class="btn btn-default" data-toggle="collapse" data-target="#keyvalue-table">Toggle Key-Value Table</button>
+  {%endif%}
+ </div>
 
- <div class="dropup" >
-  {%include "patch_commands.inc"%}
+ <div id="history-table" class="table table-bordered">
+  <h3>History</h3>
+  <table class="table table-bordered table-striped table-condensed">
+   <thead>
+    <tr>
+     <th>When</th>
+     <th>Who</th>
+     <th>What</th>
+    </tr>
+   </thead>
+   <tbody>
+    {%for h in patch.history %}
+     <tr>
+      <td style="white-space: nowrap;">{{h.date}}</td>
+      <td style="white-space: nowrap;">{{h.by_string}}</td>
+      <td width="99%">{{h.what}}</td>
+     </tr>
+    {%endfor%}
+   </tbody>
+  </table>
  </div>
+ {%if user.is_authenticated %}
+  {%include "patch_table_keyvalue.inc"%}
+ {%endif%}
 
 {%comment%}commit dialog{%endcomment%}
  <div class="modal fade" id="commitModal" role="dialog">
diff --git a/pgcommitfest/commitfest/templates/patch_commands.inc b/pgcommitfest/commitfest/templates/patch_administrative.inc
similarity index 53%
rename from pgcommitfest/commitfest/templates/patch_commands.inc
rename to pgcommitfest/commitfest/templates/patch_administrative.inc
index 8f09b18c..ca3ff0a0 100644
--- a/pgcommitfest/commitfest/templates/patch_commands.inc
+++ b/pgcommitfest/commitfest/templates/patch_administrative.inc
@@ -12,17 +12,39 @@
    <div class="btn-group">
     <a class="btn btn-default dropdown-toggle" data-toggle="dropdown" href="#">Change Status <span class="caret"></span></a>
     <ul class="dropdown-menu" role="menu">
-     <li role="presentation" class="dropdown-header">Open statuses</li>
-     <li role="presentation"><a href="status/review/">Needs review</a></li>
-     <li role="presentation"><a href="status/author/">Waiting on Author</a></li>
-     <li role="presentation"><a href="status/committer/">Ready for Committer</a></li>
+     <li role="presentation" class="dropdown-header">Assign to:</li>
+     <li role="presentation"><a href="status/review/">Reviewer</a></li>
+     <li role="presentation"><a href="status/author/">Author</a></li>
+     <li role="presentation"><a href="status/committer/">Committer</a></li>
      <li role="presentation" class="divider"></li>
-     <li role="presentation" class="dropdown-header">Closed statuses</li>
-     <li role="presentation"><a href="close/reject/" onclick="return verify_reject()">Rejected</a></li>
-     <li role="presentation"><a href="close/withdrawn/" onclick="return verify_withdrawn()">Withdrawn</a></li>
-     <li role="presentation"><a href="close/feedback/" onclick="return verify_returned()">Returned with feedback</a></li>
-     <li role="presentation"><a href="close/next/?cfid={{cf.id}}" onclick="return verify_next()">Move to next CF</a></li>
-     <li role="presentation"><a href="close/committed/" onclick="return flagCommitted({%if patch.committer%}'{{patch.committer}}'{%elif is_committer%}'{{user.username}}'{%else%}null{%endif%})">Committed</a></li>
+     <li role="presentation" class="dropdown-header">Resolve:</li>
+     <li role="presentation"><a href="close/reject/" onclick="return verify_reject()">Reject</a></li>
+     <li role="presentation"><a href="close/withdrawn/" onclick="return verify_withdrawn()">Withdraw</a></li>
+     <li role="presentation"><a href="close/feedback/" onclick="return verify_returned()">Return</a></li>
+     <li role="presentation"><a href="close/committed/" onclick="return flagCommitted({%if patch.committer%}'{{patch.committer}}'{%elif is_committer%}'{{user.username}}'{%else%}null{%endif%})">Commit</a></li>
+     <li role="presentation" class="divider"></li>
+     <li role="presentation" class="dropdown-header">Move To:</li>
+     {%if not cf.isopen and workflow.open %}
+      <li role="presentation">
+       <a
+        href="transition/?fromcfid={{poc.commitfest.id}}&tocfid={{workflow.open.id}}">
+        {{workflow.open.name}}</a>
+      </li>
+     {%endif%}
+     {%if not cf.isinprogress and workflow.progress %}
+      <li role="presentation">
+       <a
+        href="transition/?fromcfid={{poc.commitfest.id}}&tocfid={{workflow.progress.id}}">
+        {{workflow.progress.name}}</a>
+      </li>
+     {%endif%}
+     {%if not cf.isparked and workflow.parked %}
+      <li role="presentation">
+       <a
+        href="transition/?fromcfid={{poc.commitfest.id}}&tocfid={{workflow.parked.id}}">
+        {{workflow.parked.name}}</a>
+      </li>
+     {%endif%}
     </ul>
    </div>
 
diff --git a/pgcommitfest/commitfest/templates/patch_table_keyvalue.inc b/pgcommitfest/commitfest/templates/patch_table_keyvalue.inc
new file mode 100644
index 00000000..27acdab7
--- /dev/null
+++ b/pgcommitfest/commitfest/templates/patch_table_keyvalue.inc
@@ -0,0 +1,195 @@
+{%load commitfest%}
+<table id="keyvalue-table" class="table table-bordered">
+ <tbody>
+  <tr>
+   <th>ID</th>
+   <td><a href="/patch/{{patch.id}}">{{patch.id}}</a></td>
+  </tr>
+  <tr>
+   <th>Title</th>
+   <td>{{patch.name}}</td>
+  </tr>
+  <tr>
+   <th>CI (CFBot)</th>
+   <td>
+    {%if not cfbot_branch %}
+     <span class="label label-default">Not processed</span></a>
+    {%elif cfbot_branch.needs_rebase_since %}
+     <a href="{{cfbot_branch.apply_url}}">
+      <span class="label label-warning" title="View git apply logs">Needs rebase!</span></a>
+     Needs rebase {% cfsince cfbot_branch.needs_rebase_since %}. {%if cfbot_branch.failing_since and cfbot_branch.failing_since != cfbot_branch.needs_rebase_since %}Failing {% cfsince cfbot_branch.failing_since %}. {%endif%}<br>Additional links previous successfully applied patch (outdated):<br>
+     <a href="https://github.com/postgresql-cfbot/postgresql/compare/cf/{{patch.id}}~1...cf/{{patch.id}}" title="View previous successfully applied patch set on GitHub"><img class="github-logo" src="/media/commitfest/github-mark.svg"/></a>
+     <a href="https://cirrus-ci.com/github/postgresql-cfbot/postgresql/cf%2F{{patch.id}}">
+      <span class="label label-default">Summary</span></a>
+    {%else%}
+     <a href="https://github.com/postgresql-cfbot/postgresql/compare/cf/{{patch.id}}~1...cf/{{patch.id}}" title="View last patch set on GitHub"><img class="github-logo" src="/media/commitfest/github-mark.svg"/></a>
+     <a href="https://cirrus-ci.com/github/postgresql-cfbot/postgresql/cf%2F{{patch.id}}">
+      <span class="label label-default">Summary</span></a>
+     {%for c in cfbot_tasks %}
+      {%if c.status == 'COMPLETED'%}
+       <a href="https://cirrus-ci.com/task/{{c.task_id}}" title="{{c.task_name}}: {{c.status}}"><img src="/media/commitfest/new_success.svg"/></a>
+      {%elif c.status == 'CREATED' or c.status == 'SCHEDULED' %}
+       <a href="https://cirrus-ci.com/task/{{c.task_id}}" title="{{c.task_name}}: {{c.status}}"><img src="/media/commitfest/waiting_to_start.svg"/></a>
+      {%elif c.status == 'EXECUTING' %}
+       <a href="https://cirrus-ci.com/task/{{c.task_id}}" title="{{c.task_name}}: {{c.status}}"><img src="/media/commitfest/running.svg"/></a>
+      {%else %}
+       <a href="https://cirrus-ci.com/task/{{c.task_id}}" title="{{c.task_name}}: {{c.status}}"><img src="/media/commitfest/new_failure.svg"/></a>
+      {%endif%}
+     {%endfor%}
+    {%endif%}
+    {%if cfbot_branch %}
+     <button class="btn btn-default" title="This adds the following to your clipboard (needs to be run in an existing git repo):
+                                            git remote add commitfest https://github.com/postgresql-cfbot/postgresql.git
+                                            git fetch commitfest cf/{{patch.id}}
+                                            git checkout commitfest/cf/{{patch.id}}" onclick="addGitCheckoutToClipboard({{patch.id}})">Copy git checkout commands</button>
+    {%endif%}
+   </a>
+  </td>
+ </tr>
+ <tr>
+  <th>Stats (from CFBot)</th>
+  <td>
+   {%if cfbot_branch and cfbot_branch.commit_id %}
+    {%if cfbot_branch.version %}
+     Patch version: {{ cfbot_branch.version }},
+    {%endif%}
+    Patch count: {{ cfbot_branch.patch_count }},
+    First patch: <span class="additions">+{{ cfbot_branch.first_additions }}</span><span class="deletions">&#8722;{{ cfbot_branch.first_deletions }}</span>,
+    All patches: <span class="additions">+{{ cfbot_branch.all_additions }}</span><span class="deletions">&#8722;{{ cfbot_branch.all_deletions }}</span>
+   {%else%}
+    Unknown
+   {%endif%}
+  </tr>
+  <tr>
+   <th>Topic</th>
+   <td>{{patch.topic}}</td>
+  </tr>
+  <tr>
+   <th>Created</th>
+   <td>{{patch.created}}</td>
+  </tr>
+  <tr>
+   <th style="white-space: nowrap;">Last modified</th>
+   <td>{{patch.modified}} ({% cfwhen patch.modified %})</td>
+  </tr>
+  <tr>
+   <th style="white-space: nowrap;">Latest email</th>
+   <td>{%if patch.lastmail%}{{patch.lastmail}} ({% cfwhen patch.lastmail %}){%endif%}</td>
+  </tr>
+  <tr>
+   <th>Status</th>
+   <td>{%for c in patch_commitfests %}
+    <div style="margin-bottom: 3px;"><a href="/{{c.commitfest.id}}/">{{c.commitfest}}</a>: <span class="label label-{{c.status|patchstatuslabel}}">{{c.statusstring}}</span></div>
+   {%endfor%}
+   </td>
+  </tr>
+  <tr>
+   <th>Target version</th>
+   <td>{%if patch.targetversion%}<span class="label label-default">{{patch.targetversion}}</span>{%endif%}</td>
+  </tr>
+  <tr>
+   <th>Authors</th>
+   <td>{{patch.authors_string}}</td>
+  </tr>
+  <tr>
+   <th>Reviewers</th>
+   <td>{{patch.reviewers_string}}<a href="reviewer/{{is_reviewer|yesno:"remove,become"}}/" class="btn btn-default pull-right">{{is_reviewer|yesno:"Remove from reviewers,Become reviewer"}}</a></td>
+  </tr>
+  <tr>
+   <th>Committer</th>
+   <td>{%if patch.committer%}{{patch.committer.fullname}}{%endif%}
+    {%if is_committer%}<a href="committer/{{is_this_committer|yesno:"remove,become"}}/" class="btn btn-default pull-right">{{is_this_committer|yesno:"Unclaim as committer,Claim as committer"}}</a>{%endif%}
+   </td>
+  </tr>
+  <tr>
+   <th>Links</th>
+   {%if patch.wikilink%}
+    <a href="{{patch.wikilink}}">Wiki</a>{%endif%}{%if patch.gitlink%}
+     <a href="{{patch.gitlink}}">Git</a>
+    {%endif%}</td>
+ </tr>
+ <tr>
+  <th>Emails</th>
+  <td>
+   {%if user.is_authenticated%}
+    <div style="float:right"><button class="btn btn-default" onclick="attachThread({{cf.id}},{{patch.id}})">Attach thread</button></div>
+   {%else%}
+    <div style="float:right"><button class="btn btn-default" onclick="location.href='/account/login/?next=/{{cf.id}}/{{patch.id}}/%3Fattachthreadnow'">Attach thread</button></div>
+   {%endif%}
+   <dl>
+    {%for t in patch.mailthread_set.all%}
+     <dt><a href="https://www.postgresql.org/message-id/flat/{{t.messageid}}">{{t.subject}}</a> <button type="button" class="close close-nofloat" title="Detach this thread" onclick="detachThread({{cf.id}},{{patch.id}},'{{t.messageid}}')">&times;</button></dt>
+     <dd>
+      First at <a href="https://www.postgresql.org/message-id/{{t.messageid}}">{{t.firstmessage}}</a> by {{t.firstauthor|hidemail}}<br/>
+      Latest at <a href="https://www.postgresql.org/message-id/{{t.latestmsgid}}">{{t.latestmessage}}</a> by {{t.latestauthor|hidemail}}<br/>
+      {%for ta in t.mailthreadattachment_set.all%}
+       {%if forloop.first%}
+        Latest attachment (<a href="https://www.postgresql.org/message-id/attachment/{{ta.attachmentid}}/{{ta.filename}}">{{ta.filename}}</a>) at <a href="https://www.postgresql.org/message-id/{{ta.messageid}}">{{ta.date}}</a> from {{ta.author|hidemail}} <button type="button" class="btn btn-default btn-xs" data-toggle="collapse" data-target="#att{{t.pk}}" title="Show all attachments"><i class="glyphicon glyphicon-plus"></i></button>
+        <div id="att{{t.pk}}" class="collapse">
+       {%endif%}
+       &nbsp;&nbsp;&nbsp;&nbsp;Attachment (<a href="https://www.postgresql.org/message-id/attachment/{{ta.attachmentid}}/{{ta.filename}}">{{ta.filename}}</a>) at <a href="https://www.postgresql.org/message-id/{{ta.messageid}}">{{ta.date}}</a> from {{ta.author|hidemail}} (Patch: {{ta.ispatch|yesno:"Yes,No,Pending check"}})<br/>
+       {%if forloop.last%}</div>{%endif%}
+      {%endfor%}
+      <div>
+       {%for a in t.mailthreadannotation_set.all%}
+        {%if forloop.first%}
+         <h4>Annotations</h4>
+         <table class="table table-bordered table-striped table-condensed small">
+          <thead>
+           <tr>
+            <th>When</th>
+            <th>Who</th>
+            <th>Mail</th>
+            <th>Annotation</th>
+           </tr>
+          </thead>
+          <tbody>
+        {%endif%}
+        <tr>
+         <td>{{a.date}}</td>
+         <td style="white-space: nowrap">{{a.user_string}}</td>
+         <td style="white-space: nowrap">From {{a.mailauthor}}<br/>at <a href="https://www.postgresql.org/message-id/{{a.msgid}}">{{a.maildate}}</a></td>
+         <td width="99%">{{a.annotationtext}} <button type="button" class="close" title="Delete this annotation" onclick="deleteAnnotation({{a.id}})">&times;</button></td>
+        </tr>
+        {%if forloop.last%}
+         </body>
+         </table>
+        {%endif%}
+       {%endfor%}
+       {%if user.is_authenticated%}<button class="btn btn-xs btn-default" onclick="addAnnotation({{t.id}})">Add annotation</button>{%endif%}
+      </div>
+     </dd>
+    {%endfor%}
+   </dl>
+  </td>
+ </tr>
+ <tr>
+  <th>History</th>
+  <td>
+   <div style="max-height: 200px; overflow-y: scroll;">
+    <table class="table table-bordered table-striped table-condensed">
+     <thead>
+      <tr>
+       <th>When</th>
+       <th>Who</th>
+       <th>What</th>
+      </tr>
+     </thead>
+     <tbody>
+      {%for h in patch.history %}
+       <tr>
+        <td style="white-space: nowrap;">{{h.date}}</td>
+        <td style="white-space: nowrap;">{{h.by_string}}</td>
+        <td width="99%">{{h.what}}</td>
+       </tr>
+      {%endfor%}
+     </tbody>
+    </table>
+   </div>
+   {%if user.is_authenticated%}
+    <a href="{{is_subscribed|yesno:"unsubscribe,subscribe"}}/" class="btn btn-default">{{is_subscribed|yesno:"Unsubscribe from patch update emails,Subscribe to patch update emails"}}</a>
+   {%endif%}
+  </td>
+ </tr>
+</tbody>
+</table>
diff --git a/pgcommitfest/commitfest/templates/patch_tr_cfbot.inc b/pgcommitfest/commitfest/templates/patch_tr_cfbot.inc
new file mode 100644
index 00000000..af43a350
--- /dev/null
+++ b/pgcommitfest/commitfest/templates/patch_tr_cfbot.inc
@@ -0,0 +1,53 @@
+{%load commitfest%}
+<tr>
+ <th>CFBot Status</th>
+ <td>
+  {%if not cfbot_branch %}
+   <span class="label label-default">Not processed</span></a>
+  {%elif cfbot_branch.needs_rebase_since %}
+   <a href="{{cfbot_branch.apply_url}}">
+    <span class="label label-warning" title="View git apply logs">Needs rebase!</span></a>
+   Needs rebase {% cfsince cfbot_branch.needs_rebase_since %}. {%if cfbot_branch.failing_since and cfbot_branch.failing_since != cfbot_branch.needs_rebase_since %}Failing {% cfsince cfbot_branch.failing_since %}. {%endif%}<br>Additional links previous successfully applied patch (outdated):<br>
+   <a href="https://github.com/postgresql-cfbot/postgresql/compare/cf/{{patch.id}}~1...cf/{{patch.id}}" title="View previous successfully applied patch set on GitHub"><img class="github-logo" src="/media/commitfest/github-mark.svg"/></a>
+   <a href="https://cirrus-ci.com/github/postgresql-cfbot/postgresql/cf%2F{{patch.id}}">
+    <span class="label label-default">Summary</span></a>
+  {%else%}
+   <a href="https://github.com/postgresql-cfbot/postgresql/compare/cf/{{patch.id}}~1...cf/{{patch.id}}" title="View last patch set on GitHub"><img class="github-logo" src="/media/commitfest/github-mark.svg"/></a>
+   <a href="https://cirrus-ci.com/github/postgresql-cfbot/postgresql/cf%2F{{patch.id}}">
+    <span class="label label-default">Summary</span></a>
+   {%for c in cfbot_tasks %}
+    {%if c.status == 'COMPLETED'%}
+     <a href="https://cirrus-ci.com/task/{{c.task_id}}" title="{{c.task_name}}: {{c.status}}"><img src="/media/commitfest/new_success.svg"/></a>
+    {%elif c.status == 'CREATED' or c.status == 'SCHEDULED' %}
+     <a href="https://cirrus-ci.com/task/{{c.task_id}}" title="{{c.task_name}}: {{c.status}}"><img src="/media/commitfest/waiting_to_start.svg"/></a>
+    {%elif c.status == 'EXECUTING' %}
+     <a href="https://cirrus-ci.com/task/{{c.task_id}}" title="{{c.task_name}}: {{c.status}}"><img src="/media/commitfest/running.svg"/></a>
+    {%else %}
+     <a href="https://cirrus-ci.com/task/{{c.task_id}}" title="{{c.task_name}}: {{c.status}}"><img src="/media/commitfest/new_failure.svg"/></a>
+    {%endif%}
+   {%endfor%}
+  {%endif%}
+  {%if cfbot_branch %}
+   <button class="btn btn-default" title="This adds the following to your clipboard (needs to be run in an existing git repo):
+                                          git remote add commitfest https://github.com/postgresql-cfbot/postgresql.git
+                                          git fetch commitfest cf/{{patch.id}}
+                                          git checkout commitfest/cf/{{patch.id}}" onclick="addGitCheckoutToClipboard({{patch.id}})">Copy git checkout commands</button>
+  {%endif%}
+ </a>
+</td>
+</tr>
+<tr>
+ <th>CFBot Calculations</th>
+ <td>
+  {%if cfbot_branch and cfbot_branch.commit_id %}
+   {%if cfbot_branch.version %}
+    Patch version: {{ cfbot_branch.version }},
+   {%endif%}
+   Patch count: {{ cfbot_branch.patch_count }},
+   First patch: <span class="additions">+{{ cfbot_branch.first_additions }}</span><span class="deletions">&#8722;{{ cfbot_branch.first_deletions }}</span>,
+   All patches: <span class="additions">+{{ cfbot_branch.all_additions }}</span><span class="deletions">&#8722;{{ cfbot_branch.all_deletions }}</span>
+  {%else%}
+   Unknown
+  {%endif%}
+ </td>
+</tr>
diff --git a/pgcommitfest/commitfest/templates/patch_tr_email.inc b/pgcommitfest/commitfest/templates/patch_tr_email.inc
new file mode 100644
index 00000000..b310b35a
--- /dev/null
+++ b/pgcommitfest/commitfest/templates/patch_tr_email.inc
@@ -0,0 +1,56 @@
+{%load commitfest%}
+<tr>
+ <th>Emails</th>
+ <td>
+  <dl>
+   {%for t in patch.mailthread_set.all%}
+    <dt style="white-space: nowrap;"><a href="https://www.postgresql.org/message-id/flat/{{t.messageid}}">{{t.subject}}</a> <button type="button" class="close close-nofloat" title="Detach this thread" onclick="detachThread({{cf.id}},{{patch.id}},'{{t.messageid}}')">&times;</button></dt>
+    <dd>
+     First at <a href="https://www.postgresql.org/message-id/{{t.messageid}}">{{t.firstmessage}}</a> by {{t.firstauthor|hidemail}}<br/>
+     Latest at <a href="https://www.postgresql.org/message-id/{{t.latestmsgid}}">{{t.latestmessage}}</a> by {{t.latestauthor|hidemail}}<br/>
+     {%for ta in t.mailthreadattachment_set.all%}
+      {%if forloop.first%}
+       Latest attachment (<a href="https://www.postgresql.org/message-id/attachment/{{ta.attachmentid}}/{{ta.filename}}">{{ta.filename}}</a>) at <a href="https://www.postgresql.org/message-id/{{ta.messageid}}">{{ta.date}}</a> from {{ta.author|hidemail}} <button type="button" class="btn btn-default btn-xs" data-toggle="collapse" data-target="#att{{t.pk}}" title="Show all attachments"><i class="glyphicon glyphicon-plus"></i></button>
+       <div id="att{{t.pk}}" class="collapse">
+      {%endif%}
+      &nbsp;&nbsp;&nbsp;&nbsp;Attachment (<a href="https://www.postgresql.org/message-id/attachment/{{ta.attachmentid}}/{{ta.filename}}">{{ta.filename}}</a>) at <a href="https://www.postgresql.org/message-id/{{ta.messageid}}">{{ta.date}}</a> from {{ta.author|hidemail}} (Patch: {{ta.ispatch|yesno:"Yes,No,Pending check"}})<br/>
+      {%if forloop.last%}</div>{%endif%}
+     {%endfor%}
+     <div>
+      {%for a in t.mailthreadannotation_set.all%}
+       {%if forloop.first%}
+        <h4>Annotations</h4>
+        <table class="table table-bordered table-striped table-condensed small">
+         <thead>
+          <tr>
+           <th>When</th>
+           <th>Who</th>
+           <th>Mail</th>
+           <th>Annotation</th>
+          </tr>
+         </thead>
+         <tbody>
+       {%endif%}
+       <tr>
+        <td>{{a.date}}</td>
+        <td style="white-space: nowrap">{{a.user_string}}</td>
+        <td style="white-space: nowrap">From {{a.mailauthor}}<br/>at <a href="https://www.postgresql.org/message-id/{{a.msgid}}">{{a.maildate}}</a></td>
+        <td width="99%">{{a.annotationtext}} <button type="button" class="close" title="Delete this annotation" onclick="deleteAnnotation({{a.id}})">&times;</button></td>
+       </tr>
+       {%if forloop.last%}
+        </body>
+        </table>
+       {%endif%}
+      {%endfor%}
+      {%if user.is_authenticated%}<button class="btn btn-xs btn-default" onclick="addAnnotation({{t.id}})">Add annotation</button>{%endif%}
+      {%if user.is_authenticated%}
+       <div><button class="btn btn-default" onclick="attachThread({{cf.id}},{{patch.id}})">Attach thread</button></div>
+      {%else%}
+       <div><button class="btn btn-default" onclick="location.href='/account/login/?next=/{{cf.id}}/{{patch.id}}/%3Fattachthreadnow'">Attach New Thread</button></div>
+      {%endif%}
+     </div>
+    </dd>
+   {%endfor%}
+  </dl>
+ </td>
+</tr>
diff --git a/pgcommitfest/commitfest/templates/patch_workflow.inc b/pgcommitfest/commitfest/templates/patch_workflow.inc
new file mode 100644
index 00000000..3fd91521
--- /dev/null
+++ b/pgcommitfest/commitfest/templates/patch_workflow.inc
@@ -0,0 +1,151 @@
+{%load commitfest%}
+<div class="workflow">
+ <table class="table table-bordered">
+  {%if user.is_authenticated %}
+   <thead>
+    <tr>
+     <th>Assign To</th>
+     <th>Annotate</th>
+     <th>Resolve</th>
+     <th>Move To</th>
+    </tr>
+   </thead>
+   <tbody>
+    <tr>
+    <!-- Change -->
+     <td>
+      {%if poc.status == poc.STATUS_AUTHOR %}
+       <a class="btn btn-default" href="status/review/">Reviewer</a>
+      {%endif%}
+      {%if poc.status == poc.STATUS_REVIEW or poc.status == poc.STATUS_COMMITTER %}
+       <a class="btn btn-default" href="status/author/">Author</a>
+      {%endif%}
+      {%if poc.status == poc.STATUS_REVIEW %}
+       <a class="btn btn-default" href="status/committer/">Committer</a>
+      {%endif%}
+     </td>
+    <!-- Annotate -->
+     <td>
+      <a class="btn btn-default" href="comment/">Comment</a>
+      <a class="btn btn-default" href="review/">Review</a>
+     </td>
+    <!-- Resolve -->
+     <td>
+      {%if is_committer or is_author %}
+       {%if is_committer and poc.is_open %}
+        {%if not poc.isparked and poc.is_committer %}
+         <a class="btn btn-default" href="close/committed/" onclick="return flagCommitted({%if patch.committer%}'{{patch.committer}}'{%elif is_committer%}'{{user.username}}'{%else%}null{%endif%})">Commit</a>
+        {%endif%}
+        <a class="btn btn-default" href="close/reject/" onclick="return verify_reject()">Reject</a>
+       {%endif%}
+       {%if is_author %}
+        <a class="btn btn-default" href="close/withdrawn/" onclick="return verify_withdrawn()">Withdraw</a>
+       {%endif%}
+       {%if is_committer and not poc.is_open %}
+        <a class="btn btn-default" href="status/author/">
+         {{poc.is_committed|yesno:"Revert,Re-Open"}}
+        </a>
+       {%endif%}
+      {%else%}
+       <span>No Actions Available</span>
+      {%endif%}
+     </td>
+    <!-- Move -->
+     <td>
+      {%if poc.is_open %}
+       {%if not cf.isopen and workflow.open %}
+        {%if is_author or is_committer %}
+         <a class="btn btn-default"
+            href="transition/?fromcfid={{poc.commitfest.id}}&tocfid={{workflow.open.id}}">
+          {{workflow.open.name}}</a>
+        {%endif%}
+       {%endif%}
+
+       {%if not cf.isinprogress and workflow.progress %}
+        {%if is_committer %}
+         <a class="btn btn-default"
+            href="transition/?fromcfid={{poc.commitfest.id}}&tocfid={{workflow.progress.id}}">
+          {{workflow.progress.name}}</a>
+        {%endif%}
+       {%endif%}
+
+       {%if not cf.isparked and workflow.parked %}
+        {%if is_author %}
+         <a class="btn btn-default"
+            href="transition/?fromcfid={{poc.commitfest.id}}&tocfid={{workflow.parked.id}}">
+          {{workflow.parked.name}}</a>
+        {%endif%}
+       {%endif%}
+      {%else%}
+       <span>Cannot move closed patches.</span>
+      {%endif%}
+     </td>
+    </tr>
+   </tbody>
+  {%endif%}
+  <thead>
+   <tr>
+    <th>Author(s)</th>
+    <th>Reviewer(s)</th>
+    <th>Committer</th>
+    <th>Status</th>
+   </tr>
+  </thead>
+  <tbody>
+   <tr>
+   <!-- Change -->
+    <td>
+     {%for author in authors %}
+      <div>{{author.first_name}} {{author.last_name}}</div>
+     {%endfor%}
+     {%if is_author or is_committer %}
+      <a class="btn btn-default " href="edit/">Edit Authors</a>
+     {%endif%}
+    </td>
+   <!-- Annotate -->
+    <td>
+     {%for reviewer in reviewers %}
+      <div>{{reviewer.first_name}} {{reviewer.last_name}}</div>
+     {%endfor%}
+     {%if user.is_authenticated %}
+      <div><a href="reviewer/{{is_reviewer|yesno:"remove,become"}}/" class="btn btn-default">{{is_reviewer|yesno:"Remove Self,Become Reviewer"}}</a></div>
+     {%endif%}
+    </td>
+   <!-- Resolve -->
+    <td>
+     {%if patch.committer%}<div>{{patch.committer.fullname}}</div>{%endif%}
+     {%if is_committer%}<div><a href="committer/{{is_this_committer|yesno:"remove,become"}}/" class="btn btn-default">{{is_this_committer|yesno:"Unclaim,Claim for Self"}}</a></div>{%endif%}
+    </td>
+   <!-- Move -->
+    <td>
+     <div>
+      <span class="label label-{{poc.commitfest.status|patchstatuslabel}}">{{poc.commitfest.statusstring}}</span>
+      <span class="label label-{{poc.status|patchstatuslabel}}">{{poc.statusstring}}</span>
+      <span>
+       Version:
+      </span>
+      <span class="label label-default">{%if patch.targetversion%}{{patch.targetversion}}{%else%}N/A{%endif%}</span>
+      {%if user.is_authenticated %}
+       <a href="edit/" class="glyphicon glyphicon-edit"></a>
+      {%endif%}
+      {%if user.is_authenticated %}
+       <span class="pull-right">Updates: <a href="{{is_subscribed|yesno:"unsubscribe,subscribe"}}/" class="btn btn-default">{{is_subscribed|yesno:"Unsubscribe,Subscribe"}}</a></span>
+      {%endif%}
+     </div>
+     <div>
+      <span>
+       Topic:
+      </span>
+      <span>{{ patch.topic }}</span>
+      {%if user.is_authenticated %}
+       <a href="edit/" class="glyphicon glyphicon-edit"></a>
+      {%endif%}
+     </div>
+     <div>
+      <span>Last Modified:</span> <span>{{patch.modified}} ({% cfwhen patch.modified %})</span>
+     </div>
+    </td>
+   </tr>
+  </tbody>
+ </table>
+</div>
diff --git a/pgcommitfest/commitfest/templates/workflow.html b/pgcommitfest/commitfest/templates/workflow.html
new file mode 100644
index 00000000..eff223f3
--- /dev/null
+++ b/pgcommitfest/commitfest/templates/workflow.html
@@ -0,0 +1,244 @@
+{%extends "base.html"%}
+{%block contents%}
+ <h2>Key Concepts</h2>
+ <h3>The Commitfest Workflow</h3>
+ <p>
+  The commitfest workflow is a model for patch writing management.  The workflow
+  is designed to manage new feature patches.  Bug fixes are typically handled
+  without the overhead of creating patches in the Commitfest application.
+  Thus the worflow broadly separates patches into "drafts" and
+  "potentially committable".  The former go onto the "Drafts v19" commitfest
+  while the later are initially placed into the open commitfest, which is
+  eventually converted into the ongoing commitfest.
+  Second, they can be awaiting changes,
+  awaiting guidance, awaiting review, or waiting to be committed (status).
+  The workflow uses commitfests and patch status in combination to communicate
+  the overall state of the patch within these two categories.
+ </p>
+ <p>
+  Commitfests are just bins filled with patches.  Bins have a defined lifespan
+  after which they are "Closed" and a new bin for the same category is created.
+  Each bin serves a role in the workflow and there are 3 active categories of bins.
+  <ul>
+   <li>Drafts - annual, drafts for new features</li>
+   <li>Open - scheduled, proposed new features</li>
+   <li>Ongoing - scheduled, review and commit new features</li>
+  </ul>
+  There are three active categories of patch status covering the four statuses.
+  <ul>
+   <li>Author - the author needs to make changes</li>
+   <li>Reviewer - the patch needs guidance or a review</li>
+   <li>Committer - the patch is ready to be committed</li>
+  </ul>
+  And there are three preferred categories of patch status for when a patch has
+  been resolved, either by being committed or not.
+  <ul>
+   <li>Committed - a committer has applied the patches to the git repo</li>
+   <li>Withdrawn - the author has withdrawn the patch from consideration</li>
+   <li>Rejected - a committer has decided that the patch should not be applied</li>
+  </ul>
+  (See notes below for all available statuses and their intended usage.)
+ </p>
+ <p>
+  The drafts bin is where patches waiting on significant work go.  A reviewer
+  patch status category here mainly means awaiting guidance, though that will
+  often lead to a final review, allowing the patch to be moved to the open bin
+  with the committer patch status category already set.
+ </p>
+ <p>
+  The open and ongoing bins are the ones that strictly comprise a commitfest.
+  The open bin always exists and new features ready for review and commit go
+  here.  At scheduled points, the open bin becomes an ongoing bin and a new
+  open bin is created (possibly by reclassifying an existing future bin).
+ </p>
+ <p>
+  The ongoing bin only exists during an active commitfest period, during which
+  no new patches should be added and the community reviewers and committers are
+  encouraged to focus on reviews and commits.  At the end of the scheduled period
+  the bin is closed.  This is only bin that is not present at all times.
+ </p>
+ <p>
+  The workflow is designed with the release cycle in mind, in particular
+  the start of the annual feature freeze period.  At this time the drafts
+  bin is closed and a new one is created.  If the feature freeze is for
+  version 18 then the new drafts bin is called "Drafts v19".
+ </p>
+ <h3>Patches on Commitfests (PoC)</h3>
+ <h4>Overview</h4>
+ <p>
+  PoCs are the central concept of the commitfest workflow.  When you are viewing
+  a Patch you are always implicitly acting within its primary PoC.  The key
+  property of a PoC is its state, the possible values of which are: active,
+  inactive, moved, and abandoned.  Each is described in detail below.
+ </p>
+ <h4>Active</h4>
+ <p>
+  A Patch is active if, in its primary PoC, it is in one of the following states:
+  <ul>
+   <li>Waiting on Author (Author)</li>
+   <li>Review Needed (Reviewer)</li>
+   <li>Ready for Committer (Committer)</li>
+  </ul>
+  These correspond to the three people-related fields of the patch and indicate
+  whose effort is presently needed.  Of course, a patch may be in a state for
+  which no person is assigned; in which case the patch is advertising itself as
+  needing that kind of attention.
+ </p>
+ <h4>Inactive</h4>
+ <p>
+  A Patch is inactive if, in its primary PoC, it is in one of the following states:
+  <ul>
+   <li>Committed</li>
+   <li>Withdrawn</li>
+   <li>Rejected</li>
+   <li>Returned With Feedback*</li>
+  </ul>
+ </p>
+ <p>
+  A Committed patch is one whose files have been committed to the repository.
+  A Withdrawn patch is one where the author(s) have decided to no longer pursue
+  working on the patch and have proactively communicated that intent through
+  updating the PoC to this state.
+ </p>
+ <p>
+  A Withdrawn patch is the desired outcome if a patch is not going to be committed.
+  Only an author can withdraw a patch.  If the patch simply needs work it should
+  updated to author and placed into whichever commitfest bin is appropriate.
+ </p>
+ <p>
+  A Rejected patch has the same effect as Withdrawn but communicates that the
+  community, not the author, made the status change.  This should only be used
+  when it is when the author is unable or unwilling to withdraw the patch or park
+  it for rework.
+ </p>
+ <p>
+  *Returned With Feedback complements rejected in that the implication of rejected
+  is that the feature the patch introduces is unwanted while, here, the implementation
+  is simply not acceptable.  The workflow takes a different approach and considers
+  both to be less desirable than withdraw.  Considering the distinction between
+  author and committer making the decision to be the key difference the workflow
+  leaves reject as the fallback non-commit option and makes returned a deprecated
+  administrator-only option.
+ </p>
+ <h3>Special PoC Situations</h3>
+ <h4>Moved</h4>
+ <p>
+  Moved PoCs are a side-effect of having commitfests, and volume.  Many active
+  patches cannot be gotten to within a commitfest.  In order to keep them visible
+  they must be moved to another commitfest, creating a new PoC with the same
+  state.  The PoC they were moved from is updated to "Moved" to indicate this
+  flow-of-time action.
+ </p>
+ <h4>Is Abandoned</h4>
+ <p>
+  This check returns true if the PoC state is in the set of active states
+  but the commitfest is closed.  Conceptually, this is the same as
+  withdrawn, but through inaction.
+ </p>
+ <h3>Reverted Patches</h3>
+ <p>
+  Not every patch that is committed stays that way.  The Workflow doesn't have
+  any statuses for this, though the user interface does provide an action that
+  a committer can invoke.  Upon doing so the PoC status is updated to
+  author.  The history flow of commited followed by author indictes a probable
+  revert.
+ </p>
+ <h3>Patches</h3>
+ <h4>Overview</h4>
+ <p>
+  Patches are the basic unit of work in the workflow.  They are moved around
+  between various commitfest bins during their lifetime to reflect how they
+  interact with the focus of the community during each time period.
+  This movement, combined with the fact that a patch may only have a single
+  status, means that only one of the patch's PoCs can have a state that isn't
+  "Moved".  While a patch's status is derived from its primary PoC, everything
+  else is attached to the patch itself.
+ </p>
+ <h4>Authors, Reviewers, and Committers</h4>
+ <p>
+  Especially in open source projects, attribution for work is important.  Git
+  commit messages include author and reviewer attributions (among others) and
+  inherently identify the committer.  To aid the committer in properly recording
+  attributions in the commit message record authors and reviewers on the patch.
+ </p>
+ <p>
+  Additionally, the commitfest application uses this information to provide
+  user-specific reporting and notifications.
+ </p>
+ <h4>Threads</h4>
+ <p>
+  The actual content of a patch does not existing within the commitfest application
+  (more-or-less); instead, the patch file submission and review process is all done
+  on the -hackers mailing list.  Threads are the means by which the commitfest
+  is made aware of these files and can incorporate information pertaining to them
+  into its reporting.
+ </p>
+ <h3>Commitfests</h3>
+ <h4>Overview</h4>
+ <p>
+  Commitfests are just a collection of patches.  The workflow described above
+  explains the purpose of these collections and defines which patches belong in
+  in which collection.  One key constraint the described workflow imposes is that
+  among the statuses listed below at most one commitfest can be in each of them
+  at any given time (except for "Closed").  This allows for implementing movement
+  of a patch to be keyed to commitfest status type without the need for further
+  disambiguation.
+ </p>
+ <h4>Ongoing</h4>
+ <p>
+  An Active (see Workflow above) period where no new features should be added
+  and the goal is to get as many review"patches committed as possible.
+ </p>
+ <h4>Open</h4>
+ <p>
+  Patches ready for final review and commit, according to the author, are placed
+  in the current open commitfest.  Upon the scheduled start date it is manually
+  updated to be an ongoing commitfest, at which point no new patches should be
+  added.
+ </p>
+ <h4>Future</h4>
+ <p>
+  The PostgreSQL project works on a schedule release cycle.  At the beginning
+  of each cycle the planned commitfest periods are decided and communicated to
+  the community via the creation of future commitfests.  While classified as
+  future these commitfests are not permitted to associated with any patches.
+  Their classification is changed to open as each prior ongoing commitfest
+  closes.
+ </p>
+ <h4>Drafts</h4>
+ <p>
+  The commitfest setup as drafts is used to hold patches that are not intended
+  to be formally reviewed and committed.  Another term is "work-in-progress" (WIP).
+  Within the Workflow, at the start of the v18 feature freeze, the existing
+  "Draft v18" commitfest is closed and a new "Draft v19" commitfest is created.
+  This allows for a fresh start coinciding with the project release cycle.
+  And while commits cannot accumulate within a drafts commitfest, withdrawn and
+  rejected patches would and so having a truly never-closing commitfest is not
+  ideal.  Similarly, given the volume of patches, getting rid of abandonment
+  is counter-productive.  This workflow provides a middle-ground between
+  every-other-month and never patch moving requirements.
+ </p>
+ <h4>Closed</h4>
+ <p>
+  Drafts and ongoing commitfests are closed (open ones
+  always go through ongoing) when the period they cover has passed.
+  Closing a commitfest does not impact its related PoCs; though no new PoCs can
+  be created for a closed commitfest.
+ </p>
+ <h3>History</h3>
+ <p>
+  Textual event log for a patch.
+ </p>
+ <h3>CFBot</h3>
+ <p>
+  The CFBot is continuous integration (CI) infrastructure that uses threads
+  to retrieve patch files, builds and tests them, and posts the results to
+  the Patch.  Only active PoCs are tested.
+ </p>
+ <h3>Administrative Actions</h3>
+ <p>
+  Protections put in place to guide the described workflow can be bypassed
+  by those with the appropriate permissions.  Exercising these elevated
+  permissions is called an administrative action, or administratively performed.
+ </p>
+{%endblock%}
diff --git a/pgcommitfest/commitfest/views.py b/pgcommitfest/commitfest/views.py
index 40616ff8..3799a496 100644
--- a/pgcommitfest/commitfest/views.py
+++ b/pgcommitfest/commitfest/views.py
@@ -42,6 +42,7 @@
     Patch,
     PatchHistory,
     PatchOnCommitFest,
+    Workflow,
 )
 
 
@@ -66,6 +67,16 @@ def home(request):
     )
 
 
+def workflow(request):
+    return render(
+        request,
+        "workflow.html",
+        {
+            "title": "Workflow Overview",
+        },
+    )
+
+
 @login_required
 def me(request):
     cfs = list(CommitFest.objects.filter(status=CommitFest.STATUS_INPROGRESS))
@@ -658,33 +669,28 @@ def patch(request, patchid):
     patch_commitfests = (
         PatchOnCommitFest.objects.select_related("commitfest")
         .filter(patch=patch)
-        .order_by("-commitfest__startdate")
+        .order_by("-enterdate")
         .all()
     )
     cf = patch_commitfests[0].commitfest
 
-    committers = Committer.objects.filter(active=True).order_by(
-        "user__last_name", "user__first_name"
-    )
-
     cfbot_branch = getattr(patch, "cfbot_branch", None)
     cfbot_tasks = patch.cfbot_tasks.order_by("position") if cfbot_branch else []
 
     # XXX: this creates a session, so find a smarter way. Probably handle
     # it in the callback and just ask the user then?
     if request.user.is_authenticated:
-        committer = [c for c in committers if c.user == request.user]
-        if len(committer) > 0:
-            is_committer = True
-            is_this_committer = committer[0] == patch.committer
-        else:
-            is_committer = is_this_committer = False
-
+        is_committer, is_this_committer, all_committers = Workflow.isCommitter(
+            request.user, patch
+        )
+        is_author = request.user in patch.authors.all()
         is_reviewer = request.user in patch.reviewers.all()
         is_subscribed = patch.subscribers.filter(id=request.user.id).exists()
     else:
-        is_committer = False
-        is_this_committer = False
+        is_committer, is_this_committer, all_committers = Workflow.isCommitter(
+            None, None
+        )
+        is_author = False
         is_reviewer = False
         is_subscribed = False
 
@@ -692,6 +698,7 @@ def patch(request, patchid):
         request,
         "patch.html",
         {
+            "poc": patch_commitfests[0],
             "cf": cf,
             "patch": patch,
             "patch_commitfests": patch_commitfests,
@@ -699,15 +706,23 @@ def patch(request, patchid):
             "cfbot_tasks": cfbot_tasks,
             "is_committer": is_committer,
             "is_this_committer": is_this_committer,
+            "is_author": is_author,
             "is_reviewer": is_reviewer,
             "is_subscribed": is_subscribed,
-            "committers": committers,
+            "authors": patch.authors.all(),
+            "reviewers": patch.reviewers.all(),
+            "committers": all_committers,
             "attachnow": "attachthreadnow" in request.GET,
             "title": patch.name,
             "breadcrumbs": [
                 {"title": cf.title, "href": "/%s/" % cf.pk},
             ],
             "userprofile": getattr(request.user, "userprofile", UserProfile()),
+            "workflow": {
+                "open": Workflow.open_cf(),
+                "progress": Workflow.inprogress_cf(),
+                "parked": Workflow.parked_cf(),
+            },
         },
     )
 
@@ -964,14 +979,7 @@ def comment(request, patchid, what):
 @login_required
 @transaction.atomic
 def status(request, patchid, status):
-    patch = get_object_or_404(Patch.objects.select_related(), pk=patchid)
-    cf = patch.current_commitfest()
-    poc = get_object_or_404(
-        PatchOnCommitFest.objects.select_related(),
-        commitfest__id=cf.id,
-        patch__id=patchid,
-    )
-
+    # Active status only since those can be freely swapped between.
     if status == "review":
         newstatus = PatchOnCommitFest.STATUS_REVIEW
     elif status == "author":
@@ -981,16 +989,13 @@ def status(request, patchid, status):
     else:
         raise Exception("Can't happen")
 
-    if newstatus != poc.status:
-        # Only save it if something actually changed
-        poc.status = newstatus
-        poc.patch.set_modified()
-        poc.patch.save()
-        poc.save()
+    poc = Workflow.get_poc_for_patchid_or_404(patchid)
 
-        PatchHistory(
-            patch=poc.patch, by=request.user, what="New status: %s" % poc.statusstring
-        ).save_and_notify()
+    try:
+        if Workflow.updatePOCStatus(poc, newstatus, request.user):
+            messages.info(request, "Updated status to %s" % poc.statusstring)
+    except Exception as e:
+        messages.error(request, "Failed to update status of patch: {}".format(e))
 
     return HttpResponseRedirect("/patch/%s/" % (poc.patch.id))
 
@@ -998,149 +1003,76 @@ def status(request, patchid, status):
 @login_required
 @transaction.atomic
 def close(request, patchid, status):
-    patch = get_object_or_404(Patch.objects.select_related(), pk=patchid)
-    cf = patch.current_commitfest()
+    poc = Workflow.get_poc_for_patchid_or_404(patchid)
+    committer = None
+
+    # Inactive statuses only, except moved (next), which is handled by /transition
+    if status == "reject":
+        newstatus = PatchOnCommitFest.STATUS_REJECTED
+    elif status == "withdrawn":
+        newstatus = PatchOnCommitFest.STATUS_WITHDRAWN
+    elif status == "feedback":
+        newstatus = PatchOnCommitFest.STATUS_RETURNED
+    elif status == "committed":
+        committer = get_object_or_404(Committer, user__username=request.GET["c"])
+        newstatus = PatchOnCommitFest.STATUS_COMMITTED
+    else:
+        raise Exception("Can't happen")
 
     try:
-        request_cfid = int(request.GET.get("cfid", ""))
-    except ValueError:
-        # int() failed, ignore
-        request_cfid = None
+        if committer:
+            Workflow.setCommitter(poc, committer, request.user)
+            messages.info(request, "Committed by %s" % committer.user)
+        if Workflow.updatePOCStatus(poc, newstatus, request.user):
+            messages.info(request, "Closed.  Updated status to %s" % poc.statusstring)
+    except Exception as e:
+        messages.error(request, "Failed to close patch: {}".format(e))
+
+    return HttpResponseRedirect("/patch/%s/" % poc.patch.id)
+
+
+@login_required
+@transaction.atomic
+def transition(request, patchid):
+    from_cf = Workflow.getCommitfest(request.GET.get("fromcfid", None))
 
-    if request_cfid is not None and request_cfid != cf.id:
-        # The cfid parameter is only added to the /next/ link. That's the only
-        # close operation where two people pressing the button at the same time
-        # can have unintended effects.
+    if from_cf is None:
         messages.error(
             request,
-            "The patch was moved to a new commitfest by someone else. Please double check if you still want to retry this operation.",
+            "Unknown from commitfest id {}".format(request.GET.get("fromcfid", None)),
         )
-        return HttpResponseRedirect("/%s/%s/" % (cf.id, patch.id))
+        return HttpResponseRedirect("/patch/%s/" % (patchid))
 
-    poc = get_object_or_404(
-        PatchOnCommitFest.objects.select_related(),
-        commitfest__id=cf.id,
-        patch__id=patchid,
-    )
+    cur_poc = Workflow.get_poc_for_patchid_or_404(patchid)
 
-    poc.leavedate = datetime.now()
-
-    # We know the status can't be one of the ones below, since we
-    # have checked that we're not closed yet. Therefor, we don't
-    # need to check if the individual status has changed.
-    if status == "reject":
-        poc.status = PatchOnCommitFest.STATUS_REJECTED
-    elif status == "withdrawn":
-        poc.status = PatchOnCommitFest.STATUS_WITHDRAWN
-    elif status == "feedback":
-        poc.status = PatchOnCommitFest.STATUS_RETURNED
-    elif status == "next":
-        # Only some patch statuses can actually be moved.
-        if poc.status in (
-            PatchOnCommitFest.STATUS_COMMITTED,
-            PatchOnCommitFest.STATUS_NEXT,
-            PatchOnCommitFest.STATUS_RETURNED,
-            PatchOnCommitFest.STATUS_REJECTED,
-        ):
-            # Can't be moved!
-            messages.error(
-                request,
-                "A patch in status {0} cannot be moved to next commitfest.".format(
-                    poc.statusstring
-                ),
-            )
-            return HttpResponseRedirect("/%s/%s/" % (poc.commitfest.id, poc.patch.id))
-        elif poc.status in (
-            PatchOnCommitFest.STATUS_REVIEW,
-            PatchOnCommitFest.STATUS_AUTHOR,
-            PatchOnCommitFest.STATUS_COMMITTER,
-        ):
-            # This one can be moved
-            pass
-        else:
-            messages.error(request, "Invalid existing patch status")
-
-        oldstatus = poc.status
-
-        poc.status = PatchOnCommitFest.STATUS_NEXT
-        # Figure out the commitfest to actually put it on
-        newcf = CommitFest.objects.filter(status=CommitFest.STATUS_OPEN)
-        if len(newcf) == 0:
-            # Ok, there is no open CF at all. Let's see if there is a
-            # future one.
-            newcf = CommitFest.objects.filter(status=CommitFest.STATUS_FUTURE)
-            if len(newcf) == 0:
-                messages.error(request, "No open and no future commitfest exists!")
-                return HttpResponseRedirect(
-                    "/%s/%s/" % (poc.commitfest.id, poc.patch.id)
-                )
-            elif len(newcf) != 1:
-                messages.error(
-                    request, "No open and multiple future commitfests exist!"
-                )
-                return HttpResponseRedirect(
-                    "/%s/%s/" % (poc.commitfest.id, poc.patch.id)
-                )
-        elif len(newcf) != 1:
-            messages.error(request, "Multiple open commitfests exists!")
-            return HttpResponseRedirect("/%s/%s/" % (poc.commitfest.id, poc.patch.id))
-        elif newcf[0] == poc.commitfest:
-            # The current open CF is the same one that we are already on.
-            # In this case, try to see if there is a future CF we can
-            # move it to.
-            newcf = CommitFest.objects.filter(status=CommitFest.STATUS_FUTURE)
-            if len(newcf) == 0:
-                messages.error(
-                    request,
-                    "Cannot move patch to the same commitfest, and no future commitfests exist!",
-                )
-                return HttpResponseRedirect(
-                    "/%s/%s/" % (poc.commitfest.id, poc.patch.id)
-                )
-            elif len(newcf) != 1:
-                messages.error(
-                    request,
-                    "Cannot move patch to the same commitfest, and multiple future commitfests exist!",
-                )
-                return HttpResponseRedirect(
-                    "/%s/%s/" % (poc.commitfest.id, poc.patch.id)
-                )
-        # Create a mapping to the new commitfest that we are bouncing
-        # this patch to.
-        newpoc = PatchOnCommitFest(
-            patch=poc.patch,
-            commitfest=newcf[0],
-            status=oldstatus,
-            enterdate=datetime.now(),
+    if from_cf != cur_poc.commitfest:
+        messages.error(
+            request,
+            "Transition aborted, Redirect performed.  Commitfest for patch already changed.",
         )
-        newpoc.save()
-    elif status == "committed":
-        committer = get_object_or_404(Committer, user__username=request.GET["c"])
-        if committer != poc.patch.committer:
-            # Committer changed!
-            prevcommitter = poc.patch.committer
-            poc.patch.committer = committer
-            PatchHistory(
-                patch=poc.patch,
-                by=request.user,
-                what="Changed committer to %s" % committer,
-            ).save_and_notify(prevcommitter=prevcommitter)
-        poc.status = PatchOnCommitFest.STATUS_COMMITTED
-    else:
-        raise Exception("Can't happen")
+        return HttpResponseRedirect("/patch/%s/" % (cur_poc.patch.id))
+
+    target_cf = Workflow.getCommitfest(request.GET.get("tocfid", None))
 
-    poc.patch.set_modified()
-    poc.patch.save()
-    poc.save()
+    if target_cf is None:
+        messages.error(
+            request,
+            "Unknown destination commitfest id {}".format(
+                request.GET.get("tocfid", None)
+            ),
+        )
+        return HttpResponseRedirect("/patch/%s/" % (cur_poc.patch.id))
 
-    PatchHistory(
-        patch=poc.patch,
-        by=request.user,
-        what="Closed in commitfest %s with status: %s"
-        % (poc.commitfest, poc.statusstring),
-    ).save_and_notify()
+    try:
+        new_poc = Workflow.transitionPatch(cur_poc, target_cf, request.user)
+        messages.info(
+            request, "Transitioned patch to commitfest %s" % new_poc.commitfest.name
+        )
+    except Exception as e:
+        messages.error(request, "Failed to transition patch: {}".format(e))
+        return HttpResponseRedirect("/patch/%s/" % (cur_poc.patch.id))
 
-    return HttpResponseRedirect("/%s/%s/" % (poc.commitfest.id, poc.patch.id))
+    return HttpResponseRedirect("/patch/%s/" % (new_poc.patch.id))
 
 
 @login_required
@@ -1172,33 +1104,28 @@ def reviewer(request, patchid, status):
 @login_required
 @transaction.atomic
 def committer(request, patchid, status):
-    patch = get_object_or_404(Patch, pk=patchid)
+    poc = Workflow.get_poc_for_patchid_or_404(patchid)
 
-    committer = list(Committer.objects.filter(user=request.user, active=True))
-    if len(committer) == 0:
+    find_me = list(Committer.objects.filter(user=request.user, active=True))
+    if len(find_me) == 0:
         return HttpResponseForbidden("Only committers can do that!")
-    committer = committer[0]
-
-    is_committer = committer == patch.committer
+    me = find_me[0]
+
+    # At this point it doesn't matter who the existing committer, if any, is.
+    # If someone else is one we are permitted to become the new one, then
+    # remove ourselves.  So removing them is acceptable, and desireable for admin.
+    # So we just need to know whether we are leaving the patch with ourselves
+    # as the commiter, or None.
+    if status == "become":
+        new_committer = me
+    elif status == "remove":
+        new_committer = None
+
+    # We could ignore a no-op become...but we expect the application to
+    # prevent such things on this particular interface.
+    if not Workflow.setCommitter(poc, new_committer, request.user):
+        raise Exception("Not expecting a no-op: toggling committer")
 
-    prevcommitter = patch.committer
-    if status == "become" and not is_committer:
-        patch.committer = committer
-        patch.set_modified()
-        PatchHistory(
-            patch=patch,
-            by=request.user,
-            what="Added %s as committer" % request.user.username,
-        ).save_and_notify(prevcommitter=prevcommitter)
-    elif status == "remove" and is_committer:
-        patch.committer = None
-        patch.set_modified()
-        PatchHistory(
-            patch=patch,
-            by=request.user,
-            what="Removed %s from committers" % request.user.username,
-        ).save_and_notify(prevcommitter=prevcommitter)
-    patch.save()
     return HttpResponseRedirect("../../")
 
 
diff --git a/pgcommitfest/urls.py b/pgcommitfest/urls.py
index a67f55fc..b81f2c11 100644
--- a/pgcommitfest/urls.py
+++ b/pgcommitfest/urls.py
@@ -15,6 +15,7 @@
 
 urlpatterns = [
     re_path(r"^$", views.home),
+    re_path(r"^workflow/$", views.workflow),
     re_path(r"^me/$", views.me),
     re_path(r"^archive/$", views.archive),
     re_path(r"^activity(?P<rss>\.rss)?/", views.activity),
@@ -26,9 +27,8 @@
     re_path(r"^patch/(\d+)/edit/$", views.patchform),
     re_path(r"^(\d+)/new/$", views.newpatch),
     re_path(r"^patch/(\d+)/status/(review|author|committer)/$", views.status),
-    re_path(
-        r"^patch/(\d+)/close/(reject|withdrawn|feedback|committed|next)/$", views.close
-    ),
+    re_path(r"^patch/(\d+)/close/(reject|withdrawn|feedback|committed)/$", views.close),
+    re_path(r"^patch/(\d+)/transition/$", views.transition),
     re_path(r"^patch/(\d+)/reviewer/(become|remove)/$", views.reviewer),
     re_path(r"^patch/(\d+)/committer/(become|remove)/$", views.committer),
     re_path(r"^patch/(\d+)/(un)?subscribe/$", views.subscribe),