Merge changes from topic "change-messages-rest-api"

* changes:
  Add REST endpoint to get one change message
  Add REST endpoint for listing change messages
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 0b458ae..a79b7a5 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1171,6 +1171,15 @@
 +
 Default is true.
 
+[[change.enableParallelFormatting]]change.enableParallelFormatting::
++
+Whether or not changes can be formatted in parallel when requesting
+multiple changes at once. An example for this is Dashboards.
++
+This setting is experimental.
++
+Default is `false`.
+
 [[change.showAssigneeInChangesTable]]change.showAssigneeInChangesTable::
 +
 Show assignee field in changes table. If set to false, assignees will
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 8cde19e..ac7be2c 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -5819,7 +5819,11 @@
 Whether the new change should be set to work in progress.
 |`base_change`        |optional|
 A link:#change-id[\{change-id\}] that identifies the base change for a create
-change operation.
+change operation. Mutually exclusive with `base_commit`.
+|`base_commit`        |optional|
+A 40-digit hex SHA-1 of the commit which will be the parent commit of the newly
+created change. If set, it must be a merged commit on the destination branch.
+Mutually exclusive with `base_change`.
 |`new_branch`         |optional, default to `false`|
 Allow creating a new branch when set to `true`.
 |`merge`              |optional|
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AuthInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AuthInfo.java
index d2e5d49..43281bd 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AuthInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AuthInfo.java
@@ -69,9 +69,7 @@
     List<AgreementInfo> agreements = new ArrayList<>();
     JsArray<AgreementInfo> contributorAgreements = _contributorAgreements();
     if (contributorAgreements != null) {
-      for (AgreementInfo a : Natives.asList(contributorAgreements)) {
-        agreements.add(a);
-      }
+      agreements.addAll(Natives.asList(contributorAgreements));
     }
     return agreements;
   }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/DownloadInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/DownloadInfo.java
index 8d56a1c..a22a1e8 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/DownloadInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/DownloadInfo.java
@@ -31,9 +31,7 @@
 
   public final List<String> archives() {
     List<String> archives = new ArrayList<>();
-    for (String f : Natives.asList(_archives())) {
-      archives.add(f);
-    }
+    archives.addAll(Natives.asList(_archives()));
     return archives;
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml
index 536af4f..d629fc2 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml
@@ -526,6 +526,7 @@
               <td ui:field='actionDate'/>
             </tr>
             <tr ui:field='hashtagTableRow'>
+              <th><ui:msg>Hashtags</ui:msg></th>
               <td colspan='2'>
                 <c:Hashtags ui:field='hashtags'/>
               </td>
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index fbf3b84..bf01615 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -101,6 +101,8 @@
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
@@ -271,6 +273,7 @@
   protected BlockStrategy noSleepBlockStrategy = t -> {}; // Don't sleep in tests.
 
   @Inject private ChangeIndexCollection changeIndexes;
+  @Inject private AccountIndexCollection accountIndexes;
   @Inject private EventRecorder.Factory eventRecorderFactory;
   @Inject private InProcessProtocol inProcessProtocol;
   @Inject private Provider<AnonymousUser> anonymousUser;
@@ -878,6 +881,23 @@
     };
   }
 
+  protected AutoCloseable disableAccountIndex() {
+    AccountIndex searchIndex = accountIndexes.getSearchIndex();
+    if (!(searchIndex instanceof DisabledAccountIndex)) {
+      accountIndexes.setSearchIndex(new DisabledAccountIndex(searchIndex), false);
+    }
+
+    return new AutoCloseable() {
+      @Override
+      public void close() {
+        AccountIndex searchIndex = accountIndexes.getSearchIndex();
+        if (searchIndex instanceof DisabledAccountIndex) {
+          accountIndexes.setSearchIndex(((DisabledAccountIndex) searchIndex).unwrap(), false);
+        }
+      }
+    };
+  }
+
   protected static Gson newGson() {
     return OutputFormat.JSON_COMPACT.newGson();
   }
@@ -1286,9 +1306,7 @@
   protected void assertDiffForNewFile(
       DiffInfo diff, RevCommit commit, String path, String expectedContentSideB) throws Exception {
     List<String> expectedLines = new ArrayList<>();
-    for (String line : expectedContentSideB.split("\n")) {
-      expectedLines.add(line);
-    }
+    Collections.addAll(expectedLines, expectedContentSideB.split("\n"));
 
     assertThat(diff.binary).isNull();
     assertThat(diff.changeType).isEqualTo(ChangeType.ADDED);
diff --git a/java/com/google/gerrit/acceptance/DisabledAccountIndex.java b/java/com/google/gerrit/acceptance/DisabledAccountIndex.java
new file mode 100644
index 0000000..91baafb
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/DisabledAccountIndex.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.index.account.AccountIndex;
+
+/** This class wraps an index and assumes the search index can't handle any queries. */
+public class DisabledAccountIndex implements AccountIndex {
+  private final AccountIndex index;
+
+  public DisabledAccountIndex(AccountIndex index) {
+    this.index = index;
+  }
+
+  public AccountIndex unwrap() {
+    return index;
+  }
+
+  @Override
+  public Schema<AccountState> getSchema() {
+    return index.getSchema();
+  }
+
+  @Override
+  public void close() {
+    index.close();
+  }
+
+  @Override
+  public void replace(AccountState obj) {
+    throw new UnsupportedOperationException("AccountIndex is disabled");
+  }
+
+  @Override
+  public void delete(Account.Id key) {
+    throw new UnsupportedOperationException("AccountIndex is disabled");
+  }
+
+  @Override
+  public void deleteAll() {
+    throw new UnsupportedOperationException("AccountIndex is disabled");
+  }
+
+  @Override
+  public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts) {
+    throw new UnsupportedOperationException("AccountIndex is disabled");
+  }
+
+  @Override
+  public void markReady(boolean ready) {
+    throw new UnsupportedOperationException("AccountIndex is disabled");
+  }
+}
diff --git a/java/com/google/gerrit/common/data/AccessSection.java b/java/com/google/gerrit/common/data/AccessSection.java
index 95f48b1..82dc620 100644
--- a/java/com/google/gerrit/common/data/AccessSection.java
+++ b/java/com/google/gerrit/common/data/AccessSection.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import java.util.ArrayList;
 import java.util.HashSet;
-import java.util.Iterator;
 import java.util.List;
 import java.util.Set;
 
@@ -94,11 +93,7 @@
 
   public void removePermission(String name) {
     if (permissions != null) {
-      for (Iterator<Permission> itr = permissions.iterator(); itr.hasNext(); ) {
-        if (name.equalsIgnoreCase(itr.next().getName())) {
-          itr.remove();
-        }
-      }
+      permissions.removeIf(permission -> name.equalsIgnoreCase(permission.getName()));
     }
   }
 
diff --git a/java/com/google/gerrit/common/data/Permission.java b/java/com/google/gerrit/common/data/Permission.java
index 4ee176b..dff30d7 100644
--- a/java/com/google/gerrit/common/data/Permission.java
+++ b/java/com/google/gerrit/common/data/Permission.java
@@ -177,11 +177,7 @@
 
   public void removeRule(GroupReference group) {
     if (rules != null) {
-      for (Iterator<PermissionRule> itr = rules.iterator(); itr.hasNext(); ) {
-        if (sameGroup(itr.next(), group)) {
-          itr.remove();
-        }
-      }
+      rules.removeIf(permissionRule -> sameGroup(permissionRule, group));
     }
   }
 
diff --git a/java/com/google/gerrit/common/data/SubmitRecord.java b/java/com/google/gerrit/common/data/SubmitRecord.java
index ae8b2bb..8638d6d 100644
--- a/java/com/google/gerrit/common/data/SubmitRecord.java
+++ b/java/com/google/gerrit/common/data/SubmitRecord.java
@@ -32,7 +32,7 @@
     }
 
     // The change can be submitted, unless at least one plugin prevents it.
-    return in.stream().noneMatch(r -> r.status != Status.OK);
+    return in.stream().map(SubmitRecord::status).allMatch(SubmitRecord.Status::allowsSubmission);
   }
 
   public enum Status {
@@ -56,7 +56,11 @@
      *
      * <p>Additional detail may be available in {@link SubmitRecord#errorMessage}.
      */
-    RULE_ERROR
+    RULE_ERROR;
+
+    private boolean allowsSubmission() {
+      return this == OK || this == FORCED;
+    }
   }
 
   public Status status;
@@ -178,4 +182,8 @@
   public int hashCode() {
     return Objects.hash(status, labels, errorMessage, requirements);
   }
+
+  private Status status() {
+    return status;
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/ChangeInput.java b/java/com/google/gerrit/extensions/common/ChangeInput.java
index c8e7bca..36dd8f2 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInput.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInput.java
@@ -30,6 +30,7 @@
   public Boolean isPrivate;
   public Boolean workInProgress;
   public String baseChange;
+  public String baseCommit;
   public Boolean newBranch;
   public MergeInput merge;
 
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index dc2639b..913128e 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -296,7 +296,7 @@
       RestCollection<RestResource, RestResource> rc = members.get();
       globals
           .permissionBackend
-          .user(globals.currentUser.get())
+          .currentUser()
           .checkAny(GlobalPermission.fromAnnotation(rc.getClass()));
 
       viewData = new ViewData(null, null);
@@ -1182,7 +1182,7 @@
       throws AuthException, PermissionBackendException {
     globals
         .permissionBackend
-        .user(globals.currentUser.get())
+        .currentUser()
         .checkAny(GlobalPermission.fromAnnotation(d.pluginName, d.view.getClass()));
   }
 
diff --git a/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java b/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java
index c7a92a3..ee87397 100644
--- a/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java
+++ b/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java
@@ -21,7 +21,6 @@
 import com.google.common.collect.Maps;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
-import java.util.Iterator;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
 
@@ -67,12 +66,7 @@
   }
 
   void doPrune() {
-    Iterator<Map.Entry<Object, ValueGauge>> i = cells.entrySet().iterator();
-    while (i.hasNext()) {
-      if (!i.next().getValue().set) {
-        i.remove();
-      }
-    }
+    cells.entrySet().removeIf(objectValueGaugeEntry -> !objectValueGaugeEntry.getValue().set);
   }
 
   void doEndSet() {
diff --git a/java/com/google/gerrit/metrics/dropwizard/GetMetric.java b/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
index f0ae97e..ae1e6ec 100644
--- a/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
+++ b/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -25,23 +24,21 @@
 
 class GetMetric implements RestReadView<MetricResource> {
   private final PermissionBackend permissionBackend;
-  private final CurrentUser user;
   private final DropWizardMetricMaker metrics;
 
   @Option(name = "--data-only", usage = "return only values")
   boolean dataOnly;
 
   @Inject
-  GetMetric(PermissionBackend permissionBackend, CurrentUser user, DropWizardMetricMaker metrics) {
+  GetMetric(PermissionBackend permissionBackend, DropWizardMetricMaker metrics) {
     this.permissionBackend = permissionBackend;
-    this.user = user;
     this.metrics = metrics;
   }
 
   @Override
   public MetricJson apply(MetricResource resource)
       throws AuthException, PermissionBackendException {
-    permissionBackend.user(user).check(GlobalPermission.VIEW_CACHES);
+    permissionBackend.currentUser().check(GlobalPermission.VIEW_CACHES);
     return new MetricJson(
         resource.getMetric(), metrics.getAnnotations(resource.getName()), dataOnly);
   }
diff --git a/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java b/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
index 59f6b97..b028a16 100644
--- a/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
+++ b/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
@@ -17,7 +17,6 @@
 import com.codahale.metrics.Metric;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -32,7 +31,6 @@
 
 class ListMetrics implements RestReadView<ConfigResource> {
   private final PermissionBackend permissionBackend;
-  private final CurrentUser user;
   private final DropWizardMetricMaker metrics;
 
   @Option(name = "--data-only", usage = "return only values")
@@ -47,17 +45,15 @@
   List<String> query = new ArrayList<>();
 
   @Inject
-  ListMetrics(
-      PermissionBackend permissionBackend, CurrentUser user, DropWizardMetricMaker metrics) {
+  ListMetrics(PermissionBackend permissionBackend, DropWizardMetricMaker metrics) {
     this.permissionBackend = permissionBackend;
-    this.user = user;
     this.metrics = metrics;
   }
 
   @Override
   public Map<String, MetricJson> apply(ConfigResource resource)
       throws AuthException, PermissionBackendException {
-    permissionBackend.user(user).check(GlobalPermission.VIEW_CACHES);
+    permissionBackend.currentUser().check(GlobalPermission.VIEW_CACHES);
 
     SortedMap<String, MetricJson> out = new TreeMap<>();
     List<String> prefixes = new ArrayList<>(query.size());
diff --git a/java/com/google/gerrit/pgm/init/BaseInit.java b/java/com/google/gerrit/pgm/init/BaseInit.java
index a5b4f46..88e48aa 100644
--- a/java/com/google/gerrit/pgm/init/BaseInit.java
+++ b/java/com/google/gerrit/pgm/init/BaseInit.java
@@ -72,7 +72,6 @@
 import java.nio.file.attribute.BasicFileAttributes;
 import java.util.ArrayList;
 import java.util.Collections;
-import java.util.Iterator;
 import java.util.List;
 import java.util.Set;
 import javax.sql.DataSource;
@@ -202,12 +201,7 @@
       }
       List<String> names = pluginsDistribution.listPluginNames();
       if (pluginsToInstall != null) {
-        for (Iterator<String> i = names.iterator(); i.hasNext(); ) {
-          String n = i.next();
-          if (!pluginsToInstall.contains(n)) {
-            i.remove();
-          }
-        }
+        names.removeIf(n -> !pluginsToInstall.contains(n));
       }
       return names;
     } catch (FileNotFoundException e) {
diff --git a/java/com/google/gerrit/server/ApprovalsUtil.java b/java/com/google/gerrit/server/ApprovalsUtil.java
index 90ee38d..8ffe33d 100644
--- a/java/com/google/gerrit/server/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/ApprovalsUtil.java
@@ -108,7 +108,6 @@
   }
 
   private final NotesMigration migration;
-  private final IdentifiedUser.GenericFactory userFactory;
   private final ApprovalCopier copier;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
@@ -117,12 +116,10 @@
   @Inject
   public ApprovalsUtil(
       NotesMigration migration,
-      IdentifiedUser.GenericFactory userFactory,
       ApprovalCopier copier,
       PermissionBackend permissionBackend,
       ProjectCache projectCache) {
     this.migration = migration;
-    this.userFactory = userFactory;
     this.copier = copier;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
@@ -267,9 +264,12 @@
 
   private boolean canSee(ReviewDb db, ChangeNotes notes, Account.Id accountId) {
     try {
-      IdentifiedUser user = userFactory.create(accountId);
       return projectCache.checkedGet(notes.getProjectName()).statePermitsRead()
-          && permissionBackend.user(user).change(notes).database(db).test(ChangePermission.READ);
+          && permissionBackend
+              .absentUser(accountId)
+              .change(notes)
+              .database(db)
+              .test(ChangePermission.READ);
     } catch (IOException | PermissionBackendException e) {
       log.warn(
           String.format(
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 70b3d68..ab2c26b 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -89,6 +89,7 @@
         "//lib/ow2:ow2-asm-tree",
         "//lib/ow2:ow2-asm-util",
         "//lib/prolog:runtime",
+        "//proto:cache_java_proto",
     ],
 )
 
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index 82ca8d2..252eb61 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -26,15 +26,24 @@
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Streams;
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.sql.Timestamp;
@@ -42,16 +51,31 @@
 import java.util.List;
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 /** Utilities for manipulating patch sets. */
 @Singleton
 public class PatchSetUtil {
   private final NotesMigration migration;
+  private final Provider<ApprovalsUtil> approvalsUtilProvider;
+  private final ProjectCache projectCache;
+  private final Provider<ReviewDb> dbProvider;
+  private final GitRepositoryManager repoManager;
 
   @Inject
-  PatchSetUtil(NotesMigration migration) {
+  PatchSetUtil(
+      NotesMigration migration,
+      Provider<ApprovalsUtil> approvalsUtilProvider,
+      ProjectCache projectCache,
+      Provider<ReviewDb> dbProvider,
+      GitRepositoryManager repoManager) {
     this.migration = migration;
+    this.approvalsUtilProvider = approvalsUtilProvider;
+    this.projectCache = projectCache;
+    this.dbProvider = dbProvider;
+    this.repoManager = repoManager;
   }
 
   public PatchSet current(ReviewDb db, ChangeNotes notes) throws OrmException {
@@ -155,4 +179,49 @@
     update.setGroups(groups);
     db.patchSets().update(Collections.singleton(ps));
   }
+
+  /** Check if the current patch set of the change is locked. */
+  public void checkPatchSetNotLocked(ChangeNotes notes, CurrentUser user)
+      throws OrmException, IOException, ResourceConflictException {
+    if (isPatchSetLocked(notes, user)) {
+      throw new ResourceConflictException(
+          String.format("The current patch set of change %s is locked", notes.getChangeId()));
+    }
+  }
+
+  /** Is the current patch set locked against state changes? */
+  public boolean isPatchSetLocked(ChangeNotes notes, CurrentUser user)
+      throws OrmException, IOException {
+    Change change = notes.getChange();
+    if (change.getStatus() == Change.Status.MERGED) {
+      return false;
+    }
+
+    ProjectState projectState = projectCache.checkedGet(notes.getProjectName());
+    checkNotNull(projectState, "Failed to load project %s", notes.getProjectName());
+
+    ApprovalsUtil approvalsUtil = approvalsUtilProvider.get();
+    for (PatchSetApproval ap :
+        approvalsUtil.byPatchSet(
+            dbProvider.get(), notes, user, change.currentPatchSetId(), null, null)) {
+      LabelType type = projectState.getLabelTypes(notes, user).byLabel(ap.getLabel());
+      if (type != null
+          && ap.getValue() == 1
+          && type.getFunction() == LabelFunction.PATCH_SET_LOCK) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /** Returns the full commit message for the given project at the given patchset revision */
+  public String getFullCommitMessage(Project.NameKey project, PatchSet patchSet)
+      throws IOException {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit src = rw.parseCommit(ObjectId.fromString(patchSet.getRevision().get()));
+      rw.parseBody(src);
+      return src.getFullMessage();
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
index c375067..6f5318e 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
@@ -27,6 +27,10 @@
 import com.google.common.collect.Sets;
 import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.AccountCache;
@@ -113,28 +117,33 @@
     private final ExternalIdCache externalIdCache;
     private final AccountCache accountCache;
     private final Provider<AccountIndexer> accountIndexer;
+    private final MetricMaker metricMaker;
 
     @Inject
     Factory(
         ExternalIdCache externalIdCache,
         AccountCache accountCache,
-        Provider<AccountIndexer> accountIndexer) {
+        Provider<AccountIndexer> accountIndexer,
+        MetricMaker metricMaker) {
       this.externalIdCache = externalIdCache;
       this.accountCache = accountCache;
       this.accountIndexer = accountIndexer;
+      this.metricMaker = metricMaker;
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo)
         throws IOException, ConfigInvalidException {
-      return new ExternalIdNotes(externalIdCache, accountCache, accountIndexer, allUsersRepo)
+      return new ExternalIdNotes(
+              externalIdCache, accountCache, accountIndexer, metricMaker, allUsersRepo)
           .load();
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
         throws IOException, ConfigInvalidException {
-      return new ExternalIdNotes(externalIdCache, accountCache, accountIndexer, allUsersRepo)
+      return new ExternalIdNotes(
+              externalIdCache, accountCache, accountIndexer, metricMaker, allUsersRepo)
           .load(rev);
     }
   }
@@ -142,22 +151,24 @@
   @Singleton
   public static class FactoryNoReindex implements ExternalIdNotesLoader {
     private final ExternalIdCache externalIdCache;
+    private final MetricMaker metricMaker;
 
     @Inject
-    FactoryNoReindex(ExternalIdCache externalIdCache) {
+    FactoryNoReindex(ExternalIdCache externalIdCache, MetricMaker metricMaker) {
       this.externalIdCache = externalIdCache;
+      this.metricMaker = metricMaker;
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo)
         throws IOException, ConfigInvalidException {
-      return new ExternalIdNotes(externalIdCache, null, null, allUsersRepo).load();
+      return new ExternalIdNotes(externalIdCache, null, null, metricMaker, allUsersRepo).load();
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
         throws IOException, ConfigInvalidException {
-      return new ExternalIdNotes(externalIdCache, null, null, allUsersRepo).load(rev);
+      return new ExternalIdNotes(externalIdCache, null, null, metricMaker, allUsersRepo).load(rev);
     }
   }
 
@@ -169,7 +180,8 @@
    */
   public static ExternalIdNotes loadReadOnly(Repository allUsersRepo)
       throws IOException, ConfigInvalidException {
-    return new ExternalIdNotes(new DisabledExternalIdCache(), null, null, allUsersRepo)
+    return new ExternalIdNotes(
+            new DisabledExternalIdCache(), null, null, new DisabledMetricMaker(), allUsersRepo)
         .setReadOnly()
         .load();
   }
@@ -186,7 +198,8 @@
    */
   public static ExternalIdNotes loadReadOnly(Repository allUsersRepo, @Nullable ObjectId rev)
       throws IOException, ConfigInvalidException {
-    return new ExternalIdNotes(new DisabledExternalIdCache(), null, null, allUsersRepo)
+    return new ExternalIdNotes(
+            new DisabledExternalIdCache(), null, null, new DisabledMetricMaker(), allUsersRepo)
         .setReadOnly()
         .load(rev);
   }
@@ -195,16 +208,23 @@
    * Loads the external ID notes for updates without cache evictions. The external ID notes are
    * loaded from the current tip of the {@code refs/meta/external-ids} branch.
    *
+   * <p>Use this only from init, schema upgrades and tests.
+   *
+   * <p>Metrics are disabled.
+   *
    * @return {@link ExternalIdNotes} instance that doesn't updates caches on save
    */
   public static ExternalIdNotes loadNoCacheUpdate(Repository allUsersRepo)
       throws IOException, ConfigInvalidException {
-    return new ExternalIdNotes(new DisabledExternalIdCache(), null, null, allUsersRepo).load();
+    return new ExternalIdNotes(
+            new DisabledExternalIdCache(), null, null, new DisabledMetricMaker(), allUsersRepo)
+        .load();
   }
 
   private final ExternalIdCache externalIdCache;
   @Nullable private final AccountCache accountCache;
   @Nullable private final Provider<AccountIndexer> accountIndexer;
+  private final Counter0 updateCount;
   private final Repository repo;
 
   private NoteMap noteMap;
@@ -223,10 +243,15 @@
       ExternalIdCache externalIdCache,
       @Nullable AccountCache accountCache,
       @Nullable Provider<AccountIndexer> accountIndexer,
+      MetricMaker metricMaker,
       Repository allUsersRepo) {
     this.externalIdCache = checkNotNull(externalIdCache, "externalIdCache");
     this.accountCache = accountCache;
     this.accountIndexer = accountIndexer;
+    this.updateCount =
+        metricMaker.newCounter(
+            "notedb/external_id_update_count",
+            new Description("Total number of external ID updates.").setRate().setUnit("updates"));
     this.repo = checkNotNull(allUsersRepo, "allUsersRepo");
   }
 
@@ -600,7 +625,9 @@
   @Override
   public RevCommit commit(MetaDataUpdate update) throws IOException {
     oldRev = revision != null ? revision.copy() : ObjectId.zeroId();
-    return super.commit(update);
+    RevCommit commit = super.commit(update);
+    updateCount.increment();
+    return commit;
   }
 
   /**
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index 501b3a4..46d9180 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -48,7 +48,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectJson;
@@ -88,7 +87,6 @@
     ProjectApiImpl create(String name);
   }
 
-  private final CurrentUser user;
   private final PermissionBackend permissionBackend;
   private final CreateProject.Factory createProjectFactory;
   private final ProjectApiImpl.Factory projectApi;
@@ -123,7 +121,6 @@
 
   @AssistedInject
   ProjectApiImpl(
-      CurrentUser user,
       PermissionBackend permissionBackend,
       CreateProject.Factory createProjectFactory,
       ProjectApiImpl.Factory projectApi,
@@ -155,7 +152,6 @@
       SetParent setParent,
       @Assisted ProjectResource project) {
     this(
-        user,
         permissionBackend,
         createProjectFactory,
         projectApi,
@@ -191,7 +187,6 @@
 
   @AssistedInject
   ProjectApiImpl(
-      CurrentUser user,
       PermissionBackend permissionBackend,
       CreateProject.Factory createProjectFactory,
       ProjectApiImpl.Factory projectApi,
@@ -223,7 +218,6 @@
       SetParent setParent,
       @Assisted String name) {
     this(
-        user,
         permissionBackend,
         createProjectFactory,
         projectApi,
@@ -258,7 +252,6 @@
   }
 
   private ProjectApiImpl(
-      CurrentUser user,
       PermissionBackend permissionBackend,
       CreateProject.Factory createProjectFactory,
       ProjectApiImpl.Factory projectApi,
@@ -290,7 +283,6 @@
       GetParent getParent,
       SetParent setParent,
       String name) {
-    this.user = user;
     this.permissionBackend = permissionBackend;
     this.createProjectFactory = createProjectFactory;
     this.projectApi = projectApi;
@@ -339,7 +331,7 @@
         throw new BadRequestException("name must match input.name");
       }
       CreateProject impl = createProjectFactory.create(name);
-      permissionBackend.user(user).checkAny(GlobalPermission.fromAnnotation(impl.getClass()));
+      permissionBackend.currentUser().checkAny(GlobalPermission.fromAnnotation(impl.getClass()));
       impl.apply(TopLevelResource.INSTANCE, in);
       return projectApi.create(projects.parse(name));
     } catch (Exception e) {
diff --git a/java/com/google/gerrit/server/auth/ldap/Helper.java b/java/com/google/gerrit/server/auth/ldap/Helper.java
index 795686c..5af730f 100644
--- a/java/com/google/gerrit/server/auth/ldap/Helper.java
+++ b/java/com/google/gerrit/server/auth/ldap/Helper.java
@@ -406,9 +406,7 @@
             throw new IllegalArgumentException("No variables in ldap.groupMemberPattern");
           }
 
-          for (String name : groupMemberQuery.getParameters()) {
-            accountAtts.add(name);
-          }
+          accountAtts.addAll(groupMemberQuery.getParameters());
 
           groupMemberQueryList.add(groupMemberQuery);
         }
diff --git a/java/com/google/gerrit/server/cache/CacheBinding.java b/java/com/google/gerrit/server/cache/CacheBinding.java
index ff164ba..1c0d7ef 100644
--- a/java/com/google/gerrit/server/cache/CacheBinding.java
+++ b/java/com/google/gerrit/server/cache/CacheBinding.java
@@ -16,8 +16,6 @@
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.Weigher;
-import com.google.gerrit.common.Nullable;
-import com.google.inject.TypeLiteral;
 import java.util.concurrent.TimeUnit;
 
 /** Configure a cache declared within a {@link CacheModule} instance. */
@@ -25,9 +23,6 @@
   /** Set the total size of the cache. */
   CacheBinding<K, V> maximumWeight(long weight);
 
-  /** Set the total on-disk limit of the cache */
-  CacheBinding<K, V> diskLimit(long limit);
-
   /** Set the time an element lives before being expired. */
   CacheBinding<K, V> expireAfterWrite(long duration, TimeUnit durationUnits);
 
@@ -40,41 +35,7 @@
   /**
    * Set the config name to something other than the cache name.
    *
-   * @see #configKey()
+   * @see CacheDef#configKey()
    */
   CacheBinding<K, V> configKey(String configKey);
-
-  /**
-   * Unique name for this cache.
-   *
-   * <p>The name can be used in a binding annotation {@code @Named(name)} to inject the cache
-   * configured with this binding.
-   */
-  String name();
-
-  /**
-   * Key to use when looking up configuration for this cache.
-   *
-   * <p>Typically, this will match the result of {@link #name()}, so that configuration is keyed by
-   * the actual cache name. However, it may be changed, for example to reuse the size limits of some
-   * other cache.
-   */
-  String configKey();
-
-  TypeLiteral<K> keyType();
-
-  TypeLiteral<V> valueType();
-
-  long maximumWeight();
-
-  long diskLimit();
-
-  @Nullable
-  Long expireAfterWrite(TimeUnit unit);
-
-  @Nullable
-  Weigher<K, V> weigher();
-
-  @Nullable
-  CacheLoader<K, V> loader();
 }
diff --git a/java/com/google/gerrit/server/cache/CacheDef.java b/java/com/google/gerrit/server/cache/CacheDef.java
new file mode 100644
index 0000000..adb7585
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/CacheDef.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.Weigher;
+import com.google.gerrit.common.Nullable;
+import com.google.inject.TypeLiteral;
+import java.util.concurrent.TimeUnit;
+
+public interface CacheDef<K, V> {
+  /**
+   * Unique name for this cache.
+   *
+   * <p>The name can be used in a binding annotation {@code @Named(name)} to inject the cache
+   * configured with this binding.
+   */
+  String name();
+
+  /**
+   * Key to use when looking up configuration for this cache.
+   *
+   * <p>Typically, this will match the result of {@link #name()}, so that configuration is keyed by
+   * the actual cache name. However, it may be changed, for example to reuse the size limits of some
+   * other cache.
+   */
+  String configKey();
+
+  TypeLiteral<K> keyType();
+
+  TypeLiteral<V> valueType();
+
+  long maximumWeight();
+
+  @Nullable
+  Long expireAfterWrite(TimeUnit unit);
+
+  @Nullable
+  Weigher<K, V> weigher();
+
+  @Nullable
+  CacheLoader<K, V> loader();
+}
diff --git a/java/com/google/gerrit/server/cache/CacheModule.java b/java/com/google/gerrit/server/cache/CacheModule.java
index 0e0c16f..ca399e7 100644
--- a/java/com/google/gerrit/server/cache/CacheModule.java
+++ b/java/com/google/gerrit/server/cache/CacheModule.java
@@ -24,9 +24,9 @@
 import com.google.inject.Provider;
 import com.google.inject.Scopes;
 import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
 import com.google.inject.name.Names;
 import com.google.inject.util.Types;
-import java.io.Serializable;
 import java.lang.reflect.Type;
 
 /** Miniature DSL to support binding {@link Cache} instances in Guice. */
@@ -67,15 +67,9 @@
    */
   protected <K, V> CacheBinding<K, V> cache(
       String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
-    Type type = Types.newParameterizedType(Cache.class, keyType.getType(), valType.getType());
-
-    @SuppressWarnings("unchecked")
-    Key<Cache<K, V>> key = (Key<Cache<K, V>>) Key.get(type, Names.named(name));
-
     CacheProvider<K, V> m = new CacheProvider<>(this, name, keyType, valType);
-    bind(key).toProvider(m).asEagerSingleton();
-    bind(ANY_CACHE).annotatedWith(Exports.named(name)).to(key);
-    return m.maximumWeight(1024);
+    bindCache(m, name, keyType, valType);
+    return m;
   }
 
   <K, V> Provider<CacheLoader<K, V>> bindCacheLoader(
@@ -126,7 +120,7 @@
    * @param <V> type of value stored by the cache.
    * @return binding to describe the cache.
    */
-  protected <K extends Serializable, V extends Serializable> CacheBinding<K, V> persist(
+  protected <K, V> PersistentCacheBinding<K, V> persist(
       String name, Class<K> keyType, Class<V> valType) {
     return persist(name, TypeLiteral.get(keyType), TypeLiteral.get(valType));
   }
@@ -138,7 +132,7 @@
    * @param <V> type of value stored by the cache.
    * @return binding to describe the cache.
    */
-  protected <K extends Serializable, V extends Serializable> CacheBinding<K, V> persist(
+  protected <K, V> PersistentCacheBinding<K, V> persist(
       String name, Class<K> keyType, TypeLiteral<V> valType) {
     return persist(name, TypeLiteral.get(keyType), valType);
   }
@@ -150,8 +144,40 @@
    * @param <V> type of value stored by the cache.
    * @return binding to describe the cache.
    */
-  protected <K extends Serializable, V extends Serializable> CacheBinding<K, V> persist(
+  protected <K, V> PersistentCacheBinding<K, V> persist(
       String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
-    return ((CacheProvider<K, V>) cache(name, keyType, valType)).persist(true);
+    PersistentCacheProvider<K, V> m = new PersistentCacheProvider<>(this, name, keyType, valType);
+    bindCache(m, name, keyType, valType);
+
+    Type cacheDefType =
+        Types.newParameterizedType(PersistentCacheDef.class, keyType.getType(), valType.getType());
+    @SuppressWarnings("unchecked")
+    Key<PersistentCacheDef<K, V>> cacheDefKey =
+        (Key<PersistentCacheDef<K, V>>) Key.get(cacheDefType, Names.named(name));
+    bind(cacheDefKey).toInstance(m);
+
+    // TODO(dborowitz): Once default Java serialization is removed, leave no default.
+    return m.version(0)
+        .keySerializer(new JavaCacheSerializer<>())
+        .valueSerializer(new JavaCacheSerializer<>());
+  }
+
+  private <K, V> void bindCache(
+      CacheProvider<K, V> m, String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
+    Type type = Types.newParameterizedType(Cache.class, keyType.getType(), valType.getType());
+    Named named = Names.named(name);
+
+    @SuppressWarnings("unchecked")
+    Key<Cache<K, V>> key = (Key<Cache<K, V>>) Key.get(type, named);
+    bind(key).toProvider(m).asEagerSingleton();
+    bind(ANY_CACHE).annotatedWith(Exports.named(name)).to(key);
+
+    Type cacheDefType =
+        Types.newParameterizedType(CacheDef.class, keyType.getType(), valType.getType());
+    @SuppressWarnings("unchecked")
+    Key<CacheDef<K, V>> cacheDefKey = (Key<CacheDef<K, V>>) Key.get(cacheDefType, named);
+    bind(cacheDefKey).toInstance(m);
+
+    m.maximumWeight(1024);
   }
 }
diff --git a/java/com/google/gerrit/server/cache/CacheProvider.java b/java/com/google/gerrit/server/cache/CacheProvider.java
index b1a72ca..ce94bfa 100644
--- a/java/com/google/gerrit/server/cache/CacheProvider.java
+++ b/java/com/google/gerrit/server/cache/CacheProvider.java
@@ -14,9 +14,10 @@
 
 package com.google.gerrit.server.cache;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
-import com.google.common.base.Preconditions;
 import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheLoader;
@@ -28,22 +29,19 @@
 import com.google.inject.TypeLiteral;
 import java.util.concurrent.TimeUnit;
 
-class CacheProvider<K, V> implements Provider<Cache<K, V>>, CacheBinding<K, V> {
+class CacheProvider<K, V> implements Provider<Cache<K, V>>, CacheBinding<K, V>, CacheDef<K, V> {
   private final CacheModule module;
   final String name;
   private final TypeLiteral<K> keyType;
   private final TypeLiteral<V> valType;
   private String configKey;
-  private boolean persist;
   private long maximumWeight;
-  private long diskLimit;
   private Long expireAfterWrite;
   private Provider<CacheLoader<K, V>> loader;
   private Provider<Weigher<K, V>> weigher;
 
   private String plugin;
   private MemoryCacheFactory memoryCacheFactory;
-  private PersistentCacheFactory persistentCacheFactory;
   private boolean frozen;
 
   CacheProvider(CacheModule module, String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
@@ -63,57 +61,38 @@
     this.memoryCacheFactory = factory;
   }
 
-  @Inject(optional = true)
-  void setPersistentCacheFactory(@Nullable PersistentCacheFactory factory) {
-    this.persistentCacheFactory = factory;
-  }
-
-  CacheBinding<K, V> persist(boolean p) {
-    Preconditions.checkState(!frozen, "binding frozen, cannot be modified");
-    persist = p;
-    return this;
-  }
-
   @Override
   public CacheBinding<K, V> maximumWeight(long weight) {
-    Preconditions.checkState(!frozen, "binding frozen, cannot be modified");
+    checkNotFrozen();
     maximumWeight = weight;
     return this;
   }
 
   @Override
-  public CacheBinding<K, V> diskLimit(long limit) {
-    Preconditions.checkState(!frozen, "binding frozen, cannot be modified");
-    Preconditions.checkState(persist, "diskLimit supported for persistent caches only");
-    diskLimit = limit;
-    return this;
-  }
-
-  @Override
   public CacheBinding<K, V> expireAfterWrite(long duration, TimeUnit unit) {
-    Preconditions.checkState(!frozen, "binding frozen, cannot be modified");
+    checkNotFrozen();
     expireAfterWrite = SECONDS.convert(duration, unit);
     return this;
   }
 
   @Override
   public CacheBinding<K, V> loader(Class<? extends CacheLoader<K, V>> impl) {
-    Preconditions.checkState(!frozen, "binding frozen, cannot be modified");
+    checkNotFrozen();
     loader = module.bindCacheLoader(this, impl);
     return this;
   }
 
   @Override
   public CacheBinding<K, V> weigher(Class<? extends Weigher<K, V>> impl) {
-    Preconditions.checkState(!frozen, "binding frozen, cannot be modified");
+    checkNotFrozen();
     weigher = module.bindWeigher(this, impl);
     return this;
   }
 
   @Override
   public CacheBinding<K, V> configKey(String name) {
-    Preconditions.checkState(!frozen, "binding frozen, cannot be modified");
-    configKey = Preconditions.checkNotNull(name);
+    checkNotFrozen();
+    configKey = checkNotNull(name);
     return this;
   }
 
@@ -146,14 +125,6 @@
   }
 
   @Override
-  public long diskLimit() {
-    if (diskLimit > 0) {
-      return diskLimit;
-    }
-    return 128 << 20;
-  }
-
-  @Override
   @Nullable
   public Long expireAfterWrite(TimeUnit unit) {
     return expireAfterWrite != null ? unit.convert(expireAfterWrite, SECONDS) : null;
@@ -173,18 +144,16 @@
 
   @Override
   public Cache<K, V> get() {
-    frozen = true;
+    freeze();
+    CacheLoader<K, V> ldr = loader();
+    return ldr != null ? memoryCacheFactory.build(this, ldr) : memoryCacheFactory.build(this);
+  }
 
-    if (loader != null) {
-      CacheLoader<K, V> ldr = loader.get();
-      if (persist && persistentCacheFactory != null) {
-        return persistentCacheFactory.build(this, ldr);
-      }
-      return memoryCacheFactory.build(this, ldr);
-    } else if (persist && persistentCacheFactory != null) {
-      return persistentCacheFactory.build(this);
-    } else {
-      return memoryCacheFactory.build(this);
-    }
+  protected void checkNotFrozen() {
+    checkState(!frozen, "binding frozen, cannot be modified");
+  }
+
+  protected void freeze() {
+    frozen = true;
   }
 }
diff --git a/java/com/google/gerrit/server/cache/CacheSerializer.java b/java/com/google/gerrit/server/cache/CacheSerializer.java
new file mode 100644
index 0000000..6bd1322
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/CacheSerializer.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache;
+
+import java.io.IOException;
+
+/** Interface for serializing/deserializing a type to/from a persistent cache. */
+public interface CacheSerializer<T> {
+  /** Serializes the object to a new byte array. */
+  byte[] serialize(T object) throws IOException;
+
+  /** Deserializes a single object from the given byte array. */
+  T deserialize(byte[] in) throws IOException;
+}
diff --git a/java/com/google/gerrit/server/cache/EnumCacheSerializer.java b/java/com/google/gerrit/server/cache/EnumCacheSerializer.java
new file mode 100644
index 0000000..6ea61215
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/EnumCacheSerializer.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Enums;
+import java.io.IOException;
+
+public class EnumCacheSerializer<E extends Enum<E>> implements CacheSerializer<E> {
+  private final Class<E> clazz;
+
+  public EnumCacheSerializer(Class<E> clazz) {
+    this.clazz = clazz;
+  }
+
+  @Override
+  public byte[] serialize(E object) throws IOException {
+    return object.name().getBytes(UTF_8);
+  }
+
+  @Override
+  public E deserialize(byte[] in) throws IOException {
+    String name = new String(in, UTF_8);
+    return Enums.getIfPresent(clazz, name)
+        .toJavaUtil()
+        .orElseThrow(() -> new IOException("Invalid " + clazz.getName() + " value: " + name));
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/JavaCacheSerializer.java b/java/com/google/gerrit/server/cache/JavaCacheSerializer.java
new file mode 100644
index 0000000..750c5df
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/JavaCacheSerializer.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+
+/**
+ * Serializer that uses default Java serialization.
+ *
+ * @param <T> type to serialize. Must implement {@code Serializable}, but due to implementation
+ *     details this is only checked at runtime.
+ */
+public class JavaCacheSerializer<T> implements CacheSerializer<T> {
+  @Override
+  public byte[] serialize(T object) throws IOException {
+    try (ByteArrayOutputStream bout = new ByteArrayOutputStream();
+        ObjectOutputStream oout = new ObjectOutputStream(bout)) {
+      oout.writeObject(object);
+      oout.flush();
+      return bout.toByteArray();
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  public T deserialize(byte[] in) throws IOException {
+    Object object;
+    try (ByteArrayInputStream bin = new ByteArrayInputStream(in);
+        ObjectInputStream oin = new ObjectInputStream(bin)) {
+      object = oin.readObject();
+    } catch (ClassNotFoundException e) {
+      throw new IOException("Failed to deserialize object of type", e);
+    }
+    return (T) object;
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/MemoryCacheFactory.java b/java/com/google/gerrit/server/cache/MemoryCacheFactory.java
index 49fcd5b..fc55753 100644
--- a/java/com/google/gerrit/server/cache/MemoryCacheFactory.java
+++ b/java/com/google/gerrit/server/cache/MemoryCacheFactory.java
@@ -19,7 +19,7 @@
 import com.google.common.cache.LoadingCache;
 
 public interface MemoryCacheFactory {
-  <K, V> Cache<K, V> build(CacheBinding<K, V> def);
+  <K, V> Cache<K, V> build(CacheDef<K, V> def);
 
-  <K, V> LoadingCache<K, V> build(CacheBinding<K, V> def, CacheLoader<K, V> loader);
+  <K, V> LoadingCache<K, V> build(CacheDef<K, V> def, CacheLoader<K, V> loader);
 }
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheBinding.java b/java/com/google/gerrit/server/cache/PersistentCacheBinding.java
new file mode 100644
index 0000000..429f5ab
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/PersistentCacheBinding.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.Weigher;
+import java.util.concurrent.TimeUnit;
+
+/** Configure a persistent cache declared within a {@link CacheModule} instance. */
+public interface PersistentCacheBinding<K, V> extends CacheBinding<K, V> {
+  @Override
+  PersistentCacheBinding<K, V> maximumWeight(long weight);
+
+  @Override
+  PersistentCacheBinding<K, V> expireAfterWrite(long duration, TimeUnit durationUnits);
+
+  @Override
+  PersistentCacheBinding<K, V> loader(Class<? extends CacheLoader<K, V>> clazz);
+
+  @Override
+  PersistentCacheBinding<K, V> weigher(Class<? extends Weigher<K, V>> clazz);
+
+  PersistentCacheBinding<K, V> version(int version);
+
+  /** Set the total on-disk limit of the cache */
+  PersistentCacheBinding<K, V> diskLimit(long limit);
+
+  PersistentCacheBinding<K, V> keySerializer(CacheSerializer<K> keySerializer);
+
+  PersistentCacheBinding<K, V> valueSerializer(CacheSerializer<V> valueSerializer);
+}
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheDef.java b/java/com/google/gerrit/server/cache/PersistentCacheDef.java
new file mode 100644
index 0000000..9bd120f
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/PersistentCacheDef.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache;
+
+public interface PersistentCacheDef<K, V> extends CacheDef<K, V> {
+  long diskLimit();
+
+  int version();
+
+  CacheSerializer<K> keySerializer();
+
+  CacheSerializer<V> valueSerializer();
+}
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheFactory.java b/java/com/google/gerrit/server/cache/PersistentCacheFactory.java
index d134adf..27fa9ca 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheFactory.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheFactory.java
@@ -19,9 +19,9 @@
 import com.google.common.cache.LoadingCache;
 
 public interface PersistentCacheFactory {
-  <K, V> Cache<K, V> build(CacheBinding<K, V> def);
+  <K, V> Cache<K, V> build(PersistentCacheDef<K, V> def);
 
-  <K, V> LoadingCache<K, V> build(CacheBinding<K, V> def, CacheLoader<K, V> loader);
+  <K, V> LoadingCache<K, V> build(PersistentCacheDef<K, V> def, CacheLoader<K, V> loader);
 
   void onStop(String plugin);
 }
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheProvider.java b/java/com/google/gerrit/server/cache/PersistentCacheProvider.java
new file mode 100644
index 0000000..405de4f
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/PersistentCacheProvider.java
@@ -0,0 +1,143 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.Weigher;
+import com.google.gerrit.common.Nullable;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.TypeLiteral;
+import java.io.Serializable;
+import java.util.concurrent.TimeUnit;
+
+class PersistentCacheProvider<K, V> extends CacheProvider<K, V>
+    implements Provider<Cache<K, V>>, PersistentCacheBinding<K, V>, PersistentCacheDef<K, V> {
+  private int version;
+  private long diskLimit;
+  private CacheSerializer<K> keySerializer;
+  private CacheSerializer<V> valueSerializer;
+
+  private PersistentCacheFactory persistentCacheFactory;
+
+  PersistentCacheProvider(
+      CacheModule module, String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
+    super(module, name, keyType, valType);
+    version = -1;
+  }
+
+  @Inject(optional = true)
+  void setPersistentCacheFactory(@Nullable PersistentCacheFactory factory) {
+    this.persistentCacheFactory = factory;
+  }
+
+  @Override
+  public PersistentCacheBinding<K, V> maximumWeight(long weight) {
+    return (PersistentCacheBinding<K, V>) super.maximumWeight(weight);
+  }
+
+  @Override
+  public PersistentCacheBinding<K, V> expireAfterWrite(long duration, TimeUnit durationUnits) {
+    return (PersistentCacheBinding<K, V>) super.expireAfterWrite(duration, durationUnits);
+  }
+
+  @Override
+  public PersistentCacheBinding<K, V> loader(Class<? extends CacheLoader<K, V>> clazz) {
+    return (PersistentCacheBinding<K, V>) super.loader(clazz);
+  }
+
+  @Override
+  public PersistentCacheBinding<K, V> weigher(Class<? extends Weigher<K, V>> clazz) {
+    return (PersistentCacheBinding<K, V>) super.weigher(clazz);
+  }
+
+  @Override
+  public PersistentCacheBinding<K, V> version(int version) {
+    this.version = version;
+    return this;
+  }
+
+  @Override
+  public PersistentCacheBinding<K, V> keySerializer(CacheSerializer<K> keySerializer) {
+    this.keySerializer = keySerializer;
+    return this;
+  }
+
+  @Override
+  public PersistentCacheBinding<K, V> valueSerializer(CacheSerializer<V> valueSerializer) {
+    this.valueSerializer = valueSerializer;
+    return this;
+  }
+
+  @Override
+  public PersistentCacheBinding<K, V> diskLimit(long limit) {
+    checkNotFrozen();
+    diskLimit = limit;
+    return this;
+  }
+
+  @Override
+  public long diskLimit() {
+    if (diskLimit > 0) {
+      return diskLimit;
+    }
+    return 128 << 20;
+  }
+
+  @Override
+  public int version() {
+    return version;
+  }
+
+  @Override
+  public CacheSerializer<K> keySerializer() {
+    return keySerializer;
+  }
+
+  @Override
+  public CacheSerializer<V> valueSerializer() {
+    return valueSerializer;
+  }
+
+  @Override
+  public Cache<K, V> get() {
+    if (persistentCacheFactory == null) {
+      return super.get();
+    }
+    checkState(version >= 0, "version is required");
+    checkSerializer(keyType(), keySerializer, "key");
+    checkSerializer(valueType(), valueSerializer, "value");
+    freeze();
+    CacheLoader<K, V> ldr = loader();
+    return ldr != null
+        ? persistentCacheFactory.build(this, ldr)
+        : persistentCacheFactory.build(this);
+  }
+
+  private static <T> void checkSerializer(
+      TypeLiteral<T> type, CacheSerializer<T> serializer, String name) {
+    checkState(serializer != null, "%sSerializer is required", name);
+    if (serializer instanceof JavaCacheSerializer) {
+      checkState(
+          Serializable.class.isAssignableFrom(type.getRawType()),
+          "%s type %s must implement Serializable",
+          name,
+          type);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/ProtoCacheSerializers.java b/java/com/google/gerrit/server/cache/ProtoCacheSerializers.java
new file mode 100644
index 0000000..795df72
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/ProtoCacheSerializers.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache;
+
+import com.google.protobuf.CodedOutputStream;
+import com.google.protobuf.MessageLite;
+import java.io.IOException;
+
+/** Static utilities for writing protobuf-based {@link CacheSerializer} implementations. */
+public class ProtoCacheSerializers {
+  /**
+   * Serializes a proto to a byte array.
+   *
+   * <p>Guarantees deterministic serialization and thus is suitable for use as a persistent cache
+   * key. Should be used in preference to {@link MessageLite#toByteArray()}, which is not guaranteed
+   * deterministic.
+   *
+   * @param message the proto message to serialize.
+   * @return a byte array with the message contents.
+   */
+  public static byte[] toByteArray(MessageLite message) {
+    byte[] bytes = new byte[message.getSerializedSize()];
+    CodedOutputStream cout = CodedOutputStream.newInstance(bytes);
+    cout.useDeterministicSerialization();
+    try {
+      message.writeTo(cout);
+      cout.checkNoSpaceLeft();
+      return bytes;
+    } catch (IOException e) {
+      throw new IllegalStateException("exception writing to byte array");
+    }
+  }
+
+  private ProtoCacheSerializers() {}
+}
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheBindingProxy.java b/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
similarity index 66%
rename from java/com/google/gerrit/server/cache/h2/H2CacheBindingProxy.java
rename to java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
index 2871080..7b3da31 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheBindingProxy.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
@@ -16,18 +16,16 @@
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.Weigher;
-import com.google.gerrit.server.cache.CacheBinding;
+import com.google.gerrit.server.cache.CacheSerializer;
+import com.google.gerrit.server.cache.PersistentCacheDef;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
 import com.google.inject.TypeLiteral;
 import java.util.concurrent.TimeUnit;
 
-class H2CacheBindingProxy<K, V> implements CacheBinding<K, V> {
-  private static final String MSG_NOT_SUPPORTED =
-      "This is read-only wrapper. Modifications are not supported";
+class H2CacheDefProxy<K, V> implements PersistentCacheDef<K, V> {
+  private final PersistentCacheDef<K, V> source;
 
-  private final CacheBinding<K, V> source;
-
-  H2CacheBindingProxy(CacheBinding<K, V> source) {
+  H2CacheDefProxy(PersistentCacheDef<K, V> source) {
     this.source = source;
   }
 
@@ -91,32 +89,17 @@
   }
 
   @Override
-  public CacheBinding<K, V> configKey(String configKey) {
-    throw new RuntimeException(MSG_NOT_SUPPORTED);
+  public int version() {
+    return source.version();
   }
 
   @Override
-  public CacheBinding<K, V> maximumWeight(long weight) {
-    throw new RuntimeException(MSG_NOT_SUPPORTED);
+  public CacheSerializer<K> keySerializer() {
+    return source.keySerializer();
   }
 
   @Override
-  public CacheBinding<K, V> diskLimit(long limit) {
-    throw new RuntimeException(MSG_NOT_SUPPORTED);
-  }
-
-  @Override
-  public CacheBinding<K, V> expireAfterWrite(long duration, TimeUnit durationUnits) {
-    throw new RuntimeException(MSG_NOT_SUPPORTED);
-  }
-
-  @Override
-  public CacheBinding<K, V> loader(Class<? extends CacheLoader<K, V>> clazz) {
-    throw new RuntimeException(MSG_NOT_SUPPORTED);
-  }
-
-  @Override
-  public CacheBinding<K, V> weigher(Class<? extends Weigher<K, V>> clazz) {
-    throw new RuntimeException(MSG_NOT_SUPPORTED);
+  public CacheSerializer<V> valueSerializer() {
+    return source.valueSerializer();
   }
 }
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index db964c3..451540d 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -20,8 +20,8 @@
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.server.cache.CacheBinding;
 import com.google.gerrit.server.cache.MemoryCacheFactory;
+import com.google.gerrit.server.cache.PersistentCacheDef;
 import com.google.gerrit.server.cache.PersistentCacheFactory;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.SqlStore;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
@@ -30,7 +30,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import com.google.inject.TypeLiteral;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -154,16 +153,15 @@
 
   @SuppressWarnings({"unchecked"})
   @Override
-  public <K, V> Cache<K, V> build(CacheBinding<K, V> in) {
+  public <K, V> Cache<K, V> build(PersistentCacheDef<K, V> in) {
     long limit = config.getLong("cache", in.configKey(), "diskLimit", in.diskLimit());
 
     if (cacheDir == null || limit <= 0) {
       return memCacheFactory.build(in);
     }
 
-    H2CacheBindingProxy<K, V> def = new H2CacheBindingProxy<>(in);
-    SqlStore<K, V> store =
-        newSqlStore(def.name(), def.keyType(), limit, def.expireAfterWrite(TimeUnit.SECONDS));
+    H2CacheDefProxy<K, V> def = new H2CacheDefProxy<>(in);
+    SqlStore<K, V> store = newSqlStore(def, limit);
     H2CacheImpl<K, V> cache =
         new H2CacheImpl<>(
             executor, store, def.keyType(), (Cache<K, ValueHolder<V>>) memCacheFactory.build(def));
@@ -175,16 +173,15 @@
 
   @SuppressWarnings("unchecked")
   @Override
-  public <K, V> LoadingCache<K, V> build(CacheBinding<K, V> in, CacheLoader<K, V> loader) {
+  public <K, V> LoadingCache<K, V> build(PersistentCacheDef<K, V> in, CacheLoader<K, V> loader) {
     long limit = config.getLong("cache", in.configKey(), "diskLimit", in.diskLimit());
 
     if (cacheDir == null || limit <= 0) {
       return memCacheFactory.build(in, loader);
     }
 
-    H2CacheBindingProxy<K, V> def = new H2CacheBindingProxy<>(in);
-    SqlStore<K, V> store =
-        newSqlStore(def.name(), def.keyType(), limit, def.expireAfterWrite(TimeUnit.SECONDS));
+    H2CacheDefProxy<K, V> def = new H2CacheDefProxy<>(in);
+    SqlStore<K, V> store = newSqlStore(def, limit);
     Cache<K, ValueHolder<V>> mem =
         (Cache<K, ValueHolder<V>>)
             memCacheFactory.build(
@@ -208,10 +205,9 @@
     }
   }
 
-  private <V, K> SqlStore<K, V> newSqlStore(
-      String name, TypeLiteral<K> keyType, long maxSize, Long expireAfterWrite) {
+  private <V, K> SqlStore<K, V> newSqlStore(PersistentCacheDef<K, V> def, long maxSize) {
     StringBuilder url = new StringBuilder();
-    url.append("jdbc:h2:").append(cacheDir.resolve(name).toUri());
+    url.append("jdbc:h2:").append(cacheDir.resolve(def.name()).toUri());
     if (h2CacheSize >= 0) {
       url.append(";CACHE_SIZE=");
       // H2 CACHE_SIZE is always given in KB
@@ -220,9 +216,13 @@
     if (h2AutoServer) {
       url.append(";AUTO_SERVER=TRUE");
     }
+    Long expireAfterWrite = def.expireAfterWrite(TimeUnit.SECONDS);
     return new SqlStore<>(
         url.toString(),
-        keyType,
+        def.keyType(),
+        def.keySerializer(),
+        def.valueSerializer(),
+        def.version(),
         maxSize,
         expireAfterWrite == null ? 0 : expireAfterWrite.longValue());
   }
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index eaa9af9..7db1a35 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -22,23 +22,18 @@
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.hash.BloomFilter;
-import com.google.common.hash.Funnel;
-import com.google.common.hash.Funnels;
-import com.google.common.hash.PrimitiveSink;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.server.cache.CacheSerializer;
 import com.google.gerrit.server.cache.PersistentCache;
 import com.google.inject.TypeLiteral;
 import java.io.IOException;
 import java.io.InvalidClassException;
-import java.io.ObjectOutputStream;
-import java.io.OutputStream;
 import java.sql.Connection;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.sql.Statement;
 import java.sql.Timestamp;
-import java.sql.Types;
 import java.util.Calendar;
 import java.util.Map;
 import java.util.concurrent.ArrayBlockingQueue;
@@ -254,74 +249,11 @@
     }
   }
 
-  private static class KeyType<K> {
-    String columnType() {
-      return "OTHER";
-    }
-
-    @SuppressWarnings("unchecked")
-    K get(ResultSet rs, int col) throws SQLException {
-      return (K) rs.getObject(col);
-    }
-
-    void set(PreparedStatement ps, int col, K value) throws SQLException {
-      ps.setObject(col, value, Types.JAVA_OBJECT);
-    }
-
-    Funnel<K> funnel() {
-      return new Funnel<K>() {
-        private static final long serialVersionUID = 1L;
-
-        @Override
-        public void funnel(K from, PrimitiveSink into) {
-          try (ObjectOutputStream ser = new ObjectOutputStream(new SinkOutputStream(into))) {
-            ser.writeObject(from);
-            ser.flush();
-          } catch (IOException err) {
-            throw new RuntimeException("Cannot hash as Serializable", err);
-          }
-        }
-      };
-    }
-
-    @SuppressWarnings("unchecked")
-    static <K> KeyType<K> create(TypeLiteral<K> type) {
-      if (type.getRawType() == String.class) {
-        return (KeyType<K>) STRING;
-      }
-      return (KeyType<K>) OTHER;
-    }
-
-    static final KeyType<?> OTHER = new KeyType<>();
-    static final KeyType<String> STRING =
-        new KeyType<String>() {
-          @Override
-          String columnType() {
-            return "VARCHAR(4096)";
-          }
-
-          @Override
-          String get(ResultSet rs, int col) throws SQLException {
-            return rs.getString(col);
-          }
-
-          @Override
-          void set(PreparedStatement ps, int col, String value) throws SQLException {
-            ps.setString(col, value);
-          }
-
-          @SuppressWarnings("unchecked")
-          @Override
-          Funnel<String> funnel() {
-            Funnel<?> s = Funnels.unencodedCharsFunnel();
-            return (Funnel<String>) s;
-          }
-        };
-  }
-
   static class SqlStore<K, V> {
     private final String url;
     private final KeyType<K> keyType;
+    private final CacheSerializer<V> valueSerializer;
+    private final int version;
     private final long maxSize;
     private final long expireAfterWrite;
     private final BlockingQueue<SqlHandle> handles;
@@ -330,9 +262,18 @@
     private volatile BloomFilter<K> bloomFilter;
     private int estimatedSize;
 
-    SqlStore(String jdbcUrl, TypeLiteral<K> keyType, long maxSize, long expireAfterWrite) {
+    SqlStore(
+        String jdbcUrl,
+        TypeLiteral<K> keyType,
+        CacheSerializer<K> keySerializer,
+        CacheSerializer<V> valueSerializer,
+        int version,
+        long maxSize,
+        long expireAfterWrite) {
       this.url = jdbcUrl;
-      this.keyType = KeyType.create(keyType);
+      this.keyType = createKeyType(keyType, keySerializer);
+      this.valueSerializer = valueSerializer;
+      this.version = version;
       this.maxSize = maxSize;
       this.expireAfterWrite = expireAfterWrite;
 
@@ -341,6 +282,15 @@
       this.handles = new ArrayBlockingQueue<>(keep);
     }
 
+    @SuppressWarnings("unchecked")
+    private static <T> KeyType<T> createKeyType(
+        TypeLiteral<T> type, CacheSerializer<T> serializer) {
+      if (type.getRawType() == String.class) {
+        return (KeyType<T>) StringKeyTypeImpl.INSTANCE;
+      }
+      return new ObjectKeyTypeImpl<>(serializer);
+    }
+
     synchronized void open() {
       if (bloomFilter == null) {
         bloomFilter = buildBloomFilter();
@@ -372,33 +322,43 @@
       SqlHandle c = null;
       try {
         c = acquire();
-        try (Statement s = c.conn.createStatement()) {
-          if (estimatedSize <= 0) {
-            try (ResultSet r = s.executeQuery("SELECT COUNT(*) FROM data")) {
+        if (estimatedSize <= 0) {
+          try (PreparedStatement ps =
+              c.conn.prepareStatement("SELECT COUNT(*) FROM data WHERE version=?")) {
+            ps.setInt(1, version);
+            try (ResultSet r = ps.executeQuery()) {
               estimatedSize = r.next() ? r.getInt(1) : 0;
             }
           }
+        }
 
-          BloomFilter<K> b = newBloomFilter();
-          try (ResultSet r = s.executeQuery("SELECT k FROM data")) {
+        BloomFilter<K> b = newBloomFilter();
+        try (PreparedStatement ps = c.conn.prepareStatement("SELECT k FROM data WHERE version=?")) {
+          ps.setInt(1, version);
+          try (ResultSet r = ps.executeQuery()) {
             while (r.next()) {
               b.put(keyType.get(r, 1));
             }
-          } catch (JdbcSQLException e) {
-            if (e.getCause() instanceof InvalidClassException) {
-              log.warn(
-                  "Entries cached for "
-                      + url
-                      + " have an incompatible class and can't be deserialized. "
-                      + "Cache is flushed.");
-              invalidateAll();
-            } else {
-              throw e;
-            }
           }
-          return b;
+        } catch (JdbcSQLException e) {
+          if (e.getCause() instanceof InvalidClassException) {
+            // If deserialization failed using default Java serialization, this means we are using
+            // the old serialVersionUID-based invalidation strategy. In that case, authors are
+            // most likely bumping serialVersionUID rather than using the new versioning in the
+            // CacheBinding.  That's ok; we'll continue to support both for now.
+            // TODO(dborowitz): Remove this case when Java serialization is no longer used.
+            log.warn(
+                "Entries cached for "
+                    + url
+                    + " have an incompatible class and can't be deserialized. "
+                    + "Cache is flushed.");
+            invalidateAll();
+          } else {
+            throw e;
+          }
         }
-      } catch (SQLException e) {
+        return b;
+      } catch (IOException | SQLException e) {
         log.warn("Cannot build BloomFilter for " + url + ": " + e.getMessage());
         c = close(c);
         return null;
@@ -412,9 +372,14 @@
       try {
         c = acquire();
         if (c.get == null) {
-          c.get = c.conn.prepareStatement("SELECT v, created FROM data WHERE k=?");
+          c.get = c.conn.prepareStatement("SELECT v, created FROM data WHERE k=? AND version=?");
         }
         keyType.set(c.get, 1, key);
+
+        // Silently no results when the only value in the database is an older version. This will
+        // result in put overwriting the stored value with the new version, which is intended.
+        c.get.setInt(2, version);
+
         try (ResultSet r = c.get.executeQuery()) {
           if (!r.next()) {
             missCount.incrementAndGet();
@@ -428,8 +393,7 @@
             return null;
           }
 
-          @SuppressWarnings("unchecked")
-          V val = (V) r.getObject(1);
+          V val = valueSerializer.deserialize(r.getBytes(1));
           ValueHolder<V> h = new ValueHolder<>(val);
           h.clean = true;
           hitCount.incrementAndGet();
@@ -438,7 +402,7 @@
         } finally {
           c.get.clearParameters();
         }
-      } catch (SQLException e) {
+      } catch (IOException | SQLException e) {
         if (!isOldClassNameError(e)) {
           log.warn("Cannot read cache " + url + " for " + key, e);
         }
@@ -466,13 +430,14 @@
       return 1000 * expireAfterWrite < age;
     }
 
-    private void touch(SqlHandle c, K key) throws SQLException {
+    private void touch(SqlHandle c, K key) throws IOException, SQLException {
       if (c.touch == null) {
-        c.touch = c.conn.prepareStatement("UPDATE data SET accessed=? WHERE k=?");
+        c.touch = c.conn.prepareStatement("UPDATE data SET accessed=? WHERE k=? AND version=?");
       }
       try {
         c.touch.setTimestamp(1, TimeUtil.nowTs());
         keyType.set(c.touch, 2, key);
+        c.touch.setInt(3, version);
         c.touch.executeUpdate();
       } finally {
         c.touch.clearParameters();
@@ -495,19 +460,21 @@
         c = acquire();
         if (c.put == null) {
           c.put =
-              c.conn.prepareStatement("MERGE INTO data (k, v, created, accessed) VALUES(?,?,?,?)");
+              c.conn.prepareStatement(
+                  "MERGE INTO data (k, v, version, created, accessed) VALUES(?,?,?,?,?)");
         }
         try {
           keyType.set(c.put, 1, key);
-          c.put.setObject(2, holder.value, Types.JAVA_OBJECT);
-          c.put.setTimestamp(3, new Timestamp(holder.created));
-          c.put.setTimestamp(4, TimeUtil.nowTs());
+          c.put.setBytes(2, valueSerializer.serialize(holder.value));
+          c.put.setInt(3, version);
+          c.put.setTimestamp(4, new Timestamp(holder.created));
+          c.put.setTimestamp(5, TimeUtil.nowTs());
           c.put.executeUpdate();
           holder.clean = true;
         } finally {
           c.put.clearParameters();
         }
-      } catch (SQLException e) {
+      } catch (IOException | SQLException e) {
         log.warn("Cannot put into cache " + url, e);
         c = close(c);
       } finally {
@@ -520,7 +487,7 @@
       try {
         c = acquire();
         invalidate(c, key);
-      } catch (SQLException e) {
+      } catch (IOException | SQLException e) {
         log.warn("Cannot invalidate cache " + url, e);
         c = close(c);
       } finally {
@@ -528,12 +495,13 @@
       }
     }
 
-    private void invalidate(SqlHandle c, K key) throws SQLException {
+    private void invalidate(SqlHandle c, K key) throws IOException, SQLException {
       if (c.invalidate == null) {
-        c.invalidate = c.conn.prepareStatement("DELETE FROM data WHERE k=?");
+        c.invalidate = c.conn.prepareStatement("DELETE FROM data WHERE k=? and version=?");
       }
       try {
         keyType.set(c.invalidate, 1, key);
+        c.invalidate.setInt(2, version);
         c.invalidate.executeUpdate();
       } finally {
         c.invalidate.clearParameters();
@@ -560,7 +528,15 @@
       SqlHandle c = null;
       try {
         c = acquire();
+        try (PreparedStatement ps = c.conn.prepareStatement("DELETE FROM data WHERE version!=?")) {
+          ps.setInt(1, version);
+          int oldEntries = ps.executeUpdate();
+          log.info(
+              "Pruned {} entries not matching version {} from cache {}", oldEntries, version, url);
+        }
         try (Statement s = c.conn.createStatement()) {
+          // Compute size without restricting to version (although obsolete data was just pruned
+          // anyway).
           long used = 0;
           try (ResultSet r = s.executeQuery("SELECT SUM(space) FROM data")) {
             used = r.next() ? r.getLong(1) : 0;
@@ -570,8 +546,7 @@
           }
 
           try (ResultSet r =
-              s.executeQuery(
-                  "SELECT" + " k" + ",space" + ",created" + " FROM data" + " ORDER BY accessed")) {
+              s.executeQuery("SELECT k, space, created FROM data ORDER BY accessed")) {
             while (maxSize < used && r.next()) {
               K key = keyType.get(r, 1);
               Timestamp created = r.getTimestamp(3);
@@ -584,7 +559,7 @@
             }
           }
         }
-      } catch (SQLException e) {
+      } catch (IOException | SQLException e) {
         log.warn("Cannot prune cache " + url, e);
         c = close(c);
       } finally {
@@ -599,7 +574,8 @@
       try {
         c = acquire();
         try (Statement s = c.conn.createStatement();
-            ResultSet r = s.executeQuery("SELECT" + " COUNT(*)" + ",SUM(space)" + " FROM data")) {
+            // Stats include total size regardless of version.
+            ResultSet r = s.executeQuery("SELECT COUNT(*), SUM(space) FROM data")) {
           if (r.next()) {
             size = r.getLong(1);
             space = r.getLong(2);
@@ -662,6 +638,7 @@
         stmt.addBatch(
             "ALTER TABLE data ADD COLUMN IF NOT EXISTS "
                 + "space BIGINT AS OCTET_LENGTH(k) + OCTET_LENGTH(v)");
+        stmt.addBatch("ALTER TABLE data ADD COLUMN IF NOT EXISTS version INT DEFAULT 0 NOT NULL");
         stmt.executeBatch();
       }
     }
@@ -694,22 +671,4 @@
       return null;
     }
   }
-
-  private static class SinkOutputStream extends OutputStream {
-    private final PrimitiveSink sink;
-
-    SinkOutputStream(PrimitiveSink sink) {
-      this.sink = sink;
-    }
-
-    @Override
-    public void write(int b) {
-      sink.putByte((byte) b);
-    }
-
-    @Override
-    public void write(byte[] b, int p, int n) {
-      sink.putBytes(b, p, n);
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/cache/h2/KeyType.java b/java/com/google/gerrit/server/cache/h2/KeyType.java
new file mode 100644
index 0000000..3045e20
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/h2/KeyType.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.h2;
+
+import com.google.common.hash.Funnel;
+import java.io.IOException;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+interface KeyType<K> {
+  String columnType();
+
+  K get(ResultSet rs, int col) throws IOException, SQLException;
+
+  void set(PreparedStatement ps, int col, K key) throws IOException, SQLException;
+
+  Funnel<K> funnel();
+}
diff --git a/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java b/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java
new file mode 100644
index 0000000..b1a65fe
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java
@@ -0,0 +1,63 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.h2;
+
+import com.google.common.hash.Funnel;
+import com.google.common.hash.Funnels;
+import com.google.common.hash.PrimitiveSink;
+import com.google.gerrit.server.cache.CacheSerializer;
+import java.io.IOException;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+class ObjectKeyTypeImpl<K> implements KeyType<K> {
+  private final CacheSerializer<K> serializer;
+
+  ObjectKeyTypeImpl(CacheSerializer<K> serializer) {
+    this.serializer = serializer;
+  }
+
+  @Override
+  public String columnType() {
+    return "OTHER";
+  }
+
+  @Override
+  public K get(ResultSet rs, int col) throws IOException, SQLException {
+    return serializer.deserialize(rs.getBytes(col));
+  }
+
+  @Override
+  public void set(PreparedStatement ps, int col, K key) throws IOException, SQLException {
+    ps.setBytes(col, serializer.serialize(key));
+  }
+
+  @Override
+  public Funnel<K> funnel() {
+    return new Funnel<K>() {
+      private static final long serialVersionUID = 1L;
+
+      @Override
+      public void funnel(K from, PrimitiveSink into) {
+        try {
+          Funnels.byteArrayFunnel().funnel(serializer.serialize(from), into);
+        } catch (IOException e) {
+          throw new RuntimeException("Cannot hash", e);
+        }
+      }
+    };
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/h2/StringKeyTypeImpl.java b/java/com/google/gerrit/server/cache/h2/StringKeyTypeImpl.java
new file mode 100644
index 0000000..8083ea5
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/h2/StringKeyTypeImpl.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.h2;
+
+import com.google.common.hash.Funnel;
+import com.google.common.hash.Funnels;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+enum StringKeyTypeImpl implements KeyType<String> {
+  INSTANCE;
+
+  @Override
+  public String columnType() {
+    return "VARCHAR(4096)";
+  }
+
+  @Override
+  public String get(ResultSet rs, int col) throws SQLException {
+    return rs.getString(col);
+  }
+
+  @Override
+  public void set(PreparedStatement ps, int col, String value) throws SQLException {
+    ps.setString(col, value);
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  public Funnel<String> funnel() {
+    Funnel<?> s = Funnels.unencodedCharsFunnel();
+    return (Funnel<String>) s;
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java b/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
index 126da8e..f16912a 100644
--- a/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
+++ b/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
@@ -20,7 +20,7 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.cache.Weigher;
-import com.google.gerrit.server.cache.CacheBinding;
+import com.google.gerrit.server.cache.CacheDef;
 import com.google.gerrit.server.cache.ForwardingRemovalListener;
 import com.google.gerrit.server.cache.MemoryCacheFactory;
 import com.google.gerrit.server.config.ConfigUtil;
@@ -42,17 +42,17 @@
   }
 
   @Override
-  public <K, V> Cache<K, V> build(CacheBinding<K, V> def) {
+  public <K, V> Cache<K, V> build(CacheDef<K, V> def) {
     return create(def).build();
   }
 
   @Override
-  public <K, V> LoadingCache<K, V> build(CacheBinding<K, V> def, CacheLoader<K, V> loader) {
+  public <K, V> LoadingCache<K, V> build(CacheDef<K, V> def, CacheLoader<K, V> loader) {
     return create(def).build(loader);
   }
 
   @SuppressWarnings("unchecked")
-  private <K, V> CacheBuilder<K, V> create(CacheBinding<K, V> def) {
+  private <K, V> CacheBuilder<K, V> create(CacheDef<K, V> def) {
     CacheBuilder<K, V> builder = newCacheBuilder();
     builder.recordStats();
     builder.maximumWeight(
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index a08203e..34cbea3 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -39,7 +39,6 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.events.CommitReceivedEvent;
@@ -93,7 +92,6 @@
 
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
-  private final IdentifiedUser.GenericFactory userFactory;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final PatchSetUtil psUtil;
   private final ApprovalsUtil approvalsUtil;
@@ -143,7 +141,6 @@
   ChangeInserter(
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
-      IdentifiedUser.GenericFactory userFactory,
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetUtil psUtil,
       ApprovalsUtil approvalsUtil,
@@ -159,7 +156,6 @@
       @Assisted String refName) {
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
-    this.userFactory = userFactory;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.psUtil = psUtil;
     this.approvalsUtil = approvalsUtil;
@@ -465,9 +461,8 @@
         .filter(
             accountId -> {
               try {
-                IdentifiedUser user = userFactory.create(accountId);
                 return permissionBackend
-                        .user(user)
+                        .absentUser(accountId)
                         .change(notes)
                         .database(db)
                         .test(ChangePermission.READ)
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 31702ba..33dc5cb 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -117,6 +117,7 @@
 import com.google.gerrit.server.account.AccountInfoComparator;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.GpgApiAdapter;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
@@ -158,6 +159,7 @@
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -273,6 +275,7 @@
   private final RemoveReviewerControl removeReviewerControl;
   private final TrackingFooters trackingFooters;
   private final Metrics metrics;
+  private final boolean enableParallelFormatting;
   private final ExecutorService fanOutExecutor;
 
   private boolean lazyLoad = true;
@@ -307,6 +310,7 @@
       RemoveReviewerControl removeReviewerControl,
       TrackingFooters trackingFooters,
       Metrics metrics,
+      @GerritServerConfig Config config,
       @FanOutExecutor ExecutorService fanOutExecutor,
       @Assisted Iterable<ListChangesOption> options) {
     this.db = db;
@@ -334,6 +338,7 @@
     this.removeReviewerControl = removeReviewerControl;
     this.trackingFooters = trackingFooters;
     this.metrics = metrics;
+    this.enableParallelFormatting = config.getBoolean("change", "enableParallelFormatting", false);
     this.fanOutExecutor = fanOutExecutor;
     this.options = Sets.immutableEnumSet(options);
   }
@@ -504,7 +509,7 @@
       }
 
       long numProjects = changes.stream().map(c -> c.project()).distinct().count();
-      if (!lazyLoad || changes.size() < 3 || numProjects < 2) {
+      if (!enableParallelFormatting || !lazyLoad || changes.size() < 3 || numProjects < 2) {
         // Format these changes in the request thread as the multithreading overhead would be too
         // high.
         List<ChangeInfo> result = new ArrayList<>(changes.size());
diff --git a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
index 7b44d8f..6449662 100644
--- a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
+++ b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
@@ -16,8 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
-import static org.eclipse.jgit.lib.ObjectIdSerializer.readWithoutMarker;
-import static org.eclipse.jgit.lib.ObjectIdSerializer.writeWithoutMarker;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.cache.Cache;
@@ -30,6 +28,10 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.CacheSerializer;
+import com.google.gerrit.server.cache.EnumCacheSerializer;
+import com.google.gerrit.server.cache.ProtoCacheSerializers;
+import com.google.gerrit.server.cache.proto.Cache.ChangeKindKeyProto;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.InMemoryInserter;
@@ -39,10 +41,8 @@
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.name.Named;
+import com.google.protobuf.ByteString;
 import java.io.IOException;
-import java.io.ObjectInputStream;
-import java.io.ObjectOutputStream;
-import java.io.Serializable;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Objects;
@@ -51,6 +51,7 @@
 import java.util.concurrent.ExecutionException;
 import org.eclipse.jgit.errors.LargeObjectException;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.Repository;
@@ -72,7 +73,10 @@
         bind(ChangeKindCache.class).to(ChangeKindCacheImpl.class);
         persist(ID_CACHE, Key.class, ChangeKind.class)
             .maximumWeight(2 << 20)
-            .weigher(ChangeKindWeigher.class);
+            .weigher(ChangeKindWeigher.class)
+            .version(1)
+            .keySerializer(new Key.Serializer())
+            .valueSerializer(new EnumCacheSerializer<>(ChangeKind.class));
       }
     };
   }
@@ -122,9 +126,7 @@
     }
   }
 
-  public static class Key implements Serializable {
-    private static final long serialVersionUID = 1L;
-
+  public static class Key {
     private transient ObjectId prior;
     private transient ObjectId next;
     private transient String strategyName;
@@ -171,16 +173,28 @@
       return Objects.hash(prior, next, strategyName);
     }
 
-    private void writeObject(ObjectOutputStream out) throws IOException {
-      writeWithoutMarker(out, prior);
-      writeWithoutMarker(out, next);
-      out.writeUTF(strategyName);
-    }
+    @VisibleForTesting
+    static class Serializer implements CacheSerializer<Key> {
+      @Override
+      public byte[] serialize(Key object) throws IOException {
+        byte[] buf = new byte[Constants.OBJECT_ID_LENGTH];
+        ChangeKindKeyProto.Builder b = ChangeKindKeyProto.newBuilder();
+        object.getPrior().copyRawTo(buf, 0);
+        b.setPrior(ByteString.copyFrom(buf));
+        object.getNext().copyRawTo(buf, 0);
+        b.setNext(ByteString.copyFrom(buf));
+        b.setStrategyName(object.getStrategyName());
+        return ProtoCacheSerializers.toByteArray(b.build());
+      }
 
-    private void readObject(ObjectInputStream in) throws IOException {
-      prior = readWithoutMarker(in);
-      next = readWithoutMarker(in);
-      strategyName = in.readUTF();
+      @Override
+      public Key deserialize(byte[] in) throws IOException {
+        ChangeKindKeyProto proto = ChangeKindKeyProto.parseFrom(in);
+        return new Key(
+            ObjectId.fromRaw(proto.getPrior().toByteArray()),
+            ObjectId.fromRaw(proto.getNext().toByteArray()),
+            proto.getStrategyName());
+      }
     }
   }
 
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index 3a32f8f..d05d133 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -311,7 +311,11 @@
   }
 
   private void validate(RepoContext ctx)
-      throws AuthException, ResourceConflictException, IOException, PermissionBackendException {
+      throws AuthException, ResourceConflictException, IOException, PermissionBackendException,
+          OrmException {
+    // Not allowed to create a new patch set if the current patch set is locked.
+    psUtil.checkPatchSetNotLocked(origNotes, ctx.getUser());
+
     if (checkAddPatchSetPermission) {
       permissionBackend
           .user(ctx.getUser())
diff --git a/java/com/google/gerrit/server/config/TrackingFootersProvider.java b/java/com/google/gerrit/server/config/TrackingFootersProvider.java
index 2b1af36..7b23fcc 100644
--- a/java/com/google/gerrit/server/config/TrackingFootersProvider.java
+++ b/java/com/google/gerrit/server/config/TrackingFootersProvider.java
@@ -62,7 +62,7 @@
       } else if (system.length() > TrackingId.TRACKING_SYSTEM_MAX_CHAR) {
         configValid = false;
         log.error(
-            "String to long \""
+            "String too long \""
                 + system
                 + "\" in gerrit.config "
                 + TRACKING_ID_TAG
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index 83f0120..80d1cd1 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -394,7 +394,8 @@
   }
 
   private void assertCanEdit(ChangeNotes notes)
-      throws AuthException, PermissionBackendException, IOException, ResourceConflictException {
+      throws AuthException, PermissionBackendException, IOException, ResourceConflictException,
+          OrmException {
     if (!currentUser.get().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
@@ -406,6 +407,8 @@
               "change %s is %s", c.getChangeId(), c.getStatus().toString().toLowerCase()));
     }
 
+    // Not allowed to edit if the current patch set is locked.
+    patchSetUtil.checkPatchSetNotLocked(notes, currentUser.get());
     try {
       permissionBackend
           .currentUser()
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index e557250..34b0357 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -158,7 +158,7 @@
     try {
       a.commitMessage = changeDataFactory.create(db, change).commitMessage();
     } catch (Exception e) {
-      log.error("Error while getting full commit message for change " + a.number);
+      log.error("Error while getting full commit message for change " + a.number, e);
     }
     a.url = getChangeUrl(change);
     a.owner = asAccountAttribute(change.getOwner());
@@ -452,7 +452,7 @@
     } catch (PatchListObjectTooLargeException e) {
       log.warn("Cannot get patch list: " + e.getMessage());
     } catch (PatchListNotAvailableException e) {
-      log.warn("Cannot get patch list", e);
+      log.error("Cannot get patch list", e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java
index 1dd5537..b0bbce4 100644
--- a/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -129,7 +129,7 @@
           error);
     } else {
       log.warn(
-          "Error in listener {} for event {}: {}",
+          "Error in event listener {} for event {}: {}",
           listener.getClass().getName(),
           event.getClass().getName(),
           error.getMessage());
@@ -140,7 +140,7 @@
     if (log.isDebugEnabled()) {
       log.debug(String.format("Error in event listener %s", listener.getClass().getName()), error);
     } else {
-      log.warn("Error in listener {}: {}", listener.getClass().getName(), error.getMessage());
+      log.warn("Error in event listener {}: {}", listener.getClass().getName(), error.getMessage());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/git/TagCache.java b/java/com/google/gerrit/server/git/TagCache.java
index d346997..b8acd0a 100644
--- a/java/com/google/gerrit/server/git/TagCache.java
+++ b/java/com/google/gerrit/server/git/TagCache.java
@@ -103,7 +103,7 @@
     cache.put(name.get(), val);
   }
 
-  static class EntryVal implements Serializable {
+  public static class EntryVal implements Serializable {
     static final long serialVersionUID = 1L;
 
     transient TagSetHolder holder;
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index 11e149c..3bc44c5 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -85,7 +85,7 @@
       };
 
   private ScheduledExecutorService defaultQueue;
-  private int defaultQueueSize;
+  private final int defaultQueueSize;
   private final IdGenerator idGenerator;
   private final CopyOnWriteArrayList<Executor> queues;
 
@@ -94,6 +94,7 @@
     this(idGenerator, cfg.getInt("execution", "defaultThreadPoolSize", 1));
   }
 
+  /** Constructor to allow binding the WorkQueue more explicitly in a vhost setup. */
   public WorkQueue(IdGenerator idGenerator, int defaultThreadPoolSize) {
     this.idGenerator = idGenerator;
     this.queues = new CopyOnWriteArrayList<>();
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 5f65814..dbb6bcf 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -2452,6 +2452,13 @@
 
       RevCommit newCommit = rp.getRevWalk().parseCommit(newCommitId);
       RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
+
+      // Not allowed to create a new patch set if the current patch set is locked.
+      if (psUtil.isPatchSetLocked(notes, user)) {
+        reject(inputCommand, "cannot add patch set to " + ontoChange + ".");
+        return false;
+      }
+
       try {
         permissions.change(notes).database(db).check(ChangePermission.ADD_PATCH_SET);
       } catch (AuthException no) {
diff --git a/java/com/google/gerrit/server/mail/AutoReplyMailFilter.java b/java/com/google/gerrit/server/mail/AutoReplyMailFilter.java
index 481c2e9..199731e 100644
--- a/java/com/google/gerrit/server/mail/AutoReplyMailFilter.java
+++ b/java/com/google/gerrit/server/mail/AutoReplyMailFilter.java
@@ -33,7 +33,7 @@
 
         if (prec.equals("list") || prec.equals("junk") || prec.equals("bulk")) {
           log.error(
-              "Message %s has a Precedence header. Will ignore and delete message.", message.id());
+              "Message {} has a Precedence header. Will ignore and delete message.", message.id());
           return false;
         }
 
@@ -43,7 +43,7 @@
 
         if (!autoSubmitted.equals("no")) {
           log.error(
-              "Message %s has an Auto-Submitted header. Will ignore and delete message.",
+              "Message {} has an Auto-Submitted header. Will ignore and delete message.",
               message.id());
           return false;
         }
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 6c05ecc..11f50a9 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -27,7 +27,6 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.mail.MailHeader;
@@ -100,8 +99,7 @@
 
     /** Is the from user in an email squelching group? */
     try {
-      IdentifiedUser user = args.identifiedUserFactory.create(id);
-      args.permissionBackend.user(user).check(GlobalPermission.EMAIL_REVIEWERS);
+      args.permissionBackend.absentUser(id).check(GlobalPermission.EMAIL_REVIEWERS);
     } catch (AuthException | PermissionBackendException e) {
       emailOnlyAuthors = true;
     }
diff --git a/java/com/google/gerrit/server/mail/send/EmailHeader.java b/java/com/google/gerrit/server/mail/send/EmailHeader.java
index 0bfe428..29354f2 100644
--- a/java/com/google/gerrit/server/mail/send/EmailHeader.java
+++ b/java/com/google/gerrit/server/mail/send/EmailHeader.java
@@ -23,7 +23,6 @@
 import java.text.SimpleDateFormat;
 import java.util.ArrayList;
 import java.util.Collections;
-import java.util.Iterator;
 import java.util.List;
 import java.util.Locale;
 import java.util.Objects;
@@ -185,11 +184,7 @@
     }
 
     void remove(java.lang.String email) {
-      for (Iterator<Address> i = list.iterator(); i.hasNext(); ) {
-        if (i.next().getEmail().equals(email)) {
-          i.remove();
-        }
-      }
+      list.removeIf(address -> address.getEmail().equals(email));
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
index b08e594..10e8b0b 100644
--- a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
@@ -114,9 +114,7 @@
     smtpPass = cfg.getString("sendemail", null, "smtppass");
 
     Set<String> rcpt = new HashSet<>();
-    for (String addr : cfg.getStringList("sendemail", null, "allowrcpt")) {
-      rcpt.add(addr);
-    }
+    Collections.addAll(rcpt, cfg.getStringList("sendemail", null, "allowrcpt"));
     allowrcpt = Collections.unmodifiableSet(rcpt);
     importance = cfg.getString("sendemail", null, "importance");
     expiryDays = cfg.getInt("sendemail", null, "expiryDays", 0);
diff --git a/java/com/google/gerrit/server/notedb/RepoSequence.java b/java/com/google/gerrit/server/notedb/RepoSequence.java
index 3554f4b..47fec59 100644
--- a/java/com/google/gerrit/server/notedb/RepoSequence.java
+++ b/java/com/google/gerrit/server/notedb/RepoSequence.java
@@ -92,6 +92,7 @@
   private final Project.NameKey projectName;
   private final String refName;
   private final Seed seed;
+  private final int floor;
   private final int batchSize;
   private final Runnable afterReadRef;
   private final Retryer<RefUpdate.Result> retryer;
@@ -119,7 +120,28 @@
         seed,
         batchSize,
         Runnables.doNothing(),
-        RETRYER);
+        RETRYER,
+        0);
+  }
+
+  public RepoSequence(
+      GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
+      Project.NameKey projectName,
+      String name,
+      Seed seed,
+      int batchSize,
+      int floor) {
+    this(
+        repoManager,
+        gitRefUpdated,
+        projectName,
+        name,
+        seed,
+        batchSize,
+        Runnables.doNothing(),
+        RETRYER,
+        floor);
   }
 
   @VisibleForTesting
@@ -132,6 +154,19 @@
       int batchSize,
       Runnable afterReadRef,
       Retryer<RefUpdate.Result> retryer) {
+    this(repoManager, gitRefUpdated, projectName, name, seed, batchSize, afterReadRef, retryer, 0);
+  }
+
+  RepoSequence(
+      GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
+      Project.NameKey projectName,
+      String name,
+      Seed seed,
+      int batchSize,
+      Runnable afterReadRef,
+      Retryer<RefUpdate.Result> retryer,
+      int floor) {
     this.repoManager = checkNotNull(repoManager, "repoManager");
     this.gitRefUpdated = checkNotNull(gitRefUpdated, "gitRefUpdated");
     this.projectName = checkNotNull(projectName, "projectName");
@@ -145,6 +180,7 @@
     this.refName = RefNames.REFS_SEQUENCES + name;
 
     this.seed = checkNotNull(seed, "seed");
+    this.floor = floor;
 
     checkArgument(batchSize > 0, "expected batchSize > 0, got: %s", batchSize);
     this.batchSize = batchSize;
@@ -282,6 +318,7 @@
         oldId = ref.getObjectId();
         next = parse(rw, oldId);
       }
+      next = Math.max(floor, next);
       return store(repo, rw, oldId, next + count);
     }
   }
diff --git a/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java b/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java
index 611f32e..1fffab4 100644
--- a/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java
+++ b/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java
@@ -68,7 +68,7 @@
         setCommentRevId(c, cache, change, ps);
       } catch (PatchListNotAvailableException e) {
         log.warn(
-            "Unable to determine parent commit of patch set {} ({}); omitting inline comment",
+            "Unable to determine parent commit of patch set {} ({}); omitting inline comment {}",
             ps.getId(),
             ps.getRevision(),
             c);
diff --git a/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java b/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
index b408355..59fdde7 100644
--- a/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
+++ b/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
@@ -560,6 +560,8 @@
       throws OrmException, IOException {
     try (ReviewDb db = schemaFactory.open()) {
       @SuppressWarnings("deprecation")
+      final int nextChangeId = db.nextChangeId();
+
       RepoSequence seq =
           new RepoSequence(
               repoManager,
@@ -569,8 +571,9 @@
               // If sequenceGap is 0, this writes into the sequence ref the same ID that is returned
               // by the call to seq.next() below. If we actually used this as a change ID, that
               // would be a problem, but we just discard it, so this is safe.
-              () -> db.nextChangeId() + sequenceGap - 1,
-              1);
+              () -> nextChangeId + sequenceGap - 1,
+              1,
+              nextChangeId);
       seq.next();
     }
     return saveState(prev, READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY);
diff --git a/java/com/google/gerrit/server/patch/DiffSummaryLoader.java b/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
index 4d614ff..8bca19f 100644
--- a/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
+++ b/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
@@ -22,12 +22,8 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.Callable;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 public class DiffSummaryLoader implements Callable<DiffSummary> {
-  static final Logger log = LoggerFactory.getLogger(DiffSummaryLoader.class);
-
   public interface Factory {
     DiffSummaryLoader create(DiffSummaryKey key, Project.NameKey project);
   }
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 990c318..b13d921 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -21,19 +21,14 @@
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -52,19 +47,11 @@
   static class Factory {
     private final ChangeData.Factory changeDataFactory;
     private final ChangeNotes.Factory notesFactory;
-    private final ApprovalsUtil approvalsUtil;
-    private final PatchSetUtil patchSetUtil;
 
     @Inject
-    Factory(
-        ChangeData.Factory changeDataFactory,
-        ChangeNotes.Factory notesFactory,
-        ApprovalsUtil approvalsUtil,
-        PatchSetUtil patchSetUtil) {
+    Factory(ChangeData.Factory changeDataFactory, ChangeNotes.Factory notesFactory) {
       this.changeDataFactory = changeDataFactory;
       this.notesFactory = notesFactory;
-      this.approvalsUtil = approvalsUtil;
-      this.patchSetUtil = patchSetUtil;
     }
 
     ChangeControl create(
@@ -74,27 +61,19 @@
     }
 
     ChangeControl create(RefControl refControl, ChangeNotes notes) {
-      return new ChangeControl(changeDataFactory, approvalsUtil, refControl, notes, patchSetUtil);
+      return new ChangeControl(changeDataFactory, refControl, notes);
     }
   }
 
   private final ChangeData.Factory changeDataFactory;
-  private final ApprovalsUtil approvalsUtil;
   private final RefControl refControl;
   private final ChangeNotes notes;
-  private final PatchSetUtil patchSetUtil;
 
   private ChangeControl(
-      ChangeData.Factory changeDataFactory,
-      ApprovalsUtil approvalsUtil,
-      RefControl refControl,
-      ChangeNotes notes,
-      PatchSetUtil patchSetUtil) {
+      ChangeData.Factory changeDataFactory, RefControl refControl, ChangeNotes notes) {
     this.changeDataFactory = changeDataFactory;
-    this.approvalsUtil = approvalsUtil;
     this.refControl = refControl;
     this.notes = notes;
-    this.patchSetUtil = patchSetUtil;
   }
 
   ForChange asForChange(@Nullable ChangeData cd, @Nullable Provider<ReviewDb> db) {
@@ -105,8 +84,7 @@
     if (getUser().equals(who)) {
       return this;
     }
-    return new ChangeControl(
-        changeDataFactory, approvalsUtil, refControl.forUser(who), notes, patchSetUtil);
+    return new ChangeControl(changeDataFactory, refControl.forUser(who), notes);
   }
 
   private CurrentUser getUser() {
@@ -130,26 +108,24 @@
   }
 
   /** Can this user abandon this change? */
-  private boolean canAbandon(ReviewDb db) throws OrmException {
-    return (isOwner() // owner (aka creator) of the change can abandon
-            || refControl.isOwner() // branch owner can abandon
-            || getProjectControl().isOwner() // project owner can abandon
-            || refControl.canPerform(Permission.ABANDON) // user can abandon a specific ref
-            || getProjectControl().isAdmin())
-        && !isPatchSetLocked(db);
+  private boolean canAbandon() {
+    return isOwner() // owner (aka creator) of the change can abandon
+        || refControl.isOwner() // branch owner can abandon
+        || getProjectControl().isOwner() // project owner can abandon
+        || refControl.canPerform(Permission.ABANDON) // user can abandon a specific ref
+        || getProjectControl().isAdmin();
   }
 
   /** Can this user rebase this change? */
-  private boolean canRebase(ReviewDb db) throws OrmException {
+  private boolean canRebase() {
     return (isOwner() || refControl.canSubmit(isOwner()) || refControl.canRebase())
-        && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE)
-        && !isPatchSetLocked(db);
+        && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
   }
 
   /** Can this user restore this change? */
-  private boolean canRestore(ReviewDb db) throws OrmException {
+  private boolean canRestore() {
     // Anyone who can abandon the change can restore it, as long as they can create changes.
-    return canAbandon(db) && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
+    return canAbandon() && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
   }
 
   /** The range of permitted values associated with a label permission. */
@@ -158,8 +134,8 @@
   }
 
   /** Can this user add a patch set to this change? */
-  private boolean canAddPatchSet(ReviewDb db) throws OrmException {
-    if (!(refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE)) || isPatchSetLocked(db)) {
+  private boolean canAddPatchSet() {
+    if (!refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE)) {
       return false;
     }
     if (isOwner()) {
@@ -168,29 +144,6 @@
     return refControl.canAddPatchSet();
   }
 
-  /** Is the current patch set locked against state changes? */
-  private boolean isPatchSetLocked(ReviewDb db) throws OrmException {
-    if (getChange().getStatus() == Change.Status.MERGED) {
-      return false;
-    }
-
-    for (PatchSetApproval ap :
-        approvalsUtil.byPatchSet(
-            db, notes, getUser(), getChange().currentPatchSetId(), null, null)) {
-      LabelType type =
-          getProjectControl()
-              .getProjectState()
-              .getLabelTypes(notes, getUser())
-              .byLabel(ap.getLabel());
-      if (type != null
-          && ap.getValue() == 1
-          && type.getFunction() == LabelFunction.PATCH_SET_LOCK) {
-        return true;
-      }
-    }
-    return false;
-  }
-
   /** Is this user the owner of the change? */
   private boolean isOwner() {
     if (getUser().isIdentifiedUser()) {
@@ -355,12 +308,12 @@
           case READ:
             return isVisible(db(), changeData());
           case ABANDON:
-            return canAbandon(db());
+            return canAbandon();
           case DELETE:
             return (isOwner() && refControl.canPerform(Permission.DELETE_OWN_CHANGES))
                 || getProjectControl().isAdmin();
           case ADD_PATCH_SET:
-            return canAddPatchSet(db());
+            return canAddPatchSet();
           case EDIT_ASSIGNEE:
             return canEditAssignee();
           case EDIT_DESCRIPTION:
@@ -370,9 +323,9 @@
           case EDIT_TOPIC_NAME:
             return canEditTopicName();
           case REBASE:
-            return canRebase(db());
+            return canRebase();
           case RESTORE:
-            return canRestore(db());
+            return canRestore();
           case SUBMIT:
             return refControl.canSubmit(isOwner());
 
diff --git a/java/com/google/gerrit/server/permissions/ChangePermission.java b/java/com/google/gerrit/server/permissions/ChangePermission.java
index 6a23cdd..ba1785d 100644
--- a/java/com/google/gerrit/server/permissions/ChangePermission.java
+++ b/java/com/google/gerrit/server/permissions/ChangePermission.java
@@ -20,15 +20,39 @@
 
 public enum ChangePermission implements ChangePermissionOrLabel {
   READ,
+  /**
+   * The change can't be restored if its current patch set is locked.
+   *
+   * <p>Before checking this permission, the caller should first verify the current patch set of the
+   * change is not locked by calling {@code PatchSetUtil.isPatchSetLocked}.
+   */
   RESTORE,
   DELETE,
+  /**
+   * The change can't be abandoned if its current patch set is locked.
+   *
+   * <p>Before checking this permission, the caller should first verify the current patch set of the
+   * change is not locked by calling {@code PatchSetUtil.isPatchSetLocked}.
+   */
   ABANDON,
   EDIT_ASSIGNEE,
   EDIT_DESCRIPTION,
   EDIT_HASHTAGS,
   EDIT_TOPIC_NAME,
   REMOVE_REVIEWER,
+  /**
+   * A new patch set can't be added if the patch set is locked for the change.
+   *
+   * <p>Before checking this permission, the caller should first verify the current patch set of the
+   * change is not locked by calling {@code PatchSetUtil.isPatchSetLocked}.
+   */
   ADD_PATCH_SET,
+  /**
+   * The change can't be rebased if its current patch set is locked.
+   *
+   * <p>Before checking this permission, the caller should first verify the current patch set of the
+   * change is not locked by calling {@code PatchSetUtil.isPatchSetLocked}.
+   */
   REBASE,
   SUBMIT,
   SUBMIT_AS("submit on behalf of other users");
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index 3872a38..4cbd77e 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -357,8 +357,14 @@
     }
 
     /**
-     * Returns a partition of the provided refs that are visible to the user that this instance is
-     * scoped to.
+     * Filter a map of references by visibility.
+     *
+     * @param refs a map of references to filter.
+     * @param repo an open {@link Repository} handle for this instance's project
+     * @param opts further options for filtering.
+     * @return a partition of the provided refs that are visible to the user that this instance is
+     *     scoped to.
+     * @throws PermissionBackendException if failure consulting backend configuration.
      */
     public abstract Map<String, Ref> filter(
         Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
diff --git a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index 5d5a95f..89f302b 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.index.query.IsVisibleToPredicate;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -40,19 +41,22 @@
   protected final CurrentUser user;
   protected final PermissionBackend permissionBackend;
   protected final ProjectCache projectCache;
+  private final Provider<AnonymousUser> anonymousUserProvider;
 
   public ChangeIsVisibleToPredicate(
       Provider<ReviewDb> db,
       ChangeNotes.Factory notesFactory,
       CurrentUser user,
       PermissionBackend permissionBackend,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      Provider<AnonymousUser> anonymousUserProvider) {
     super(ChangeQueryBuilder.FIELD_VISIBLETO, IndexUtils.describe(user));
     this.db = db;
     this.notesFactory = notesFactory;
     this.user = user;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
+    this.anonymousUserProvider = anonymousUserProvider;
   }
 
   @Override
@@ -81,13 +85,12 @@
     }
 
     boolean visible;
+    PermissionBackend.WithUser withUser =
+        user.isIdentifiedUser()
+            ? permissionBackend.absentUser(user.getAccountId())
+            : permissionBackend.user(anonymousUserProvider.get());
     try {
-      visible =
-          permissionBackend
-              .user(user)
-              .indexedChange(cd, notes)
-              .database(db)
-              .test(ChangePermission.READ);
+      visible = withUser.indexedChange(cd, notes).database(db).test(ChangePermission.READ);
     } catch (PermissionBackendException e) {
       Throwable cause = e.getCause();
       if (cause instanceof RepositoryNotFoundException) {
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 630f2f3..68b139f 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -44,6 +44,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -214,6 +215,7 @@
     final StarredChangesUtil starredChangesUtil;
     final SubmitDryRun submitDryRun;
     final GroupMembers groupMembers;
+    final Provider<AnonymousUser> anonymousUserProvider;
 
     private final Provider<CurrentUser> self;
 
@@ -246,7 +248,8 @@
         StarredChangesUtil starredChangesUtil,
         AccountCache accountCache,
         NotesMigration notesMigration,
-        GroupMembers groupMembers) {
+        GroupMembers groupMembers,
+        Provider<AnonymousUser> anonymousUserProvider) {
       this(
           db,
           queryProvider,
@@ -274,7 +277,8 @@
           starredChangesUtil,
           accountCache,
           notesMigration,
-          groupMembers);
+          groupMembers,
+          anonymousUserProvider);
     }
 
     private Arguments(
@@ -304,7 +308,8 @@
         StarredChangesUtil starredChangesUtil,
         AccountCache accountCache,
         NotesMigration notesMigration,
-        GroupMembers groupMembers) {
+        GroupMembers groupMembers,
+        Provider<AnonymousUser> anonymousUserProvider) {
       this.db = db;
       this.queryProvider = queryProvider;
       this.rewriter = rewriter;
@@ -332,6 +337,7 @@
       this.hasOperands = hasOperands;
       this.notesMigration = notesMigration;
       this.groupMembers = groupMembers;
+      this.anonymousUserProvider = anonymousUserProvider;
     }
 
     Arguments asUser(CurrentUser otherUser) {
@@ -362,7 +368,8 @@
           starredChangesUtil,
           accountCache,
           notesMigration,
-          groupMembers);
+          groupMembers,
+          anonymousUserProvider);
     }
 
     Arguments asUser(Account.Id otherId) {
@@ -909,7 +916,12 @@
 
   public Predicate<ChangeData> visibleto(CurrentUser user) {
     return new ChangeIsVisibleToPredicate(
-        args.db, args.notesFactory, user, args.permissionBackend, args.projectCache);
+        args.db,
+        args.notesFactory,
+        user,
+        args.permissionBackend,
+        args.projectCache,
+        args.anonymousUserProvider);
   }
 
   public Predicate<ChangeData> is_visible() throws QueryParseException {
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
index 5fe918e..21d7b2d 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.index.query.QueryProcessor;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
@@ -65,6 +66,7 @@
   private final DynamicMap<ChangeAttributeFactory> attributeFactories;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
+  private final Provider<AnonymousUser> anonymousUserProvider;
 
   static {
     // It is assumed that basic rewrites do not touch visibleto predicates.
@@ -85,7 +87,8 @@
       ChangeNotes.Factory notesFactory,
       DynamicMap<ChangeAttributeFactory> attributeFactories,
       PermissionBackend permissionBackend,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      Provider<AnonymousUser> anonymousUserProvider) {
     super(
         metricMaker,
         ChangeSchemaDefinitions.INSTANCE,
@@ -100,6 +103,7 @@
     this.attributeFactories = attributeFactories;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
+    this.anonymousUserProvider = anonymousUserProvider;
   }
 
   @Override
@@ -143,7 +147,12 @@
     return new AndChangeSource(
         pred,
         new ChangeIsVisibleToPredicate(
-            db, notesFactory, userProvider.get(), permissionBackend, projectCache),
+            db,
+            notesFactory,
+            userProvider.get(),
+            permissionBackend,
+            projectCache,
+            anonymousUserProvider),
         start);
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index 7240452..19fd4a1 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -123,7 +123,7 @@
     // Check the user has 'READ' permission.
     try {
       PermissionBackend.ForChange perm =
-          permissionBackend.user(reviewer).database(dbProvider).change(cd);
+          permissionBackend.absentUser(approver).database(dbProvider).change(cd);
       ProjectState projectState = projectCache.checkedGet(cd.project());
       return projectState != null
           && projectState.statePermitsRead()
diff --git a/java/com/google/gerrit/server/restapi/change/Abandon.java b/java/com/google/gerrit/server/restapi/change/Abandon.java
index 5485cf6..116cbdb 100644
--- a/java/com/google/gerrit/server/restapi/change/Abandon.java
+++ b/java/com/google/gerrit/server/restapi/change/Abandon.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
-
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.TimeUtil;
@@ -29,6 +27,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.AbandonOp;
 import com.google.gerrit.server.change.ChangeJson;
@@ -47,14 +46,19 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 public class Abandon extends RetryingRestModifyView<ChangeResource, AbandonInput, ChangeInfo>
     implements UiAction<ChangeResource> {
+  private static final Logger log = LoggerFactory.getLogger(Abandon.class);
+
   private final Provider<ReviewDb> dbProvider;
   private final ChangeJson.Factory json;
   private final AbandonOp.Factory abandonOpFactory;
   private final NotifyUtil notifyUtil;
+  private final PatchSetUtil patchSetUtil;
 
   @Inject
   Abandon(
@@ -62,27 +66,32 @@
       ChangeJson.Factory json,
       RetryHelper retryHelper,
       AbandonOp.Factory abandonOpFactory,
-      NotifyUtil notifyUtil) {
+      NotifyUtil notifyUtil,
+      PatchSetUtil patchSetUtil) {
     super(retryHelper);
     this.dbProvider = dbProvider;
     this.json = json;
     this.abandonOpFactory = abandonOpFactory;
     this.notifyUtil = notifyUtil;
+    this.patchSetUtil = patchSetUtil;
   }
 
   @Override
   protected ChangeInfo applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource req, AbandonInput input)
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, AbandonInput input)
       throws RestApiException, UpdateException, OrmException, PermissionBackendException,
           IOException, ConfigInvalidException {
-    req.permissions().database(dbProvider).check(ChangePermission.ABANDON);
+    // Not allowed to abandon if the current patch set is locked.
+    patchSetUtil.checkPatchSetNotLocked(rsrc.getNotes(), rsrc.getUser());
 
-    NotifyHandling notify = input.notify == null ? defaultNotify(req.getChange()) : input.notify;
+    rsrc.permissions().database(dbProvider).check(ChangePermission.ABANDON);
+
+    NotifyHandling notify = input.notify == null ? defaultNotify(rsrc.getChange()) : input.notify;
     Change change =
         abandon(
             updateFactory,
-            req.getNotes(),
-            req.getUser(),
+            rsrc.getNotes(),
+            rsrc.getUser(),
             input.message,
             notify,
             notifyUtil.resolveAccounts(input.notifyDetails));
@@ -135,13 +144,29 @@
 
   @Override
   public UiAction.Description getDescription(ChangeResource rsrc) {
+    UiAction.Description description =
+        new UiAction.Description()
+            .setLabel("Abandon")
+            .setTitle("Abandon the change")
+            .setVisible(false);
+
     Change change = rsrc.getChange();
-    return new UiAction.Description()
-        .setLabel("Abandon")
-        .setTitle("Abandon the change")
-        .setVisible(
-            and(
-                change.getStatus().isOpen(),
-                rsrc.permissions().database(dbProvider).testCond(ChangePermission.ABANDON)));
+    if (!change.getStatus().isOpen()) {
+      return description;
+    }
+
+    try {
+      if (patchSetUtil.isPatchSetLocked(rsrc.getNotes(), rsrc.getUser())) {
+        return description;
+      }
+    } catch (OrmException | IOException e) {
+      log.error(
+          String.format(
+              "Failed to check if the current patch set of change %s is locked", change.getId()),
+          e);
+      return description;
+    }
+
+    return description.setVisible(rsrc.permissions().testOrFalse(ChangePermission.ABANDON));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index d8aa880..9f86e46 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -81,6 +81,7 @@
 import java.util.List;
 import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -180,6 +181,10 @@
       }
     }
 
+    if (input.baseChange != null && input.baseCommit != null) {
+      throw new BadRequestException("only provide one of base_change or base_commit");
+    }
+
     ProjectResource rsrc = projectsCollection.parse(input.project);
     boolean privateByDefault = rsrc.getProjectState().is(BooleanProjectConfig.PRIVATE_BY_DEFAULT);
     boolean isPrivate = input.isPrivate == null ? privateByDefault : input.isPrivate;
@@ -205,6 +210,7 @@
         RevWalk rw = new RevWalk(reader)) {
       ObjectId parentCommit;
       List<String> groups;
+      Ref destRef = git.getRefDatabase().exactRef(refName);
       if (input.baseChange != null) {
         List<ChangeNotes> notes = changeFinder.find(input.baseChange);
         if (notes.size() != 1) {
@@ -219,8 +225,21 @@
         PatchSet ps = psUtil.current(db.get(), change);
         parentCommit = ObjectId.fromString(ps.getRevision().get());
         groups = ps.getGroups();
+      } else if (input.baseCommit != null) {
+        try {
+          parentCommit = ObjectId.fromString(input.baseCommit);
+        } catch (InvalidObjectIdException e) {
+          throw new UnprocessableEntityException(
+              String.format("Base %s doesn't represent a valid SHA-1", input.baseCommit));
+        }
+        RevCommit parentRevCommit = rw.parseCommit(parentCommit);
+        RevCommit destRefRevCommit = rw.parseCommit(destRef.getObjectId());
+        if (!rw.isMergedInto(parentRevCommit, destRefRevCommit)) {
+          throw new BadRequestException(
+              String.format("Commit %s doesn't exist on ref %s", input.baseCommit, refName));
+        }
+        groups = Collections.emptyList();
       } else {
-        Ref destRef = git.getRefDatabase().exactRef(refName);
         if (destRef != null) {
           if (Boolean.TRUE.equals(input.newBranch)) {
             throw new ResourceConflictException(
@@ -231,8 +250,7 @@
           if (Boolean.TRUE.equals(input.newBranch)) {
             parentCommit = null;
           } else {
-            throw new UnprocessableEntityException(
-                String.format("Branch %s does not exist.", refName));
+            throw new BadRequestException("Must provide a destination branch");
           }
         }
         groups = Collections.emptyList();
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index 374aaec..ff85880 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -50,7 +50,6 @@
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.restapi.project.CommitsCollection;
@@ -126,8 +125,11 @@
   @Override
   protected Response<ChangeInfo> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource rsrc, MergePatchSetInput in)
-      throws OrmException, IOException, InvalidChangeOperationException, RestApiException,
-          UpdateException, PermissionBackendException {
+      throws OrmException, IOException, RestApiException, UpdateException,
+          PermissionBackendException {
+    // Not allowed to create a new patch set if the current patch set is locked.
+    psUtil.checkPatchSetNotLocked(rsrc.getNotes(), rsrc.getUser());
+
     rsrc.permissions().database(db).check(ChangePermission.ADD_PATCH_SET);
 
     ProjectState projectState = projectCache.checkedGet(rsrc.getProject());
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index 2ad954d..6e049438 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -134,6 +134,9 @@
       throw new ResourceConflictException("Change is already destined for the specified branch");
     }
 
+    // Not allowed to move if the current patch set is locked.
+    psUtil.checkPatchSetNotLocked(rsrc.getNotes(), rsrc.getUser());
+
     // Move requires abandoning this change, and creating a new change.
     try {
       rsrc.permissions().database(dbProvider).check(ABANDON);
@@ -279,24 +282,41 @@
 
   @Override
   public UiAction.Description getDescription(ChangeResource rsrc) {
+    UiAction.Description description =
+        new UiAction.Description()
+            .setLabel("Move Change")
+            .setTitle("Move change to a different branch")
+            .setVisible(false);
+
     Change change = rsrc.getChange();
-    boolean projectStatePermitsWrite = false;
+    if (!change.getStatus().isOpen()) {
+      return description;
+    }
+
     try {
-      projectStatePermitsWrite = projectCache.checkedGet(rsrc.getProject()).statePermitsWrite();
+      if (!projectCache.checkedGet(rsrc.getProject()).statePermitsWrite()) {
+        return description;
+      }
     } catch (IOException e) {
       log.error("Failed to check if project state permits write: " + rsrc.getProject(), e);
+      return description;
     }
-    return new UiAction.Description()
-        .setLabel("Move Change")
-        .setTitle("Move change to a different branch")
-        .setVisible(
-            and(
-                change.getStatus().isOpen() && projectStatePermitsWrite,
-                and(
-                    permissionBackend
-                        .user(rsrc.getUser())
-                        .ref(change.getDest())
-                        .testCond(CREATE_CHANGE),
-                    rsrc.permissions().database(dbProvider).testCond(ABANDON))));
+
+    try {
+      if (psUtil.isPatchSetLocked(rsrc.getNotes(), rsrc.getUser())) {
+        return description;
+      }
+    } catch (OrmException | IOException e) {
+      log.error(
+          String.format(
+              "Failed to check if the current patch set of change %s is locked", change.getId()),
+          e);
+      return description;
+    }
+
+    return description.setVisible(
+        and(
+            permissionBackend.user(rsrc.getUser()).ref(change.getDest()).testCond(CREATE_CHANGE),
+            rsrc.permissions().database(dbProvider).testCond(ABANDON)));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index c9c43cb..eb46521 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -33,7 +33,6 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyUtil;
 import com.google.gerrit.server.change.PatchSetInserter;
-import com.google.gerrit.server.edit.UnchangedCommitMessageException;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -68,7 +67,7 @@
     extends RetryingRestModifyView<ChangeResource, CommitMessageInput, Response<?>> {
 
   private final GitRepositoryManager repositoryManager;
-  private final Provider<CurrentUser> currentUserProvider;
+  private final Provider<CurrentUser> userProvider;
   private final Provider<ReviewDb> db;
   private final TimeZone tz;
   private final PatchSetInserter.Factory psInserterFactory;
@@ -81,7 +80,7 @@
   PutMessage(
       RetryHelper retryHelper,
       GitRepositoryManager repositoryManager,
-      Provider<CurrentUser> currentUserProvider,
+      Provider<CurrentUser> userProvider,
       Provider<ReviewDb> db,
       PatchSetInserter.Factory psInserterFactory,
       PermissionBackend permissionBackend,
@@ -91,7 +90,7 @@
       ProjectCache projectCache) {
     super(retryHelper);
     this.repositoryManager = repositoryManager;
-    this.currentUserProvider = currentUserProvider;
+    this.userProvider = userProvider;
     this.db = db;
     this.psInserterFactory = psInserterFactory;
     this.tz = gerritIdent.getTimeZone();
@@ -104,8 +103,8 @@
   @Override
   protected Response<String> applyImpl(
       BatchUpdate.Factory updateFactory, ChangeResource resource, CommitMessageInput input)
-      throws IOException, UnchangedCommitMessageException, RestApiException, UpdateException,
-          PermissionBackendException, OrmException, ConfigInvalidException {
+      throws IOException, RestApiException, UpdateException, PermissionBackendException,
+          OrmException, ConfigInvalidException {
     PatchSet ps = psUtil.current(db.get(), resource.getNotes());
     if (ps == null) {
       throw new ResourceConflictException("current revision is missing");
@@ -140,7 +139,7 @@
       Timestamp ts = TimeUtil.nowTs();
       try (BatchUpdate bu =
           updateFactory.create(
-              db.get(), resource.getChange().getProject(), currentUserProvider.get(), ts)) {
+              db.get(), resource.getChange().getProject(), userProvider.get(), ts)) {
         // Ensure that BatchUpdate will update the same repo
         bu.setRepository(repository, new RevWalk(objectInserter.newReader()), objectInserter);
 
@@ -170,8 +169,7 @@
     builder.setTreeId(basePatchSetCommit.getTree());
     builder.setParentIds(basePatchSetCommit.getParents());
     builder.setAuthor(basePatchSetCommit.getAuthorIdent());
-    builder.setCommitter(
-        currentUserProvider.get().asIdentifiedUser().newCommitterIdent(timestamp, tz));
+    builder.setCommitter(userProvider.get().asIdentifiedUser().newCommitterIdent(timestamp, tz));
     builder.setMessage(commitMessage);
     ObjectId newCommitId = objectInserter.insert(builder);
     objectInserter.flush();
@@ -179,13 +177,17 @@
   }
 
   private void ensureCanEditCommitMessage(ChangeNotes changeNotes)
-      throws AuthException, PermissionBackendException, IOException, ResourceConflictException {
-    if (!currentUserProvider.get().isIdentifiedUser()) {
+      throws AuthException, PermissionBackendException, IOException, ResourceConflictException,
+          OrmException {
+    if (!userProvider.get().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
+
+    // Not allowed to put message if the current patch set is locked.
+    psUtil.checkPatchSetNotLocked(changeNotes, userProvider.get());
     try {
       permissionBackend
-          .user(currentUserProvider.get())
+          .user(userProvider.get())
           .database(db.get())
           .change(changeNotes)
           .check(ChangePermission.ADD_PATCH_SET);
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 767ef7b..02e7c18 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -14,16 +14,12 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
-
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -81,6 +77,7 @@
   private final Provider<ReviewDb> dbProvider;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
+  private final PatchSetUtil patchSetUtil;
 
   @Inject
   public Rebase(
@@ -91,7 +88,8 @@
       ChangeJson.Factory json,
       Provider<ReviewDb> dbProvider,
       PermissionBackend permissionBackend,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      PatchSetUtil patchSetUtil) {
     super(retryHelper);
     this.repoManager = repoManager;
     this.rebaseFactory = rebaseFactory;
@@ -100,13 +98,17 @@
     this.dbProvider = dbProvider;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
+    this.patchSetUtil = patchSetUtil;
   }
 
   @Override
   protected ChangeInfo applyImpl(
       BatchUpdate.Factory updateFactory, RevisionResource rsrc, RebaseInput input)
-      throws EmailException, OrmException, UpdateException, RestApiException, IOException,
-          NoSuchChangeException, PermissionBackendException {
+      throws OrmException, UpdateException, RestApiException, IOException,
+          PermissionBackendException {
+    // Not allowed to rebase if the current patch set is locked.
+    patchSetUtil.checkPatchSetNotLocked(rsrc.getNotes(), rsrc.getUser());
+
     rsrc.permissions().database(dbProvider).check(ChangePermission.REBASE);
     projectCache.checkedGet(rsrc.getProject()).checkStatePermitsWrite();
 
@@ -203,40 +205,54 @@
   }
 
   @Override
-  public UiAction.Description getDescription(RevisionResource resource) {
-    PatchSet patchSet = resource.getPatchSet();
-    Change change = resource.getChange();
-    Branch.NameKey dest = change.getDest();
-    boolean visible = change.getStatus().isOpen() && resource.isCurrent();
-    boolean enabled = false;
+  public UiAction.Description getDescription(RevisionResource rsrc) {
+    UiAction.Description description =
+        new UiAction.Description()
+            .setLabel("Rebase")
+            .setTitle("Rebase onto tip of branch or parent change")
+            .setVisible(false);
+
+    Change change = rsrc.getChange();
+    if (!(change.getStatus().isOpen() && rsrc.isCurrent())) {
+      return description;
+    }
 
     try {
-      visible &= projectCache.checkedGet(resource.getProject()).statePermitsWrite();
-    } catch (IOException e) {
-      log.error("Failed to check if project state permits write: " + resource.getProject(), e);
-      visible = false;
-    }
-
-    if (visible) {
-      try (Repository repo = repoManager.openRepository(dest.getParentKey());
-          RevWalk rw = new RevWalk(repo)) {
-        visible = hasOneParent(rw, resource.getPatchSet());
-        if (visible) {
-          enabled = rebaseUtil.canRebase(patchSet, dest, repo, rw);
-        }
-      } catch (IOException e) {
-        log.error("Failed to check if patch set can be rebased: " + resource.getPatchSet(), e);
-        visible = false;
+      if (!projectCache.checkedGet(rsrc.getProject()).statePermitsWrite()) {
+        return description;
       }
+    } catch (IOException e) {
+      log.error("Failed to check if project state permits write: " + rsrc.getProject(), e);
+      return description;
     }
 
-    BooleanCondition permissionCond =
-        resource.permissions().database(dbProvider).testCond(ChangePermission.REBASE);
-    return new UiAction.Description()
-        .setLabel("Rebase")
-        .setTitle("Rebase onto tip of branch or parent change")
-        .setVisible(and(visible, permissionCond))
-        .setEnabled(and(enabled, permissionCond));
+    try {
+      if (patchSetUtil.isPatchSetLocked(rsrc.getNotes(), rsrc.getUser())) {
+        return description;
+      }
+    } catch (OrmException | IOException e) {
+      log.error(
+          String.format(
+              "Failed to check if the current patch set of change %s is locked", change.getId()),
+          e);
+      return description;
+    }
+
+    boolean enabled = false;
+    try (Repository repo = repoManager.openRepository(change.getDest().getParentKey());
+        RevWalk rw = new RevWalk(repo)) {
+      if (hasOneParent(rw, rsrc.getPatchSet())) {
+        enabled = rebaseUtil.canRebase(rsrc.getPatchSet(), change.getDest(), repo, rw);
+      }
+    } catch (IOException e) {
+      log.error("Failed to check if patch set can be rebased: " + rsrc.getPatchSet(), e);
+      return description;
+    }
+
+    if (rsrc.permissions().database(dbProvider).testOrFalse(ChangePermission.REBASE)) {
+      return description.setVisible(true).setEnabled(enabled);
+    }
+    return description;
   }
 
   public static class CurrentRevision
@@ -254,7 +270,7 @@
     @Override
     protected ChangeInfo applyImpl(
         BatchUpdate.Factory updateFactory, ChangeResource rsrc, RebaseInput input)
-        throws EmailException, OrmException, UpdateException, RestApiException, IOException,
+        throws OrmException, UpdateException, RestApiException, IOException,
             PermissionBackendException {
       PatchSet ps = psUtil.current(rebase.dbProvider.get(), rsrc.getNotes());
       if (ps == null) {
diff --git a/java/com/google/gerrit/server/restapi/change/Restore.java b/java/com/google/gerrit/server/restapi/change/Restore.java
index 642c35a..d5bfea1 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
-
 import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.RestoreInput;
@@ -90,17 +88,20 @@
 
   @Override
   protected ChangeInfo applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource req, RestoreInput input)
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, RestoreInput input)
       throws RestApiException, UpdateException, OrmException, PermissionBackendException,
           IOException {
-    req.permissions().database(dbProvider).check(ChangePermission.RESTORE);
-    projectCache.checkedGet(req.getProject()).checkStatePermitsWrite();
+    // Not allowed to restore if the current patch set is locked.
+    psUtil.checkPatchSetNotLocked(rsrc.getNotes(), rsrc.getUser());
+
+    rsrc.permissions().database(dbProvider).check(ChangePermission.RESTORE);
+    projectCache.checkedGet(rsrc.getProject()).checkStatePermitsWrite();
 
     Op op = new Op(input);
     try (BatchUpdate u =
         updateFactory.create(
-            dbProvider.get(), req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
-      u.addOp(req.getId(), op).execute();
+            dbProvider.get(), rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      u.addOp(rsrc.getId(), op).execute();
     }
     return json.noOptions().format(op.change);
   }
@@ -161,18 +162,39 @@
 
   @Override
   public UiAction.Description getDescription(ChangeResource rsrc) {
-    boolean projectStatePermitsWrite = false;
+    UiAction.Description description =
+        new UiAction.Description()
+            .setLabel("Restore")
+            .setTitle("Restore the change")
+            .setVisible(false);
+
+    Change change = rsrc.getChange();
+    if (change.getStatus() != Status.ABANDONED) {
+      return description;
+    }
+
     try {
-      projectStatePermitsWrite = projectCache.checkedGet(rsrc.getProject()).statePermitsWrite();
+      if (!projectCache.checkedGet(rsrc.getProject()).statePermitsWrite()) {
+        return description;
+      }
     } catch (IOException e) {
       log.error("Failed to check if project state permits write: " + rsrc.getProject(), e);
+      return description;
     }
-    return new UiAction.Description()
-        .setLabel("Restore")
-        .setTitle("Restore the change")
-        .setVisible(
-            and(
-                rsrc.getChange().getStatus() == Status.ABANDONED && projectStatePermitsWrite,
-                rsrc.permissions().database(dbProvider).testCond(ChangePermission.RESTORE)));
+
+    try {
+      if (psUtil.isPatchSetLocked(rsrc.getNotes(), rsrc.getUser())) {
+        return description;
+      }
+    } catch (OrmException | IOException e) {
+      log.error(
+          String.format(
+              "Failed to check if the current patch set of change %s is locked", change.getId()),
+          e);
+      return description;
+    }
+
+    boolean visible = rsrc.permissions().database(dbProvider).testOrFalse(ChangePermission.RESTORE);
+    return description.setVisible(visible);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerJson.java b/java/com/google/gerrit/server/restapi/change/ReviewerJson.java
index d74be7d..cfd20c2 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerJson.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerJson.java
@@ -80,7 +80,10 @@
       ReviewerInfo info =
           format(
               new ReviewerInfo(rsrc.getReviewerUser().getAccountId().get()),
-              permissionBackend.user(rsrc.getReviewerUser()).database(db).change(cd),
+              permissionBackend
+                  .absentUser(rsrc.getReviewerUser().getAccountId())
+                  .database(db)
+                  .change(cd),
               cd);
       loader.put(info);
       infos.add(info);
diff --git a/java/com/google/gerrit/server/rules/PrologEnvironment.java b/java/com/google/gerrit/server/rules/PrologEnvironment.java
index 170ff23..9dd0b86 100644
--- a/java/com/google/gerrit/server/rules/PrologEnvironment.java
+++ b/java/com/google/gerrit/server/rules/PrologEnvironment.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.PatchListCache;
@@ -175,6 +176,7 @@
     private final Provider<AnonymousUser> anonymousUser;
     private final int reductionLimit;
     private final int compileLimit;
+    private final PatchSetUtil patchsetUtil;
 
     @Inject
     Args(
@@ -185,7 +187,8 @@
         PatchSetInfoFactory patchSetInfoFactory,
         IdentifiedUser.GenericFactory userFactory,
         Provider<AnonymousUser> anonymousUser,
-        @GerritServerConfig Config config) {
+        @GerritServerConfig Config config,
+        PatchSetUtil patchsetUtil) {
       this.projectCache = projectCache;
       this.permissionBackend = permissionBackend;
       this.repositoryManager = repositoryManager;
@@ -193,6 +196,7 @@
       this.patchSetInfoFactory = patchSetInfoFactory;
       this.userFactory = userFactory;
       this.anonymousUser = anonymousUser;
+      this.patchsetUtil = patchsetUtil;
 
       int limit = config.getInt("rules", null, "reductionLimit", 100000);
       reductionLimit = limit <= 0 ? Integer.MAX_VALUE : limit;
@@ -240,5 +244,9 @@
     public AnonymousUser getAnonymousUser() {
       return anonymousUser.get();
     }
+
+    public PatchSetUtil getPatchsetUtil() {
+      return patchsetUtil;
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/rules/StoredValues.java b/java/com/google/gerrit/server/rules/StoredValues.java
index 9fc3557..6770732 100644
--- a/java/com/google/gerrit/server/rules/StoredValues.java
+++ b/java/com/google/gerrit/server/rules/StoredValues.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.Emails;
@@ -89,6 +90,22 @@
         }
       };
 
+  public static final StoredValue<String> COMMIT_MESSAGE =
+      new StoredValue<String>() {
+        @Override
+        public String createValue(Prolog engine) {
+          Change change = getChange(engine);
+          PatchSet ps = getPatchSet(engine);
+          PrologEnvironment env = (PrologEnvironment) engine.control;
+          PatchSetUtil patchSetUtil = env.getArgs().getPatchsetUtil();
+          try {
+            return patchSetUtil.getFullCommitMessage(change.getProject(), ps);
+          } catch (IOException e) {
+            throw new SystemException(e.getMessage());
+          }
+        }
+      };
+
   public static final StoredValue<PatchList> PATCH_LIST =
       new StoredValue<PatchList>() {
         @Override
diff --git a/java/com/google/gerrit/server/schema/GroupBundle.java b/java/com/google/gerrit/server/schema/GroupBundle.java
index 3c1c409..b001d6f 100644
--- a/java/com/google/gerrit/server/schema/GroupBundle.java
+++ b/java/com/google/gerrit/server/schema/GroupBundle.java
@@ -742,7 +742,7 @@
     ADD(1),
     REMOVE(2);
 
-    private int order;
+    private final int order;
 
     Action(int order) {
       this.order = order;
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index d09615f..22d463d 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -38,6 +38,7 @@
 import com.google.gerrit.server.update.RepoOnlyOp;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayDeque;
@@ -95,7 +96,7 @@
   @Singleton
   public static class Factory {
     private final GitModules.Factory gitmodulesFactory;
-    private final PersonIdent myIdent;
+    private final Provider<PersonIdent> serverIdent;
     private final Config cfg;
     private final ProjectCache projectCache;
     private final BatchUpdate.Factory batchUpdateFactory;
@@ -103,12 +104,12 @@
     @Inject
     Factory(
         GitModules.Factory gitmodulesFactory,
-        @GerritPersonIdent PersonIdent myIdent,
+        @GerritPersonIdent Provider<PersonIdent> serverIdent,
         @GerritServerConfig Config cfg,
         ProjectCache projectCache,
         BatchUpdate.Factory batchUpdateFactory) {
       this.gitmodulesFactory = gitmodulesFactory;
-      this.myIdent = myIdent;
+      this.serverIdent = serverIdent;
       this.cfg = cfg;
       this.projectCache = projectCache;
       this.batchUpdateFactory = batchUpdateFactory;
@@ -117,7 +118,13 @@
     public SubmoduleOp create(Set<Branch.NameKey> updatedBranches, MergeOpRepoManager orm)
         throws SubmoduleException {
       return new SubmoduleOp(
-          gitmodulesFactory, myIdent, cfg, projectCache, batchUpdateFactory, updatedBranches, orm);
+          gitmodulesFactory,
+          serverIdent.get(),
+          cfg,
+          projectCache,
+          batchUpdateFactory,
+          updatedBranches,
+          orm);
     }
   }
 
diff --git a/java/com/google/gerrit/sshd/AliasCommand.java b/java/com/google/gerrit/sshd/AliasCommand.java
index 0ac7765..cb61651 100644
--- a/java/com/google/gerrit/sshd/AliasCommand.java
+++ b/java/com/google/gerrit/sshd/AliasCommand.java
@@ -18,7 +18,6 @@
 import com.google.common.util.concurrent.Atomics;
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -33,7 +32,6 @@
 /** Command that executes some other command. */
 public class AliasCommand extends BaseCommand {
   private final DispatchCommandProvider root;
-  private final CurrentUser currentUser;
   private final PermissionBackend permissionBackend;
   private final CommandName command;
   private final AtomicReference<Command> atomicCmd;
@@ -41,11 +39,9 @@
   AliasCommand(
       @CommandName(Commands.ROOT) DispatchCommandProvider root,
       PermissionBackend permissionBackend,
-      CurrentUser currentUser,
       CommandName command) {
     this.root = root;
     this.permissionBackend = permissionBackend;
-    this.currentUser = currentUser;
     this.command = command;
     this.atomicCmd = Atomics.newReference();
   }
@@ -114,7 +110,7 @@
     try {
       Set<GlobalOrPluginPermission> check = GlobalPermission.fromAnnotation(cmd.getClass());
       try {
-        permissionBackend.user(currentUser).checkAny(check);
+        permissionBackend.currentUser().checkAny(check);
       } catch (AuthException err) {
         throw new UnloggedFailure(BaseCommand.STATUS_NOT_ADMIN, "fatal: " + err.getMessage());
       }
diff --git a/java/com/google/gerrit/sshd/AliasCommandProvider.java b/java/com/google/gerrit/sshd/AliasCommandProvider.java
index 0ef0473..085b6d6 100644
--- a/java/com/google/gerrit/sshd/AliasCommandProvider.java
+++ b/java/com/google/gerrit/sshd/AliasCommandProvider.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.sshd;
 
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -29,7 +28,6 @@
   private DispatchCommandProvider root;
 
   @Inject private PermissionBackend permissionBackend;
-  @Inject private CurrentUser currentUser;
 
   public AliasCommandProvider(CommandName command) {
     this.command = command;
@@ -37,6 +35,6 @@
 
   @Override
   public Command get() {
-    return new AliasCommand(root, permissionBackend, currentUser, command);
+    return new AliasCommand(root, permissionBackend, command);
   }
 }
diff --git a/java/com/google/gerrit/sshd/ChangeArgumentParser.java b/java/com/google/gerrit/sshd/ChangeArgumentParser.java
index 39ee13a..9fc4cda 100644
--- a/java/com/google/gerrit/sshd/ChangeArgumentParser.java
+++ b/java/com/google/gerrit/sshd/ChangeArgumentParser.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeFinder;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -39,7 +38,6 @@
 import java.util.Map;
 
 public class ChangeArgumentParser {
-  private final CurrentUser currentUser;
   private final ChangesCollection changesCollection;
   private final ChangeFinder changeFinder;
   private final ReviewDb db;
@@ -48,13 +46,11 @@
 
   @Inject
   ChangeArgumentParser(
-      CurrentUser currentUser,
       ChangesCollection changesCollection,
       ChangeFinder changeFinder,
       ReviewDb db,
       ChangeNotes.Factory changeNotesFactory,
       PermissionBackend permissionBackend) {
-    this.currentUser = currentUser;
     this.changesCollection = changesCollection;
     this.changeFinder = changeFinder;
     this.db = db;
@@ -83,7 +79,7 @@
     List<ChangeNotes> toAdd = new ArrayList<>(changes.size());
     boolean canMaintainServer;
     try {
-      permissionBackend.user(currentUser).check(GlobalPermission.MAINTAIN_SERVER);
+      permissionBackend.currentUser().check(GlobalPermission.MAINTAIN_SERVER);
       canMaintainServer = true;
     } catch (AuthException | PermissionBackendException e) {
       canMaintainServer = false;
@@ -93,7 +89,7 @@
           && inProject(projectState, notes.getProjectName())
           && (canMaintainServer
               || (permissionBackend
-                      .user(currentUser)
+                      .currentUser()
                       .change(notes)
                       .database(db)
                       .test(ChangePermission.READ)
diff --git a/java/com/google/gerrit/sshd/DispatchCommand.java b/java/com/google/gerrit/sshd/DispatchCommand.java
index 3f2e258..490dd52 100644
--- a/java/com/google/gerrit/sshd/DispatchCommand.java
+++ b/java/com/google/gerrit/sshd/DispatchCommand.java
@@ -19,7 +19,6 @@
 import com.google.common.collect.Sets;
 import com.google.common.util.concurrent.Atomics;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.args4j.SubcommandHandler;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -42,7 +41,6 @@
     DispatchCommand create(Map<String, CommandProvider> map);
   }
 
-  private final CurrentUser currentUser;
   private final PermissionBackend permissionBackend;
   private final Map<String, CommandProvider> commands;
   private final AtomicReference<Command> atomicCmd;
@@ -54,11 +52,7 @@
   private List<String> args = new ArrayList<>();
 
   @Inject
-  DispatchCommand(
-      CurrentUser user,
-      PermissionBackend permissionBackend,
-      @Assisted Map<String, CommandProvider> all) {
-    this.currentUser = user;
+  DispatchCommand(PermissionBackend permissionBackend, @Assisted Map<String, CommandProvider> all) {
     this.permissionBackend = permissionBackend;
     commands = all;
     atomicCmd = Atomics.newReference();
@@ -125,7 +119,7 @@
     }
     try {
       permissionBackend
-          .user(currentUser)
+          .currentUser()
           .checkAny(GlobalPermission.fromAnnotation(pluginName, cmd.getClass()));
     } catch (AuthException e) {
       throw new UnloggedFailure(BaseCommand.STATUS_NOT_ADMIN, e.getMessage());
diff --git a/java/com/google/gerrit/sshd/commands/AdminQueryShell.java b/java/com/google/gerrit/sshd/commands/AdminQueryShell.java
index ef1cd81..c520e79 100644
--- a/java/com/google/gerrit/sshd/commands/AdminQueryShell.java
+++ b/java/com/google/gerrit/sshd/commands/AdminQueryShell.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -34,7 +33,6 @@
 final class AdminQueryShell extends SshCommand {
   @Inject private PermissionBackend permissionBackend;
   @Inject private QueryShell.Factory factory;
-  @Inject private IdentifiedUser currentUser;
 
   @Option(name = "--format", usage = "Set output format")
   private QueryShell.OutputFormat format = QueryShell.OutputFormat.PRETTY;
@@ -45,7 +43,7 @@
   @Override
   protected void run() throws Failure {
     try {
-      permissionBackend.user(currentUser).check(GlobalPermission.ACCESS_DATABASE);
+      permissionBackend.currentUser().check(GlobalPermission.ACCESS_DATABASE);
     } catch (AuthException err) {
       throw die(err.getMessage());
     } catch (PermissionBackendException e) {
diff --git a/java/gerrit/PRED_commit_message_1.java b/java/gerrit/PRED_commit_message_1.java
index c5aa367..05bb4bb 100644
--- a/java/gerrit/PRED_commit_message_1.java
+++ b/java/gerrit/PRED_commit_message_1.java
@@ -14,7 +14,6 @@
 
 package gerrit;
 
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
@@ -41,9 +40,9 @@
     engine.setB0();
     Term a1 = arg1.dereference();
 
-    PatchSetInfo psInfo = StoredValues.PATCH_SET_INFO.get(engine);
+    String commitMessage = StoredValues.COMMIT_MESSAGE.get(engine);
 
-    SymbolTerm msg = SymbolTerm.create(psInfo.getMessage());
+    SymbolTerm msg = SymbolTerm.create(commitMessage);
     if (!a1.unify(msg, engine.trail)) {
       return engine.fail();
     }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index ee5d3b0..129d98a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -53,6 +53,7 @@
 import com.google.gson.stream.JsonReader;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
@@ -839,9 +840,7 @@
   private static void assertReviewers(
       ChangeInfo c, ReviewerState reviewerState, TestAccount... accounts) throws Exception {
     List<TestAccount> accountList = new ArrayList<>(accounts.length);
-    for (TestAccount a : accounts) {
-      accountList.add(a);
-    }
+    Collections.addAll(accountList, accounts);
     assertReviewers(c, reviewerState, accountList);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index ac17ec8..e510e25 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -208,6 +208,36 @@
   }
 
   @Test
+  public void createChangeWithParentCommit() throws Exception {
+    Map<String, PushOneCommit.Result> setup =
+        changeInTwoBranches("foo", "foo.txt", "bar", "bar.txt");
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.baseCommit = setup.get("master").getCommit().getId().name();
+    assertCreateSucceeds(input);
+  }
+
+  @Test
+  public void createChangeWithBadParentCommitFails() throws Exception {
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.baseCommit = "notasha1";
+    assertCreateFails(
+        input, UnprocessableEntityException.class, "Base notasha1 doesn't represent a valid SHA-1");
+  }
+
+  @Test
+  public void createChangeWithParentCommitOnWrongBranchFails() throws Exception {
+    Map<String, PushOneCommit.Result> setup =
+        changeInTwoBranches("foo", "foo.txt", "bar", "bar.txt");
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.branch = "foo";
+    input.baseCommit = setup.get("bar").getCommit().getId().name();
+    assertCreateFails(
+        input,
+        BadRequestException.class,
+        String.format("Commit %s doesn't exist on ref refs/heads/foo", input.baseCommit));
+  }
+
+  @Test
   public void createChangeWithoutAccessToParentCommitFails() throws Exception {
     Map<String, PushOneCommit.Result> results =
         changeInTwoBranches("invisible-branch", "a.txt", "visible-branch", "b.txt");
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
index 93ad2fe..be0879a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -229,8 +229,9 @@
     grant(project, "refs/heads/*", Permission.LABEL + "Patch-Set-Lock");
     revision(r).review(new ReviewInput().label("Patch-Set-Lock", 1));
 
-    exception.expect(AuthException.class);
-    exception.expectMessage("move not permitted");
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        String.format("The current patch set of change %s is locked", r.getChange().getId()));
     move(r.getChangeId(), newBranch.get());
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java
index 27dc951..a5d78c6 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java
@@ -333,7 +333,7 @@
 
   @Test
   public void enableSequencesNoGap() throws Exception {
-    testEnableSequences(0, 2, "12");
+    testEnableSequences(0, 3, "13");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
new file mode 100644
index 0000000..a4d9acb
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
@@ -0,0 +1,123 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.rules;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import java.util.Collection;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Tests the Prolog rules to make sure they work even when the change and account indexes are not
+ * available.
+ */
+@NoHttpd
+public class RulesIT extends AbstractDaemonTest {
+  private static final String RULE_TEMPLATE =
+      "submit_rule(submit(W)) :- \n" + "%s,\n" + "W = label('OK', ok(user(1000000))).";
+
+  @Inject private SubmitRuleEvaluator.Factory evaluatorFactory;
+
+  @Before
+  public void setUp() {
+    // We don't want caches to interfere with our tests. If we didn't, the cache would take
+    // precedence over the index, which would never be called.
+    baseConfig.setString("cache", "changes", "memoryLimit", "0");
+    baseConfig.setString("cache", "projects", "memoryLimit", "0");
+  }
+
+  @Test
+  public void testUnresolvedCommentsCountPredicate() throws Exception {
+    modifySubmitRules("gerrit:unresolved_comments_count(0)");
+    assertThat(statusForRule()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testUploaderPredicate() throws Exception {
+    modifySubmitRules("gerrit:uploader(U)");
+    assertThat(statusForRule()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testUnresolvedCommentsCount() throws Exception {
+    modifySubmitRules("gerrit:commit_message_matches('.*')");
+    assertThat(statusForRule()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testUserPredicate() throws Exception {
+    // This test results in a RULE_ERROR as Prolog tries to find accounts by email, using the index.
+    // TODO(maximeg) get OK results
+    modifySubmitRules("commit_author(user(1000000), 'John Doe', 'john.doe@example.com')");
+    assertThat(statusForRule()).isEqualTo(SubmitRecord.Status.RULE_ERROR);
+  }
+
+  @Test
+  public void testCommitAuthorPredicate() throws Exception {
+    // This test results in a RULE_ERROR as Prolog tries to find accounts by email, using the index.
+    // TODO(maximeg) get OK results
+    modifySubmitRules("gerrit:commit_author(Id)");
+    assertThat(statusForRule()).isEqualTo(SubmitRecord.Status.RULE_ERROR);
+  }
+
+  private SubmitRecord.Status statusForRule() throws Exception {
+    String oldHead = getRemoteHead().name();
+    PushOneCommit.Result result1 =
+        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+    testRepo.reset(oldHead);
+    ChangeData cd = result1.getChange();
+
+    Collection<SubmitRecord> records;
+    try (AutoCloseable changeIndex = disableChangeIndex()) {
+      try (AutoCloseable accountIndex = disableAccountIndex()) {
+        SubmitRuleEvaluator ruleEvaluator = evaluatorFactory.create(SubmitRuleOptions.defaults());
+        records = ruleEvaluator.evaluate(cd);
+      }
+    }
+
+    assertThat(records).hasSize(1);
+    SubmitRecord record = records.iterator().next();
+    return record.status;
+  }
+
+  private void modifySubmitRules(String ruleTested) throws Exception {
+    String newContent = String.format(RULE_TEMPLATE, ruleTested);
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      TestRepository<?> testRepo = new TestRepository<>((InMemoryRepository) repo);
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .author(admin.getIdent())
+          .committer(admin.getIdent())
+          .add("rules.pl", newContent)
+          .message("Modify rules.pl")
+          .create();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/common/data/SubmitRecordTest.java b/javatests/com/google/gerrit/common/data/SubmitRecordTest.java
index 6a90f45..5386b87 100644
--- a/javatests/com/google/gerrit/common/data/SubmitRecordTest.java
+++ b/javatests/com/google/gerrit/common/data/SubmitRecordTest.java
@@ -21,14 +21,17 @@
 import org.junit.Test;
 
 public class SubmitRecordTest {
-
-  private static SubmitRecord OK_RECORD;
-  private static SubmitRecord NOT_READY_RECORD;
+  private static final SubmitRecord OK_RECORD;
+  private static final SubmitRecord FORCED_RECORD;
+  private static final SubmitRecord NOT_READY_RECORD;
 
   static {
     OK_RECORD = new SubmitRecord();
     OK_RECORD.status = SubmitRecord.Status.OK;
 
+    FORCED_RECORD = new SubmitRecord();
+    FORCED_RECORD.status = SubmitRecord.Status.FORCED;
+
     NOT_READY_RECORD = new SubmitRecord();
     NOT_READY_RECORD.status = SubmitRecord.Status.NOT_READY;
   }
@@ -49,6 +52,14 @@
   }
 
   @Test
+  public void okWhenForced() {
+    Collection<SubmitRecord> submitRecords = new ArrayList<>();
+    submitRecords.add(FORCED_RECORD);
+
+    assertThat(SubmitRecord.allRecordsOK(submitRecords)).isTrue();
+  }
+
+  @Test
   public void emptyResultIfInvalid() {
     Collection<SubmitRecord> submitRecords = new ArrayList<>();
     submitRecords.add(NOT_READY_RECORD);
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 2ddfaa9..3864676 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -53,6 +53,7 @@
         "//lib:gson",
         "//lib:guava-retrying",
         "//lib:gwtorm",
+        "//lib:protobuf",
         "//lib:truth-java8-extension",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
@@ -60,5 +61,6 @@
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//proto:cache_java_proto",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/cache/EnumCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/EnumCacheSerializerTest.java
new file mode 100644
index 0000000..0e04d32
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/EnumCacheSerializerTest.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class EnumCacheSerializerTest {
+  @Test
+  public void serialize() throws Exception {
+    assertRoundTrip(MyEnum.FOO);
+    assertRoundTrip(MyEnum.BAR);
+    assertRoundTrip(MyEnum.BAZ);
+  }
+
+  private enum MyEnum {
+    FOO,
+    BAR,
+    BAZ;
+  }
+
+  private static void assertRoundTrip(MyEnum e) throws Exception {
+    CacheSerializer<MyEnum> s = new EnumCacheSerializer<>(MyEnum.class);
+    assertThat(s.deserialize(s.serialize(e))).isEqualTo(e);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/JavaCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/JavaCacheSerializerTest.java
new file mode 100644
index 0000000..41d07b9
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/JavaCacheSerializerTest.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.auto.value.AutoValue;
+import java.io.Serializable;
+import org.junit.Test;
+
+public class JavaCacheSerializerTest {
+  @Test
+  public void builtInTypes() throws Exception {
+    assertRoundTrip("foo");
+    assertRoundTrip(Integer.valueOf(1234));
+    assertRoundTrip(Boolean.TRUE);
+  }
+
+  @Test
+  public void customType() throws Exception {
+    assertRoundTrip(new AutoValue_JavaCacheSerializerTest_MyType(123, "four five six"));
+  }
+
+  @AutoValue
+  abstract static class MyType implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    abstract Integer anInt();
+
+    abstract String aString();
+  }
+
+  private static <T extends Serializable> void assertRoundTrip(T input) throws Exception {
+    JavaCacheSerializer<T> s = new JavaCacheSerializer<>();
+    assertThat(s.deserialize(s.serialize(input))).isEqualTo(input);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/h2/BUILD b/javatests/com/google/gerrit/server/cache/h2/BUILD
index 0ac4aef..e2b9257 100644
--- a/javatests/com/google/gerrit/server/cache/h2/BUILD
+++ b/javatests/com/google/gerrit/server/cache/h2/BUILD
@@ -9,6 +9,7 @@
         "//lib:guava",
         "//lib:h2",
         "//lib:junit",
+        "//lib:truth",
         "//lib/guice",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
index 80bca6d..32d0d27 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -14,62 +14,134 @@
 
 package com.google.gerrit.server.cache.h2;
 
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertTrue;
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
 import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.server.cache.CacheSerializer;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.SqlStore;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
 import com.google.inject.TypeLiteral;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
-import org.junit.Before;
 import org.junit.Test;
 
 public class H2CacheTest {
+  private static final TypeLiteral<String> KEY_TYPE = new TypeLiteral<String>() {};
+  private static final int DEFAULT_VERSION = 1234;
   private static int dbCnt;
 
-  private Cache<String, ValueHolder<Boolean>> mem;
-  private H2CacheImpl<String, Boolean> impl;
+  private static int nextDbId() {
+    return ++dbCnt;
+  }
 
-  @Before
-  public void setUp() {
-    mem = CacheBuilder.newBuilder().build();
-
-    TypeLiteral<String> keyType = new TypeLiteral<String>() {};
-    SqlStore<String, Boolean> store =
-        new SqlStore<>("jdbc:h2:mem:Test_" + (++dbCnt), keyType, 1 << 20, 0);
-    impl = new H2CacheImpl<>(MoreExecutors.directExecutor(), store, keyType, mem);
+  private static H2CacheImpl<String, String> newH2CacheImpl(
+      int id, Cache<String, ValueHolder<String>> mem, int version) {
+    SqlStore<String, String> store =
+        new SqlStore<>(
+            "jdbc:h2:mem:Test_" + id,
+            KEY_TYPE,
+            StringSerializer.INSTANCE,
+            StringSerializer.INSTANCE,
+            version,
+            1 << 20,
+            0);
+    return new H2CacheImpl<>(MoreExecutors.directExecutor(), store, KEY_TYPE, mem);
   }
 
   @Test
   public void get() throws ExecutionException {
-    assertNull(impl.getIfPresent("foo"));
+    Cache<String, ValueHolder<String>> mem = CacheBuilder.newBuilder().build();
+    H2CacheImpl<String, String> impl = newH2CacheImpl(nextDbId(), mem, DEFAULT_VERSION);
 
-    final AtomicBoolean called = new AtomicBoolean();
-    assertTrue(
-        impl.get(
-            "foo",
-            () -> {
-              called.set(true);
-              return true;
-            }));
-    assertTrue("used Callable", called.get());
-    assertTrue("exists in cache", impl.getIfPresent("foo"));
+    assertThat(impl.getIfPresent("foo")).isNull();
+
+    AtomicBoolean called = new AtomicBoolean();
+    assertThat(
+            impl.get(
+                "foo",
+                () -> {
+                  called.set(true);
+                  return "bar";
+                }))
+        .isEqualTo("bar");
+    assertThat(called.get()).named("Callable was called").isTrue();
+    assertThat(impl.getIfPresent("foo")).named("in-memory value").isEqualTo("bar");
     mem.invalidate("foo");
-    assertTrue("exists on disk", impl.getIfPresent("foo"));
+    assertThat(impl.getIfPresent("foo")).named("persistent value").isEqualTo("bar");
 
     called.set(false);
-    assertTrue(
-        impl.get(
-            "foo",
-            () -> {
-              called.set(true);
-              return true;
-            }));
-    assertFalse("did not invoke Callable", called.get());
+    assertThat(
+            impl.get(
+                "foo",
+                () -> {
+                  called.set(true);
+                  return "baz";
+                }))
+        .named("cached value")
+        .isEqualTo("bar");
+    assertThat(called.get()).named("Callable was called").isFalse();
+  }
+
+  @Test
+  public void stringSerializer() {
+    String input = "foo";
+    byte[] serialized = StringSerializer.INSTANCE.serialize(input);
+    assertThat(serialized).isEqualTo(new byte[] {'f', 'o', 'o'});
+    assertThat(StringSerializer.INSTANCE.deserialize(serialized)).isEqualTo(input);
+  }
+
+  @Test
+  public void version() throws Exception {
+    int id = nextDbId();
+    H2CacheImpl<String, String> oldImpl = newH2CacheImpl(id, disableMemCache(), DEFAULT_VERSION);
+    H2CacheImpl<String, String> newImpl =
+        newH2CacheImpl(id, disableMemCache(), DEFAULT_VERSION + 1);
+
+    assertThat(oldImpl.diskStats().space()).isEqualTo(0);
+    assertThat(newImpl.diskStats().space()).isEqualTo(0);
+    oldImpl.put("key", "val");
+    assertThat(oldImpl.getIfPresent("key")).isEqualTo("val");
+    assertThat(oldImpl.diskStats().space()).isEqualTo(12);
+    assertThat(oldImpl.diskStats().hitCount()).isEqualTo(1);
+
+    // Can't find key in cache with wrong version, but the data is still there.
+    assertThat(newImpl.diskStats().requestCount()).isEqualTo(0);
+    assertThat(newImpl.diskStats().space()).isEqualTo(12);
+    assertThat(newImpl.getIfPresent("key")).isNull();
+    assertThat(newImpl.diskStats().space()).isEqualTo(12);
+
+    // Re-putting it via the new cache works, and uses the same amount of space.
+    newImpl.put("key", "val2");
+    assertThat(newImpl.getIfPresent("key")).isEqualTo("val2");
+    assertThat(newImpl.diskStats().hitCount()).isEqualTo(1);
+    assertThat(newImpl.diskStats().space()).isEqualTo(14);
+
+    // Now it's no longer in the old cache.
+    assertThat(oldImpl.diskStats().space()).isEqualTo(14);
+    assertThat(oldImpl.getIfPresent("key")).isNull();
+  }
+
+  // TODO(dborowitz): Won't be necessary when we use a real StringSerializer in the server code.
+  private enum StringSerializer implements CacheSerializer<String> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(String object) {
+      return object.getBytes(UTF_8);
+    }
+
+    @Override
+    public String deserialize(byte[] in) {
+      // TODO(dborowitz): Consider using CharsetDecoder directly in the real implementation, to get
+      // checked exceptions.
+      return new String(in, UTF_8);
+    }
+  }
+
+  private static <K, V> Cache<K, ValueHolder<V>> disableMemCache() {
+    return CacheBuilder.newBuilder().maximumSize(0).build();
   }
 }
diff --git a/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
new file mode 100644
index 0000000..4470f55
--- /dev/null
+++ b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.server.cache.CacheSerializer;
+import com.google.gerrit.server.cache.proto.Cache.ChangeKindKeyProto;
+import com.google.protobuf.ByteString;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class ChangeKindCacheImplTest {
+  @Test
+  public void keySerializer() throws Exception {
+    ChangeKindCacheImpl.Key key =
+        new ChangeKindCacheImpl.Key(
+            ObjectId.zeroId(),
+            ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
+            "aStrategy");
+    CacheSerializer<ChangeKindCacheImpl.Key> s = new ChangeKindCacheImpl.Key.Serializer();
+    byte[] serialized = s.serialize(key);
+    assertThat(ChangeKindKeyProto.parseFrom(serialized))
+        .isEqualTo(
+            ChangeKindKeyProto.newBuilder()
+                .setPrior(bytes(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
+                .setNext(
+                    bytes(
+                        0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef,
+                        0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef))
+                .setStrategyName("aStrategy")
+                .build());
+    assertThat(s.deserialize(serialized)).isEqualTo(key);
+  }
+
+  private static ByteString bytes(int... ints) {
+    byte[] bytes = new byte[ints.length];
+    for (int i = 0; i < ints.length; i++) {
+      bytes[i] = (byte) ints[i];
+    }
+    return ByteString.copyFrom(bytes);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
index edd4abf..b525504 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
@@ -27,7 +27,7 @@
         new FakeQueryBuilder.Definition<>(FakeQueryBuilder.class),
         new ChangeQueryBuilder.Arguments(
             null, null, null, null, null, null, null, null, null, null, null, null, null, null,
-            null, null, null, null, null, indexes, null, null, null, null, null, null, null));
+            null, null, null, null, null, indexes, null, null, null, null, null, null, null, null));
   }
 
   @Operator
diff --git a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java b/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
index 152b057..314941e 100644
--- a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
+++ b/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
@@ -48,7 +48,8 @@
             cfg.setInt("rules", null, "compileReductionLimit", (int) 1e6);
             bind(PrologEnvironment.Args.class)
                 .toInstance(
-                    new PrologEnvironment.Args(null, null, null, null, null, null, null, cfg));
+                    new PrologEnvironment.Args(
+                        null, null, null, null, null, null, null, cfg, null));
           }
         });
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
index a652e26..85f01e7 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
@@ -72,6 +72,17 @@
             </gr-autocomplete>
           </span>
         </section>
+        <section class$="[[_computeBranchClass(baseChange)]]">
+          <span class="title">Provide base commit sha1 for change</span>
+          <span class="value">
+            <input
+                is="iron-input"
+                id="baseCommitInput"
+                maxlength="40"
+                placeholder="(optional)"
+                bind-value="{{baseCommit}}">
+          </span>
+        </section>
         <section>
           <span class="title">Enter topic for new change</span>
           <span class="value">
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
index 8252d88..b738829 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
@@ -37,6 +37,7 @@
         },
       },
       baseChange: String,
+      baseCommit: String,
       privateByDefault: String,
       canCreate: {
         type: Boolean,
@@ -73,7 +74,8 @@
       const isPrivate = this.$.privateChangeCheckBox.checked;
       const isWip = true;
       return this.$.restAPI.createChange(this.repoName, this.branch,
-          this.subject, this.topic, isPrivate, isWip, this.baseChange)
+          this.subject, this.topic, isPrivate, isWip, this.baseChange,
+          this.baseCommit)
           .then(changeCreated => {
             if (!changeCreated) { return; }
             Gerrit.Nav.navigateToChange(changeCreated);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.html b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.html
index d57dd329..e9bc674 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.html
@@ -19,8 +19,8 @@
 <link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/gr-table-styles.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/gr-table-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -34,6 +34,7 @@
 <dom-module id="gr-repo-detail-list">
   <template>
     <style include="gr-form-styles"></style>
+    <style include="gr-table-styles"></style>
     <style include="shared-styles">
       .tags td.name {
         min-width: 25em;
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
index 6a3ea54..df09dfe 100644
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
@@ -144,7 +144,9 @@
     },
 
     _getReviewerSuggestions(input) {
-      if (!this.change) { return Promise.resolve([]); }
+      if (!this.change || !this.change._number) { return Promise.resolve([]); }
+
+      input = `cansee:${this.change._number} ${input}`;
       const api = this.$.restAPI;
       const xhr = this.allowAnyUser ?
           api.getSuggestedAccounts(input) :
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
index 07c1554..c63f64d 100644
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
@@ -80,6 +80,7 @@
 
       element = fixture('basic');
       element.change = {
+        _number: 42,
         owner,
         reviewers: {
           CC: [existingReviewer1],
@@ -179,12 +180,14 @@
 
       element._getReviewerSuggestions('').then(() => {
         assert.isTrue(suggestReviewerStub.calledOnce);
+        assert.isTrue(suggestReviewerStub.calledWith(42, 'cansee:42 '));
         assert.isFalse(suggestAccountStub.called);
         element.allowAnyUser = true;
 
         element._getReviewerSuggestions('').then(() => {
           assert.isTrue(suggestReviewerStub.calledOnce);
           assert.isTrue(suggestAccountStub.calledOnce);
+          assert.isTrue(suggestAccountStub.calledWith('cansee:42 '));
           done();
         });
       });
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
index 7ef62ea..cd7ec2b 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
@@ -287,7 +287,7 @@
       </section>
       <template is="dom-if" if="[[serverConfig.note_db_enabled]]">
         <section class="hashtag">
-          <span class="title">Hashtag</span>
+          <span class="title">Hashtags</span>
           <span class="value">
             <template is="dom-repeat" items="[[change.hashtags]]">
               <gr-linked-chip
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html
index 536b943..2a33343 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html
@@ -18,6 +18,7 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-icons/gr-icons.html">
 
 <dom-module id="gr-change-requirements">
   <template strip-whitespace>
@@ -26,16 +27,15 @@
         display: inline-block;
         font-weight: initial;
         text-align: center;
-        width: 1em;
       }
-      .unsatisfied .status {
+      .unsatisfied .icon {
         color: #FFA62F;
       }
-      .satisfied .status {
+      .satisfied .icon {
         color: #388E3C;
       }
       .requirement {
-        padding: .1em .3em;
+        padding: .1em 0;
       }
       .requirementContainer:not(:first-of-type) {
         margin-top: .25em;
@@ -46,7 +46,7 @@
     </style>
     <template is="dom-if" if="[[_showWip]]">
       <div class="requirement unsatisfied changeIsWip">
-        <span class="status">â§—</span>
+        <span class="status"><iron-icon class="icon" icon="gr-icons:hourglass"></iron-icon></span>
         Work in Progress
       </div>
     </template>
@@ -55,7 +55,9 @@
           is="dom-repeat"
           items="[[labels]]">
         <div class$="requirement [[item.style]]">
-          <span class="status">[[item.status]]</span>
+          <span class="status">
+            <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
+          </span>
           Label <span class="labelName">[[item.label]]</span>
         </div>
       </template>
@@ -65,7 +67,7 @@
         items="[[requirements]]">
       <div class$="requirement [[_computeRequirementClass(item.satisfied)]]">
         <span class="status">
-          [[_computeRequirementStatus(item.satisfied)]]
+          <iron-icon id="icon" icon="[[_computeRequirementIcon(item.satisfied)]]"></iron-icon>
         </span>
         [[item.fallback_text]]
       </div>
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
index 884e9cd..765f639 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
@@ -75,9 +75,9 @@
         const obj = labels[label];
         if (obj.optional) { continue; }
 
-        const status = this._computeRequirementStatus(obj.approved);
+        const icon = this._computeRequirementIcon(obj.approved);
         const style = this._computeRequirementClass(obj.approved);
-        _labels.push({label, status, style});
+        _labels.push({label, icon, style});
       }
 
       return _labels;
@@ -91,11 +91,11 @@
       }
     },
 
-    _computeRequirementStatus(requirementStatus) {
+    _computeRequirementIcon(requirementStatus) {
       if (requirementStatus) {
-        return '✓';
+        return 'gr-icons:check';
       } else {
-        return 'â§—';
+        return 'gr-icons:hourglass';
       }
     },
   });
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
index 560a33d..efac0c2 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
@@ -51,8 +51,8 @@
       assert.equal(element._computeRequirementClass(true), 'satisfied');
       assert.equal(element._computeRequirementClass(false), 'unsatisfied');
 
-      assert.equal(element._computeRequirementStatus(true), '✓');
-      assert.equal(element._computeRequirementStatus(false), 'â§—');
+      assert.equal(element._computeRequirementIcon(true), 'gr-icons:check');
+      assert.equal(element._computeRequirementIcon(false), 'gr-icons:hourglass');
     });
 
     test('properly converts satisfied labels', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index c978281..2f30fb0 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -186,8 +186,8 @@
     keyBindings: {
       'shift+left': '_handleShiftLeftKey',
       'shift+right': '_handleShiftRightKey',
-      'i': '_handleIKey',
-      'shift+i': '_handleCapitalIKey',
+      'i:keyup': '_handleIKey',
+      'shift+i:keyup': '_handleCapitalIKey',
       'down j': '_handleDownKey',
       'up k': '_handleUpKey',
       'c': '_handleCKey',
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
index df3f04f..9987521 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -530,24 +530,24 @@
         const files = Polymer.dom(element.root).querySelectorAll('.file-row');
         element.$.fileCursor.stops = files;
         element.$.fileCursor.setCursorAtIndex(0);
-        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
+        MockInteractions.keyUpOn(element, 73, null, 'i');
         flushAsynchronousOperations();
         assert.include(element._expandedFilePaths, element.diffs[0].path);
-        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
+        MockInteractions.keyUpOn(element, 73, null, 'i');
         flushAsynchronousOperations();
         assert.notInclude(element._expandedFilePaths, element.diffs[0].path);
         element.$.fileCursor.setCursorAtIndex(1);
-        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
+        MockInteractions.keyUpOn(element, 73, null, 'i');
         flushAsynchronousOperations();
         assert.include(element._expandedFilePaths, element.diffs[1].path);
 
-        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
+        MockInteractions.keyUpOn(element, 73, 'shift', 'i');
         flushAsynchronousOperations();
         for (const index in element.diffs) {
           if (!element.diffs.hasOwnProperty(index)) { continue; }
           assert.include(element._expandedFilePaths, element.diffs[index].path);
         }
-        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
+        MockInteractions.keyUpOn(element, 73, 'shift', 'i');
         flushAsynchronousOperations();
         for (const index in element.diffs) {
           if (!element.diffs.hasOwnProperty(index)) { continue; }
@@ -1338,7 +1338,7 @@
     });
 
     test('cursor with individually opened files', () => {
-      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
+      MockInteractions.keyUpOn(element, 73, null, 'i');
       flushAsynchronousOperations();
       let diffs = renderAndGetNewDiffs(0);
       const diffStops = diffs[0].getCursorStops();
@@ -1364,7 +1364,7 @@
 
       // The file cusor is now at 1.
       assert.equal(element.$.fileCursor.index, 1);
-      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
+      MockInteractions.keyUpOn(element, 73, null, 'i');
       flushAsynchronousOperations();
 
       diffs = renderAndGetNewDiffs(1);
@@ -1379,7 +1379,7 @@
     });
 
     test('cursor with toggle all files', () => {
-      MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
+      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
       flushAsynchronousOperations();
 
       const diffs = renderAndGetNewDiffs(0);
@@ -1426,7 +1426,7 @@
       });
 
       test('n key with some files expanded and no shift key', () => {
-        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
+        MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
         flushAsynchronousOperations();
         assert.equal(nextChunkStub.callCount, 1);
 
@@ -1441,7 +1441,7 @@
       });
 
       test('n key with some files expanded and shift key', () => {
-        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
+        MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
         flushAsynchronousOperations();
         assert.equal(nextChunkStub.callCount, 1);
 
@@ -1455,7 +1455,7 @@
       });
 
       test('n key without all files expanded and shift key', () => {
-        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, 'shift', 'i');
+        MockInteractions.keyUpOn(fileRows[0], 73, 'shift', 'i');
         flushAsynchronousOperations();
 
         MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
@@ -1468,7 +1468,7 @@
       });
 
       test('n key without all files expanded and no shift key', () => {
-        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, 'shift', 'i');
+        MockInteractions.keyUpOn(fileRows[0], 73, 'shift', 'i');
         flushAsynchronousOperations();
 
         MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
@@ -1601,7 +1601,7 @@
       commentStub.withArgs('ecf0b9fa_fe1a5f62').returns(
           commentStubRes2);
       // Expand the commit message diff
-      MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
+      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
       const diffs = renderAndGetNewDiffs(0);
       flushAsynchronousOperations();
 
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
index e8c720a..97e7e78 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -217,7 +217,8 @@
       _sendDisabled: {
         type: Boolean,
         computed: '_computeSendButtonDisabled(_sendButtonLabel, diffDrafts, ' +
-            'draft, _reviewersMutated, _labelsChanged, _includeComments)',
+            'draft, _reviewersMutated, _labelsChanged, _includeComments, ' +
+            'disabled)',
         observer: '_sendDisabledChanged',
       },
     },
@@ -859,10 +860,9 @@
     },
 
     _computeSendButtonDisabled(buttonLabel, drafts, text, reviewersMutated,
-        labelsChanged, includeComments) {
-      if (buttonLabel === ButtonLabels.START_REVIEW) {
-        return false;
-      }
+        labelsChanged, includeComments, disabled) {
+      if (disabled) { return true; }
+      if (buttonLabel === ButtonLabels.START_REVIEW) { return false; }
       const hasDrafts = includeComments && Object.keys(drafts).length;
       return !hasDrafts && !text.length && !reviewersMutated && !labelsChanged;
     },
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
index 41d7b49..f9108c7 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
@@ -1087,17 +1087,17 @@
       assert.isFalse(fn('Start review'));
       assert.isTrue(fn('Send', {}, '', false, false, false));
       // Mock nonempty comment draft array, with seding comments.
-      assert.isFalse(fn('Send', {file: ['draft']}, '', false, false,
-          true));
+      assert.isFalse(fn('Send', {file: ['draft']}, '', false, false, true));
       // Mock nonempty comment draft array, without seding comments.
-      assert.isTrue(fn('Send', {file: ['draft']}, '', false, false,
-          false));
+      assert.isTrue(fn('Send', {file: ['draft']}, '', false, false, false));
       // Mock nonempty change message.
       assert.isFalse(fn('Send', {}, 'test', false, false, false));
       // Mock reviewers mutated.
       assert.isFalse(fn('Send', {}, '', true, false, false));
       // Mock labels changed.
       assert.isFalse(fn('Send', {}, '', false, true, false));
+      // Whole dialog is disabled.
+      assert.isTrue(fn('Send', {}, '', false, true, false, true));
     });
 
     test('_submit blocked when no mutations exist', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
index 1ca678932..2201a9a 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
@@ -54,10 +54,6 @@
         display: flex;
         margin-right: 1rem;
       }
-      .draftsOnly gr-diff-comment-thread,
-      .unresolvedOnly gr-diff-comment-thread {
-        display: none
-      }
       .draftsOnly:not(.unresolvedOnly) gr-diff-comment-thread[has-draft],
       .unresolvedOnly:not(.draftsOnly) gr-diff-comment-thread[unresolved],
       .draftsOnly.unresolvedOnly gr-diff-comment-thread[has-draft][unresolved] {
@@ -68,12 +64,12 @@
       <div class="toggleItem">
         <paper-toggle-button
             id="unresolvedToggle"
-            on-change="_toggleUnresolved"></paper-toggle-button>
+            checked="{{_unresolvedOnly}}"></paper-toggle-button>
           Only unresolved threads</div>
       <div class$="toggleItem draftToggle [[_computeShowDraftToggle(loggedIn)]]">
         <paper-toggle-button
             id="draftToggle"
-            on-change="_toggleDrafts"></paper-toggle-button>
+            checked="{{_draftsOnly}}"></paper-toggle-button>
           Only threads with drafts</div>
     </div>
     <div id="threads">
@@ -82,7 +78,7 @@
       </template>
       <template
           is="dom-repeat"
-          items="[[_sortedThreads]]"
+          items="[[_filteredThreads]]"
           as="thread"
           initial-count="5"
           target-framerate="60">
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
index 934f0f2..69d77f6 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
@@ -34,10 +34,24 @@
       loggedIn: Boolean,
       _sortedThreads: {
         type: Array,
-        computed: '_computeSortedThreads(threads.*)',
+      },
+      _filteredThreads: {
+        type: Array,
+        computed: '_computeFilteredThreads(_sortedThreads, _unresolvedOnly, ' +
+            '_draftsOnly)',
+      },
+      _unresolvedOnly: {
+        type: Boolean,
+        value: false,
+      },
+      _draftsOnly: {
+        type: Boolean,
+        value: false,
       },
     },
 
+    observers: ['_computeSortedThreads(threads.*)'],
+
     _computeShowDraftToggle(loggedIn) {
       return loggedIn ? 'show' : '';
     },
@@ -49,36 +63,60 @@
      *  - Resolved threads with drafts (reverse chronological)
      *  - Resolved threads without drafts (reverse chronological)
      * @param {!Object} changeRecord
-     * @return {!Array}
      */
     _computeSortedThreads(changeRecord) {
       const threads = changeRecord.base;
       if (!threads) { return []; }
-      return threads.map(this._getThreadWithSortInfo).sort((c1, c2) => {
-        const c1Date = c1.__date || util.parseDate(c1.updated);
-        const c2Date = c2.__date || util.parseDate(c2.updated);
-        const dateCompare = c2Date - c1Date;
-        if (c2.unresolved || c1.unresolved) {
-          if (!c1.unresolved) { return 1; }
-          if (!c2.unresolved) { return -1; }
-        }
-        if (c2.hasDraft || c1.hasDraft) {
-          if (!c1.hasDraft) { return 1; }
-          if (!c2.hasDraft) { return -1; }
-        }
+      this._updateSortedThreads(threads);
+    },
 
-        if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) {
-          return 0;
+    _updateSortedThreads(threads) {
+      this._sortedThreads =
+          threads.map(this._getThreadWithSortInfo).sort((c1, c2) => {
+            const c1Date = c1.__date || util.parseDate(c1.updated);
+            const c2Date = c2.__date || util.parseDate(c2.updated);
+            const dateCompare = c2Date - c1Date;
+            if (c2.unresolved || c1.unresolved) {
+              if (!c1.unresolved) { return 1; }
+              if (!c2.unresolved) { return -1; }
+            }
+            if (c2.hasDraft || c1.hasDraft) {
+              if (!c1.hasDraft) { return 1; }
+              if (!c2.hasDraft) { return -1; }
+            }
+
+            if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) {
+              return 0;
+            }
+            return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
+          });
+    },
+
+    _computeFilteredThreads(sortedThreads, unresolvedOnly, draftsOnly) {
+      return sortedThreads.filter(c => {
+        if (draftsOnly) {
+          return c.hasDraft;
+        } else if (unresolvedOnly) {
+          return c.unresolved;
+        } else {
+          return c;
         }
-        return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
       }).map(threadInfo => threadInfo.thread);
     },
 
     _getThreadWithSortInfo(thread) {
       const lastComment = thread.comments[thread.comments.length - 1] || {};
+
+      const lastNonDraftComment =
+          (lastComment.__draft && thread.comments.length > 1) ?
+          thread.comments[thread.comments.length - 2] :
+          lastComment;
+
       return {
         thread,
-        unresolved: !!lastComment.unresolved,
+        // Use the unresolved bit for the last non draft comment. This is what
+        // anybody other than the current user would see.
+        unresolved: !!lastNonDraftComment.unresolved,
         hasDraft: !!lastComment.__draft,
         updated: lastComment.updated,
       };
@@ -100,6 +138,10 @@
     },
 
     _handleCommentsChanged(e) {
+      // Reset threads so thread computations occur on deep array changes to
+      // threads comments that are not observed naturally.
+      this._updateSortedThreads(this.threads);
+
       this.dispatchEvent(new CustomEvent('thread-list-modified',
           {detail: {rootId: e.detail.rootId, path: e.detail.path}}));
     },
@@ -107,13 +149,5 @@
     _isOnParent(side) {
       return !!side;
     },
-
-    _toggleUnresolved() {
-      this.$.threads.classList.toggle('unresolvedOnly');
-    },
-
-    _toggleDrafts() {
-      this.$.threads.classList.toggle('draftsOnly');
-    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
index 53557c6..804446a 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
@@ -37,15 +37,6 @@
     let element;
     let sandbox;
     let threadElements;
-    const computeVisibleNumber = threads => {
-      let count = 0;
-      for (const thread of threads) {
-        if (getComputedStyle(thread).display !== 'none') {
-          count++;
-        }
-      }
-      return count;
-    };
 
     setup(() => {
       sandbox = sinon.sandbox.create();
@@ -74,7 +65,7 @@
               in_reply_to: 'ecf0b9fa_fe1a5f62',
               updated: '2018-02-13 22:48:48.018000000',
               message: 'draft',
-              unresolved: true,
+              unresolved: false,
               __draft: true,
               __draftID: '0.m683trwff68',
               __editing: false,
@@ -196,54 +187,67 @@
     });
 
     test('there are five threads by default', () => {
-      assert.equal(computeVisibleNumber(threadElements), 5);
+      assert.equal(Polymer.dom(element.root)
+          .querySelectorAll('gr-diff-comment-thread').length, 5);
     });
 
     test('_computeSortedThreads', () => {
       assert.equal(element._sortedThreads.length, 5);
       // Draft and unresolved
-      assert.equal(element._sortedThreads[0].rootId, 'ecf0b9fa_fe1a5f62');
+      assert.equal(element._sortedThreads[0].thread.rootId,
+          'ecf0b9fa_fe1a5f62');
       // unresolved
-      assert.equal(element._sortedThreads[1].rootId, 'scaddf38_44770ec1');
+      assert.equal(element._sortedThreads[1].thread.rootId,
+          'scaddf38_44770ec1');
       // unresolved
-      assert.equal(element._sortedThreads[2].rootId, '8caddf38_44770ec1');
+      assert.equal(element._sortedThreads[2].thread.rootId,
+          '8caddf38_44770ec1');
       // resolved and draft
-      assert.equal(element._sortedThreads[3].rootId, 'zcf0b9fa_fe1a5f62');
+      assert.equal(element._sortedThreads[3].thread.rootId,
+          'zcf0b9fa_fe1a5f62');
       // resolved
-      assert.equal(element._sortedThreads[4].rootId, '09a9fb0a_1484e6cf');
+      assert.equal(element._sortedThreads[4].thread.rootId,
+          '09a9fb0a_1484e6cf');
     });
 
     test('thread removal', () => {
       threadElements[1].fire('thread-discard', {rootId: 'scaddf38_44770ec1'});
       flushAsynchronousOperations();
       assert.equal(element._sortedThreads.length, 4);
-      assert.equal(element._sortedThreads[0].rootId, 'ecf0b9fa_fe1a5f62');
+      assert.equal(element._sortedThreads[0].thread.rootId,
+          'ecf0b9fa_fe1a5f62');
       // unresolved
-      assert.equal(element._sortedThreads[1].rootId, '8caddf38_44770ec1');
+      assert.equal(element._sortedThreads[1].thread.rootId,
+          '8caddf38_44770ec1');
       // resolved and draft
-      assert.equal(element._sortedThreads[2].rootId, 'zcf0b9fa_fe1a5f62');
+      assert.equal(element._sortedThreads[2].thread.rootId,
+          'zcf0b9fa_fe1a5f62');
       // resolved
-      assert.equal(element._sortedThreads[3].rootId, '09a9fb0a_1484e6cf');
+      assert.equal(element._sortedThreads[3].thread.rootId,
+          '09a9fb0a_1484e6cf');
     });
 
     test('toggle unresolved only shows unressolved comments', () => {
       MockInteractions.tap(element.$.unresolvedToggle);
       flushAsynchronousOperations();
-      assert.equal(computeVisibleNumber(threadElements), 3);
+      assert.equal(Polymer.dom(element.root)
+          .querySelectorAll('gr-diff-comment-thread').length, 3);
     });
 
     test('toggle drafts only shows threads with draft comments', () => {
       MockInteractions.tap(element.$.draftToggle);
       flushAsynchronousOperations();
-      assert.equal(computeVisibleNumber(threadElements), 2);
+      assert.equal(Polymer.dom(element.root)
+          .querySelectorAll('gr-diff-comment-thread').length, 2);
     });
 
     test('toggle drafts and unresolved only shows threads with drafts and ' +
-        'unresolved', () => {
+        'publicly unresolved ', () => {
       MockInteractions.tap(element.$.draftToggle);
       MockInteractions.tap(element.$.unresolvedToggle);
       flushAsynchronousOperations();
-      assert.equal(computeVisibleNumber(threadElements), 1);
+      assert.equal(Polymer.dom(element.root)
+          .querySelectorAll('gr-diff-comment-thread').length, 2);
     });
 
     test('modification events are consumed and displatched', () => {
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
index 0b78df5..e4cb243 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
@@ -55,10 +55,11 @@
         padding-top: 1em;
       }
       .key {
+        background-color: var(--chip-background-color);
+        border: 1px solid var(--border-color);
+        border-radius: 3px;
         display: inline-block;
         font-family: var(--font-family-bold);
-        border-radius: 3px;
-        background-color: #f1f2f3;
         padding: .1em .5em;
         text-align: center;
       }
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
index d31825f..daa86fa 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
@@ -33,7 +33,7 @@
 </test-fixture>
 
 <script>
-  suite('gr-diff tests', () => {
+  suite('gr-plugin-host tests', () => {
     let element;
     let sandbox;
     let url;
@@ -54,8 +54,8 @@
       sandbox.stub(Gerrit, '_setPluginsCount');
       element.config = {
         plugin: {
-          html_resource_paths: ['foo/bar', 'baz'],
-          js_resource_paths: ['42'],
+          html_resource_paths: ['plugins/foo/bar', 'plugins/baz'],
+          js_resource_paths: ['plugins/42'],
         },
       };
       assert.isTrue(Gerrit._setPluginsCount.calledWith(3));
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
index 8a86f93..89ab8fe 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
@@ -31,7 +31,7 @@
       }
       .container {
         align-items: center;
-        background: var(--table-header-background-color);
+        background: var(--chip-background-color);
         border-radius: .75em;
         display: inline-flex;
         padding: 0 .5em;
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
index 1eaad4e..f39398f 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
@@ -312,6 +312,12 @@
           // For any normal keypress, return focus to the input to allow for
           // unbroken user input.
           this.$.input.inputElement.focus();
+
+          // Since this has been a normal keypress, the suggestions will have
+          // been based on a previous input. Clear them. This prevents an
+          // outdated suggestion from being used if the input keystroke is
+          // immediately followed by a commit keystroke. @see Issue 8655
+          this._suggestions = [];
       }
       this.fire('input-keydown', {keyCode: e.keyCode, input: this.$.input});
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
index 872f79f..d257cdd 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
@@ -379,6 +379,21 @@
       assert.isTrue(commitStub.calledOnce);
     });
 
+    test('issue 8655', () => {
+      function makeSuggestion(s) { return {name: s, text: s, value: s}; }
+      const keydownSpy = sandbox.spy(element, '_handleKeydown');
+      element.setText('file:');
+      element._suggestions =
+          [makeSuggestion('file:'), makeSuggestion('-file:')];
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 88, null, 'x');
+      // Must set the value, because the MockInteraction does not.
+      element.$.input.value = 'file:x';
+      assert.isTrue(keydownSpy.calledOnce);
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null, 'enter');
+      assert.isTrue(keydownSpy.calledTwice);
+      assert.equal(element.text, 'file:x');
+    });
+
     suite('focus', () => {
       let commitSpy;
       let focusSpy;
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
index 96066de..8581396 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
@@ -76,6 +76,7 @@
         --paper-input-container-input: {
           font-size: var(--font-size-normal);
         }
+        --paper-input-container-focus-color: var(--link-color);
       }
     </style>
       <label
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
index ae363ea..cbae987 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
@@ -40,6 +40,8 @@
       <g id="chevron-left"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.html -->
       <g id="chevron-right"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#ic_hourglass_full-->
+      <g id="hourglass"><path d="M6 2v6h.01L6 8.01 10 12l-4 4 .01.01H6V22h12v-5.99h-.01L18 16l-4-4 4-3.99-.01-.01H18V2H6z"/><path d="M0 0h24v24H0V0z" fill="none"/></g>
       <!-- This is a custom PolyGerrit SVG -->
       <g id="side-by-side"><path d="M17.1578947,10.8888889 L2.84210526,10.8888889 C2.37894737,10.8888889 2,11.2888889 2,11.7777778 L2,17.1111111 C2,17.6 2.37894737,18 2.84210526,18 L17.1578947,18 C17.6210526,18 18,17.6 18,17.1111111 L18,11.7777778 C18,11.2888889 17.6210526,10.8888889 17.1578947,10.8888889 Z M17.1578947,2 L2.84210526,2 C2.37894737,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.37894737,9.11111111 2.84210526,9.11111111 L17.1578947,9.11111111 C17.6210526,9.11111111 18,8.71111111 18,8.22222222 L18,2.88888889 C18,2.4 17.6210526,2 17.1578947,2 Z M16.1973628,2 L2.78874238,2 C2.35493407,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.35493407,9.11111111 2.78874238,9.11111111 L16.1973628,9.11111111 C16.6311711,9.11111111 16.9861052,8.71111111 16.9861052,8.22222222 L16.9861052,2.88888889 C16.9861052,2.4 16.6311711,2 16.1973628,2 Z" id="Shape" transform="scale(1.2) translate(10.000000, 10.000000) rotate(-90.000000) translate(-10.000000, -10.000000)"/></g>
       <!-- This is a custom PolyGerrit SVG -->
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
index d9fdc59..7d91f41 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
@@ -351,6 +351,15 @@
       return Gerrit.awaitPluginsLoaded();
     });
 
+    test('multiple ui plugins per java plugin', () => {
+      const file1 = 'http://test.com/plugins/qaz/static/foo.nocache.js';
+      const file2 = 'http://test.com/plugins/qaz/static/bar.js';
+      Gerrit._setPluginsPending([file1, file2]);
+      Gerrit.install(() => {}, '0.1', file1);
+      Gerrit.install(() => {}, '0.1', file2);
+      return Gerrit.awaitPluginsLoaded();
+    });
+
     test('plugin install errors shows toasts', () => {
       const alertStub = sandbox.stub();
       document.addEventListener('show-alert', alertStub);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
index ef20206..0b2133b 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
@@ -34,7 +34,7 @@
     CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK: 'change-metadata-item',
   };
 
-  const PLUGIN_LOADING_TIMEOUT_MS = 60000;
+  const PLUGIN_LOADING_TIMEOUT_MS = 10000;
 
   let _restAPI;
   const getRestAPI = () => {
@@ -561,7 +561,7 @@
       o[getPluginNameFromUrl(url)] = url;
       return o;
     }, {});
-    Gerrit._setPluginsCount(plugins.length);
+    Gerrit._setPluginsCount(Object.keys(_pluginsPending).length);
   };
 
   Gerrit._setPluginsCount = function(count) {
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html
index 71999acaf..7df77ca 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html
@@ -28,10 +28,15 @@
         font-size: var(--font-size-normal);
         max-width: 25em;
       }
+      #filter:focus {
+        outline: none;
+      }
       #topContainer {
+        align-items: center;
         display: flex;
+        height: 3rem;
         justify-content: space-between;
-        margin: 1em;
+        margin: 0 1em;
       }
       #createNewContainer:not(.show) {
         display: none;
@@ -44,18 +49,24 @@
         text-decoration: underline;
       }
       nav {
-        padding: .5em 0;
-        text-align: center;
+        align-items: center;
+        display: flex;
+        height: 3rem;
+        justify-content: flex-end;
+        margin-right: 20px;
       }
-      nav a {
-        display: inline-block;
+      nav,
+      iron-icon {
+        color: var(--deemphasized-text-color);
       }
-      nav a:first-of-type {
-        margin-right: .5em;
+      iron-icon {
+        height: 1.85rem;
+        margin-left: 16px;
+        width: 1.85rem;
       }
     </style>
     <div id="topContainer">
-      <div>
+      <div class="filterContainer">
         <label>Filter:</label>
         <input is="iron-input"
             type="text"
@@ -64,7 +75,7 @@
       </div>
       <div id="createNewContainer"
           class$="[[_computeCreateClass(createNew)]]">
-        <gr-button id="createNew" on-tap="_createNewItem">
+        <gr-button primary link id="createNew" on-tap="_createNewItem">
           Create New
         </gr-button>
       </div>
@@ -73,11 +84,14 @@
     <nav>
       <a id="prevArrow"
           href$="[[_computeNavLink(offset, -1, itemsPerPage, filter, path)]]"
-          hidden$="[[_hidePrevArrow(offset)]]" hidden>&larr; Prev</a>
+          hidden$="[[_hidePrevArrow(loading, offset)]]" hidden>
+        <iron-icon icon="gr-icons:chevron-left"></iron-icon>
+      </a>
       <a id="nextArrow"
           href$="[[_computeNavLink(offset, 1, itemsPerPage, filter, path)]]"
           hidden$="[[_hideNextArrow(loading, items)]]" hidden>
-        Next &rarr;</a>
+        <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+      </a>
     </nav>
   </template>
   <script src="gr-list-view.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
index 53691fc..8b83eb3 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
@@ -84,8 +84,8 @@
       return createNew ? 'show' : '';
     },
 
-    _hidePrevArrow(offset) {
-      return offset === 0;
+    _hidePrevArrow(loading, offset) {
+      return loading || offset === 0;
     },
 
     _hideNextArrow(loading, items) {
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html
index d1cf80a..09e68dd 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html
@@ -114,11 +114,12 @@
     });
 
     test('prev button', () => {
+      assert.isTrue(element._hidePrevArrow(true, 0));
       flush(() => {
         let offset = 0;
-        assert.isTrue(element._hidePrevArrow(offset));
+        assert.isTrue(element._hidePrevArrow(false, offset));
         offset = 5;
-        assert.isFalse(element._hidePrevArrow(offset));
+        assert.isFalse(element._hidePrevArrow(false, offset));
       });
     });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index b5e7e40..9dc51ba 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -1410,9 +1410,10 @@
      * @param {boolean=} opt_isPrivate
      * @param {boolean=} opt_workInProgress
      * @param {string=} opt_baseChange
+     * @param {string=} opt_baseCommit
      */
     createChange(project, branch, subject, opt_topic, opt_isPrivate,
-        opt_workInProgress, opt_baseChange) {
+        opt_workInProgress, opt_baseChange, opt_baseCommit) {
       return this.send('POST', '/changes/', {
         project,
         branch,
@@ -1421,6 +1422,7 @@
         is_private: opt_isPrivate,
         work_in_progress: opt_workInProgress,
         base_change: opt_baseChange,
+        base_commit: opt_baseCommit,
       }).then(response => this.getResponseObject(response));
     },
 
diff --git a/polygerrit-ui/app/styles/gr-form-styles.html b/polygerrit-ui/app/styles/gr-form-styles.html
index 88c75c8..f4b367b 100644
--- a/polygerrit-ui/app/styles/gr-form-styles.html
+++ b/polygerrit-ui/app/styles/gr-form-styles.html
@@ -59,9 +59,6 @@
       .gr-form-styles .emptyHeader {
         text-align: right;
       }
-      .gr-form-styles tbody tr:nth-child(even):not(.loading) {
-        background-color: #f4f4f4;
-      }
       .gr-form-styles table {
         width: 50em;
       }
diff --git a/polygerrit-ui/app/styles/gr-page-nav-styles.html b/polygerrit-ui/app/styles/gr-page-nav-styles.html
index 6d62f23..6eee5a8 100644
--- a/polygerrit-ui/app/styles/gr-page-nav-styles.html
+++ b/polygerrit-ui/app/styles/gr-page-nav-styles.html
@@ -26,7 +26,7 @@
         display: block;
         padding: 0 2em;
       }
-      .navStyles li  a {
+      .navStyles li a {
         display: block;
         overflow: hidden;
         text-overflow: ellipsis;
@@ -50,8 +50,8 @@
       }
       .navStyles .selected {
         background-color: var(--view-background-color);
-        border-bottom: 1px dotted #808080;
-        border-top: 1px dotted #808080;
+        border-bottom: 1px solid var(--border-color);
+        border-top: 1px solid var(--border-color);
         font-family: var(--font-family-bold);
       }
       .navStyles a {
diff --git a/polygerrit-ui/app/styles/gr-table-styles.html b/polygerrit-ui/app/styles/gr-table-styles.html
index 2bcd743..5e40735 100644
--- a/polygerrit-ui/app/styles/gr-table-styles.html
+++ b/polygerrit-ui/app/styles/gr-table-styles.html
@@ -18,29 +18,64 @@
 <dom-module id="gr-table-styles">
   <template>
     <style>
-      .genericList .loading {
-        display: none;
-      }
       .genericList {
         border-collapse: collapse;
         width: 100%;
       }
-      .genericList tr.table {
+      .genericList td {
+        height: 2.25rem;
+        padding: .3rem 0;
+        vertical-align: middle;
+      }
+      .genericList tr {
         border-bottom: 1px solid var(--border-color);
       }
+      .genericList th {
+        white-space: nowrap;
+      }
+      .genericList th,
+      .genericList td {
+        padding-right: 1rem;
+      }
+      .genericList tr th:first-of-type,
+      .genericList tr td:first-of-type {
+        padding-left: 1rem;
+      }
+      .genericList tr:first-of-type {
+        border-top: 1px solid var(--border-color);
+      }
+      .genericList tr th:last-of-type,
+      .genericList tr td:last-of-type {
+        border-left: 1px solid var(--border-color);
+        text-align: center;
+        padding: 0 1em;
+      }
+      .genericList tr th.delete,
+      .genericList tr td.delete,
+      .genericList tr.loadingMsg td {
+        border-left: none;
+      }
+      .genericList .loading {
+        border: none;
+        display: none;
+      }
       .genericList td {
         flex-shrink: 0;
-        padding: .3em .5em;
       }
-      .genericList th {
-        background-color: var(--border-color);
-        border-bottom: 1px solid var(--border-color);
+      .genericList .topHeader,
+      .genericList .groupHeader {
+        color: var(--primary-text-color);
         font-family: var(--font-family-bold);
-        padding: .3em .5em;
         text-align: left;
+        vertical-align: middle
+      }
+      .genericList .topHeader {
+        background-color: var(--table-header-background-color);
+        font-size: var(--font-size-large);
+        height: 3rem;
       }
       .genericList .groupHeader {
-        background-color: #eee;
+        background-color: var(--table-subheader-background-color);
       }
       .genericList a {
         color: var(--primary-text-color);
@@ -50,12 +85,12 @@
         text-decoration: underline;
       }
       .genericList .description {
-        width: 70%;
+        width: 99%;
       }
       .genericList .loadingMsg {
         color: var(--deemphasized-text-color);
         display: block;
-        padding: 1em var(--default-horizontal-margin);
+        padding: .3em var(--default-horizontal-margin);
       }
       .genericList .loadingMsg:not(.loading) {
         display: none;
diff --git a/proto/BUILD b/proto/BUILD
new file mode 100644
index 0000000..4528dcb
--- /dev/null
+++ b/proto/BUILD
@@ -0,0 +1,10 @@
+proto_library(
+    name = "cache_proto",
+    srcs = ["cache.proto"],
+)
+
+java_proto_library(
+    name = "cache_java_proto",
+    visibility = ["//visibility:public"],
+    deps = [":cache_proto"],
+)
diff --git a/proto/cache.proto b/proto/cache.proto
new file mode 100644
index 0000000..4a84ab1
--- /dev/null
+++ b/proto/cache.proto
@@ -0,0 +1,27 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package gerrit.cache;
+
+option java_package = "com.google.gerrit.server.cache.proto";
+
+// Serialized form of com.google.gerrit.server.change.CHangeKindCacheImpl.Key.
+// Next ID: 4
+message ChangeKindKeyProto {
+  bytes prior = 1;
+  bytes next = 2;
+  string strategy_name = 3;
+}
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index f054fad..a6b0964 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -145,6 +145,7 @@
   doc = make_classpath()
   src = set()
   lib = set()
+  proto = set()
   gwt_src = set()
   gwt_lib = set()
   plugins = set()
@@ -170,6 +171,9 @@
       # JGit dependency from external repository
       if 'gerrit-' not in p and 'jgit' in p:
         lib.add(p)
+      # Assume any jars in /proto/ are from java_proto_library rules
+      if '/bin/proto/' in p:
+        proto.add(p)
     else:
       # Don't mess up with Bazel internal test runner dependencies.
       # When we use Eclipse we rely on it for running the tests
@@ -239,6 +243,11 @@
             continue
         classpathentry('lib', j, s)
 
+  for p in sorted(proto):
+    s = p.replace('-fastbuild/bin/proto/lib', '-fastbuild/genfiles/proto/')
+    s = s.replace('.jar', '-src.jar')
+    classpathentry('lib', p, s)
+
   for s in sorted(gwt_src):
     p = path.join(ROOT, s, 'src', 'main', 'java')
     if path.exists(p):