Merge "GetRelatedIT.getRelatedManyGroups: Remove unused commit list"
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index db6f883..ced4609 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -140,9 +140,6 @@
 * `notedb/stage_update_latency`: Latency for staging updates to NoteDb by table.
 * `notedb/read_latency`: NoteDb read latency by table.
 * `notedb/parse_latency`: NoteDb parse latency by table.
-* `notedb/auto_rebuild_latency`: NoteDb auto-rebuilding latency by table.
-* `notedb/auto_rebuild_failure_count`: NoteDb auto-rebuilding attempts that
-failed by table.
 * `notedb/external_id_update_count`: Total number of external ID updates.
 * `notedb/read_all_external_ids_latency`: Latency for reading all
 external ID's from NoteDb.
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index eae3e6b..6e3b100 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -112,9 +112,10 @@
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.index.group.GroupIndexer;
+import com.google.gerrit.server.notedb.AbstractChangeNotes;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.MutableNotesMigration;
+import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.testing.Util;
@@ -126,7 +127,6 @@
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gerrit.testing.FakeGroupAuditService;
-import com.google.gerrit.testing.NoteDbMode;
 import com.google.gerrit.testing.SshMode;
 import com.google.gson.Gson;
 import com.google.gwtorm.server.OrmException;
@@ -261,7 +261,7 @@
   @Inject protected PluginConfigFactory pluginConfig;
   @Inject protected Revisions revisions;
   @Inject protected SystemGroupBackend systemGroupBackend;
-  @Inject protected MutableNotesMigration notesMigration;
+  @Inject protected NotesMigration notesMigration;
   @Inject protected ChangeNotes.Factory notesFactory;
   @Inject protected BatchAbandon batchAbandon;
   @Inject protected TestSshKeys sshKeys;
@@ -293,6 +293,7 @@
   @Inject private AccountIndexer accountIndexer;
   @Inject private Groups groups;
   @Inject private GroupIndexer groupIndexer;
+  @Inject private AbstractChangeNotes.Args changeNotesArgs;
 
   private ProjectResetter resetter;
   private List<Repository> toClose;
@@ -596,7 +597,6 @@
       server.close();
       server = null;
     }
-    NoteDbMode.resetFromEnv(notesMigration);
   }
 
   protected void closeSsh() {
@@ -837,12 +837,12 @@
   }
 
   protected Context disableDb() {
-    notesMigration.setFailOnLoadForTest(true);
+    changeNotesArgs.failOnLoadForTest.set(true);
     return atrScope.disableDb();
   }
 
   protected void enableDb(Context preDisableContext) {
-    notesMigration.setFailOnLoadForTest(false);
+    changeNotesArgs.failOnLoadForTest.set(false);
     atrScope.set(preDisableContext);
   }
 
@@ -1184,11 +1184,6 @@
   }
 
   @Nullable
-  protected RevCommit getRemoteHead(String project, String branch) throws Exception {
-    return getRemoteHead(new Project.NameKey(project), branch);
-  }
-
-  @Nullable
   protected RevCommit getRemoteHead() throws Exception {
     return getRemoteHead(project, "master");
   }
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 4788ab7..077dd65 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -45,7 +45,6 @@
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gerrit.testing.FakeGroupAuditService;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
-import com.google.gerrit.testing.NoteDbMode;
 import com.google.gerrit.testing.SshMode;
 import com.google.inject.AbstractModule;
 import com.google.inject.Injector;
@@ -482,8 +481,6 @@
     cfg.setInt("receive", null, "threadPoolSize", 1);
     cfg.setInt("index", null, "threads", 1);
     cfg.setBoolean("index", null, "reindexAfterRefUpdate", false);
-
-    NoteDbMode.newNotesMigrationFromEnv().setConfigValues(cfg);
   }
 
   private static Injector createTestInjector(Daemon daemon) throws Exception {
diff --git a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
index e15d162..9c4b6b7 100644
--- a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
+++ b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.config.TrackingFootersProvider;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.schema.NotesMigrationSchemaFactory;
 import com.google.gerrit.server.schema.ReviewDbFactory;
 import com.google.gerrit.server.schema.ReviewDbSchemaModule;
@@ -75,7 +74,6 @@
 
     bind(MetricMaker.class).to(DisabledMetricMaker.class);
 
-    install(new NotesMigration.Module());
     TypeLiteral<SchemaFactory<ReviewDb>> schemaFactory =
         new TypeLiteral<SchemaFactory<ReviewDb>>() {};
     bind(schemaFactory).to(NotesMigrationSchemaFactory.class);
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index d0735c8..99e07ea 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -141,7 +141,6 @@
   private final ChangeNotes.Factory notesFactory;
   private final ApprovalsUtil approvalsUtil;
   private final Provider<InternalChangeQuery> queryProvider;
-  private final ReviewDb db;
   private final TestRepository<?> testRepo;
 
   private final String subject;
@@ -265,14 +264,14 @@
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       Provider<InternalChangeQuery> queryProvider,
-      ReviewDb db,
+      // TODO(ekempin): Remove unused ReviewDb
+      @SuppressWarnings("unused") ReviewDb db,
       PersonIdent i,
       TestRepository<?> testRepo,
       String subject,
       Map<String, String> files,
       String changeId)
       throws Exception {
-    this.db = db;
     this.testRepo = testRepo;
     this.notesFactory = notesFactory;
     this.approvalsUtil = approvalsUtil;
@@ -419,7 +418,7 @@
         Change c, ReviewerStateInternal state, List<TestAccount> expectedReviewers)
         throws OrmException {
       Iterable<Account.Id> actualIds =
-          approvalsUtil.getReviewers(notesFactory.createChecked(db, c)).byState(state);
+          approvalsUtil.getReviewers(notesFactory.createChecked(c)).byState(state);
       assertThat(actualIds)
           .containsExactlyElementsIn(Sets.newHashSet(TestAccount.ids(expectedReviewers)));
     }
diff --git a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
index 3b589b2..cbfd1ca 100644
--- a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
+++ b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
@@ -15,15 +15,15 @@
 package com.google.gerrit.elasticsearch;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gson.FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.apache.commons.codec.binary.Base64.decodeBase64;
 
 import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.CharStreams;
 import com.google.gerrit.common.Nullable;
@@ -40,6 +40,8 @@
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.reviewdb.converter.ProtoConverter;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gson.Gson;
@@ -48,9 +50,9 @@
 import com.google.gson.JsonElement;
 import com.google.gson.JsonObject;
 import com.google.gson.JsonParser;
-import com.google.gwtorm.protobuf.ProtobufCodec;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
+import com.google.protobuf.MessageLite;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
@@ -85,14 +87,22 @@
   protected static final String SETTINGS = "settings";
 
   protected static <T> List<T> decodeProtos(
-      JsonObject doc, String fieldName, ProtobufCodec<T> codec) {
+      JsonObject doc, String fieldName, ProtoConverter<?, T> converter) {
     JsonArray field = doc.getAsJsonArray(fieldName);
     if (field == null) {
       return null;
     }
-    return FluentIterable.from(field)
-        .transform(i -> codec.decode(decodeBase64(i.toString())))
-        .toList();
+    return Streams.stream(field)
+        .map(JsonElement::toString)
+        .map(Base64::decodeBase64)
+        .map(bytes -> parseProtoFrom(bytes, converter))
+        .collect(toImmutableList());
+  }
+
+  private static <P extends MessageLite, T> T parseProtoFrom(
+      byte[] bytes, ProtoConverter<P, T> converter) {
+    P message = Protos.parseUnchecked(converter.getParser(), bytes);
+    return converter.fromProto(message);
   }
 
   static String getContent(Response response) throws IOException {
diff --git a/java/com/google/gerrit/elasticsearch/BUILD b/java/com/google/gerrit/elasticsearch/BUILD
index 8d23051..d5c586b 100644
--- a/java/com/google/gerrit/elasticsearch/BUILD
+++ b/java/com/google/gerrit/elasticsearch/BUILD
@@ -9,6 +9,7 @@
         "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//lib:gson",
diff --git a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
index 1224c61..9aa65a7 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -14,9 +14,7 @@
 
 package com.google.gerrit.elasticsearch;
 
-import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.APPROVAL_CODEC;
 import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.CHANGE_CODEC;
-import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.PATCH_SET_CODEC;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -45,6 +43,8 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Id;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.converter.PatchSetApprovalProtoConverter;
+import com.google.gerrit.reviewdb.converter.PatchSetProtoConverter;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
@@ -230,11 +230,14 @@
     // Any decoding that is done here must also be done in {@link LuceneChangeIndex}.
 
     // Patch sets.
-    cd.setPatchSets(decodeProtos(source, ChangeField.PATCH_SET.getName(), PATCH_SET_CODEC));
+    cd.setPatchSets(
+        decodeProtos(source, ChangeField.PATCH_SET.getName(), PatchSetProtoConverter.INSTANCE));
 
     // Approvals.
     if (source.get(ChangeField.APPROVAL.getName()) != null) {
-      cd.setCurrentApprovals(decodeProtos(source, ChangeField.APPROVAL.getName(), APPROVAL_CODEC));
+      cd.setCurrentApprovals(
+          decodeProtos(
+              source, ChangeField.APPROVAL.getName(), PatchSetApprovalProtoConverter.INSTANCE));
     } else if (fields.contains(ChangeField.APPROVAL.getName())) {
       cd.setCurrentApprovals(Collections.emptyList());
     }
diff --git a/java/com/google/gerrit/extensions/api/groups/GroupApi.java b/java/com/google/gerrit/extensions/api/groups/GroupApi.java
index fe85eaa..067f120 100644
--- a/java/com/google/gerrit/extensions/api/groups/GroupApi.java
+++ b/java/com/google/gerrit/extensions/api/groups/GroupApi.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.common.GroupOptionsInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.Arrays;
 import java.util.List;
 
 public interface GroupApi {
@@ -97,7 +98,18 @@
    *     com.google.gerrit.extensions.api.accounts.Accounts#id(String)}
    * @throws RestApiException
    */
-  void addMembers(String... members) throws RestApiException;
+  void addMembers(List<String> members) throws RestApiException;
+
+  /**
+   * Add members to a group.
+   *
+   * @param members list of member identifiers, in any format accepted by {@link
+   *     com.google.gerrit.extensions.api.accounts.Accounts#id(String)}
+   * @throws RestApiException
+   */
+  default void addMembers(String... members) throws RestApiException {
+    addMembers(Arrays.asList(members));
+  }
 
   /**
    * Remove members from a group.
@@ -106,7 +118,18 @@
    *     com.google.gerrit.extensions.api.accounts.Accounts#id(String)}
    * @throws RestApiException
    */
-  void removeMembers(String... members) throws RestApiException;
+  void removeMembers(List<String> members) throws RestApiException;
+
+  /**
+   * Remove members from a group.
+   *
+   * @param members list of member identifiers, in any format accepted by {@link
+   *     com.google.gerrit.extensions.api.accounts.Accounts#id(String)}
+   * @throws RestApiException
+   */
+  default void removeMembers(String... members) throws RestApiException {
+    removeMembers(Arrays.asList(members));
+  }
 
   /**
    * Lists the subgroups of this group.
@@ -122,7 +145,17 @@
    * @param groups list of group identifiers, in any format accepted by {@link Groups#id(String)}
    * @throws RestApiException
    */
-  void addGroups(String... groups) throws RestApiException;
+  void addGroups(List<String> groups) throws RestApiException;
+
+  /**
+   * Adds subgroups to this group.
+   *
+   * @param groups list of group identifiers, in any format accepted by {@link Groups#id(String)}
+   * @throws RestApiException
+   */
+  default void addGroups(String... groups) throws RestApiException {
+    addGroups(Arrays.asList(groups));
+  }
 
   /**
    * Removes subgroups from this group.
@@ -130,7 +163,17 @@
    * @param groups list of group identifiers, in any format accepted by {@link Groups#id(String)}
    * @throws RestApiException
    */
-  void removeGroups(String... groups) throws RestApiException;
+  void removeGroups(List<String> groups) throws RestApiException;
+
+  /**
+   * Removes subgroups from this group.
+   *
+   * @param groups list of group identifiers, in any format accepted by {@link Groups#id(String)}
+   * @throws RestApiException
+   */
+  default void removeGroups(String... groups) throws RestApiException {
+    removeGroups(Arrays.asList(groups));
+  }
 
   /**
    * Returns the audit log of the group.
@@ -215,12 +258,12 @@
     }
 
     @Override
-    public void addMembers(String... members) throws RestApiException {
+    public void addMembers(List<String> members) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public void removeMembers(String... members) throws RestApiException {
+    public void removeMembers(List<String> members) throws RestApiException {
       throw new NotImplementedException();
     }
 
@@ -230,12 +273,12 @@
     }
 
     @Override
-    public void addGroups(String... groups) throws RestApiException {
+    public void addGroups(List<String> groups) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public void removeGroups(String... groups) throws RestApiException {
+    public void removeGroups(List<String> groups) throws RestApiException {
       throw new NotImplementedException();
     }
 
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 8b113b2..9f6049e 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -78,7 +78,6 @@
 import com.google.gerrit.server.mail.receive.MailReceiver;
 import com.google.gerrit.server.mail.send.SmtpEmailSender;
 import com.google.gerrit.server.mime.MimeUtil2Module;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.permissions.DefaultPermissionBackendModule;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
@@ -277,7 +276,6 @@
       modules.add(new GerritServerConfigModule());
     }
     modules.add(new DatabaseModule());
-    modules.add(new NotesMigration.Module());
     modules.add(new DropWizardMetricMaker.ApiModule());
     return Guice.createInjector(PRODUCTION, modules);
   }
diff --git a/java/com/google/gerrit/lucene/BUILD b/java/com/google/gerrit/lucene/BUILD
index 9c6ba74..3b18f2c 100644
--- a/java/com/google/gerrit/lucene/BUILD
+++ b/java/com/google/gerrit/lucene/BUILD
@@ -31,11 +31,13 @@
         "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/logging",
         "//lib:guava",
         "//lib:gwtorm",
+        "//lib:protobuf",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index b208a31..51e95ed 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -14,10 +14,9 @@
 
 package com.google.gerrit.lucene;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
-import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.APPROVAL_CODEC;
 import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.CHANGE_CODEC;
-import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.PATCH_SET_CODEC;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
@@ -42,10 +41,14 @@
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.converter.PatchSetApprovalProtoConverter;
+import com.google.gerrit.reviewdb.converter.PatchSetProtoConverter;
+import com.google.gerrit.reviewdb.converter.ProtoConverter;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -58,18 +61,17 @@
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
-import com.google.gwtorm.protobuf.ProtobufCodec;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.OrmRuntimeException;
 import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
+import com.google.protobuf.MessageLite;
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Set;
@@ -511,7 +513,7 @@
   }
 
   private void decodePatchSets(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    List<PatchSet> patchSets = decodeProtos(doc, PATCH_SET_FIELD, PATCH_SET_CODEC);
+    List<PatchSet> patchSets = decodeProtos(doc, PATCH_SET_FIELD, PatchSetProtoConverter.INSTANCE);
     if (!patchSets.isEmpty()) {
       // Will be an empty list for schemas prior to when this field was stored;
       // this cannot be valid since a change needs at least one patch set.
@@ -520,7 +522,8 @@
   }
 
   private void decodeApprovals(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setCurrentApprovals(decodeProtos(doc, APPROVAL_FIELD, APPROVAL_CODEC));
+    cd.setCurrentApprovals(
+        decodeProtos(doc, APPROVAL_FIELD, PatchSetApprovalProtoConverter.INSTANCE));
   }
 
   private void decodeChangedLines(ListMultimap<String, IndexableField> doc, ChangeData cd) {
@@ -652,18 +655,20 @@
   }
 
   private static <T> List<T> decodeProtos(
-      ListMultimap<String, IndexableField> doc, String fieldName, ProtobufCodec<T> codec) {
-    Collection<IndexableField> fields = doc.get(fieldName);
-    if (fields.isEmpty()) {
-      return Collections.emptyList();
-    }
+      ListMultimap<String, IndexableField> doc, String fieldName, ProtoConverter<?, T> converter) {
+    return doc.get(fieldName)
+        .stream()
+        .map(IndexableField::binaryValue)
+        .map(bytesRef -> parseProtoFrom(bytesRef, converter))
+        .collect(toImmutableList());
+  }
 
-    List<T> result = new ArrayList<>(fields.size());
-    for (IndexableField f : fields) {
-      BytesRef r = f.binaryValue();
-      result.add(codec.decode(r.bytes, r.offset, r.length));
-    }
-    return result;
+  private static <P extends MessageLite, T> T parseProtoFrom(
+      BytesRef bytesRef, ProtoConverter<P, T> converter) {
+    P message =
+        Protos.parseUnchecked(
+            converter.getParser(), bytesRef.bytes, bytesRef.offset, bytesRef.length);
+    return converter.fromProto(message);
   }
 
   private static List<byte[]> copyAsBytes(Collection<IndexableField> fields) {
diff --git a/java/com/google/gerrit/pgm/init/InitModule.java b/java/com/google/gerrit/pgm/init/InitModule.java
index 75145de..f2fc001 100644
--- a/java/com/google/gerrit/pgm/init/InitModule.java
+++ b/java/com/google/gerrit/pgm/init/InitModule.java
@@ -42,7 +42,6 @@
     // Steps are executed in the order listed here.
     //
     step().to(InitGitManager.class);
-    step().to(InitNoteDb.class);
     step().to(InitLogging.class);
     step().to(InitIndex.class);
     step().to(InitAuth.class);
diff --git a/java/com/google/gerrit/pgm/init/InitNoteDb.java b/java/com/google/gerrit/pgm/init/InitNoteDb.java
deleted file mode 100644
index dc52868..0000000
--- a/java/com/google/gerrit/pgm/init/InitNoteDb.java
+++ /dev/null
@@ -1,50 +0,0 @@
-// 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.pgm.init;
-
-import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
-import static com.google.gerrit.server.notedb.NotesMigration.SECTION_NOTE_DB;
-
-import com.google.gerrit.pgm.init.api.InitStep;
-import com.google.gerrit.pgm.init.api.Section;
-import com.google.gerrit.server.notedb.NotesMigrationState;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import org.eclipse.jgit.lib.Config;
-
-/** Initialize the NoteDb in gerrit site. */
-@Singleton
-class InitNoteDb implements InitStep {
-
-  private final Section noteDbChanges;
-
-  @Inject
-  InitNoteDb(Section.Factory sections) {
-    this.noteDbChanges = sections.get(SECTION_NOTE_DB, CHANGES.key());
-  }
-
-  @Override
-  public void run() {
-    initNoteDb();
-  }
-
-  private void initNoteDb() {
-    Config defaultConfig = new Config();
-    NotesMigrationState.FINAL.setConfigValues(defaultConfig);
-    for (String name : defaultConfig.getNames(SECTION_NOTE_DB, CHANGES.key())) {
-      noteDbChanges.set(name, defaultConfig.getString(SECTION_NOTE_DB, CHANGES.key(), name));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/pgm/util/SiteProgram.java b/java/com/google/gerrit/pgm/util/SiteProgram.java
index e273c02..a889277f 100644
--- a/java/com/google/gerrit/pgm/util/SiteProgram.java
+++ b/java/com/google/gerrit/pgm/util/SiteProgram.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.git.GitRepositoryManagerModule;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.schema.DatabaseModule;
 import com.google.gerrit.server.schema.ReviewDbSchemaModule;
 import com.google.gerrit.server.securestore.SecureStoreClassName;
@@ -123,7 +122,6 @@
     modules.add(new DatabaseModule());
     modules.add(new ReviewDbSchemaModule());
     modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
-    modules.add(new NotesMigration.Module());
 
     try {
       return Guice.createInjector(PRODUCTION, modules);
diff --git a/java/com/google/gerrit/proto/BUILD b/java/com/google/gerrit/proto/BUILD
index 48185d6..b831e92 100644
--- a/java/com/google/gerrit/proto/BUILD
+++ b/java/com/google/gerrit/proto/BUILD
@@ -12,3 +12,13 @@
         "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
+
+java_library(
+    name = "proto",
+    srcs = ["Protos.java"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//lib:gwtorm",
+        "//lib:protobuf",
+    ],
+)
diff --git a/java/com/google/gerrit/proto/Protos.java b/java/com/google/gerrit/proto/Protos.java
new file mode 100644
index 0000000..f8c63a3
--- /dev/null
+++ b/java/com/google/gerrit/proto/Protos.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.proto;
+
+import com.google.gwtorm.protobuf.ProtobufCodec;
+import com.google.protobuf.ByteString;
+import com.google.protobuf.CodedOutputStream;
+import com.google.protobuf.MessageLite;
+import com.google.protobuf.Parser;
+import java.io.IOException;
+
+/** Static utilities for dealing with protobuf-based objects. */
+public class Protos {
+  /**
+   * Serializes a proto to a byte array.
+   *
+   * <p>Guarantees deterministic serialization. No matter whether the use case cares about
+   * determinism or not, always use this method 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", e);
+    }
+  }
+
+  /**
+   * Serializes a proto to a {@code ByteString}.
+   *
+   * <p>Guarantees deterministic serialization. No matter whether the use case cares about
+   * determinism or not, always use this method in preference to {@link MessageLite#toByteString()},
+   * which is not guaranteed deterministic.
+   *
+   * @param message the proto message to serialize
+   * @return a {@code ByteString} with the message contents
+   */
+  public static ByteString toByteString(MessageLite message) {
+    try (ByteString.Output bout = ByteString.newOutput(message.getSerializedSize())) {
+      CodedOutputStream outputStream = CodedOutputStream.newInstance(bout);
+      outputStream.useDeterministicSerialization();
+      message.writeTo(outputStream);
+      outputStream.flush();
+      return bout.toByteString();
+    } catch (IOException e) {
+      throw new IllegalStateException("exception writing to ByteString", e);
+    }
+  }
+
+  /**
+   * Serializes an object to a {@link ByteString} using a protobuf codec.
+   *
+   * <p>Guarantees deterministic serialization. No matter whether the use case cares about
+   * determinism or not, always use this method in preference to {@link
+   * ProtobufCodec#encodeToByteString(Object)}, which is not guaranteed deterministic.
+   *
+   * @param object the object to serialize.
+   * @param codec codec for serializing.
+   * @return a {@code ByteString} with the message contents.
+   */
+  public static <T> ByteString toByteString(T object, ProtobufCodec<T> codec) {
+    try (ByteString.Output bout = ByteString.newOutput()) {
+      CodedOutputStream cout = CodedOutputStream.newInstance(bout);
+      cout.useDeterministicSerialization();
+      codec.encode(object, cout);
+      cout.flush();
+      return bout.toByteString();
+    } catch (IOException e) {
+      throw new IllegalStateException("exception writing to ByteString", e);
+    }
+  }
+
+  /**
+   * Parses a byte array to a protobuf message.
+   *
+   * @param parser parser for the proto type.
+   * @param in byte array with the message contents.
+   * @return parsed proto.
+   */
+  public static <M extends MessageLite> M parseUnchecked(Parser<M> parser, byte[] in) {
+    try {
+      return parser.parseFrom(in);
+    } catch (IOException e) {
+      throw new IllegalArgumentException("exception parsing byte array to proto", e);
+    }
+  }
+
+  /**
+   * Parses a specific segment of a byte array to a protobuf message.
+   *
+   * @param parser parser for the proto type
+   * @param in byte array with the message contents
+   * @param offset offset in the byte array to start reading from
+   * @param length amount of read bytes
+   * @return parsed proto
+   */
+  public static <M extends MessageLite> M parseUnchecked(
+      Parser<M> parser, byte[] in, int offset, int length) {
+    try {
+      return parser.parseFrom(in, offset, length);
+    } catch (IOException e) {
+      throw new IllegalArgumentException("exception parsing byte array to proto", e);
+    }
+  }
+
+  /**
+   * Parses a {@code ByteString} to a protobuf message.
+   *
+   * @param parser parser for the proto type
+   * @param byteString {@code ByteString} with the message contents
+   * @return parsed proto
+   */
+  public static <M extends MessageLite> M parseUnchecked(Parser<M> parser, ByteString byteString) {
+    try {
+      return parser.parseFrom(byteString);
+    } catch (IOException e) {
+      throw new IllegalArgumentException("exception parsing ByteString to proto", e);
+    }
+  }
+
+  private Protos() {}
+}
diff --git a/java/com/google/gerrit/proto/testing/BUILD b/java/com/google/gerrit/proto/testing/BUILD
new file mode 100644
index 0000000..5f64c85
--- /dev/null
+++ b/java/com/google/gerrit/proto/testing/BUILD
@@ -0,0 +1,13 @@
+package(default_testonly = 1)
+
+java_library(
+    name = "testing",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/server/cache/serialize",
+        "//lib:guava",
+        "//lib/commons:lang3",
+        "//lib/truth",
+    ],
+)
diff --git a/java/com/google/gerrit/server/cache/testing/SerializedClassSubject.java b/java/com/google/gerrit/proto/testing/SerializedClassSubject.java
similarity index 90%
rename from java/com/google/gerrit/server/cache/testing/SerializedClassSubject.java
rename to java/com/google/gerrit/proto/testing/SerializedClassSubject.java
index b902c1c..9ca6c9b 100644
--- a/java/com/google/gerrit/server/cache/testing/SerializedClassSubject.java
+++ b/java/com/google/gerrit/proto/testing/SerializedClassSubject.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache.testing;
+package com.google.gerrit.proto.testing;
 
 import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.common.truth.Truth.assertAbout;
@@ -30,13 +30,14 @@
 import org.apache.commons.lang3.reflect.FieldUtils;
 
 /**
- * Subject about classes that are serialized into persistent caches.
+ * Subject about classes that are serialized into persistent caches or indices.
  *
  * <p>Hand-written {@link com.google.gerrit.server.cache.serialize.CacheSerializer CacheSerializer}
- * implementations depend on the exact representation of the data stored in a class, so it is
- * important to verify any assumptions about the structure of the serialized classes. This class
- * contains assertions about serialized classes, and should be used for every class that has a
- * custom serializer implementation.
+ * and {@link com.google.gerrit.reviewdb.converter.ProtoConverter ProtoConverter} implementations
+ * depend on the exact representation of the data stored in a class, so it is important to verify
+ * any assumptions about the structure of the serialized classes. This class contains assertions
+ * about serialized classes, and should be used for every class that has a custom serializer
+ * implementation.
  *
  * <p>Changing fields of a serialized class (or abstract methods, in the case of {@code @AutoValue}
  * classes) will likely require changes to the serializer implementation, and may require bumping
diff --git a/java/com/google/gerrit/reviewdb/BUILD b/java/com/google/gerrit/reviewdb/BUILD
index 76e35a1..9afa258 100644
--- a/java/com/google/gerrit/reviewdb/BUILD
+++ b/java/com/google/gerrit/reviewdb/BUILD
@@ -10,5 +10,7 @@
         "//java/com/google/gerrit/extensions:api",
         "//lib:guava",
         "//lib:gwtorm",
+        "//lib:protobuf",
+        "//proto:reviewdb_java_proto",
     ],
 )
diff --git a/java/com/google/gerrit/reviewdb/client/PatchSet.java b/java/com/google/gerrit/reviewdb/client/PatchSet.java
index 849fd75..a249a12 100644
--- a/java/com/google/gerrit/reviewdb/client/PatchSet.java
+++ b/java/com/google/gerrit/reviewdb/client/PatchSet.java
@@ -39,7 +39,7 @@
     return isChangeRef(name);
   }
 
-  static String joinGroups(List<String> groups) {
+  public static String joinGroups(List<String> groups) {
     if (groups == null) {
       throw new IllegalArgumentException("groups may not be null");
     }
diff --git a/java/com/google/gerrit/reviewdb/converter/AccountIdProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/AccountIdProtoConverter.java
new file mode 100644
index 0000000..4209d10
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/AccountIdProtoConverter.java
@@ -0,0 +1,38 @@
+// 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.reviewdb.converter;
+
+import com.google.gerrit.proto.reviewdb.Reviewdb;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.protobuf.Parser;
+
+public enum AccountIdProtoConverter implements ProtoConverter<Reviewdb.Account_Id, Account.Id> {
+  INSTANCE;
+
+  @Override
+  public Reviewdb.Account_Id toProto(Account.Id accountId) {
+    return Reviewdb.Account_Id.newBuilder().setId(accountId.get()).build();
+  }
+
+  @Override
+  public Account.Id fromProto(Reviewdb.Account_Id proto) {
+    return new Account.Id(proto.getId());
+  }
+
+  @Override
+  public Parser<Reviewdb.Account_Id> getParser() {
+    return Reviewdb.Account_Id.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverter.java
new file mode 100644
index 0000000..6eb3359
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverter.java
@@ -0,0 +1,38 @@
+// 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.reviewdb.converter;
+
+import com.google.gerrit.proto.reviewdb.Reviewdb;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.protobuf.Parser;
+
+public enum ChangeIdProtoConverter implements ProtoConverter<Reviewdb.Change_Id, Change.Id> {
+  INSTANCE;
+
+  @Override
+  public Reviewdb.Change_Id toProto(Change.Id changeId) {
+    return Reviewdb.Change_Id.newBuilder().setId(changeId.get()).build();
+  }
+
+  @Override
+  public Change.Id fromProto(Reviewdb.Change_Id proto) {
+    return new Change.Id(proto.getId());
+  }
+
+  @Override
+  public Parser<Reviewdb.Change_Id> getParser() {
+    return Reviewdb.Change_Id.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverter.java
new file mode 100644
index 0000000..f8667f1
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverter.java
@@ -0,0 +1,46 @@
+// 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.reviewdb.converter;
+
+import com.google.gerrit.proto.reviewdb.Reviewdb;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.protobuf.Parser;
+
+public enum ChangeMessageKeyProtoConverter
+    implements ProtoConverter<Reviewdb.ChangeMessage_Key, ChangeMessage.Key> {
+  INSTANCE;
+
+  private final ProtoConverter<Reviewdb.Change_Id, Change.Id> changeIdConverter =
+      ChangeIdProtoConverter.INSTANCE;
+
+  @Override
+  public Reviewdb.ChangeMessage_Key toProto(ChangeMessage.Key messageKey) {
+    return Reviewdb.ChangeMessage_Key.newBuilder()
+        .setChangeId(changeIdConverter.toProto(messageKey.getParentKey()))
+        .setUuid(messageKey.get())
+        .build();
+  }
+
+  @Override
+  public ChangeMessage.Key fromProto(Reviewdb.ChangeMessage_Key proto) {
+    return new ChangeMessage.Key(changeIdConverter.fromProto(proto.getChangeId()), proto.getUuid());
+  }
+
+  @Override
+  public Parser<Reviewdb.ChangeMessage_Key> getParser() {
+    return Reviewdb.ChangeMessage_Key.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverter.java
new file mode 100644
index 0000000..99d9ca7
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverter.java
@@ -0,0 +1,97 @@
+// 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.reviewdb.converter;
+
+import com.google.gerrit.proto.reviewdb.Reviewdb;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.protobuf.Parser;
+import java.sql.Timestamp;
+import java.util.Objects;
+
+public enum ChangeMessageProtoConverter
+    implements ProtoConverter<Reviewdb.ChangeMessage, ChangeMessage> {
+  INSTANCE;
+
+  private final ProtoConverter<Reviewdb.ChangeMessage_Key, ChangeMessage.Key>
+      changeMessageKeyConverter = ChangeMessageKeyProtoConverter.INSTANCE;
+  private final ProtoConverter<Reviewdb.Account_Id, Account.Id> accountIdConverter =
+      AccountIdProtoConverter.INSTANCE;
+  private final ProtoConverter<Reviewdb.PatchSet_Id, PatchSet.Id> patchSetIdConverter =
+      PatchSetIdProtoConverter.INSTANCE;
+
+  @Override
+  public Reviewdb.ChangeMessage toProto(ChangeMessage changeMessage) {
+    Reviewdb.ChangeMessage.Builder builder =
+        Reviewdb.ChangeMessage.newBuilder()
+            .setKey(changeMessageKeyConverter.toProto(changeMessage.getKey()));
+    Account.Id author = changeMessage.getAuthor();
+    if (author != null) {
+      builder.setAuthorId(accountIdConverter.toProto(author));
+    }
+    Timestamp writtenOn = changeMessage.getWrittenOn();
+    if (writtenOn != null) {
+      builder.setWrittenOn(writtenOn.getTime());
+    }
+    String message = changeMessage.getMessage();
+    if (message != null) {
+      builder.setMessage(message);
+    }
+    PatchSet.Id patchSetId = changeMessage.getPatchSetId();
+    if (patchSetId != null) {
+      builder.setPatchset(patchSetIdConverter.toProto(patchSetId));
+    }
+    String tag = changeMessage.getTag();
+    if (tag != null) {
+      builder.setTag(tag);
+    }
+    Account.Id realAuthor = changeMessage.getRealAuthor();
+    // ChangeMessage#getRealAuthor automatically delegates to ChangeMessage#getAuthor if the real
+    // author is not set. However, the previous protobuf representation kept 'realAuthor' empty if
+    // it wasn't set. To ensure binary compatibility, simulate the previous behavior.
+    if (realAuthor != null && !Objects.equals(realAuthor, author)) {
+      builder.setRealAuthor(accountIdConverter.toProto(realAuthor));
+    }
+    return builder.build();
+  }
+
+  @Override
+  public ChangeMessage fromProto(Reviewdb.ChangeMessage proto) {
+    ChangeMessage.Key key =
+        proto.hasKey() ? changeMessageKeyConverter.fromProto(proto.getKey()) : null;
+    Account.Id author =
+        proto.hasAuthorId() ? accountIdConverter.fromProto(proto.getAuthorId()) : null;
+    Timestamp writtenOn = proto.hasWrittenOn() ? new Timestamp(proto.getWrittenOn()) : null;
+    PatchSet.Id patchSetId =
+        proto.hasPatchset() ? patchSetIdConverter.fromProto(proto.getPatchset()) : null;
+    ChangeMessage changeMessage = new ChangeMessage(key, author, writtenOn, patchSetId);
+    if (proto.hasMessage()) {
+      changeMessage.setMessage(proto.getMessage());
+    }
+    if (proto.hasTag()) {
+      changeMessage.setTag(proto.getTag());
+    }
+    if (proto.hasRealAuthor()) {
+      changeMessage.setRealAuthor(accountIdConverter.fromProto(proto.getRealAuthor()));
+    }
+    return changeMessage;
+  }
+
+  @Override
+  public Parser<Reviewdb.ChangeMessage> getParser() {
+    return Reviewdb.ChangeMessage.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/LabelIdProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/LabelIdProtoConverter.java
new file mode 100644
index 0000000..7f71f56
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/LabelIdProtoConverter.java
@@ -0,0 +1,38 @@
+// 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.reviewdb.converter;
+
+import com.google.gerrit.proto.reviewdb.Reviewdb;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.protobuf.Parser;
+
+public enum LabelIdProtoConverter implements ProtoConverter<Reviewdb.LabelId, LabelId> {
+  INSTANCE;
+
+  @Override
+  public Reviewdb.LabelId toProto(LabelId labelId) {
+    return Reviewdb.LabelId.newBuilder().setId(labelId.get()).build();
+  }
+
+  @Override
+  public LabelId fromProto(Reviewdb.LabelId proto) {
+    return new LabelId(proto.getId());
+  }
+
+  @Override
+  public Parser<Reviewdb.LabelId> getParser() {
+    return Reviewdb.LabelId.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverter.java
new file mode 100644
index 0000000..a37ddf7
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverter.java
@@ -0,0 +1,56 @@
+// 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.reviewdb.converter;
+
+import com.google.gerrit.proto.reviewdb.Reviewdb;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.protobuf.Parser;
+
+public enum PatchSetApprovalKeyProtoConverter
+    implements ProtoConverter<Reviewdb.PatchSetApproval_Key, PatchSetApproval.Key> {
+  INSTANCE;
+
+  private final ProtoConverter<Reviewdb.PatchSet_Id, PatchSet.Id> patchSetIdConverter =
+      PatchSetIdProtoConverter.INSTANCE;
+  private final ProtoConverter<Reviewdb.Account_Id, Account.Id> accountIdConverter =
+      AccountIdProtoConverter.INSTANCE;
+  private final ProtoConverter<Reviewdb.LabelId, LabelId> labelIdConverter =
+      LabelIdProtoConverter.INSTANCE;
+
+  @Override
+  public Reviewdb.PatchSetApproval_Key toProto(PatchSetApproval.Key key) {
+    return Reviewdb.PatchSetApproval_Key.newBuilder()
+        .setPatchSetId(patchSetIdConverter.toProto(key.getParentKey()))
+        .setAccountId(accountIdConverter.toProto(key.getAccountId()))
+        .setCategoryId(labelIdConverter.toProto(key.getLabelId()))
+        .build();
+  }
+
+  @Override
+  public PatchSetApproval.Key fromProto(Reviewdb.PatchSetApproval_Key proto) {
+    return new PatchSetApproval.Key(
+        patchSetIdConverter.fromProto(proto.getPatchSetId()),
+        accountIdConverter.fromProto(proto.getAccountId()),
+        labelIdConverter.fromProto(proto.getCategoryId()));
+  }
+
+  @Override
+  public Parser<Reviewdb.PatchSetApproval_Key> getParser() {
+    return Reviewdb.PatchSetApproval_Key.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverter.java
new file mode 100644
index 0000000..fbaff10
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverter.java
@@ -0,0 +1,81 @@
+// 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.reviewdb.converter;
+
+import com.google.gerrit.proto.reviewdb.Reviewdb;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.protobuf.Parser;
+import java.sql.Timestamp;
+import java.util.Objects;
+
+public enum PatchSetApprovalProtoConverter
+    implements ProtoConverter<Reviewdb.PatchSetApproval, PatchSetApproval> {
+  INSTANCE;
+
+  private final ProtoConverter<Reviewdb.PatchSetApproval_Key, PatchSetApproval.Key>
+      patchSetApprovalKeyProtoConverter = PatchSetApprovalKeyProtoConverter.INSTANCE;
+  private final ProtoConverter<Reviewdb.Account_Id, Account.Id> accountIdConverter =
+      AccountIdProtoConverter.INSTANCE;
+
+  @Override
+  public Reviewdb.PatchSetApproval toProto(PatchSetApproval patchSetApproval) {
+    Reviewdb.PatchSetApproval.Builder builder =
+        Reviewdb.PatchSetApproval.newBuilder()
+            .setKey(patchSetApprovalKeyProtoConverter.toProto(patchSetApproval.getKey()))
+            .setValue(patchSetApproval.getValue())
+            .setGranted(patchSetApproval.getGranted().getTime())
+            .setPostSubmit(patchSetApproval.isPostSubmit());
+
+    String tag = patchSetApproval.getTag();
+    if (tag != null) {
+      builder.setTag(tag);
+    }
+    Account.Id realAccountId = patchSetApproval.getRealAccountId();
+    // PatchSetApproval#getRealAccountId automatically delegates to PatchSetApproval#getAccountId if
+    // the real author is not set. However, the previous protobuf representation kept
+    // 'realAccountId' empty if it wasn't set. To ensure binary compatibility, simulate the previous
+    // behavior.
+    if (realAccountId != null && !Objects.equals(realAccountId, patchSetApproval.getAccountId())) {
+      builder.setRealAccountId(accountIdConverter.toProto(realAccountId));
+    }
+
+    return builder.build();
+  }
+
+  @Override
+  public PatchSetApproval fromProto(Reviewdb.PatchSetApproval proto) {
+    PatchSetApproval patchSetApproval =
+        new PatchSetApproval(
+            patchSetApprovalKeyProtoConverter.fromProto(proto.getKey()),
+            (short) proto.getValue(),
+            new Timestamp(proto.getGranted()));
+    if (proto.hasTag()) {
+      patchSetApproval.setTag(proto.getTag());
+    }
+    if (proto.hasRealAccountId()) {
+      patchSetApproval.setRealAccountId(accountIdConverter.fromProto(proto.getRealAccountId()));
+    }
+    if (proto.hasPostSubmit()) {
+      patchSetApproval.setPostSubmit(proto.getPostSubmit());
+    }
+    return patchSetApproval;
+  }
+
+  @Override
+  public Parser<Reviewdb.PatchSetApproval> getParser() {
+    return Reviewdb.PatchSetApproval.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverter.java
new file mode 100644
index 0000000..f518a54
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverter.java
@@ -0,0 +1,45 @@
+// 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.reviewdb.converter;
+
+import com.google.gerrit.proto.reviewdb.Reviewdb;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.protobuf.Parser;
+
+public enum PatchSetIdProtoConverter implements ProtoConverter<Reviewdb.PatchSet_Id, PatchSet.Id> {
+  INSTANCE;
+
+  private final ProtoConverter<Reviewdb.Change_Id, Change.Id> changeIdConverter =
+      ChangeIdProtoConverter.INSTANCE;
+
+  @Override
+  public Reviewdb.PatchSet_Id toProto(PatchSet.Id patchSetId) {
+    return Reviewdb.PatchSet_Id.newBuilder()
+        .setChangeId(changeIdConverter.toProto(patchSetId.getParentKey()))
+        .setPatchSetId(patchSetId.get())
+        .build();
+  }
+
+  @Override
+  public PatchSet.Id fromProto(Reviewdb.PatchSet_Id proto) {
+    return new PatchSet.Id(changeIdConverter.fromProto(proto.getChangeId()), proto.getPatchSetId());
+  }
+
+  @Override
+  public Parser<Reviewdb.PatchSet_Id> getParser() {
+    return Reviewdb.PatchSet_Id.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/PatchSetProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/PatchSetProtoConverter.java
new file mode 100644
index 0000000..ffdc346
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/PatchSetProtoConverter.java
@@ -0,0 +1,93 @@
+// 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.reviewdb.converter;
+
+import com.google.gerrit.proto.reviewdb.Reviewdb;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.protobuf.Parser;
+import java.sql.Timestamp;
+import java.util.List;
+
+public enum PatchSetProtoConverter implements ProtoConverter<Reviewdb.PatchSet, PatchSet> {
+  INSTANCE;
+
+  private final ProtoConverter<Reviewdb.PatchSet_Id, PatchSet.Id> patchSetIdConverter =
+      PatchSetIdProtoConverter.INSTANCE;
+  private final ProtoConverter<Reviewdb.RevId, RevId> revIdConverter = RevIdProtoConverter.INSTANCE;
+  private final ProtoConverter<Reviewdb.Account_Id, Account.Id> accountIdConverter =
+      AccountIdProtoConverter.INSTANCE;
+
+  @Override
+  public Reviewdb.PatchSet toProto(PatchSet patchSet) {
+    Reviewdb.PatchSet.Builder builder =
+        Reviewdb.PatchSet.newBuilder().setId(patchSetIdConverter.toProto(patchSet.getId()));
+    RevId revision = patchSet.getRevision();
+    if (revision != null) {
+      builder.setRevision(revIdConverter.toProto(revision));
+    }
+    Account.Id uploader = patchSet.getUploader();
+    if (uploader != null) {
+      builder.setUploaderAccountId(accountIdConverter.toProto(uploader));
+    }
+    Timestamp createdOn = patchSet.getCreatedOn();
+    if (createdOn != null) {
+      builder.setCreatedOn(createdOn.getTime());
+    }
+    List<String> groups = patchSet.getGroups();
+    if (!groups.isEmpty()) {
+      builder.setGroups(PatchSet.joinGroups(groups));
+    }
+    String pushCertificate = patchSet.getPushCertificate();
+    if (pushCertificate != null) {
+      builder.setPushCertificate(pushCertificate);
+    }
+    String description = patchSet.getDescription();
+    if (description != null) {
+      builder.setDescription(description);
+    }
+    return builder.build();
+  }
+
+  @Override
+  public PatchSet fromProto(Reviewdb.PatchSet proto) {
+    PatchSet patchSet = new PatchSet(patchSetIdConverter.fromProto(proto.getId()));
+    if (proto.hasRevision()) {
+      patchSet.setRevision(revIdConverter.fromProto(proto.getRevision()));
+    }
+    if (proto.hasUploaderAccountId()) {
+      patchSet.setUploader(accountIdConverter.fromProto(proto.getUploaderAccountId()));
+    }
+    if (proto.hasCreatedOn()) {
+      patchSet.setCreatedOn(new Timestamp(proto.getCreatedOn()));
+    }
+    if (proto.hasGroups()) {
+      patchSet.setGroups(PatchSet.splitGroups(proto.getGroups()));
+    }
+    if (proto.hasPushCertificate()) {
+      patchSet.setPushCertificate(proto.getPushCertificate());
+    }
+    if (proto.hasDescription()) {
+      patchSet.setDescription(proto.getDescription());
+    }
+    return patchSet;
+  }
+
+  @Override
+  public Parser<Reviewdb.PatchSet> getParser() {
+    return Reviewdb.PatchSet.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/ProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ProtoConverter.java
new file mode 100644
index 0000000..568759c
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/ProtoConverter.java
@@ -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.
+
+package com.google.gerrit.reviewdb.converter;
+
+import com.google.protobuf.MessageLite;
+import com.google.protobuf.Parser;
+
+public interface ProtoConverter<P extends MessageLite, C> {
+
+  P toProto(C valueClass);
+
+  C fromProto(P proto);
+
+  Parser<P> getParser();
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/RevIdProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/RevIdProtoConverter.java
new file mode 100644
index 0000000..6402b6b
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/RevIdProtoConverter.java
@@ -0,0 +1,38 @@
+// 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.reviewdb.converter;
+
+import com.google.gerrit.proto.reviewdb.Reviewdb;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.protobuf.Parser;
+
+public enum RevIdProtoConverter implements ProtoConverter<Reviewdb.RevId, RevId> {
+  INSTANCE;
+
+  @Override
+  public Reviewdb.RevId toProto(RevId revId) {
+    return Reviewdb.RevId.newBuilder().setId(revId.get()).build();
+  }
+
+  @Override
+  public RevId fromProto(Reviewdb.RevId proto) {
+    return new RevId(proto.getId());
+  }
+
+  @Override
+  public Parser<Reviewdb.RevId> getParser() {
+    return Reviewdb.RevId.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/server/ReviewDbCodecs.java b/java/com/google/gerrit/reviewdb/server/ReviewDbCodecs.java
index 2958464..7ff2284 100644
--- a/java/com/google/gerrit/reviewdb/server/ReviewDbCodecs.java
+++ b/java/com/google/gerrit/reviewdb/server/ReviewDbCodecs.java
@@ -15,24 +15,12 @@
 package com.google.gerrit.reviewdb.server;
 
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gwtorm.protobuf.CodecFactory;
 import com.google.gwtorm.protobuf.ProtobufCodec;
 
 /** {@link ProtobufCodec} instances for ReviewDb types. */
 public class ReviewDbCodecs {
-  public static final ProtobufCodec<PatchSetApproval> APPROVAL_CODEC =
-      CodecFactory.encoder(PatchSetApproval.class);
-
   public static final ProtobufCodec<Change> CHANGE_CODEC = CodecFactory.encoder(Change.class);
 
-  public static final ProtobufCodec<ChangeMessage> MESSAGE_CODEC =
-      CodecFactory.encoder(ChangeMessage.class);
-
-  public static final ProtobufCodec<PatchSet> PATCH_SET_CODEC =
-      CodecFactory.encoder(PatchSet.class);
-
   private ReviewDbCodecs() {}
 }
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 8401852..df3308c 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -40,6 +40,7 @@
         "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/prettify:server",
+        "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/ioutil",
diff --git a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java b/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
index bb1ade7..bfe46d2 100644
--- a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
+++ b/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
@@ -21,12 +21,12 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.SetMultimap;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto;
 import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto.ExternalIdProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
-import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
-import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import java.util.Collection;
 
 /** Cache value containing all external IDs. */
@@ -68,7 +68,7 @@
           .stream()
           .map(extId -> toProto(idConverter, extId))
           .forEach(allBuilder::addExternalId);
-      return ProtoCacheSerializers.toByteArray(allBuilder.build());
+      return Protos.toByteArray(allBuilder.build());
     }
 
     private static ExternalIdProto toProto(ObjectIdConverter idConverter, ExternalId externalId) {
@@ -92,7 +92,7 @@
     public AllExternalIds deserialize(byte[] in) {
       ObjectIdConverter idConverter = ObjectIdConverter.create();
       return create(
-          ProtoCacheSerializers.parseUnchecked(AllExternalIdsProto.parser(), in)
+          Protos.parseUnchecked(AllExternalIdsProto.parser(), in)
               .getExternalIdList()
               .stream()
               .map(proto -> toExternalId(idConverter, proto))
diff --git a/java/com/google/gerrit/server/api/groups/GroupApiImpl.java b/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
index 9909ed7..b70a029 100644
--- a/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
+++ b/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
@@ -47,7 +47,6 @@
 import com.google.gerrit.server.restapi.group.PutOwner;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.util.Arrays;
 import java.util.List;
 
 class GroupApiImpl implements GroupApi {
@@ -217,18 +216,18 @@
   }
 
   @Override
-  public void addMembers(String... members) throws RestApiException {
+  public void addMembers(List<String> members) throws RestApiException {
     try {
-      addMembers.apply(rsrc, AddMembers.Input.fromMembers(Arrays.asList(members)));
+      addMembers.apply(rsrc, AddMembers.Input.fromMembers(members));
     } catch (Exception e) {
       throw asRestApiException("Cannot add group members", e);
     }
   }
 
   @Override
-  public void removeMembers(String... members) throws RestApiException {
+  public void removeMembers(List<String> members) throws RestApiException {
     try {
-      deleteMembers.apply(rsrc, AddMembers.Input.fromMembers(Arrays.asList(members)));
+      deleteMembers.apply(rsrc, AddMembers.Input.fromMembers(members));
     } catch (Exception e) {
       throw asRestApiException("Cannot remove group members", e);
     }
@@ -244,18 +243,18 @@
   }
 
   @Override
-  public void addGroups(String... groups) throws RestApiException {
+  public void addGroups(List<String> groups) throws RestApiException {
     try {
-      addSubgroups.apply(rsrc, AddSubgroups.Input.fromGroups(Arrays.asList(groups)));
+      addSubgroups.apply(rsrc, AddSubgroups.Input.fromGroups(groups));
     } catch (Exception e) {
       throw asRestApiException("Cannot add subgroups", e);
     }
   }
 
   @Override
-  public void removeGroups(String... groups) throws RestApiException {
+  public void removeGroups(List<String> groups) throws RestApiException {
     try {
-      deleteSubgroups.apply(rsrc, AddSubgroups.Input.fromGroups(Arrays.asList(groups)));
+      deleteSubgroups.apply(rsrc, AddSubgroups.Input.fromGroups(groups));
     } catch (Exception e) {
       throw asRestApiException("Cannot remove subgroups", e);
     }
diff --git a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
index 3a6be0c..0980116 100644
--- a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
+++ b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
@@ -22,12 +22,12 @@
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
 import com.google.gerrit.extensions.auth.oauth.OAuthTokenEncrypter;
 import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.proto.Cache.OAuthTokenProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.gerrit.server.cache.serialize.IntKeyCacheSerializer;
-import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
@@ -57,7 +57,7 @@
   static class Serializer implements CacheSerializer<OAuthToken> {
     @Override
     public byte[] serialize(OAuthToken object) {
-      return ProtoCacheSerializers.toByteArray(
+      return Protos.toByteArray(
           OAuthTokenProto.newBuilder()
               .setToken(object.getToken())
               .setSecret(object.getSecret())
@@ -69,7 +69,7 @@
 
     @Override
     public OAuthToken deserialize(byte[] in) {
-      OAuthTokenProto proto = ProtoCacheSerializers.parseUnchecked(OAuthTokenProto.parser(), in);
+      OAuthTokenProto proto = Protos.parseUnchecked(OAuthTokenProto.parser(), in);
       return new OAuthToken(
           proto.getToken(),
           proto.getSecret(),
diff --git a/java/com/google/gerrit/server/cache/serialize/BUILD b/java/com/google/gerrit/server/cache/serialize/BUILD
index 957a153..cd9912c 100644
--- a/java/com/google/gerrit/server/cache/serialize/BUILD
+++ b/java/com/google/gerrit/server/cache/serialize/BUILD
@@ -4,6 +4,7 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/proto",
         "//lib:guava",
         "//lib:gwtorm",
         "//lib:protobuf",
diff --git a/java/com/google/gerrit/server/cache/serialize/ObjectIdConverter.java b/java/com/google/gerrit/server/cache/serialize/ObjectIdConverter.java
new file mode 100644
index 0000000..eb946a9
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/ObjectIdConverter.java
@@ -0,0 +1,56 @@
+// 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.serialize;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static org.eclipse.jgit.lib.Constants.OBJECT_ID_LENGTH;
+
+import com.google.protobuf.ByteString;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Helper for serializing {@link ObjectId} instances to/from protobuf fields.
+ *
+ * <p>Reuse a single instance's {@link #toByteString(ObjectId)} and {@link
+ * #fromByteString(ByteString)} within a single {@link CacheSerializer#serialize} or {@link
+ * CacheSerializer#deserialize} method body to minimize allocation of temporary buffers.
+ *
+ * <p><strong>Note:</strong> This class is not threadsafe. Instances must not be stored in {@link
+ * CacheSerializer} fields if the serializer instances will be used from multiple threads.
+ */
+public class ObjectIdConverter {
+  public static ObjectIdConverter create() {
+    return new ObjectIdConverter();
+  }
+
+  private final byte[] buf = new byte[OBJECT_ID_LENGTH];
+
+  private ObjectIdConverter() {}
+
+  public ByteString toByteString(ObjectId id) {
+    id.copyRawTo(buf, 0);
+    return ByteString.copyFrom(buf);
+  }
+
+  public ObjectId fromByteString(ByteString in) {
+    checkArgument(
+        in.size() == OBJECT_ID_LENGTH,
+        "expected ByteString of length %s: %s",
+        OBJECT_ID_LENGTH,
+        in);
+    in.copyTo(buf, 0);
+    return ObjectId.fromRaw(buf);
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/ProtoCacheSerializers.java b/java/com/google/gerrit/server/cache/serialize/ProtoCacheSerializers.java
deleted file mode 100644
index 4e0b106..0000000
--- a/java/com/google/gerrit/server/cache/serialize/ProtoCacheSerializers.java
+++ /dev/null
@@ -1,127 +0,0 @@
-// 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.serialize;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static org.eclipse.jgit.lib.Constants.OBJECT_ID_LENGTH;
-
-import com.google.gwtorm.protobuf.ProtobufCodec;
-import com.google.protobuf.ByteString;
-import com.google.protobuf.CodedOutputStream;
-import com.google.protobuf.MessageLite;
-import com.google.protobuf.Parser;
-import java.io.IOException;
-import org.eclipse.jgit.lib.ObjectId;
-
-/** 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 in persistent caches.
-   * 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", e);
-    }
-  }
-
-  /**
-   * Serializes an object to a {@link ByteString} using a protobuf codec.
-   *
-   * <p>Guarantees deterministic serialization and thus is suitable for use in persistent caches.
-   * Should be used in preference to {@link ProtobufCodec#encodeToByteString(Object)}, which is not
-   * guaranteed deterministic.
-   *
-   * @param object the object to serialize.
-   * @param codec codec for serializing.
-   * @return a {@code ByteString} with the message contents.
-   */
-  public static <T> ByteString toByteString(T object, ProtobufCodec<T> codec) {
-    try (ByteString.Output bout = ByteString.newOutput()) {
-      CodedOutputStream cout = CodedOutputStream.newInstance(bout);
-      cout.useDeterministicSerialization();
-      codec.encode(object, cout);
-      cout.flush();
-      return bout.toByteString();
-    } catch (IOException e) {
-      throw new IllegalStateException("exception writing to ByteString", e);
-    }
-  }
-
-  /**
-   * Parses a byte array to a protobuf message.
-   *
-   * @param parser parser for the proto type.
-   * @param in byte array with the message contents.
-   * @return parsed proto.
-   */
-  public static <M extends MessageLite> M parseUnchecked(Parser<M> parser, byte[] in) {
-    try {
-      return parser.parseFrom(in);
-    } catch (IOException e) {
-      throw new IllegalArgumentException("exception parsing byte array to proto", e);
-    }
-  }
-
-  /**
-   * Helper for serializing {@link ObjectId} instances to/from protobuf fields.
-   *
-   * <p>Reuse a single instance's {@link #toByteString(ObjectId)} and {@link
-   * #fromByteString(ByteString)} within a single {@link CacheSerializer#serialize} or {@link
-   * CacheSerializer#deserialize} method body to minimize allocation of temporary buffers.
-   *
-   * <p><strong>Note:</strong> This class is not threadsafe. Instances must not be stored in {@link
-   * CacheSerializer} fields if the serializer instances will be used from multiple threads.
-   */
-  public static class ObjectIdConverter {
-    public static ObjectIdConverter create() {
-      return new ObjectIdConverter();
-    }
-
-    private final byte[] buf = new byte[OBJECT_ID_LENGTH];
-
-    private ObjectIdConverter() {}
-
-    public ByteString toByteString(ObjectId id) {
-      id.copyRawTo(buf, 0);
-      return ByteString.copyFrom(buf);
-    }
-
-    public ObjectId fromByteString(ByteString in) {
-      checkArgument(
-          in.size() == OBJECT_ID_LENGTH,
-          "expected ByteString of length %s: %s",
-          OBJECT_ID_LENGTH,
-          in);
-      in.copyTo(buf, 0);
-      return ObjectId.fromRaw(buf);
-    }
-  }
-
-  private ProtoCacheSerializers() {}
-}
diff --git a/java/com/google/gerrit/server/cache/testing/BUILD b/java/com/google/gerrit/server/cache/testing/BUILD
index 9a9f1ef..f7f7eb6 100644
--- a/java/com/google/gerrit/server/cache/testing/BUILD
+++ b/java/com/google/gerrit/server/cache/testing/BUILD
@@ -5,10 +5,6 @@
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
     deps = [
-        "//java/com/google/gerrit/server/cache/serialize",
-        "//lib:guava",
         "//lib:protobuf",
-        "//lib/commons:lang3",
-        "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/server/change/ChangeFinder.java b/java/com/google/gerrit/server/change/ChangeFinder.java
index 41d89ed..5e7a9bf 100644
--- a/java/com/google/gerrit/server/change/ChangeFinder.java
+++ b/java/com/google/gerrit/server/change/ChangeFinder.java
@@ -30,7 +30,6 @@
 import com.google.gerrit.reviewdb.client.Change;
 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.cache.CacheModule;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -77,7 +76,6 @@
   private final IndexConfig indexConfig;
   private final Cache<Change.Id, String> changeIdProjectCache;
   private final Provider<InternalChangeQuery> queryProvider;
-  private final Provider<ReviewDb> reviewDb;
   private final ChangeNotes.Factory changeNotesFactory;
   private final Counter1<ChangeIdType> changeIdCounter;
   private final ImmutableSet<ChangeIdType> allowedIdTypes;
@@ -87,14 +85,12 @@
       IndexConfig indexConfig,
       @Named(CACHE_NAME) Cache<Change.Id, String> changeIdProjectCache,
       Provider<InternalChangeQuery> queryProvider,
-      Provider<ReviewDb> reviewDb,
       ChangeNotes.Factory changeNotesFactory,
       MetricMaker metricMaker,
       @GerritServerConfig Config config) {
     this.indexConfig = indexConfig;
     this.changeIdProjectCache = changeIdProjectCache;
     this.queryProvider = queryProvider;
-    this.reviewDb = reviewDb;
     this.changeNotesFactory = changeNotesFactory;
     this.changeIdCounter =
         metricMaker.newCounter(
@@ -203,7 +199,7 @@
     Change.Id cId = new Change.Id(changeNumber);
     try {
       return ImmutableList.of(
-          changeNotesFactory.createChecked(reviewDb.get(), Project.NameKey.parse(project), cId));
+          changeNotesFactory.createChecked(Project.NameKey.parse(project), cId));
     } catch (NoSuchChangeException e) {
       return Collections.emptyList();
     } catch (OrmException e) {
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 889a20c..b39d716 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -323,7 +323,7 @@
       Project.NameKey project, Change.Id id, Supplier<I> changeInfoSupplier) throws OrmException {
     ChangeNotes notes;
     try {
-      notes = notesFactory.createChecked(db.get(), project, id);
+      notes = notesFactory.createChecked(project, id);
     } catch (OrmException e) {
       if (!has(CHECK)) {
         throw e;
diff --git a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
index a6786d8..a57a9a4 100644
--- a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
+++ b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
@@ -24,6 +24,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
@@ -32,8 +33,7 @@
 import com.google.gerrit.server.cache.proto.Cache.ChangeKindKeyProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.gerrit.server.cache.serialize.EnumCacheSerializer;
-import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
-import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.InMemoryInserter;
@@ -146,7 +146,7 @@
       @Override
       public byte[] serialize(Key object) {
         ObjectIdConverter idConverter = ObjectIdConverter.create();
-        return ProtoCacheSerializers.toByteArray(
+        return Protos.toByteArray(
             ChangeKindKeyProto.newBuilder()
                 .setPrior(idConverter.toByteString(object.prior()))
                 .setNext(idConverter.toByteString(object.next()))
@@ -156,8 +156,7 @@
 
       @Override
       public Key deserialize(byte[] in) {
-        ChangeKindKeyProto proto =
-            ProtoCacheSerializers.parseUnchecked(ChangeKindKeyProto.parser(), in);
+        ChangeKindKeyProto proto = Protos.parseUnchecked(ChangeKindKeyProto.parser(), in);
         ObjectIdConverter idConverter = ObjectIdConverter.create();
         return create(
             idConverter.fromByteString(proto.getPrior()),
diff --git a/java/com/google/gerrit/server/change/ConsistencyChecker.java b/java/com/google/gerrit/server/change/ConsistencyChecker.java
index b29f42a..f38d670 100644
--- a/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -413,9 +413,7 @@
         }
         try {
           Change c =
-              notesFactory
-                  .createChecked(db.get(), change().getProject(), psId.getParentKey())
-                  .getChange();
+              notesFactory.createChecked(change().getProject(), psId.getParentKey()).getChange();
           if (!c.getDest().equals(change().getDest())) {
             continue;
           }
@@ -544,7 +542,7 @@
         bu.addOp(notes.getChangeId(), new FixMergedOp(notFound));
         bu.execute();
       }
-      notes = notesFactory.createChecked(db.get(), inserter.getChange());
+      notes = notesFactory.createChecked(inserter.getChange());
       insertPatchSetProblem.status = Status.FIXED;
       insertPatchSetProblem.outcome = "Inserted as patch set " + psId.get();
     } catch (OrmException | IOException | UpdateException | RestApiException e) {
diff --git a/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java b/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
index 131f3a1..1ac558b 100644
--- a/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
+++ b/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
@@ -25,13 +25,13 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.UncheckedExecutionException;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.proto.Cache.MergeabilityKeyProto;
 import com.google.gerrit.server.cache.serialize.BooleanCacheSerializer;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
-import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
-import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.submit.SubmitDryRun;
@@ -143,7 +143,7 @@
       @Override
       public byte[] serialize(EntryKey object) {
         ObjectIdConverter idConverter = ObjectIdConverter.create();
-        return ProtoCacheSerializers.toByteArray(
+        return Protos.toByteArray(
             MergeabilityKeyProto.newBuilder()
                 .setCommit(idConverter.toByteString(object.getCommit()))
                 .setInto(idConverter.toByteString(object.getInto()))
@@ -154,8 +154,7 @@
 
       @Override
       public EntryKey deserialize(byte[] in) {
-        MergeabilityKeyProto proto =
-            ProtoCacheSerializers.parseUnchecked(MergeabilityKeyProto.parser(), in);
+        MergeabilityKeyProto proto = Protos.parseUnchecked(MergeabilityKeyProto.parser(), in);
         ObjectIdConverter idConverter = ObjectIdConverter.create();
         return new EntryKey(
             idConverter.fromByteString(proto.getCommit()),
diff --git a/java/com/google/gerrit/server/change/PureRevert.java b/java/com/google/gerrit/server/change/PureRevert.java
index 14829ea..0135683 100644
--- a/java/com/google/gerrit/server/change/PureRevert.java
+++ b/java/com/google/gerrit/server/change/PureRevert.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
@@ -27,7 +26,6 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
@@ -49,7 +47,6 @@
   private final GitRepositoryManager repoManager;
   private final ProjectCache projectCache;
   private final ChangeNotes.Factory notesFactory;
-  private final Provider<ReviewDb> dbProvider;
   private final PatchSetUtil psUtil;
 
   @Inject
@@ -58,13 +55,11 @@
       GitRepositoryManager repoManager,
       ProjectCache projectCache,
       ChangeNotes.Factory notesFactory,
-      Provider<ReviewDb> dbProvider,
       PatchSetUtil psUtil) {
     this.mergeUtilFactory = mergeUtilFactory;
     this.repoManager = repoManager;
     this.projectCache = projectCache;
     this.notesFactory = notesFactory;
-    this.dbProvider = dbProvider;
     this.psUtil = psUtil;
   }
 
@@ -81,8 +76,7 @@
       }
       PatchSet ps =
           psUtil.current(
-              notesFactory.createChecked(
-                  dbProvider.get(), notes.getProjectName(), notes.getChange().getRevertOf()));
+              notesFactory.createChecked(notes.getProjectName(), notes.getChange().getRevertOf()));
       claimedOriginal = ps.getRevision().get();
     }
 
diff --git a/java/com/google/gerrit/server/change/RebaseUtil.java b/java/com/google/gerrit/server/change/RebaseUtil.java
index 8b7c36e..6cb61c1 100644
--- a/java/com/google/gerrit/server/change/RebaseUtil.java
+++ b/java/com/google/gerrit/server/change/RebaseUtil.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -46,18 +45,15 @@
 
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeNotes.Factory notesFactory;
-  private final Provider<ReviewDb> dbProvider;
   private final PatchSetUtil psUtil;
 
   @Inject
   RebaseUtil(
       Provider<InternalChangeQuery> queryProvider,
       ChangeNotes.Factory notesFactory,
-      Provider<ReviewDb> dbProvider,
       PatchSetUtil psUtil) {
     this.queryProvider = queryProvider;
     this.notesFactory = notesFactory;
-    this.dbProvider = dbProvider;
     this.psUtil = psUtil;
   }
 
@@ -128,7 +124,7 @@
     if (rsrc.getChange().getId().equals(id)) {
       return rsrc.getNotes();
     }
-    return notesFactory.createChecked(dbProvider.get(), rsrc.getProject(), id);
+    return notesFactory.createChecked(rsrc.getProject(), id);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/events/EventBroker.java b/java/com/google/gerrit/server/events/EventBroker.java
index 94e9bb1..cf07bf6 100644
--- a/java/com/google/gerrit/server/events/EventBroker.java
+++ b/java/com/google/gerrit/server/events/EventBroker.java
@@ -177,7 +177,7 @@
     try {
       permissionBackend
           .user(user)
-          .change(notesFactory.createChecked(db, change))
+          .change(notesFactory.createChecked(change))
           .database(db)
           .check(ChangePermission.READ);
       return true;
@@ -209,10 +209,7 @@
       if (PatchSet.isChangeRef(ref)) {
         Change.Id cid = PatchSet.Id.fromRef(ref).getParentKey();
         try {
-          Change change =
-              notesFactory
-                  .createChecked(dbProvider.get(), refEvent.getProjectNameKey(), cid)
-                  .getChange();
+          Change change = notesFactory.createChecked(refEvent.getProjectNameKey(), cid).getChange();
           return isVisibleTo(change, user);
         } catch (NoSuchChangeException e) {
           logger.atFine().log(
diff --git a/java/com/google/gerrit/server/git/GroupCollector.java b/java/com/google/gerrit/server/git/GroupCollector.java
index 88632e6..e40f296 100644
--- a/java/com/google/gerrit/server/git/GroupCollector.java
+++ b/java/com/google/gerrit/server/git/GroupCollector.java
@@ -106,7 +106,6 @@
 
   public static GroupCollector create(
       ListMultimap<ObjectId, Ref> changeRefsById,
-      ReviewDb db,
       PatchSetUtil psUtil,
       ChangeNotes.Factory notesFactory,
       Project.NameKey project) {
@@ -116,7 +115,7 @@
           @Override
           public List<String> lookup(PatchSet.Id psId) throws OrmException {
             // TODO(dborowitz): Reuse open repository from caller.
-            ChangeNotes notes = notesFactory.createChecked(db, project, psId.getParentKey());
+            ChangeNotes notes = notesFactory.createChecked(project, psId.getParentKey());
             PatchSet ps = psUtil.get(notes, psId);
             return ps != null ? ps.getGroups() : null;
           }
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index cc3b415..9cfca88 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -348,7 +348,7 @@
     for (Map.Entry<String, MergeResult<? extends Sequence>> entry : mergeResults.entrySet()) {
       MergeResult<? extends Sequence> p = entry.getValue();
       try (TemporaryBuffer buf = new TemporaryBuffer.LocalFile(null, 10 * 1024 * 1024)) {
-        fmt.formatMerge(buf, p, "BASE", oursNameFormatted, theirsNameFormatted, UTF_8.name());
+        fmt.formatMerge(buf, p, "BASE", oursNameFormatted, theirsNameFormatted, UTF_8);
         buf.close();
 
         try (InputStream in = buf.openInputStream()) {
diff --git a/java/com/google/gerrit/server/git/TagSet.java b/java/com/google/gerrit/server/git/TagSet.java
index ce8814f..57637c89 100644
--- a/java/com/google/gerrit/server/git/TagSet.java
+++ b/java/com/google/gerrit/server/git/TagSet.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto;
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto.CachedRefProto;
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto.TagProto;
-import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.protobuf.ByteString;
 import java.io.IOException;
 import java.util.BitSet;
diff --git a/java/com/google/gerrit/server/git/TagSetHolder.java b/java/com/google/gerrit/server/git/TagSetHolder.java
index 4c0c035..194283e 100644
--- a/java/com/google/gerrit/server/git/TagSetHolder.java
+++ b/java/com/google/gerrit/server/git/TagSetHolder.java
@@ -17,10 +17,10 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
-import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
 import java.util.Collection;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -111,13 +111,12 @@
       if (tags != null) {
         b.setTags(tags.toProto());
       }
-      return ProtoCacheSerializers.toByteArray(b.build());
+      return Protos.toByteArray(b.build());
     }
 
     @Override
     public TagSetHolder deserialize(byte[] in) {
-      TagSetHolderProto proto =
-          ProtoCacheSerializers.parseUnchecked(TagSetHolderProto.parser(), in);
+      TagSetHolderProto proto = Protos.parseUnchecked(TagSetHolderProto.parser(), in);
       TagSetHolder holder = new TagSetHolder(new Project.NameKey(proto.getProjectName()));
       if (proto.hasTags()) {
         holder.tags = TagSet.fromProto(proto.getTags());
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
index 882f208..abbba86 100644
--- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.ProjectRunnable;
 import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.git.receive.ResultChangeIds.Key;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -341,17 +342,21 @@
 
     long deltaNanos = System.nanoTime() - startNanos;
     int totalChanges = 0;
-    for (ResultChangeIds.Key key : ResultChangeIds.Key.values()) {
-      List<Change.Id> ids = resultChangeIds.get(key);
-      metrics.changes.record(key, ids.size());
-      totalChanges += ids.size();
+
+    if (resultChangeIds.isMagicPush()) {
+      List<Change.Id> created = resultChangeIds.get(Key.CREATED);
+      metrics.changes.record(Key.CREATED, created.size());
+      List<Change.Id> replaced = resultChangeIds.get(Key.REPLACED);
+      metrics.changes.record(Key.REPLACED, replaced.size());
+      totalChanges += replaced.size() + created.size();
+    } else {
+      List<Change.Id> autoclosed = resultChangeIds.get(Key.AUTOCLOSED);
+      metrics.changes.record(Key.AUTOCLOSED, autoclosed.size());
     }
 
     if (totalChanges > 0) {
       metrics.latencyPerChange.record(
-          resultChangeIds.get(ResultChangeIds.Key.AUTOCLOSED).isEmpty()
-              ? "CREATE_REPLACE"
-              : ResultChangeIds.Key.AUTOCLOSED.name(),
+          resultChangeIds.isMagicPush() ? "CREATE_REPLACE" : ResultChangeIds.Key.AUTOCLOSED.name(),
           deltaNanos / totalChanges,
           NANOSECONDS);
     }
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 32fbd36..7f9e7a1 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -631,6 +631,7 @@
 
   private void handleRegularCommands(List<ReceiveCommand> cmds, MultiProgressMonitor progress)
       throws PermissionBackendException, IOException, NoSuchProjectException {
+    resultChangeIds.setMagicPush(false);
     for (ReceiveCommand cmd : cmds) {
       parseRegularCommand(cmd);
     }
@@ -1825,6 +1826,7 @@
 
     if (validateConnected(magicBranch.cmd, magicBranch.dest, tip)) {
       this.magicBranch = magicBranch;
+      this.resultChangeIds.setMagicPush(true);
     }
   }
 
@@ -1909,7 +1911,7 @@
 
     Change changeEnt;
     try {
-      changeEnt = notesFactory.createChecked(db, project.getNameKey(), changeId).getChange();
+      changeEnt = notesFactory.createChecked(project.getNameKey(), changeId).getChange();
     } catch (NoSuchChangeException e) {
       logger.atSevere().withCause(e).log("Change not found %s", changeId);
       reject(cmd, "change " + changeId + " not found");
@@ -1989,7 +1991,7 @@
 
     ListMultimap<ObjectId, Ref> existing = changeRefsById();
     GroupCollector groupCollector =
-        GroupCollector.create(changeRefsById(), db, psUtil, notesFactory, project.getNameKey());
+        GroupCollector.create(changeRefsById(), psUtil, notesFactory, project.getNameKey());
 
     BranchCommitValidator validator =
         commitValidatorFactory.create(projectState, magicBranch.dest, user);
@@ -2253,7 +2255,7 @@
   private boolean foundInExistingRef(Collection<Ref> existingRefs) throws OrmException {
     for (Ref ref : existingRefs) {
       ChangeNotes notes =
-          notesFactory.create(db, project.getNameKey(), Change.Id.fromRef(ref.getName()));
+          notesFactory.create(project.getNameKey(), Change.Id.fromRef(ref.getName()));
       Change change = notes.getChange();
       if (change.getDest().equals(magicBranch.dest)) {
         logger.atFine().log("Found change %s from existing refs.", change.getKey());
@@ -2562,7 +2564,7 @@
   private void readChangesForReplace() throws OrmException {
     Collection<ChangeNotes> allNotes =
         notesFactory.create(
-            db, replaceByChange.values().stream().map(r -> r.ontoChange).collect(toList()));
+            replaceByChange.values().stream().map(r -> r.ontoChange).collect(toList()));
     for (ChangeNotes notes : allNotes) {
       replaceByChange.get(notes.getChangeId()).notes = notes;
     }
@@ -3212,7 +3214,7 @@
 
   private Optional<ChangeNotes> getChangeNotes(Change.Id changeId) throws OrmException {
     try {
-      return Optional.of(notesFactory.createChecked(db, project.getNameKey(), changeId));
+      return Optional.of(notesFactory.createChecked(project.getNameKey(), changeId));
     } catch (NoSuchChangeException e) {
       return Optional.empty();
     }
diff --git a/java/com/google/gerrit/server/git/receive/ResultChangeIds.java b/java/com/google/gerrit/server/git/receive/ResultChangeIds.java
index bbf8d95..e326141 100644
--- a/java/com/google/gerrit/server/git/receive/ResultChangeIds.java
+++ b/java/com/google/gerrit/server/git/receive/ResultChangeIds.java
@@ -33,6 +33,7 @@
     AUTOCLOSED,
   }
 
+  private boolean isMagicPush;
   private final Map<Key, List<Change.Id>> ids;
 
   ResultChangeIds() {
@@ -43,16 +44,24 @@
   }
 
   /** Record a change ID update as having completed. Thread-safe. */
-  public void add(Key key, Change.Id id) {
-    synchronized (this) {
-      ids.get(key).add(id);
-    }
+  public synchronized void add(Key key, Change.Id id) {
+    ids.get(key).add(id);
   }
 
-  /** Returns change IDs of the given type for which the BatchUpdate succeeded. Thread-safe. */
-  public List<Change.Id> get(Key key) {
-    synchronized (this) {
-      return ImmutableList.copyOf(ids.get(key));
-    }
+  /** Indicate that the ReceiveCommits call involved a magic branch. */
+  public synchronized void setMagicPush(boolean magic) {
+    isMagicPush = magic;
+  }
+
+  public synchronized boolean isMagicPush() {
+    return isMagicPush;
+  }
+
+  /**
+   * Returns change IDs of the given type for which the BatchUpdate succeeded, or empty list if
+   * there are none. Thread-safe.
+   */
+  public synchronized List<Change.Id> get(Key key) {
+    return ImmutableList.copyOf(ids.get(key));
   }
 }
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index b79a1c2..bae3377 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.index.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.index.FieldDef.exact;
 import static com.google.gerrit.index.FieldDef.fullText;
 import static com.google.gerrit.index.FieldDef.intRange;
@@ -22,9 +23,7 @@
 import static com.google.gerrit.index.FieldDef.prefix;
 import static com.google.gerrit.index.FieldDef.storedOnly;
 import static com.google.gerrit.index.FieldDef.timestamp;
-import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.APPROVAL_CODEC;
 import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.CHANGE_CODEC;
-import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.PATCH_SET_CODEC;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
@@ -46,6 +45,7 @@
 import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.mail.Address;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -53,6 +53,9 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.converter.PatchSetApprovalProtoConverter;
+import com.google.gerrit.reviewdb.converter.PatchSetProtoConverter;
+import com.google.gerrit.reviewdb.converter.ProtoConverter;
 import com.google.gerrit.server.OutputFormat;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
@@ -68,10 +71,7 @@
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
 import com.google.gson.Gson;
-import com.google.gwtorm.protobuf.ProtobufCodec;
 import com.google.gwtorm.server.OrmException;
-import com.google.protobuf.CodedOutputStream;
-import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -474,7 +474,8 @@
   /** Serialized approvals for the current patch set, used for pre-populating results. */
   public static final FieldDef<ChangeData, Iterable<byte[]>> APPROVAL =
       storedOnly("_approval")
-          .buildRepeatable(cd -> toProtos(APPROVAL_CODEC, cd.currentApprovals()));
+          .buildRepeatable(
+              cd -> toProtos(PatchSetApprovalProtoConverter.INSTANCE, cd.currentApprovals()));
 
   public static String formatLabel(String label, int value) {
     return formatLabel(label, value, null);
@@ -596,7 +597,8 @@
 
   /** Serialized patch set object, used for pre-populating results. */
   public static final FieldDef<ChangeData, Iterable<byte[]>> PATCH_SET =
-      storedOnly("_patch_set").buildRepeatable(cd -> toProtos(PATCH_SET_CODEC, cd.patchSets()));
+      storedOnly("_patch_set")
+          .buildRepeatable(cd -> toProtos(PatchSetProtoConverter.INSTANCE, cd.patchSets()));
 
   /** Users who have edits on this change. */
   public static final FieldDef<ChangeData, Iterable<Integer>> EDITBY =
@@ -856,22 +858,12 @@
     return firstNonNull(c.getTopic(), "");
   }
 
-  private static <T> List<byte[]> toProtos(ProtobufCodec<T> codec, Collection<T> objs)
-      throws OrmException {
-    List<byte[]> result = Lists.newArrayListWithCapacity(objs.size());
-    ByteArrayOutputStream out = new ByteArrayOutputStream(256);
-    try {
-      for (T obj : objs) {
-        out.reset();
-        CodedOutputStream cos = CodedOutputStream.newInstance(out);
-        codec.encode(obj, cos);
-        cos.flush();
-        result.add(out.toByteArray());
-      }
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
-    return result;
+  private static <T> List<byte[]> toProtos(ProtoConverter<?, T> converter, Collection<T> objects) {
+    return objects
+        .stream()
+        .map(converter::toProto)
+        .map(Protos::toByteArray)
+        .collect(toImmutableList());
   }
 
   private static <T> FieldDef.Getter<ChangeData, T> changeGetter(Function<Change, T> func) {
diff --git a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
index 609432b..e3fb740 100644
--- a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
+++ b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
@@ -185,9 +185,7 @@
       ReviewDb db = ctx.getReviewDbProvider().get();
       try {
         Change c =
-            notesFactory
-                .createChecked(db, new Project.NameKey(event.getProjectName()), id)
-                .getChange();
+            notesFactory.createChecked(new Project.NameKey(event.getProjectName()), id).getChange();
         indexerFactory.create(executor, indexes).index(db, c);
       } catch (NoSuchChangeException e) {
         indexerFactory.create(executor, indexes).delete(id);
diff --git a/java/com/google/gerrit/server/index/change/StalenessChecker.java b/java/com/google/gerrit/server/index/change/StalenessChecker.java
index cf7db6f..d5d6b05 100644
--- a/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -36,9 +36,9 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.UsedAt;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
@@ -100,7 +100,7 @@
         repoManager,
         id,
         cd.change(),
-        ChangeNotes.readOneReviewDbChange(db.get(), id),
+        ReviewDbUtil.unwrapDb(db.get()).changes().get(id),
         parseStates(cd),
         parsePatterns(cd));
   }
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index 32d086c..d3ab0e0 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.notedb;
 
-import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
 import static java.util.Objects.requireNonNull;
 
@@ -34,6 +33,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -44,8 +44,10 @@
   @VisibleForTesting
   @Singleton
   public static class Args {
+    // TODO(dborowitz): Some less smelly way of disabling NoteDb in tests.
+    public final AtomicBoolean failOnLoadForTest;
+
     final GitRepositoryManager repoManager;
-    final NotesMigration migration;
     final AllUsersName allUsers;
     final ChangeNoteJson changeNoteJson;
     final LegacyChangeNoteRead legacyChangeNoteRead;
@@ -60,15 +62,14 @@
     @Inject
     Args(
         GitRepositoryManager repoManager,
-        NotesMigration migration,
         AllUsersName allUsers,
         ChangeNoteJson changeNoteJson,
         LegacyChangeNoteRead legacyChangeNoteRead,
         NoteDbMetrics metrics,
         Provider<ReviewDb> db,
         Provider<ChangeNotesCache> cache) {
+      this.failOnLoadForTest = new AtomicBoolean();
       this.repoManager = repoManager;
-      this.migration = migration;
       this.allUsers = allUsers;
       this.legacyChangeNoteRead = legacyChangeNoteRead;
       this.changeNoteJson = changeNoteJson;
@@ -132,8 +133,7 @@
       return self();
     }
 
-    checkState(args.migration.readChanges(), "NoteDb is required to read changes");
-    if (args.migration.failOnLoadForTest()) {
+    if (args.failOnLoadForTest.get()) {
       throw new OrmException("Reading from NoteDb is disabled");
     }
     try (Timer1.Context timer = args.metrics.readLatency.start(CHANGES);
@@ -181,8 +181,6 @@
   public ObjectId loadRevision() throws OrmException {
     if (loaded) {
       return getRevision();
-    } else if (!args.migration.readChanges()) {
-      return null;
     }
     try (Repository repo = args.repoManager.openRepository(getProjectName())) {
       Ref ref = repo.getRefDatabase().exactRef(getRefName());
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index e0cc771..1e90447 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -215,10 +215,6 @@
       return null;
     }
 
-    // Allow this method to proceed even if migration.failChangeWrites() = true.
-    // This may be used by an auto-rebuilding step that the caller does not plan
-    // to actually store.
-
     checkArgument(rw.getObjectReader().getCreatedFromInserter() == ins);
     checkNotReadOnly();
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index e5e0d51..37de143 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -47,8 +47,6 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.client.RobotComment;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
@@ -92,11 +90,6 @@
     return new ConfigInvalidException("Change " + changeId + ": " + String.format(fmt, args));
   }
 
-  @Nullable
-  public static Change readOneReviewDbChange(ReviewDb db, Change.Id id) throws OrmException {
-    return ReviewDbUtil.unwrapDb(db).changes().get(id);
-  }
-
   @Singleton
   public static class Factory {
     private final Args args;
@@ -112,23 +105,14 @@
       this.projectCache = projectCache;
     }
 
-    public ChangeNotes createChecked(ReviewDb db, Change c) throws OrmException {
-      return createChecked(db, c.getProject(), c.getId());
+    public ChangeNotes createChecked(Change c) throws OrmException {
+      return createChecked(c.getProject(), c.getId());
     }
 
-    public ChangeNotes createChecked(ReviewDb db, Project.NameKey project, Change.Id changeId)
+    public ChangeNotes createChecked(Project.NameKey project, Change.Id changeId)
         throws OrmException {
-      Change change = readOneReviewDbChange(db, changeId);
-      if (change == null) {
-        if (!args.migration.readChanges()) {
-          throw new NoSuchChangeException(changeId);
-        }
-        // Change isn't in ReviewDb, but its primary storage might be in NoteDb.
-        // Prepopulate the change exists with proper noteDbState field.
-        change = newNoteDbOnlyChange(project, changeId);
-      } else if (!change.getProject().equals(project)) {
-        throw new NoSuchChangeException(changeId);
-      }
+      // Prepopulate the change exists with proper noteDbState field.
+      Change change = newChange(project, changeId);
       return new ChangeNotes(args, change).load();
     }
 
@@ -145,7 +129,7 @@
       return changes.get(0).notes();
     }
 
-    public static Change newNoteDbOnlyChange(Project.NameKey project, Change.Id changeId) {
+    public static Change newChange(Project.NameKey project, Change.Id changeId) {
       Change change =
           new Change(
               null, changeId, null, new Branch.NameKey(project, "INVALID_NOTE_DB_ONLY"), null);
@@ -153,29 +137,9 @@
       return change;
     }
 
-    private Change loadChangeFromDb(ReviewDb db, Project.NameKey project, Change.Id changeId)
-        throws OrmException {
+    public ChangeNotes create(Project.NameKey project, Change.Id changeId) throws OrmException {
       checkArgument(project != null, "project is required");
-      Change change = readOneReviewDbChange(db, changeId);
-
-      if (change == null) {
-        if (args.migration.readChanges()) {
-          return newNoteDbOnlyChange(project, changeId);
-        }
-        throw new NoSuchChangeException(changeId);
-      }
-      checkArgument(
-          change.getProject().equals(project),
-          "passed project %s when creating ChangeNotes for %s, but actual project is %s",
-          project,
-          changeId,
-          change.getProject());
-      return change;
-    }
-
-    public ChangeNotes create(ReviewDb db, Project.NameKey project, Change.Id changeId)
-        throws OrmException {
-      return new ChangeNotes(args, loadChangeFromDb(db, project, changeId)).load();
+      return new ChangeNotes(args, newChange(project, changeId)).load();
     }
 
     /**
@@ -198,91 +162,48 @@
       return new ChangeNotes(args, change, true, refs).load();
     }
 
-    // TODO(ekempin): Remove when database backend is deleted
-    /**
-     * Instantiate ChangeNotes for a change that has been loaded by a batch read from the database.
-     */
-    private ChangeNotes createFromChangeOnlyWhenNoteDbDisabled(Change change) throws OrmException {
-      checkState(
-          !args.migration.readChanges(),
-          "do not call createFromChangeWhenNoteDbDisabled when NoteDb is enabled");
-      return new ChangeNotes(args, change).load();
-    }
-
-    public List<ChangeNotes> create(ReviewDb db, Collection<Change.Id> changeIds)
-        throws OrmException {
+    public List<ChangeNotes> create(Collection<Change.Id> changeIds) throws OrmException {
       List<ChangeNotes> notes = new ArrayList<>();
-      if (args.migration.readChanges()) {
-        for (Change.Id changeId : changeIds) {
-          try {
-            notes.add(createChecked(changeId));
-          } catch (NoSuchChangeException e) {
-            // Ignore missing changes to match Access#get(Iterable) behavior.
-          }
+      for (Change.Id changeId : changeIds) {
+        try {
+          notes.add(createChecked(changeId));
+        } catch (NoSuchChangeException e) {
+          // Ignore missing changes to match Access#get(Iterable) behavior.
         }
-        return notes;
-      }
-
-      for (Change c : ReviewDbUtil.unwrapDb(db).changes().get(changeIds)) {
-        notes.add(createFromChangeOnlyWhenNoteDbDisabled(c));
       }
       return notes;
     }
 
     public List<ChangeNotes> create(
-        ReviewDb db,
-        Project.NameKey project,
-        Collection<Change.Id> changeIds,
-        Predicate<ChangeNotes> predicate)
+        Project.NameKey project, Collection<Change.Id> changeIds, Predicate<ChangeNotes> predicate)
         throws OrmException {
       List<ChangeNotes> notes = new ArrayList<>();
-      if (args.migration.readChanges()) {
-        for (Change.Id cid : changeIds) {
-          try {
-            ChangeNotes cn = create(db, project, cid);
-            if (cn.getChange() != null && predicate.test(cn)) {
-              notes.add(cn);
-            }
-          } catch (NoSuchChangeException e) {
-            // Match ReviewDb behavior, returning not found; maybe the caller learned about it from
-            // a dangling patch set ref or something.
-            continue;
-          }
-        }
-        return notes;
-      }
-
-      for (Change c : ReviewDbUtil.unwrapDb(db).changes().get(changeIds)) {
-        if (c != null && project.equals(c.getDest().getParentKey())) {
-          ChangeNotes cn = createFromChangeOnlyWhenNoteDbDisabled(c);
-          if (predicate.test(cn)) {
+      for (Change.Id cid : changeIds) {
+        try {
+          ChangeNotes cn = create(project, cid);
+          if (cn.getChange() != null && predicate.test(cn)) {
             notes.add(cn);
           }
+        } catch (NoSuchChangeException e) {
+          // Match ReviewDb behavior, returning not found; maybe the caller learned about it from
+          // a dangling patch set ref or something.
+          continue;
         }
       }
       return notes;
     }
 
-    public ListMultimap<Project.NameKey, ChangeNotes> create(
-        ReviewDb db, Predicate<ChangeNotes> predicate) throws IOException, OrmException {
+    public ListMultimap<Project.NameKey, ChangeNotes> create(Predicate<ChangeNotes> predicate)
+        throws IOException {
       ListMultimap<Project.NameKey, ChangeNotes> m =
           MultimapBuilder.hashKeys().arrayListValues().build();
-      if (args.migration.readChanges()) {
-        for (Project.NameKey project : projectCache.all()) {
-          try (Repository repo = args.repoManager.openRepository(project)) {
-            scan(repo, project)
-                .filter(r -> !r.error().isPresent())
-                .map(ChangeNotesResult::notes)
-                .filter(predicate)
-                .forEach(n -> m.put(n.getProjectName(), n));
-          }
-        }
-      } else {
-        for (Change change : ReviewDbUtil.unwrapDb(db).changes().all()) {
-          ChangeNotes notes = createFromChangeOnlyWhenNoteDbDisabled(change);
-          if (predicate.test(notes)) {
-            m.put(change.getProject(), notes);
-          }
+      for (Project.NameKey project : projectCache.all()) {
+        try (Repository repo = args.repoManager.openRepository(project)) {
+          scan(repo, project)
+              .filter(r -> !r.error().isPresent())
+              .map(ChangeNotesResult::notes)
+              .filter(predicate)
+              .forEach(n -> m.put(n.getProjectName(), n));
         }
       }
       return ImmutableListMultimap.copyOf(m);
@@ -302,16 +223,16 @@
         return null;
       }
 
-      // TODO(dborowitz): See discussion in NoteDbBatchUpdate#newChangeContext.
-      Change change = ChangeNotes.Factory.newNoteDbOnlyChange(project, id);
+      // TODO(dborowitz): See discussion in BatchUpdate#newChangeContext.
+      Change change = ChangeNotes.Factory.newChange(project, id);
 
       logger.atFine().log("adding change %s found in project %s", id, project);
       return toResult(change);
     }
 
     @Nullable
-    private ChangeNotesResult toResult(Change rawChangeFromReviewDbOrNoteDb) {
-      ChangeNotes n = new ChangeNotes(args, rawChangeFromReviewDbOrNoteDb);
+    private ChangeNotesResult toResult(Change rawChangeFromNoteDb) {
+      ChangeNotes n = new ChangeNotes(args, rawChangeFromNoteDb);
       try {
         n.load();
       } catch (OrmException e) {
@@ -594,9 +515,10 @@
   protected void onLoad(LoadHandle handle) throws NoSuchChangeException, IOException {
     ObjectId rev = handle.id();
     if (rev == null) {
-      if (args.migration.readChanges()
-          && PrimaryStorage.of(change) == PrimaryStorage.NOTE_DB
-          && shouldExist) {
+      // TODO(ekempin): Remove the primary storage check. At the moment it is still needed for the
+      // ChangeNotesParserTest which still runs with ReviewDb changes (see TODO in
+      // TestUpdate#newChange).
+      if (PrimaryStorage.of(change) == PrimaryStorage.NOTE_DB && shouldExist) {
         throw new NoSuchChangeException(getChangeId());
       }
       loadDefaults();
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index cc316e5..add5803 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -28,8 +29,7 @@
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesKeyProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
-import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
-import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.notedb.AbstractChangeNotes.Args;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.inject.Inject;
@@ -85,7 +85,7 @@
 
       @Override
       public byte[] serialize(Key object) {
-        return ProtoCacheSerializers.toByteArray(
+        return Protos.toByteArray(
             ChangeNotesKeyProto.newBuilder()
                 .setProject(object.project().get())
                 .setChangeId(object.changeId().get())
@@ -95,8 +95,7 @@
 
       @Override
       public Key deserialize(byte[] in) {
-        ChangeNotesKeyProto proto =
-            ProtoCacheSerializers.parseUnchecked(ChangeNotesKeyProto.parser(), in);
+        ChangeNotesKeyProto proto = Protos.parseUnchecked(ChangeNotesKeyProto.parser(), in);
         return Key.create(
             new Project.NameKey(proto.getProject()),
             new Change.Id(proto.getChangeId()),
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index ca579ae..7ce7e66 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -19,10 +19,6 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
-import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.APPROVAL_CODEC;
-import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.MESSAGE_CODEC;
-import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.PATCH_SET_CODEC;
-import static com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.toByteString;
 import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
@@ -40,6 +36,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.mail.Address;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
@@ -49,6 +46,10 @@
 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.converter.ChangeMessageProtoConverter;
+import com.google.gerrit.reviewdb.converter.PatchSetApprovalProtoConverter;
+import com.google.gerrit.reviewdb.converter.PatchSetProtoConverter;
+import com.google.gerrit.reviewdb.converter.ProtoConverter;
 import com.google.gerrit.server.OutputFormat;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
@@ -59,11 +60,12 @@
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerStatusUpdateProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
-import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
-import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.index.change.ChangeField.StoredSubmitRecord;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gson.Gson;
+import com.google.protobuf.ByteString;
+import com.google.protobuf.MessageLite;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.List;
@@ -455,8 +457,15 @@
 
       object.pastAssignees().forEach(a -> b.addPastAssignee(a.get()));
       object.hashtags().forEach(b::addHashtag);
-      object.patchSets().forEach(e -> b.addPatchSet(toByteString(e.getValue(), PATCH_SET_CODEC)));
-      object.approvals().forEach(e -> b.addApproval(toByteString(e.getValue(), APPROVAL_CODEC)));
+      object
+          .patchSets()
+          .forEach(e -> b.addPatchSet(toByteString(e.getValue(), PatchSetProtoConverter.INSTANCE)));
+      object
+          .approvals()
+          .forEach(
+              e ->
+                  b.addApproval(
+                      toByteString(e.getValue(), PatchSetApprovalProtoConverter.INSTANCE)));
 
       object.reviewers().asTable().cellSet().forEach(c -> b.addReviewer(toReviewerSetEntry(c)));
       object
@@ -480,14 +489,22 @@
       object
           .submitRecords()
           .forEach(r -> b.addSubmitRecord(GSON.toJson(new StoredSubmitRecord(r))));
-      object.changeMessages().forEach(m -> b.addChangeMessage(toByteString(m, MESSAGE_CODEC)));
+      object
+          .changeMessages()
+          .forEach(m -> b.addChangeMessage(toByteString(m, ChangeMessageProtoConverter.INSTANCE)));
       object.publishedComments().values().forEach(c -> b.addPublishedComment(GSON.toJson(c)));
 
       if (object.readOnlyUntil() != null) {
         b.setReadOnlyUntil(object.readOnlyUntil().getTime()).setHasReadOnlyUntil(true);
       }
 
-      return ProtoCacheSerializers.toByteArray(b.build());
+      return Protos.toByteArray(b.build());
+    }
+
+    @VisibleForTesting
+    static <T> ByteString toByteString(T object, ProtoConverter<?, T> converter) {
+      MessageLite message = converter.toProto(object);
+      return Protos.toByteString(message);
     }
 
     private static ChangeColumnsProto toChangeColumnsProto(ChangeColumns cols) {
@@ -555,8 +572,7 @@
 
     @Override
     public ChangeNotesState deserialize(byte[] in) {
-      ChangeNotesStateProto proto =
-          ProtoCacheSerializers.parseUnchecked(ChangeNotesStateProto.parser(), in);
+      ChangeNotesStateProto proto = Protos.parseUnchecked(ChangeNotesStateProto.parser(), in);
       Change.Id changeId = new Change.Id(proto.getChangeId());
 
       ChangeNotesState.Builder b =
@@ -575,14 +591,14 @@
                   proto
                       .getPatchSetList()
                       .stream()
-                      .map(PATCH_SET_CODEC::decode)
+                      .map(bytes -> parseProtoFrom(PatchSetProtoConverter.INSTANCE, bytes))
                       .map(ps -> Maps.immutableEntry(ps.getId(), ps))
                       .collect(toImmutableList()))
               .approvals(
                   proto
                       .getApprovalList()
                       .stream()
-                      .map(APPROVAL_CODEC::decode)
+                      .map(bytes -> parseProtoFrom(PatchSetApprovalProtoConverter.INSTANCE, bytes))
                       .map(a -> Maps.immutableEntry(a.getPatchSetId(), a))
                       .collect(toImmutableList()))
               .reviewers(toReviewerSet(proto.getReviewerList()))
@@ -606,7 +622,7 @@
                   proto
                       .getChangeMessageList()
                       .stream()
-                      .map(MESSAGE_CODEC::decode)
+                      .map(bytes -> parseProtoFrom(ChangeMessageProtoConverter.INSTANCE, bytes))
                       .collect(toImmutableList()))
               .publishedComments(
                   proto
@@ -620,6 +636,12 @@
       return b.build();
     }
 
+    private static <P extends MessageLite, T> T parseProtoFrom(
+        ProtoConverter<P, T> converter, ByteString byteString) {
+      P message = Protos.parseUnchecked(converter.getParser(), byteString);
+      return converter.fromProto(message);
+    }
+
     private static ChangeColumns toChangeColumns(Change.Id changeId, ChangeColumnsProto proto) {
       ChangeColumns.Builder b =
           ChangeColumns.builder()
diff --git a/java/com/google/gerrit/server/notedb/MutableNotesMigration.java b/java/com/google/gerrit/server/notedb/MutableNotesMigration.java
deleted file mode 100644
index eb41cbc..0000000
--- a/java/com/google/gerrit/server/notedb/MutableNotesMigration.java
+++ /dev/null
@@ -1,99 +0,0 @@
-// Copyright (C) 2017 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.notedb;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.function.Function;
-import org.eclipse.jgit.lib.Config;
-
-/**
- * {@link NotesMigration} with additional methods for altering the migration state at runtime.
- *
- * <p>Almost all callers care only about inspecting the migration state, and for safety should not
- * have access to mutation methods, which must be used with extreme care. Those callers should
- * inject {@link NotesMigration}.
- *
- * <p>Some callers, namely the NoteDb migration pipeline and tests, do need to alter the migration
- * state at runtime, and those callers are expected to take the necessary precautions such as
- * keeping the in-memory and on-disk config state in sync. Those callers use this class.
- *
- * <p>Mutations to the {@link MutableNotesMigration} are guaranteed to be instantly visible to all
- * callers that use the non-mutable {@link NotesMigration}. The current implementation accomplishes
- * this by always binding {@link NotesMigration} to {@link MutableNotesMigration} in Guice, so there
- * is just one {@link NotesMigration} instance process-wide.
- */
-@Singleton
-public class MutableNotesMigration extends NotesMigration {
-  public static MutableNotesMigration newDisabled() {
-    return new MutableNotesMigration(new Config());
-  }
-
-  public static MutableNotesMigration fromConfig(Config cfg) {
-    return new MutableNotesMigration(cfg);
-  }
-
-  @Inject
-  MutableNotesMigration(@GerritServerConfig Config cfg) {
-    super(Snapshot.create(cfg));
-  }
-
-  public MutableNotesMigration setReadChanges(boolean readChanges) {
-    return set(b -> b.setReadChanges(readChanges));
-  }
-
-  public MutableNotesMigration setWriteChanges(boolean writeChanges) {
-    return set(b -> b.setWriteChanges(writeChanges));
-  }
-
-  public MutableNotesMigration setReadChangeSequence(boolean readChangeSequence) {
-    return set(b -> b.setReadChangeSequence(readChangeSequence));
-  }
-
-  public MutableNotesMigration setChangePrimaryStorage(PrimaryStorage changePrimaryStorage) {
-    return set(b -> b.setChangePrimaryStorage(changePrimaryStorage));
-  }
-
-  public MutableNotesMigration setDisableChangeReviewDb(boolean disableChangeReviewDb) {
-    return set(b -> b.setDisableChangeReviewDb(disableChangeReviewDb));
-  }
-
-  public MutableNotesMigration setFailOnLoadForTest(boolean failOnLoadForTest) {
-    return set(b -> b.setFailOnLoadForTest(failOnLoadForTest));
-  }
-
-  /**
-   * Set the in-memory values returned by this instance to match the given state.
-   *
-   * <p>This method is only intended for use by tests.
-   *
-   * <p>This <em>only</em> modifies the in-memory state; if this instance was initialized from a
-   * file-based config, the underlying storage is not updated. Callers are responsible for managing
-   * the underlying storage on their own.
-   */
-  @VisibleForTesting
-  public MutableNotesMigration setFrom(NotesMigrationState state) {
-    snapshot.set(state.snapshot());
-    return this;
-  }
-
-  private MutableNotesMigration set(Function<Snapshot.Builder, Snapshot.Builder> f) {
-    snapshot.updateAndGet(s -> f.apply(s.toBuilder()).build());
-    return this;
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/NoteDbMetrics.java b/java/com/google/gerrit/server/notedb/NoteDbMetrics.java
index be06d11..61f475f 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbMetrics.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbMetrics.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.notedb;
 
-import com.google.gerrit.metrics.Counter1;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.Field;
@@ -44,16 +43,6 @@
    */
   final Timer1<NoteDbTable> parseLatency;
 
-  /**
-   * Latency due to auto-rebuilding entities when out of date.
-   *
-   * <p>Excludes latency from reading ref to check whether the entity is up to date.
-   */
-  final Timer1<NoteDbTable> autoRebuildLatency;
-
-  /** Count of auto-rebuild attempts that failed. */
-  final Counter1<NoteDbTable> autoRebuildFailureCount;
-
   @Inject
   NoteDbMetrics(MetricMaker metrics) {
     Field<NoteDbTable> view = Field.ofEnum(NoteDbTable.class, "table");
@@ -89,19 +78,5 @@
                 .setCumulative()
                 .setUnit(Units.MICROSECONDS),
             view);
-
-    autoRebuildLatency =
-        metrics.newTimer(
-            "notedb/auto_rebuild_latency",
-            new Description("NoteDb auto-rebuilding latency by table")
-                .setCumulative()
-                .setUnit(Units.MILLISECONDS),
-            view);
-
-    autoRebuildFailureCount =
-        metrics.newCounter(
-            "notedb/auto_rebuild_failure_count",
-            new Description("NoteDb auto-rebuilding attempts that failed by table").setCumulative(),
-            view);
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index 046757d..35cd402 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -614,10 +614,6 @@
 
   @Nullable
   public BatchRefUpdate execute(boolean dryrun) throws OrmException, IOException {
-    // Check before even inspecting the list, as this is a programmer error.
-    if (migration.failChangeWrites()) {
-      throw new OrmException(CHANGES_READ_ONLY);
-    }
     if (isEmpty()) {
       return null;
     }
diff --git a/java/com/google/gerrit/server/notedb/NotesMigration.java b/java/com/google/gerrit/server/notedb/NotesMigration.java
index 28754a6..26e764a 100644
--- a/java/com/google/gerrit/server/notedb/NotesMigration.java
+++ b/java/com/google/gerrit/server/notedb/NotesMigration.java
@@ -14,129 +14,22 @@
 
 package com.google.gerrit.server.notedb;
 
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
-
-import com.google.auto.value.AutoValue;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.inject.AbstractModule;
-import java.util.concurrent.atomic.AtomicReference;
-import org.eclipse.jgit.lib.Config;
+import com.google.inject.Singleton;
+import java.util.Objects;
 
 /**
  * Current low-level settings of the NoteDb migration for changes.
  *
- * <p>This class only describes the migration state of the {@link
- * com.google.gerrit.reviewdb.client.Change Change} entity group, since it is possible for a given
- * site to be in different states of the Change NoteDb migration process while staying at the same
- * ReviewDb schema version. It does <em>not</em> describe the migration state of non-Change tables;
- * those are automatically migrated using the ReviewDb schema migration process, so the NoteDb
- * migration state at a given ReviewDb schema cannot vary.
- *
- * <p>In many places, core Gerrit code should not directly care about the NoteDb migration state,
- * and should prefer high-level APIs like {@link com.google.gerrit.server.ApprovalsUtil
- * ApprovalsUtil} that don't require callers to inspect the migration state. The
- * <em>implementation</em> of those utilities does care about the state, and should query the {@code
- * NotesMigration} for the properties of the migration, for example, {@link #changePrimaryStorage()
- * where new changes should be stored}.
- *
- * <p>Core Gerrit code is mostly interested in one facet of the migration at a time (reading or
- * writing, say), but not all combinations of return values are supported or even make sense.
- *
- * <p>This class controls the state of the migration according to options in {@code gerrit.config}.
- * In general, any changes to these options should only be made by adventurous administrators, who
- * know what they're doing, on non-production data, for the purposes of testing the NoteDb
- * implementation.
- *
- * <p><strong>Note:</strong> Callers should not assume the values returned by {@code
- * NotesMigration}'s methods will not change in a running server.
+ * <p>This class is a stub and will be removed soon; NoteDb is the only mode.
  */
-public abstract class NotesMigration {
+@Singleton
+public class NotesMigration {
   public static final String SECTION_NOTE_DB = "noteDb";
-  public static final String READ = "read";
-  public static final String WRITE = "write";
-  public static final String DISABLE_REVIEW_DB = "disableReviewDb";
-  public static final String PRIMARY_STORAGE = "primaryStorage";
-  public static final String SEQUENCE = "sequence";
-
-  public static class Module extends AbstractModule {
-    @Override
-    public void configure() {
-      bind(MutableNotesMigration.class);
-      bind(NotesMigration.class).to(MutableNotesMigration.class);
-    }
-  }
-
-  @AutoValue
-  abstract static class Snapshot {
-    static Builder builder() {
-      // Default values are defined as what we would read from an empty config.
-      return create(new Config()).toBuilder();
-    }
-
-    static Snapshot create(Config cfg) {
-      return new AutoValue_NotesMigration_Snapshot.Builder()
-          .setWriteChanges(cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), WRITE, false))
-          .setReadChanges(cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), READ, false))
-          .setReadChangeSequence(cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), SEQUENCE, false))
-          .setChangePrimaryStorage(
-              cfg.getEnum(
-                  SECTION_NOTE_DB, CHANGES.key(), PRIMARY_STORAGE, PrimaryStorage.REVIEW_DB))
-          .setDisableChangeReviewDb(
-              cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), DISABLE_REVIEW_DB, false))
-          .setFailOnLoadForTest(false) // Only set in tests, can't be set via config.
-          .build();
-    }
-
-    abstract boolean writeChanges();
-
-    abstract boolean readChanges();
-
-    abstract boolean readChangeSequence();
-
-    abstract PrimaryStorage changePrimaryStorage();
-
-    abstract boolean disableChangeReviewDb();
-
-    abstract boolean failOnLoadForTest();
-
-    abstract Builder toBuilder();
-
-    void setConfigValues(Config cfg) {
-      cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), WRITE, writeChanges());
-      cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), READ, readChanges());
-      cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), SEQUENCE, readChangeSequence());
-      cfg.setEnum(SECTION_NOTE_DB, CHANGES.key(), PRIMARY_STORAGE, changePrimaryStorage());
-      cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), DISABLE_REVIEW_DB, disableChangeReviewDb());
-    }
-
-    @AutoValue.Builder
-    abstract static class Builder {
-      abstract Builder setWriteChanges(boolean writeChanges);
-
-      abstract Builder setReadChanges(boolean readChanges);
-
-      abstract Builder setReadChangeSequence(boolean readChangeSequence);
-
-      abstract Builder setChangePrimaryStorage(PrimaryStorage changePrimaryStorage);
-
-      abstract Builder setDisableChangeReviewDb(boolean disableChangeReviewDb);
-
-      abstract Builder setFailOnLoadForTest(boolean failOnLoadForTest);
-
-      abstract Snapshot autoBuild();
-
-      Snapshot build() {
-        Snapshot s = autoBuild();
-        checkArgument(
-            !(s.disableChangeReviewDb() && s.changePrimaryStorage() != PrimaryStorage.NOTE_DB),
-            "cannot disable ReviewDb for changes if default change primary storage is ReviewDb");
-        return s;
-      }
-    }
-  }
-
-  protected final AtomicReference<Snapshot> snapshot;
+  private static final String READ = "read";
+  private static final String WRITE = "write";
+  private static final String DISABLE_REVIEW_DB = "disableReviewDb";
+  private static final String PRIMARY_STORAGE = "primaryStorage";
+  private static final String SEQUENCE = "sequence";
 
   /**
    * Read changes from NoteDb.
@@ -149,100 +42,22 @@
    * attempts to write will generate an error.
    */
   public final boolean readChanges() {
-    return snapshot.get().readChanges();
-  }
-
-  /**
-   * Write changes to NoteDb.
-   *
-   * <p>This method is awkwardly named because you should be using either {@link
-   * #commitChangeWrites()} or {@link #failChangeWrites()} instead.
-   *
-   * <p>Updates to change data are written to NoteDb refs, but ReviewDb is still the source of
-   * truth. Change data will not be written unless the NoteDb refs are already up to date, and the
-   * write path will attempt to rebuild the change if not.
-   *
-   * <p>If false, the behavior when attempting to write depends on {@code readChanges()}. If {@code
-   * readChanges() = false}, writes to NoteDb are simply ignored; if {@code true}, any attempts to
-   * write will generate an error.
-   */
-  public final boolean rawWriteChangesSetting() {
-    return snapshot.get().writeChanges();
-  }
-
-  /**
-   * Read sequential change ID numbers from NoteDb.
-   *
-   * <p>If true, change IDs are read from {@code refs/sequences/changes} in All-Projects. If false,
-   * change IDs are read from ReviewDb's native sequences.
-   */
-  public final boolean readChangeSequence() {
-    return snapshot.get().readChangeSequence();
-  }
-
-  /** @return default primary storage for new changes. */
-  public final PrimaryStorage changePrimaryStorage() {
-    return snapshot.get().changePrimaryStorage();
-  }
-
-  /**
-   * Disable ReviewDb access for changes.
-   *
-   * <p>When set, ReviewDb operations involving the Changes table become no-ops. Lookups return no
-   * results; updates do nothing, as does opening, committing, or rolling back a transaction on the
-   * Changes table.
-   */
-  public final boolean disableChangeReviewDb() {
-    return snapshot.get().disableChangeReviewDb();
-  }
-
-  /**
-   * Whether to fail when reading any data from NoteDb.
-   *
-   * <p>Used in conjunction with {@link #readChanges()} for tests.
-   */
-  public boolean failOnLoadForTest() {
-    return snapshot.get().failOnLoadForTest();
+    return true;
   }
 
   public final boolean commitChangeWrites() {
-    // It may seem odd that readChanges() without writeChanges() means we should
-    // attempt to commit writes. However, this method is used by callers to know
-    // whether or not they should short-circuit and skip attempting to read or
-    // write NoteDb refs.
-    //
-    // It is possible for commitChangeWrites() to return true and
-    // failChangeWrites() to also return true, causing an error later in the
-    // same codepath. This specific condition is used by the auto-rebuilding
-    // path to rebuild a change and stage the results, but not commit them due
-    // to failChangeWrites().
-    return rawWriteChangesSetting() || readChanges();
-  }
-
-  public final boolean failChangeWrites() {
-    return !rawWriteChangesSetting() && readChanges();
-  }
-
-  public final void setConfigValues(Config cfg) {
-    snapshot.get().setConfigValues(cfg);
+    return true;
   }
 
   @Override
   public final boolean equals(Object o) {
-    return o instanceof NotesMigration
-        && snapshot.get().equals(((NotesMigration) o).snapshot.get());
+    return o instanceof NotesMigration;
   }
 
   @Override
   public final int hashCode() {
-    return snapshot.get().hashCode();
+    return Objects.hash();
   }
 
-  protected NotesMigration(Snapshot snapshot) {
-    this.snapshot = new AtomicReference<>(snapshot);
-  }
-
-  final Snapshot snapshot() {
-    return snapshot.get();
-  }
+  public NotesMigration() {}
 }
diff --git a/java/com/google/gerrit/server/notedb/NotesMigrationState.java b/java/com/google/gerrit/server/notedb/NotesMigrationState.java
deleted file mode 100644
index c682aed..0000000
--- a/java/com/google/gerrit/server/notedb/NotesMigrationState.java
+++ /dev/null
@@ -1,92 +0,0 @@
-// Copyright (C) 2017 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.notedb;
-
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.NotesMigration.Snapshot;
-import java.util.Optional;
-import java.util.stream.Stream;
-import org.eclipse.jgit.lib.Config;
-
-/**
- * Possible high-level states of the NoteDb migration for changes.
- *
- * <p>This class describes the series of states required to migrate a site from ReviewDb-only to
- * NoteDb-only. This process has several steps, and covers only a small subset of the theoretically
- * possible combinations of {@link NotesMigration} return values.
- *
- * <p>These states are ordered: a one-way migration from ReviewDb to NoteDb will pass through states
- * in the order in which they are defined.
- */
-public enum NotesMigrationState {
-  REVIEW_DB(false, false, false, PrimaryStorage.REVIEW_DB, false),
-
-  WRITE(false, true, false, PrimaryStorage.REVIEW_DB, false),
-
-  READ_WRITE_NO_SEQUENCE(true, true, false, PrimaryStorage.REVIEW_DB, false),
-
-  READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY(true, true, true, PrimaryStorage.REVIEW_DB, false),
-
-  READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY(true, true, true, PrimaryStorage.NOTE_DB, false),
-
-  NOTE_DB(true, true, true, PrimaryStorage.NOTE_DB, true);
-
-  public static final NotesMigrationState FINAL = NOTE_DB;
-
-  public static Optional<NotesMigrationState> forConfig(Config cfg) {
-    return forSnapshot(Snapshot.create(cfg));
-  }
-
-  public static Optional<NotesMigrationState> forNotesMigration(NotesMigration migration) {
-    return forSnapshot(migration.snapshot());
-  }
-
-  private static Optional<NotesMigrationState> forSnapshot(Snapshot s) {
-    return Stream.of(values()).filter(v -> v.snapshot.equals(s)).findFirst();
-  }
-
-  private final Snapshot snapshot;
-
-  NotesMigrationState(
-      // Arguments match abstract methods in NotesMigration.
-      boolean readChanges,
-      boolean rawWriteChangesSetting,
-      boolean readChangeSequence,
-      PrimaryStorage changePrimaryStorage,
-      boolean disableChangeReviewDb) {
-    this.snapshot =
-        Snapshot.builder()
-            .setReadChanges(readChanges)
-            .setWriteChanges(rawWriteChangesSetting)
-            .setReadChangeSequence(readChangeSequence)
-            .setChangePrimaryStorage(changePrimaryStorage)
-            .setDisableChangeReviewDb(disableChangeReviewDb)
-            .build();
-  }
-
-  public void setConfigValues(Config cfg) {
-    snapshot.setConfigValues(cfg);
-  }
-
-  public String toText() {
-    Config cfg = new Config();
-    setConfigValues(cfg);
-    return cfg.toText();
-  }
-
-  Snapshot snapshot() {
-    return snapshot;
-  }
-}
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index f4e659e..b15854d 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -55,10 +55,9 @@
       this.notesFactory = notesFactory;
     }
 
-    ChangeControl create(
-        RefControl refControl, ReviewDb db, Project.NameKey project, Change.Id changeId)
+    ChangeControl create(RefControl refControl, Project.NameKey project, Change.Id changeId)
         throws OrmException {
-      return create(refControl, notesFactory.create(db, project, changeId));
+      return create(refControl, notesFactory.create(project, changeId));
     }
 
     ChangeControl create(RefControl refControl, ChangeNotes notes) {
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index 249c872..ea4073d 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -133,9 +133,10 @@
       refs = addUsersSelfSymref(refs);
     }
 
+    boolean hasReadOnRefsStar =
+        checkProjectPermission(permissionBackendForProject, ProjectPermission.READ);
     if (skipFullRefEvaluationIfAllRefsAreVisible && !projectState.isAllUsers()) {
-      if (projectState.statePermitsRead()
-          && checkProjectPermission(permissionBackendForProject, ProjectPermission.READ)) {
+      if (projectState.statePermitsRead() && hasReadOnRefsStar) {
         skipFilterCount.increment();
         return refs;
       } else if (projectControl.allRefsAreVisible(ImmutableSet.of(RefNames.REFS_CONFIG))) {
@@ -197,9 +198,22 @@
           result.put(name, ref);
         }
       } else if (isTag(ref)) {
-        // If its a tag, consider it later.
-        if (ref.getObjectId() != null) {
-          deferredTags.add(ref);
+        if (hasReadOnRefsStar) {
+          // The user has READ on refs/*. This is the broadest permission one can assign. There is
+          // no way to grant access to (specific) tags in Gerrit, so we have to assume that these
+          // users can see all tags because there could be tags that aren't reachable by any visible
+          // ref while the user can see all non-Gerrit refs. This matches Gerrit's historic
+          // behavior.
+          // This makes it so that these users could see commits that they can't see otherwise
+          // (e.g. a private change ref) if a tag was attached to it. Tags are meant to be used on
+          // the regular Git tree that users interact with, not on any of the Gerrit trees, so this
+          // is a negligible risk.
+          result.put(name, ref);
+        } else {
+          // If its a tag, consider it later.
+          if (ref.getObjectId() != null) {
+            deferredTags.add(ref);
+          }
         }
       } else if (name.startsWith(RefNames.REFS_SEQUENCES)) {
         // Sequences are internal database implementation details.
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index 787bee4..445e577 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -27,7 +27,6 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.config.GitReceivePackGroups;
@@ -98,9 +97,9 @@
     return new ForProjectImpl();
   }
 
-  ChangeControl controlFor(ReviewDb db, Change change) throws OrmException {
+  ChangeControl controlFor(Change change) throws OrmException {
     return changeControlFactory.create(
-        controlForRef(change.getDest()), db, change.getProject(), change.getId());
+        controlForRef(change.getDest()), change.getProject(), change.getId());
   }
 
   ChangeControl controlFor(ChangeNotes notes) {
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index 83ea7f8..e445eb8 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -442,9 +442,7 @@
     public ForChange change(ChangeData cd) {
       try {
         // TODO(hiesel) Force callers to call database() and use db instead of cd.db()
-        return getProjectControl()
-            .controlFor(cd.db(), cd.change())
-            .asForChange(cd, Providers.of(cd.db()));
+        return getProjectControl().controlFor(cd.change()).asForChange(cd, Providers.of(cd.db()));
       } catch (OrmException e) {
         return FailedPermissionBackend.change("unavailable", e);
       }
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 4df953e..c12201f 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -139,7 +139,7 @@
     if (missing.isEmpty()) {
       return;
     }
-    for (ChangeNotes notes : first.notesFactory.create(first.db, missing.keySet())) {
+    for (ChangeNotes notes : first.notesFactory.create(missing.keySet())) {
       missing.get(notes.getChangeId()).change = notes.getChange();
     }
   }
@@ -571,7 +571,7 @@
 
   public Change reloadChange() throws OrmException {
     try {
-      notes = notesFactory.createChecked(db, project, legacyId);
+      notes = notesFactory.createChecked(project, legacyId);
     } catch (NoSuchChangeException e) {
       throw new OrmException("Unable to load change " + legacyId, e);
     }
@@ -598,7 +598,7 @@
       if (!lazyLoad) {
         throw new OrmException("ChangeNotes not available, lazyLoad = false");
       }
-      notes = notesFactory.create(db, project(), legacyId);
+      notes = notesFactory.create(project(), legacyId);
     }
     return notes;
   }
diff --git a/java/com/google/gerrit/server/query/change/ConflictKey.java b/java/com/google/gerrit/server/query/change/ConflictKey.java
index 42f5b13..01fdbfa 100644
--- a/java/com/google/gerrit/server/query/change/ConflictKey.java
+++ b/java/com/google/gerrit/server/query/change/ConflictKey.java
@@ -20,10 +20,10 @@
 import com.google.common.base.Enums;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.proto.Cache.ConflictKeyProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
-import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
-import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -70,7 +70,7 @@
     @Override
     public byte[] serialize(ConflictKey object) {
       ObjectIdConverter idConverter = ObjectIdConverter.create();
-      return ProtoCacheSerializers.toByteArray(
+      return Protos.toByteArray(
           ConflictKeyProto.newBuilder()
               .setCommit(idConverter.toByteString(object.commit()))
               .setOtherCommit(idConverter.toByteString(object.otherCommit()))
@@ -81,7 +81,7 @@
 
     @Override
     public ConflictKey deserialize(byte[] in) {
-      ConflictKeyProto proto = ProtoCacheSerializers.parseUnchecked(ConflictKeyProto.parser(), in);
+      ConflictKeyProto proto = Protos.parseUnchecked(ConflictKeyProto.parser(), in);
       ObjectIdConverter idConverter = ObjectIdConverter.create();
       return create(
           idConverter.fromByteString(proto.getCommit()),
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 495d27c..23993ef 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -215,7 +215,6 @@
 
     List<ChangeNotes> notes =
         notesFactory.create(
-            db,
             branch.getParentKey(),
             changeIds,
             cn -> {
diff --git a/java/com/google/gerrit/server/quota/DefaultQuotaBackend.java b/java/com/google/gerrit/server/quota/DefaultQuotaBackend.java
index baa7677..c6e67ca 100644
--- a/java/com/google/gerrit/server/quota/DefaultQuotaBackend.java
+++ b/java/com/google/gerrit/server/quota/DefaultQuotaBackend.java
@@ -74,8 +74,12 @@
           responses.add(enforcer.call(p -> p.dryRun(quotaGroup, requestContext, numTokens)));
         }
       } catch (RuntimeException e) {
-        logger.atSevere().withCause(e).log("exception while enforcing quota");
-        responses.add(QuotaResponse.error("failed to request quota tokens"));
+        // Roll back the quota request for all enforcers that deducted the quota. Rethrow the
+        // exception to adhere to the API contract.
+        if (deduct) {
+          refillAfterErrorOrException(enforcers, responses, quotaGroup, requestContext, numTokens);
+        }
+        throw e;
       }
     }
 
@@ -83,11 +87,7 @@
       // Roll back the quota request for all enforcers that deducted the quota (= the request
       // succeeded). Don't touch failed enforcers as the interface contract said that failed
       // requests should not be deducted.
-      for (int i = 0; i < responses.size(); i++) {
-        if (responses.get(i).status().isOk()) {
-          enforcers.get(i).run(p -> p.refill(quotaGroup, requestContext, numTokens));
-        }
-      }
+      refillAfterErrorOrException(enforcers, responses, quotaGroup, requestContext, numTokens);
     }
 
     logger.atFine().log(
@@ -100,6 +100,19 @@
     return QuotaResponse.Aggregated.create(ImmutableList.copyOf(responses));
   }
 
+  private static void refillAfterErrorOrException(
+      List<PluginSetEntryContext<QuotaEnforcer>> enforcers,
+      List<QuotaResponse> collectedResponses,
+      String quotaGroup,
+      QuotaRequestContext requestContext,
+      long numTokens) {
+    for (int i = 0; i < collectedResponses.size(); i++) {
+      if (collectedResponses.get(i).status().isOk()) {
+        enforcers.get(i).run(p -> p.refill(quotaGroup, requestContext, numTokens));
+      }
+    }
+  }
+
   static class WithUser extends WithResource implements QuotaBackend.WithUser {
     WithUser(PluginSetContext<QuotaEnforcer> quotaEnforcers, CurrentUser user) {
       super(quotaEnforcers, QuotaRequestContext.builder().user(user).build());
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
index 16ab812..96b9519 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -151,7 +151,7 @@
     // Currently there's no way to let some updates succeed even if others fail. Even if there were,
     // all updates from this operation only happen in All-Users and thus are fully atomic, so
     // allowing partial failure would have little value.
-    batchUpdateFactory.execute(updates.values(), BatchUpdateListener.NONE, false);
+    BatchUpdate.execute(updates.values(), BatchUpdateListener.NONE, false);
 
     return ops.stream().map(Op::getResult).filter(Objects::nonNull).collect(toImmutableList());
   }
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index c7a8a96..4c34e62 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -360,8 +360,7 @@
         .setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
     if (input.keepReviewers && sourceChange != null) {
       ReviewerSet reviewerSet =
-          approvalsUtil.getReviewers(
-              changeNotesFactory.createChecked(dbProvider.get(), sourceChange));
+          approvalsUtil.getReviewers(changeNotesFactory.createChecked(sourceChange));
       Set<Account.Id> reviewers =
           new HashSet<>(reviewerSet.byState(ReviewerStateInternal.REVIEWER));
       reviewers.add(sourceChange.getOwner());
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index d426df3..e46085d 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -215,8 +215,7 @@
       ReviewDb db = dbProvider.get();
       op.merge(db, change, submitter, true, input, false);
       try {
-        change =
-            changeNotesFactory.createChecked(db, change.getProject(), change.getId()).getChange();
+        change = changeNotesFactory.createChecked(change.getProject(), change.getId()).getChange();
       } catch (NoSuchChangeException e) {
         throw new ResourceConflictException("change is deleted");
       }
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index 98ef220..c03c4c5 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -53,7 +53,6 @@
 import com.google.gerrit.server.documentation.QueryDocumentationExecutor;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginItemContext;
 import com.google.gerrit.server.plugincontext.PluginMapContext;
@@ -91,7 +90,6 @@
   private final PluginItemContext<AvatarProvider> avatar;
   private final boolean enableSignedPush;
   private final QueryDocumentationExecutor docSearcher;
-  private final NotesMigration migration;
   private final ProjectCache projectCache;
   private final AgreementJson agreementJson;
   private final ChangeIndexCollection indexes;
@@ -114,7 +112,6 @@
       PluginItemContext<AvatarProvider> avatar,
       @EnableSignedPush boolean enableSignedPush,
       QueryDocumentationExecutor docSearcher,
-      NotesMigration migration,
       ProjectCache projectCache,
       AgreementJson agreementJson,
       ChangeIndexCollection indexes,
@@ -134,7 +131,6 @@
     this.avatar = avatar;
     this.enableSignedPush = enableSignedPush;
     this.docSearcher = docSearcher;
-    this.migration = migration;
     this.projectCache = projectCache;
     this.agreementJson = agreementJson;
     this.indexes = indexes;
@@ -149,7 +145,7 @@
     info.change = getChangeInfo();
     info.download = getDownloadInfo();
     info.gerrit = getGerritInfo();
-    info.noteDbEnabled = toBoolean(isNoteDbEnabled());
+    info.noteDbEnabled = true;
     info.plugin = getPluginInfo();
     info.defaultTheme = getDefaultTheme();
     info.sshd = getSshdInfo();
@@ -313,10 +309,6 @@
     return CharMatcher.is('/').trimTrailingFrom(docUrl) + '/';
   }
 
-  private boolean isNoteDbEnabled() {
-    return migration.readChanges();
-  }
-
   private PluginConfigInfo getPluginInfo() {
     PluginConfigInfo info = new PluginConfigInfo();
     info.hasAvatars = toBoolean(avatar.hasImplementation());
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index 14da9eb..78324fa 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -264,8 +264,7 @@
   }
 
   private void initSequences(Repository git, BatchRefUpdate bru) throws IOException {
-    if (notesMigration.readChangeSequence()
-        && git.exactRef(REFS_SEQUENCES + Sequences.NAME_CHANGES) == null) {
+    if (git.exactRef(REFS_SEQUENCES + Sequences.NAME_CHANGES) == null) {
       // Can't easily reuse the inserter from MetaDataUpdate, but this shouldn't slow down site
       // initialization unduly.
       try (ObjectInserter ins = git.newObjectInserter()) {
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java b/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java
index 602b639..73deb3e 100644
--- a/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java
@@ -15,12 +15,6 @@
 package com.google.gerrit.server.schema;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
-import static com.google.gerrit.server.notedb.NotesMigration.DISABLE_REVIEW_DB;
-import static com.google.gerrit.server.notedb.NotesMigration.PRIMARY_STORAGE;
-import static com.google.gerrit.server.notedb.NotesMigration.READ;
-import static com.google.gerrit.server.notedb.NotesMigration.SECTION_NOTE_DB;
-import static com.google.gerrit.server.notedb.NotesMigration.WRITE;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
@@ -144,11 +138,11 @@
     // * Completed the change migration to NoteDB.
     // * Ran schema upgrades from a 2.16 final release.
 
-    if (!cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), WRITE, false)
-        || !cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), READ, false)
-        || cfg.getEnum(SECTION_NOTE_DB, CHANGES.key(), PRIMARY_STORAGE, PrimaryStorage.REVIEW_DB)
+    if (!cfg.getBoolean("noteDb", "changes", "write", false)
+        || !cfg.getBoolean("noteDb", "changes", "read", false)
+        || cfg.getEnum("noteDb", "changes", "primaryStorage", PrimaryStorage.REVIEW_DB)
             != PrimaryStorage.NOTE_DB
-        || !cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), DISABLE_REVIEW_DB, false)) {
+        || !cfg.getBoolean("noteDb", "changes", "disableReviewDb", false)) {
       throw new OrmException(
           "You appear to be upgrading from a 2.x site, but the NoteDb change migration was"
               + " not completed. See documentation:\n"
diff --git a/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java b/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java
index c533619..63b3eaa 100644
--- a/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java
+++ b/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.schema;
 
-import static com.google.common.base.Preconditions.checkState;
-
 import com.google.gerrit.reviewdb.server.DisallowedReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.notedb.NotesMigration;
@@ -60,8 +58,6 @@
     //    do nothing. This implementation is not a public class and callers couldn't do anything
     //    useful with it even if it were.
 
-    // First create the underlying stub.
-    checkState(migration.readChanges() && migration.disableChangeReviewDb());
     // Disable writes to change tables in ReviewDb (ReviewDb access for changes are No-Ops); all
     // other table accesses throw runtime exceptions.
     ReviewDb db = new NoChangesReviewDb();
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 81e2661..13affc3 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -607,7 +607,7 @@
       SubmoduleOp submoduleOp = subOpFactory.create(branches, orm);
       List<SubmitStrategy> strategies = getSubmitStrategies(toSubmit, submoduleOp, dryrun);
       this.allProjects = submoduleOp.getProjectsInOrder();
-      batchUpdateFactory.execute(
+      BatchUpdate.execute(
           orm.batchUpdates(allProjects),
           new SubmitStrategyListener(submitInput, strategies, commitStatus),
           dryrun);
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index 50be62a..3fefed3 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -101,39 +101,29 @@
     private final Provider<PersonIdent> serverIdent;
     private final Config cfg;
     private final ProjectCache projectCache;
-    private final BatchUpdate.Factory batchUpdateFactory;
 
     @Inject
     Factory(
         GitModules.Factory gitmodulesFactory,
         @GerritPersonIdent Provider<PersonIdent> serverIdent,
         @GerritServerConfig Config cfg,
-        ProjectCache projectCache,
-        BatchUpdate.Factory batchUpdateFactory) {
+        ProjectCache projectCache) {
       this.gitmodulesFactory = gitmodulesFactory;
       this.serverIdent = serverIdent;
       this.cfg = cfg;
       this.projectCache = projectCache;
-      this.batchUpdateFactory = batchUpdateFactory;
     }
 
     public SubmoduleOp create(Set<Branch.NameKey> updatedBranches, MergeOpRepoManager orm)
         throws SubmoduleException {
       return new SubmoduleOp(
-          gitmodulesFactory,
-          serverIdent.get(),
-          cfg,
-          projectCache,
-          batchUpdateFactory,
-          updatedBranches,
-          orm);
+          gitmodulesFactory, serverIdent.get(), cfg, projectCache, updatedBranches, orm);
     }
   }
 
   private final GitModules.Factory gitmodulesFactory;
   private final PersonIdent myIdent;
   private final ProjectCache projectCache;
-  private final BatchUpdate.Factory batchUpdateFactory;
   private final VerboseSuperprojectUpdate verboseSuperProject;
   private final boolean enableSuperProjectSubscriptions;
   private final long maxCombinedCommitMessageSize;
@@ -173,14 +163,12 @@
       PersonIdent myIdent,
       Config cfg,
       ProjectCache projectCache,
-      BatchUpdate.Factory batchUpdateFactory,
       Set<Branch.NameKey> updatedBranches,
       MergeOpRepoManager orm)
       throws SubmoduleException {
     this.gitmodulesFactory = gitmodulesFactory;
     this.myIdent = myIdent;
     this.projectCache = projectCache;
-    this.batchUpdateFactory = batchUpdateFactory;
     this.verboseSuperProject =
         cfg.getEnum("submodule", null, "verboseSuperprojectUpdate", VerboseSuperprojectUpdate.TRUE);
     this.enableSuperProjectSubscriptions =
@@ -420,7 +408,7 @@
           }
         }
       }
-      batchUpdateFactory.execute(orm.batchUpdates(superProjects), BatchUpdateListener.NONE, false);
+      BatchUpdate.execute(orm.batchUpdates(superProjects), BatchUpdateListener.NONE, false);
     } catch (RestApiException | UpdateException | IOException | NoSuchProjectException e) {
       throw new SubmoduleException("Cannot update gitlinks", e);
     }
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index a768888..2c1824c 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -17,7 +17,10 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableMultiset.toImmutableMultiset;
+import static com.google.common.flogger.LazyArgs.lazy;
+import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
@@ -32,20 +35,28 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
+import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.logging.RequestId;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.NoSuchRefException;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Module;
-import com.google.inject.Singleton;
+import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -55,6 +66,7 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.TimeZone;
+import java.util.TreeMap;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -64,81 +76,145 @@
 import org.eclipse.jgit.transport.ReceiveCommand;
 
 /**
- * Helper for a set of updates that should be applied for a site.
+ * Helper for a set of change updates that should be applied to the NoteDb database.
  *
  * <p>An update operation can be divided into three phases:
  *
  * <ol>
  *   <li>Git reference updates
- *   <li>Database updates
+ *   <li>Review metadata updates
  *   <li>Post-update steps
  *   <li>
  * </ol>
  *
  * A single conceptual operation, such as a REST API call or a merge operation, may make multiple
  * changes at each step, which all need to be serialized relative to each other. Moreover, for
- * consistency, <em>all</em> git ref updates must be performed before <em>any</em> database updates,
- * since database updates might refer to newly-created patch set refs. And all post-update steps,
- * such as hooks, should run only after all storage mutations have completed.
+ * consistency, the git ref updates must be visible to the review metadata updates, since for
+ * example the metadata might refer to newly-created patch set refs. In NoteDb, this is accomplished
+ * by combining these two phases into a single {@link BatchRefUpdate}.
  *
- * <p>Depending on the backend used, each step might support batching, for example in a {@code
- * BatchRefUpdate} or one or more database transactions. All operations in one phase must complete
- * successfully before proceeding to the next phase.
+ * <p>Similarly, all post-update steps, such as sending email, must run only after all storage
+ * mutations have completed.
  */
-public abstract class BatchUpdate implements AutoCloseable {
+public class BatchUpdate implements AutoCloseable {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public static Module module() {
     return new FactoryModule() {
       @Override
       public void configure() {
-        factory(NoteDbBatchUpdate.AssistedFactory.class);
+        factory(BatchUpdate.Factory.class);
       }
     };
   }
 
-  @Singleton
-  public static class Factory {
-    private final NoteDbBatchUpdate.AssistedFactory noteDbBatchUpdateFactory;
+  // TODO(dborowitz): Make this package-private to force all callers to use RetryHelper.
+  public interface Factory {
+    BatchUpdate create(ReviewDb db, Project.NameKey project, CurrentUser user, Timestamp when);
+  }
 
-    // TODO(dborowitz): Make this non-injectable to force all callers to use RetryHelper.
-    @Inject
-    Factory(NoteDbBatchUpdate.AssistedFactory noteDbBatchUpdateFactory) {
-      this.noteDbBatchUpdateFactory = noteDbBatchUpdateFactory;
+  public static void execute(
+      Collection<BatchUpdate> updates, BatchUpdateListener listener, boolean dryrun)
+      throws UpdateException, RestApiException {
+    requireNonNull(listener);
+    if (updates.isEmpty()) {
+      return;
     }
 
-    public BatchUpdate create(
-        ReviewDb db, Project.NameKey project, CurrentUser user, Timestamp when) {
-      return noteDbBatchUpdateFactory.create(db, project, user, when);
-    }
+    checkDifferentProject(updates);
 
-    @SuppressWarnings({"rawtypes", "unchecked"})
-    public void execute(
-        Collection<BatchUpdate> updates, BatchUpdateListener listener, boolean dryRun)
-        throws UpdateException, RestApiException {
-      requireNonNull(listener);
-      checkDifferentProject(updates);
-      // It's safe to downcast all members of the input collection in this case, because the only
-      // way a caller could have gotten any BatchUpdates in the first place is to call the create
-      // method above, which always returns instances of the type we expect. Just to be safe,
-      // copy them into an ImmutableList so there is no chance the callee can pollute the input
-      // collection.
-      ImmutableList<NoteDbBatchUpdate> noteDbUpdates =
-          (ImmutableList) ImmutableList.copyOf(updates);
-      NoteDbBatchUpdate.execute(noteDbUpdates, listener, dryRun);
-    }
+    try {
+      @SuppressWarnings("deprecation")
+      List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> indexFutures =
+          new ArrayList<>();
+      List<ChangesHandle> handles = new ArrayList<>(updates.size());
+      Order order = getOrder(updates, listener);
+      try {
+        switch (order) {
+          case REPO_BEFORE_DB:
+            for (BatchUpdate u : updates) {
+              u.executeUpdateRepo();
+            }
+            listener.afterUpdateRepos();
+            for (BatchUpdate u : updates) {
+              handles.add(u.executeChangeOps(dryrun));
+            }
+            for (ChangesHandle h : handles) {
+              h.execute();
+              indexFutures.addAll(h.startIndexFutures());
+            }
+            listener.afterUpdateRefs();
+            listener.afterUpdateChanges();
+            break;
 
-    private static void checkDifferentProject(Collection<BatchUpdate> updates) {
-      Multiset<Project.NameKey> projectCounts =
-          updates.stream().map(u -> u.project).collect(toImmutableMultiset());
-      checkArgument(
-          projectCounts.entrySet().size() == updates.size(),
-          "updates must all be for different projects, got: %s",
-          projectCounts);
+          case DB_BEFORE_REPO:
+            // Call updateChange for each op before updateRepo, but defer executing the
+            // NoteDbUpdateManager until after calling updateRepo. They share an inserter and
+            // BatchRefUpdate, so it will all execute as a single batch. But we have to let
+            // NoteDbUpdateManager actually execute the update, since it has to interleave it
+            // properly with All-Users updates.
+            //
+            // TODO(dborowitz): This may still result in multiple updates to All-Users, but that's
+            // currently not a big deal because multi-change batches generally aren't affecting
+            // drafts anyway.
+            for (BatchUpdate u : updates) {
+              handles.add(u.executeChangeOps(dryrun));
+            }
+            for (BatchUpdate u : updates) {
+              u.executeUpdateRepo();
+            }
+            for (ChangesHandle h : handles) {
+              // TODO(dborowitz): This isn't quite good enough: in theory updateRepo may want to
+              // see the results of change meta commands, but they aren't actually added to the
+              // BatchUpdate until the body of execute. To fix this, execute needs to be split up
+              // into a method that returns a BatchRefUpdate before execution. Not a big deal at the
+              // moment, because this order is only used for deleting changes, and those updateRepo
+              // implementations definitely don't need to observe the updated change meta refs.
+              h.execute();
+              indexFutures.addAll(h.startIndexFutures());
+            }
+            break;
+          default:
+            throw new IllegalStateException("invalid execution order: " + order);
+        }
+      } finally {
+        for (ChangesHandle h : handles) {
+          h.close();
+        }
+      }
+
+      ChangeIndexer.allAsList(indexFutures).get();
+
+      // Fire ref update events only after all mutations are finished, since callers may assume a
+      // patch set ref being created means the change was created, or a branch advancing meaning
+      // some changes were closed.
+      updates
+          .stream()
+          .filter(u -> u.batchRefUpdate != null)
+          .forEach(
+              u -> u.gitRefUpdated.fire(u.project, u.batchRefUpdate, u.getAccount().orElse(null)));
+
+      if (!dryrun) {
+        for (BatchUpdate u : updates) {
+          u.executePostOps();
+        }
+      }
+    } catch (Exception e) {
+      wrapAndThrowException(e);
     }
   }
 
-  static Order getOrder(Collection<? extends BatchUpdate> updates, BatchUpdateListener listener) {
+  private static void checkDifferentProject(Collection<BatchUpdate> updates) {
+    Multiset<Project.NameKey> projectCounts =
+        updates.stream().map(u -> u.project).collect(toImmutableMultiset());
+    checkArgument(
+        projectCounts.entrySet().size() == updates.size(),
+        "updates must all be for different projects, got: %s",
+        projectCounts);
+  }
+
+  private static Order getOrder(
+      Collection<? extends BatchUpdate> updates, BatchUpdateListener listener) {
     Order o = null;
     for (BatchUpdate u : updates) {
       if (o == null) {
@@ -156,7 +232,7 @@
     return o;
   }
 
-  static void wrapAndThrowException(Exception e) throws UpdateException, RestApiException {
+  private static void wrapAndThrowException(Exception e) throws UpdateException, RestApiException {
     Throwables.throwIfUnchecked(e);
 
     // Propagate REST API exceptions thrown by operations; they commonly throw exceptions like
@@ -178,32 +254,155 @@
     throw new UpdateException(e);
   }
 
-  protected GitRepositoryManager repoManager;
+  class ContextImpl implements Context {
+    @Override
+    public RepoView getRepoView() throws IOException {
+      return BatchUpdate.this.getRepoView();
+    }
 
-  protected final Project.NameKey project;
-  protected final CurrentUser user;
-  protected final Timestamp when;
-  protected final TimeZone tz;
+    @Override
+    public RevWalk getRevWalk() throws IOException {
+      return getRepoView().getRevWalk();
+    }
 
-  protected final ListMultimap<Change.Id, BatchUpdateOp> ops =
+    @Override
+    public Project.NameKey getProject() {
+      return project;
+    }
+
+    @Override
+    public Timestamp getWhen() {
+      return when;
+    }
+
+    @Override
+    public TimeZone getTimeZone() {
+      return tz;
+    }
+
+    @Override
+    public ReviewDb getDb() {
+      return db;
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return user;
+    }
+
+    @Override
+    public Order getOrder() {
+      return order;
+    }
+  }
+
+  private class RepoContextImpl extends ContextImpl implements RepoContext {
+    @Override
+    public ObjectInserter getInserter() throws IOException {
+      return getRepoView().getInserterWrapper();
+    }
+
+    @Override
+    public void addRefUpdate(ReceiveCommand cmd) throws IOException {
+      getRepoView().getCommands().add(cmd);
+    }
+  }
+
+  private class ChangeContextImpl extends ContextImpl implements ChangeContext {
+    private final ChangeNotes notes;
+    private final Map<PatchSet.Id, ChangeUpdate> updates;
+
+    private boolean deleted;
+
+    ChangeContextImpl(ChangeNotes notes) {
+      this.notes = requireNonNull(notes);
+      updates = new TreeMap<>(comparing(PatchSet.Id::get));
+    }
+
+    @Override
+    public ChangeUpdate getUpdate(PatchSet.Id psId) {
+      ChangeUpdate u = updates.get(psId);
+      if (u == null) {
+        u = changeUpdateFactory.create(notes, user, when);
+        if (newChanges.containsKey(notes.getChangeId())) {
+          u.setAllowWriteToNewRef(true);
+        }
+        u.setPatchSetId(psId);
+        updates.put(psId, u);
+      }
+      return u;
+    }
+
+    @Override
+    public ChangeNotes getNotes() {
+      return notes;
+    }
+
+    @Override
+    public void dontBumpLastUpdatedOn() {
+      // Do nothing; NoteDb effectively updates timestamp if and only if a commit was written to the
+      // change meta ref.
+    }
+
+    @Override
+    public void deleteChange() {
+      deleted = true;
+    }
+  }
+
+  /** Per-change result status from {@link #executeChangeOps}. */
+  private enum ChangeResult {
+    SKIPPED,
+    UPSERTED,
+    DELETED;
+  }
+
+  private GitRepositoryManager repoManager;
+
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final ChangeUpdate.Factory changeUpdateFactory;
+  private final NoteDbUpdateManager.Factory updateManagerFactory;
+  private final ChangeIndexer indexer;
+  private final GitReferenceUpdated gitRefUpdated;
+
+  private final ReviewDb db;
+  private final Project.NameKey project;
+  private final CurrentUser user;
+  private final Timestamp when;
+  private final TimeZone tz;
+
+  private final ListMultimap<Change.Id, BatchUpdateOp> ops =
       MultimapBuilder.linkedHashKeys().arrayListValues().build();
-  protected final Map<Change.Id, Change> newChanges = new HashMap<>();
-  protected final List<RepoOnlyOp> repoOnlyOps = new ArrayList<>();
+  private final Map<Change.Id, Change> newChanges = new HashMap<>();
+  private final List<RepoOnlyOp> repoOnlyOps = new ArrayList<>();
 
-  protected RepoView repoView;
-  protected BatchRefUpdate batchRefUpdate;
-  protected Order order;
-  protected OnSubmitValidators onSubmitValidators;
-  protected PushCertificate pushCert;
-  protected String refLogMessage;
+  private RepoView repoView;
+  private BatchRefUpdate batchRefUpdate;
+  private Order order;
+  private OnSubmitValidators onSubmitValidators;
+  private PushCertificate pushCert;
+  private String refLogMessage;
 
-  protected BatchUpdate(
+  @Inject
+  BatchUpdate(
       GitRepositoryManager repoManager,
-      PersonIdent serverIdent,
-      Project.NameKey project,
-      CurrentUser user,
-      Timestamp when) {
+      @GerritPersonIdent PersonIdent serverIdent,
+      ChangeNotes.Factory changeNotesFactory,
+      ChangeUpdate.Factory changeUpdateFactory,
+      NoteDbUpdateManager.Factory updateManagerFactory,
+      ChangeIndexer indexer,
+      GitReferenceUpdated gitRefUpdated,
+      @Assisted ReviewDb db,
+      @Assisted Project.NameKey project,
+      @Assisted CurrentUser user,
+      @Assisted Timestamp when) {
     this.repoManager = repoManager;
+    this.changeNotesFactory = changeNotesFactory;
+    this.changeUpdateFactory = changeUpdateFactory;
+    this.updateManagerFactory = updateManagerFactory;
+    this.indexer = indexer;
+    this.gitRefUpdated = gitRefUpdated;
+    this.db = db;
     this.project = project;
     this.user = user;
     this.when = when;
@@ -218,15 +417,14 @@
     }
   }
 
-  public abstract void execute(BatchUpdateListener listener)
-      throws UpdateException, RestApiException;
+  public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
+    execute(ImmutableList.of(this), listener, false);
+  }
 
   public void execute() throws UpdateException, RestApiException {
     execute(BatchUpdateListener.NONE);
   }
 
-  protected abstract Context newContext();
-
   public BatchUpdate setRepository(Repository repo, RevWalk revWalk, ObjectInserter inserter) {
     checkState(this.repoView == null, "repo already set");
     repoView = new RepoView(repo, revWalk, inserter);
@@ -257,32 +455,23 @@
     return this;
   }
 
-  protected void initRepository() throws IOException {
+  private void initRepository() throws IOException {
     if (repoView == null) {
       repoView = new RepoView(repoManager, project);
     }
   }
 
-  protected RepoView getRepoView() throws IOException {
+  private RepoView getRepoView() throws IOException {
     initRepository();
     return repoView;
   }
 
-  protected CurrentUser getUser() {
-    return user;
-  }
-
-  protected Optional<AccountState> getAccount() {
+  private Optional<AccountState> getAccount() {
     return user.isIdentifiedUser()
         ? Optional.of(user.asIdentifiedUser().state())
         : Optional.empty();
   }
 
-  protected RevWalk getRevWalk() throws IOException {
-    initRepository();
-    return repoView.getRevWalk();
-  }
-
   public Map<String, ReceiveCommand> getRefUpdates() {
     return repoView != null ? repoView.getCommands().getCommands() : ImmutableMap.of();
   }
@@ -301,7 +490,7 @@
   }
 
   public BatchUpdate insertChange(InsertChangeOp op) throws IOException {
-    Context ctx = newContext();
+    Context ctx = new ContextImpl();
     Change c = op.createChange(ctx);
     checkArgument(
         !newChanges.containsKey(c.getId()), "only one op allowed to create change %s", c.getId());
@@ -310,16 +499,165 @@
     return this;
   }
 
-  protected static void logDebug(String msg, Throwable t) {
-    // Only log if there is a requestId assigned, since those are the
-    // expensive/complicated requests like MergeOp. Doing it every time would be
-    // noisy.
-    if (RequestId.isSet()) {
-      logger.atFine().withCause(t).log("%s", msg);
+  private void executeUpdateRepo() throws UpdateException, RestApiException {
+    try {
+      logDebug("Executing updateRepo on %d ops", ops.size());
+      RepoContextImpl ctx = new RepoContextImpl();
+      for (BatchUpdateOp op : ops.values()) {
+        op.updateRepo(ctx);
+      }
+
+      logDebug("Executing updateRepo on %d RepoOnlyOps", repoOnlyOps.size());
+      for (RepoOnlyOp op : repoOnlyOps) {
+        op.updateRepo(ctx);
+      }
+
+      if (onSubmitValidators != null && !getRefUpdates().isEmpty()) {
+        // Validation of refs has to take place here and not at the beginning of executeRefUpdates.
+        // Otherwise, failing validation in a second BatchUpdate object will happen *after* the
+        // first update's executeRefUpdates has finished, hence after first repo's refs have been
+        // updated, which is too late.
+        onSubmitValidators.validate(
+            project, ctx.getRevWalk().getObjectReader(), repoView.getCommands());
+      }
+    } catch (Exception e) {
+      Throwables.throwIfInstanceOf(e, RestApiException.class);
+      throw new UpdateException(e);
     }
   }
 
-  protected static void logDebug(String msg) {
+  private class ChangesHandle implements AutoCloseable {
+    private final NoteDbUpdateManager manager;
+    private final boolean dryrun;
+    private final Map<Change.Id, ChangeResult> results;
+
+    ChangesHandle(NoteDbUpdateManager manager, boolean dryrun) {
+      this.manager = manager;
+      this.dryrun = dryrun;
+      results = new HashMap<>();
+    }
+
+    @Override
+    public void close() {
+      manager.close();
+    }
+
+    void setResult(Change.Id id, ChangeResult result) {
+      ChangeResult old = results.putIfAbsent(id, result);
+      checkArgument(old == null, "result for change %s already set: %s", id, old);
+    }
+
+    void execute() throws OrmException, IOException {
+      BatchUpdate.this.batchRefUpdate = manager.execute(dryrun);
+    }
+
+    @SuppressWarnings("deprecation")
+    List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> startIndexFutures() {
+      if (dryrun) {
+        return ImmutableList.of();
+      }
+      logDebug("Reindexing %d changes", results.size());
+      List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> indexFutures =
+          new ArrayList<>(results.size());
+      for (Map.Entry<Change.Id, ChangeResult> e : results.entrySet()) {
+        Change.Id id = e.getKey();
+        switch (e.getValue()) {
+          case UPSERTED:
+            indexFutures.add(indexer.indexAsync(project, id));
+            break;
+          case DELETED:
+            indexFutures.add(indexer.deleteAsync(id));
+            break;
+          case SKIPPED:
+            break;
+          default:
+            throw new IllegalStateException("unexpected result: " + e.getValue());
+        }
+      }
+      return indexFutures;
+    }
+  }
+
+  private ChangesHandle executeChangeOps(boolean dryrun) throws Exception {
+    logDebug("Executing change ops");
+    initRepository();
+    Repository repo = repoView.getRepository();
+    checkState(
+        repo.getRefDatabase().performsAtomicTransactions(),
+        "cannot use NoteDb with a repository that does not support atomic batch ref updates: %s",
+        repo);
+
+    ChangesHandle handle =
+        new ChangesHandle(
+            updateManagerFactory
+                .create(project)
+                .setChangeRepo(
+                    repo, repoView.getRevWalk(), repoView.getInserter(), repoView.getCommands()),
+            dryrun);
+    if (user.isIdentifiedUser()) {
+      handle.manager.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, tz));
+    }
+    handle.manager.setRefLogMessage(refLogMessage);
+    handle.manager.setPushCertificate(pushCert);
+    for (Map.Entry<Change.Id, Collection<BatchUpdateOp>> e : ops.asMap().entrySet()) {
+      Change.Id id = e.getKey();
+      ChangeContextImpl ctx = newChangeContext(id);
+      boolean dirty = false;
+      logDebug(
+          "Applying %d ops for change %s: %s",
+          e.getValue().size(),
+          id,
+          lazy(() -> e.getValue().stream().map(op -> op.getClass().getName()).collect(toSet())));
+      for (BatchUpdateOp op : e.getValue()) {
+        dirty |= op.updateChange(ctx);
+      }
+      if (!dirty) {
+        logDebug("No ops reported dirty, short-circuiting");
+        handle.setResult(id, ChangeResult.SKIPPED);
+        continue;
+      }
+      for (ChangeUpdate u : ctx.updates.values()) {
+        handle.manager.add(u);
+      }
+      if (ctx.deleted) {
+        logDebug("Change %s was deleted", id);
+        handle.manager.deleteChange(id);
+        handle.setResult(id, ChangeResult.DELETED);
+      } else {
+        handle.setResult(id, ChangeResult.UPSERTED);
+      }
+    }
+    return handle;
+  }
+
+  private ChangeContextImpl newChangeContext(Change.Id id) throws OrmException {
+    logDebug("Opening change %s for update", id);
+    Change c = newChanges.get(id);
+    boolean isNew = c != null;
+    if (!isNew) {
+      // Pass a synthetic change into ChangeNotes.Factory, which will take care of checking for
+      // existence and populating columns from the parsed notes state.
+      // TODO(dborowitz): This dance made more sense when using Reviewdb; consider a nicer way.
+      c = ChangeNotes.Factory.newChange(project, id);
+    } else {
+      logDebug("Change %s is new", id);
+    }
+    ChangeNotes notes = changeNotesFactory.createForBatchUpdate(c, !isNew);
+    return new ChangeContextImpl(notes);
+  }
+
+  private void executePostOps() throws Exception {
+    ContextImpl ctx = new ContextImpl();
+    for (BatchUpdateOp op : ops.values()) {
+      op.postUpdate(ctx);
+    }
+
+    for (RepoOnlyOp op : repoOnlyOps) {
+      op.postUpdate(ctx);
+    }
+  }
+
+  private static void logDebug(String msg) {
     // Only log if there is a requestId assigned, since those are the
     // expensive/complicated requests like MergeOp. Doing it every time would be
     // noisy.
@@ -328,7 +666,7 @@
     }
   }
 
-  protected static void logDebug(String msg, @Nullable Object arg) {
+  private static void logDebug(String msg, @Nullable Object arg) {
     // Only log if there is a requestId assigned, since those are the
     // expensive/complicated requests like MergeOp. Doing it every time would be
     // noisy.
@@ -337,16 +675,7 @@
     }
   }
 
-  protected static void logDebug(String msg, @Nullable Object arg1, @Nullable Object arg2) {
-    // Only log if there is a requestId assigned, since those are the
-    // expensive/complicated requests like MergeOp. Doing it every time would be
-    // noisy.
-    if (RequestId.isSet()) {
-      logger.atFine().log(msg, arg1, arg2);
-    }
-  }
-
-  protected static void logDebug(
+  private static void logDebug(
       String msg, @Nullable Object arg1, @Nullable Object arg2, @Nullable Object arg3) {
     // Only log if there is a requestId assigned, since those are the
     // expensive/complicated requests like MergeOp. Doing it every time would be
diff --git a/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java b/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java
deleted file mode 100644
index d881b0f..0000000
--- a/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java
+++ /dev/null
@@ -1,457 +0,0 @@
-// Copyright (C) 2017 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.update;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.common.flogger.LazyArgs.lazy;
-import static java.util.Comparator.comparing;
-import static java.util.Objects.requireNonNull;
-import static java.util.stream.Collectors.toSet;
-
-import com.google.common.base.Throwables;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.index.change.ChangeIndexer;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NoteDbUpdateManager;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.TimeZone;
-import java.util.TreeMap;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
-/**
- * {@link BatchUpdate} implementation using only NoteDb that updates code refs and meta refs in a
- * single {@link org.eclipse.jgit.lib.BatchRefUpdate}.
- *
- * <p>Used when {@code noteDb.changes.disableReviewDb=true}, at which point ReviewDb is not
- * consulted during updates.
- */
-public class NoteDbBatchUpdate extends BatchUpdate {
-  public interface AssistedFactory {
-    NoteDbBatchUpdate create(
-        ReviewDb db, Project.NameKey project, CurrentUser user, Timestamp when);
-  }
-
-  static void execute(
-      ImmutableList<NoteDbBatchUpdate> updates, BatchUpdateListener listener, boolean dryrun)
-      throws UpdateException, RestApiException {
-    if (updates.isEmpty()) {
-      return;
-    }
-
-    try {
-      @SuppressWarnings("deprecation")
-      List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> indexFutures =
-          new ArrayList<>();
-      List<ChangesHandle> handles = new ArrayList<>(updates.size());
-      Order order = getOrder(updates, listener);
-      try {
-        switch (order) {
-          case REPO_BEFORE_DB:
-            for (NoteDbBatchUpdate u : updates) {
-              u.executeUpdateRepo();
-            }
-            listener.afterUpdateRepos();
-            for (NoteDbBatchUpdate u : updates) {
-              handles.add(u.executeChangeOps(dryrun));
-            }
-            for (ChangesHandle h : handles) {
-              h.execute();
-              indexFutures.addAll(h.startIndexFutures());
-            }
-            listener.afterUpdateRefs();
-            listener.afterUpdateChanges();
-            break;
-
-          case DB_BEFORE_REPO:
-            // Call updateChange for each op before updateRepo, but defer executing the
-            // NoteDbUpdateManager until after calling updateRepo. They share an inserter and
-            // BatchRefUpdate, so it will all execute as a single batch. But we have to let
-            // NoteDbUpdateManager actually execute the update, since it has to interleave it
-            // properly with All-Users updates.
-            //
-            // TODO(dborowitz): This may still result in multiple updates to All-Users, but that's
-            // currently not a big deal because multi-change batches generally aren't affecting
-            // drafts anyway.
-            for (NoteDbBatchUpdate u : updates) {
-              handles.add(u.executeChangeOps(dryrun));
-            }
-            for (NoteDbBatchUpdate u : updates) {
-              u.executeUpdateRepo();
-            }
-            for (ChangesHandle h : handles) {
-              // TODO(dborowitz): This isn't quite good enough: in theory updateRepo may want to
-              // see the results of change meta commands, but they aren't actually added to the
-              // BatchUpdate until the body of execute. To fix this, execute needs to be split up
-              // into a method that returns a BatchRefUpdate before execution. Not a big deal at the
-              // moment, because this order is only used for deleting changes, and those updateRepo
-              // implementations definitely don't need to observe the updated change meta refs.
-              h.execute();
-              indexFutures.addAll(h.startIndexFutures());
-            }
-            break;
-          default:
-            throw new IllegalStateException("invalid execution order: " + order);
-        }
-      } finally {
-        for (ChangesHandle h : handles) {
-          h.close();
-        }
-      }
-
-      ChangeIndexer.allAsList(indexFutures).get();
-
-      // Fire ref update events only after all mutations are finished, since callers may assume a
-      // patch set ref being created means the change was created, or a branch advancing meaning
-      // some changes were closed.
-      updates
-          .stream()
-          .filter(u -> u.batchRefUpdate != null)
-          .forEach(
-              u -> u.gitRefUpdated.fire(u.project, u.batchRefUpdate, u.getAccount().orElse(null)));
-
-      if (!dryrun) {
-        for (NoteDbBatchUpdate u : updates) {
-          u.executePostOps();
-        }
-      }
-    } catch (Exception e) {
-      wrapAndThrowException(e);
-    }
-  }
-
-  class ContextImpl implements Context {
-    @Override
-    public RepoView getRepoView() throws IOException {
-      return NoteDbBatchUpdate.this.getRepoView();
-    }
-
-    @Override
-    public RevWalk getRevWalk() throws IOException {
-      return getRepoView().getRevWalk();
-    }
-
-    @Override
-    public Project.NameKey getProject() {
-      return project;
-    }
-
-    @Override
-    public Timestamp getWhen() {
-      return when;
-    }
-
-    @Override
-    public TimeZone getTimeZone() {
-      return tz;
-    }
-
-    @Override
-    public ReviewDb getDb() {
-      return db;
-    }
-
-    @Override
-    public CurrentUser getUser() {
-      return user;
-    }
-
-    @Override
-    public Order getOrder() {
-      return order;
-    }
-  }
-
-  private class RepoContextImpl extends ContextImpl implements RepoContext {
-    @Override
-    public ObjectInserter getInserter() throws IOException {
-      return getRepoView().getInserterWrapper();
-    }
-
-    @Override
-    public void addRefUpdate(ReceiveCommand cmd) throws IOException {
-      getRepoView().getCommands().add(cmd);
-    }
-  }
-
-  private class ChangeContextImpl extends ContextImpl implements ChangeContext {
-    private final ChangeNotes notes;
-    private final Map<PatchSet.Id, ChangeUpdate> updates;
-
-    private boolean deleted;
-
-    protected ChangeContextImpl(ChangeNotes notes) {
-      this.notes = requireNonNull(notes);
-      updates = new TreeMap<>(comparing(PatchSet.Id::get));
-    }
-
-    @Override
-    public ChangeUpdate getUpdate(PatchSet.Id psId) {
-      ChangeUpdate u = updates.get(psId);
-      if (u == null) {
-        u = changeUpdateFactory.create(notes, user, when);
-        if (newChanges.containsKey(notes.getChangeId())) {
-          u.setAllowWriteToNewRef(true);
-        }
-        u.setPatchSetId(psId);
-        updates.put(psId, u);
-      }
-      return u;
-    }
-
-    @Override
-    public ChangeNotes getNotes() {
-      return notes;
-    }
-
-    @Override
-    public void dontBumpLastUpdatedOn() {
-      // Do nothing; NoteDb effectively updates timestamp if and only if a commit was written to the
-      // change meta ref.
-    }
-
-    @Override
-    public void deleteChange() {
-      deleted = true;
-    }
-  }
-
-  /** Per-change result status from {@link #executeChangeOps}. */
-  private enum ChangeResult {
-    SKIPPED,
-    UPSERTED,
-    DELETED;
-  }
-
-  private final ChangeNotes.Factory changeNotesFactory;
-  private final ChangeUpdate.Factory changeUpdateFactory;
-  private final NoteDbUpdateManager.Factory updateManagerFactory;
-  private final ChangeIndexer indexer;
-  private final GitReferenceUpdated gitRefUpdated;
-  private final ReviewDb db;
-
-  @Inject
-  NoteDbBatchUpdate(
-      GitRepositoryManager repoManager,
-      @GerritPersonIdent PersonIdent serverIdent,
-      ChangeNotes.Factory changeNotesFactory,
-      ChangeUpdate.Factory changeUpdateFactory,
-      NoteDbUpdateManager.Factory updateManagerFactory,
-      ChangeIndexer indexer,
-      GitReferenceUpdated gitRefUpdated,
-      @Assisted ReviewDb db,
-      @Assisted Project.NameKey project,
-      @Assisted CurrentUser user,
-      @Assisted Timestamp when) {
-    super(repoManager, serverIdent, project, user, when);
-    this.changeNotesFactory = changeNotesFactory;
-    this.changeUpdateFactory = changeUpdateFactory;
-    this.updateManagerFactory = updateManagerFactory;
-    this.indexer = indexer;
-    this.gitRefUpdated = gitRefUpdated;
-    this.db = db;
-  }
-
-  @Override
-  public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
-    execute(ImmutableList.of(this), listener, false);
-  }
-
-  @Override
-  protected Context newContext() {
-    return new ContextImpl();
-  }
-
-  private void executeUpdateRepo() throws UpdateException, RestApiException {
-    try {
-      logDebug("Executing updateRepo on %d ops", ops.size());
-      RepoContextImpl ctx = new RepoContextImpl();
-      for (BatchUpdateOp op : ops.values()) {
-        op.updateRepo(ctx);
-      }
-
-      logDebug("Executing updateRepo on %d RepoOnlyOps", repoOnlyOps.size());
-      for (RepoOnlyOp op : repoOnlyOps) {
-        op.updateRepo(ctx);
-      }
-
-      if (onSubmitValidators != null && !getRefUpdates().isEmpty()) {
-        // Validation of refs has to take place here and not at the beginning of executeRefUpdates.
-        // Otherwise, failing validation in a second BatchUpdate object will happen *after* the
-        // first update's executeRefUpdates has finished, hence after first repo's refs have been
-        // updated, which is too late.
-        onSubmitValidators.validate(
-            project, ctx.getRevWalk().getObjectReader(), repoView.getCommands());
-      }
-    } catch (Exception e) {
-      Throwables.throwIfInstanceOf(e, RestApiException.class);
-      throw new UpdateException(e);
-    }
-  }
-
-  private class ChangesHandle implements AutoCloseable {
-    private final NoteDbUpdateManager manager;
-    private final boolean dryrun;
-    private final Map<Change.Id, ChangeResult> results;
-
-    ChangesHandle(NoteDbUpdateManager manager, boolean dryrun) {
-      this.manager = manager;
-      this.dryrun = dryrun;
-      results = new HashMap<>();
-    }
-
-    @Override
-    public void close() {
-      manager.close();
-    }
-
-    void setResult(Change.Id id, ChangeResult result) {
-      ChangeResult old = results.putIfAbsent(id, result);
-      checkArgument(old == null, "result for change %s already set: %s", id, old);
-    }
-
-    void execute() throws OrmException, IOException {
-      NoteDbBatchUpdate.this.batchRefUpdate = manager.execute(dryrun);
-    }
-
-    @SuppressWarnings("deprecation")
-    List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> startIndexFutures() {
-      if (dryrun) {
-        return ImmutableList.of();
-      }
-      logDebug("Reindexing %d changes", results.size());
-      List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> indexFutures =
-          new ArrayList<>(results.size());
-      for (Map.Entry<Change.Id, ChangeResult> e : results.entrySet()) {
-        Change.Id id = e.getKey();
-        switch (e.getValue()) {
-          case UPSERTED:
-            indexFutures.add(indexer.indexAsync(project, id));
-            break;
-          case DELETED:
-            indexFutures.add(indexer.deleteAsync(id));
-            break;
-          case SKIPPED:
-            break;
-          default:
-            throw new IllegalStateException("unexpected result: " + e.getValue());
-        }
-      }
-      return indexFutures;
-    }
-  }
-
-  private ChangesHandle executeChangeOps(boolean dryrun) throws Exception {
-    logDebug("Executing change ops");
-    initRepository();
-    Repository repo = repoView.getRepository();
-    checkState(
-        repo.getRefDatabase().performsAtomicTransactions(),
-        "cannot use NoteDb with a repository that does not support atomic batch ref updates: %s",
-        repo);
-
-    ChangesHandle handle =
-        new ChangesHandle(
-            updateManagerFactory
-                .create(project)
-                .setChangeRepo(
-                    repo, repoView.getRevWalk(), repoView.getInserter(), repoView.getCommands()),
-            dryrun);
-    if (user.isIdentifiedUser()) {
-      handle.manager.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, tz));
-    }
-    handle.manager.setRefLogMessage(refLogMessage);
-    handle.manager.setPushCertificate(pushCert);
-    for (Map.Entry<Change.Id, Collection<BatchUpdateOp>> e : ops.asMap().entrySet()) {
-      Change.Id id = e.getKey();
-      ChangeContextImpl ctx = newChangeContext(id);
-      boolean dirty = false;
-      logDebug(
-          "Applying %d ops for change %s: %s",
-          e.getValue().size(),
-          id,
-          lazy(() -> e.getValue().stream().map(op -> op.getClass().getName()).collect(toSet())));
-      for (BatchUpdateOp op : e.getValue()) {
-        dirty |= op.updateChange(ctx);
-      }
-      if (!dirty) {
-        logDebug("No ops reported dirty, short-circuiting");
-        handle.setResult(id, ChangeResult.SKIPPED);
-        continue;
-      }
-      for (ChangeUpdate u : ctx.updates.values()) {
-        handle.manager.add(u);
-      }
-      if (ctx.deleted) {
-        logDebug("Change %s was deleted", id);
-        handle.manager.deleteChange(id);
-        handle.setResult(id, ChangeResult.DELETED);
-      } else {
-        handle.setResult(id, ChangeResult.UPSERTED);
-      }
-    }
-    return handle;
-  }
-
-  private ChangeContextImpl newChangeContext(Change.Id id) throws OrmException {
-    logDebug("Opening change %s for update", id);
-    Change c = newChanges.get(id);
-    boolean isNew = c != null;
-    if (!isNew) {
-      // Pass a synthetic change into ChangeNotes.Factory, which will take care of checking for
-      // existence and populating columns from the parsed notes state.
-      // TODO(dborowitz): This dance made more sense when using Reviewdb; consider a nicer way.
-      c = ChangeNotes.Factory.newNoteDbOnlyChange(project, id);
-    } else {
-      logDebug("Change %s is new", id);
-    }
-    ChangeNotes notes = changeNotesFactory.createForBatchUpdate(c, !isNew);
-    return new ChangeContextImpl(notes);
-  }
-
-  private void executePostOps() throws Exception {
-    ContextImpl ctx = new ContextImpl();
-    for (BatchUpdateOp op : ops.values()) {
-      op.postUpdate(ctx);
-    }
-
-    for (RepoOnlyOp op : repoOnlyOps) {
-      op.postUpdate(ctx);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/update/RetryHelper.java b/java/com/google/gerrit/server/update/RetryHelper.java
index 9bdf293..d2bccf1 100644
--- a/java/com/google/gerrit/server/update/RetryHelper.java
+++ b/java/com/google/gerrit/server/update/RetryHelper.java
@@ -147,21 +147,18 @@
   @Nullable private final Consumer<RetryerBuilder<?>> overwriteDefaultRetryerStrategySetup;
 
   @Inject
-  RetryHelper(
-      @GerritServerConfig Config cfg,
-      Metrics metrics,
-      NoteDbBatchUpdate.AssistedFactory noteDbBatchUpdateFactory) {
-    this(cfg, metrics, noteDbBatchUpdateFactory, null);
+  RetryHelper(@GerritServerConfig Config cfg, Metrics metrics, BatchUpdate.Factory updateFactory) {
+    this(cfg, metrics, updateFactory, null);
   }
 
   @VisibleForTesting
   public RetryHelper(
       @GerritServerConfig Config cfg,
       Metrics metrics,
-      NoteDbBatchUpdate.AssistedFactory noteDbBatchUpdateFactory,
+      BatchUpdate.Factory updateFactory,
       @Nullable Consumer<RetryerBuilder<?>> overwriteDefaultRetryerStrategySetup) {
     this.metrics = metrics;
-    this.updateFactory = new BatchUpdate.Factory(noteDbBatchUpdateFactory);
+    this.updateFactory = updateFactory;
 
     Duration defaultTimeout =
         Duration.ofMillis(
diff --git a/java/com/google/gerrit/sshd/ChangeArgumentParser.java b/java/com/google/gerrit/sshd/ChangeArgumentParser.java
index 7a9f298..b402533 100644
--- a/java/com/google/gerrit/sshd/ChangeArgumentParser.java
+++ b/java/com/google/gerrit/sshd/ChangeArgumentParser.java
@@ -121,7 +121,7 @@
   }
 
   private List<ChangeNotes> changeFromNotesFactory(String id) throws OrmException, UnloggedFailure {
-    return changeNotesFactory.create(db, parseId(id));
+    return changeNotesFactory.create(parseId(id));
   }
 
   private List<Change.Id> parseId(String id) throws UnloggedFailure {
diff --git a/java/com/google/gerrit/sshd/commands/PatchSetParser.java b/java/com/google/gerrit/sshd/commands/PatchSetParser.java
index a1e8f07..d174561 100644
--- a/java/com/google/gerrit/sshd/commands/PatchSetParser.java
+++ b/java/com/google/gerrit/sshd/commands/PatchSetParser.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 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.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -37,7 +36,6 @@
 
 @Singleton
 public class PatchSetParser {
-  private final Provider<ReviewDb> db;
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeNotes.Factory notesFactory;
   private final PatchSetUtil psUtil;
@@ -45,12 +43,10 @@
 
   @Inject
   PatchSetParser(
-      Provider<ReviewDb> db,
       Provider<InternalChangeQuery> queryProvider,
       ChangeNotes.Factory notesFactory,
       PatchSetUtil psUtil,
       ChangeFinder changeFinder) {
-    this.db = db;
     this.queryProvider = queryProvider;
     this.notesFactory = notesFactory;
     this.psUtil = psUtil;
@@ -129,11 +125,11 @@
   private ChangeNotes getNotes(@Nullable ProjectState projectState, Change.Id changeId)
       throws OrmException, UnloggedFailure {
     if (projectState != null) {
-      return notesFactory.create(db.get(), projectState.getNameKey(), changeId);
+      return notesFactory.create(projectState.getNameKey(), changeId);
     }
     try {
       ChangeNotes notes = changeFinder.findOne(changeId);
-      return notesFactory.create(db.get(), notes.getProjectName(), changeId);
+      return notesFactory.create(notes.getProjectName(), changeId);
     } catch (NoSuchChangeException e) {
       throw error("\"" + changeId + "\" no such change");
     }
diff --git a/java/com/google/gerrit/testing/GerritServerTests.java b/java/com/google/gerrit/testing/GerritServerTests.java
index 69806e1..75479d2 100644
--- a/java/com/google/gerrit/testing/GerritServerTests.java
+++ b/java/com/google/gerrit/testing/GerritServerTests.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.testing;
 
-import com.google.gerrit.server.notedb.MutableNotesMigration;
+import com.google.gerrit.server.notedb.NotesMigration;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Rule;
 import org.junit.rules.TestRule;
@@ -28,7 +28,7 @@
 
   @ConfigSuite.Name private String configName;
 
-  protected MutableNotesMigration notesMigration;
+  protected NotesMigration notesMigration;
 
   @Rule
   public TestRule testRunner =
@@ -39,21 +39,13 @@
             @Override
             public void evaluate() throws Throwable {
               beforeTest();
-              try {
-                base.evaluate();
-              } finally {
-                afterTest();
-              }
+              base.evaluate();
             }
           };
         }
       };
 
-  public void beforeTest() throws Exception {
-    notesMigration = NoteDbMode.newNotesMigrationFromEnv();
-  }
-
-  public void afterTest() {
-    NoteDbMode.resetFromEnv(notesMigration);
+  public void beforeTest() {
+    notesMigration = new NotesMigration();
   }
 }
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 682e8c2..ac7224f 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -73,8 +73,6 @@
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
-import com.google.gerrit.server.notedb.MutableNotesMigration;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.DiffExecutor;
 import com.google.gerrit.server.permissions.DefaultPermissionBackendModule;
 import com.google.gerrit.server.plugins.ServerInformationImpl;
@@ -135,15 +133,13 @@
   }
 
   private final Config cfg;
-  private final MutableNotesMigration notesMigration;
 
   public InMemoryModule() {
-    this(newDefaultConfig(), NoteDbMode.newNotesMigrationFromEnv());
+    this(newDefaultConfig());
   }
 
-  public InMemoryModule(Config cfg, MutableNotesMigration notesMigration) {
+  public InMemoryModule(Config cfg) {
     this.cfg = cfg;
-    this.notesMigration = notesMigration;
   }
 
   public void inject(Object instance) {
@@ -191,8 +187,6 @@
     bind(GitRepositoryManager.class).to(InMemoryRepositoryManager.class);
     bind(InMemoryRepositoryManager.class).in(SINGLETON);
     bind(TrackingFooters.class).toProvider(TrackingFootersProvider.class).in(SINGLETON);
-    bind(MutableNotesMigration.class).toInstance(notesMigration);
-    bind(NotesMigration.class).to(MutableNotesMigration.class);
     bind(ListeningExecutorService.class)
         .annotatedWith(ChangeUpdateExecutor.class)
         .toInstance(MoreExecutors.newDirectExecutorService());
diff --git a/java/com/google/gerrit/testing/InMemoryTestEnvironment.java b/java/com/google/gerrit/testing/InMemoryTestEnvironment.java
index f665015..a8afdb3 100644
--- a/java/com/google/gerrit/testing/InMemoryTestEnvironment.java
+++ b/java/com/google/gerrit/testing/InMemoryTestEnvironment.java
@@ -108,8 +108,7 @@
     Config cfg = configProvider.get();
     InMemoryModule.setDefaults(cfg);
 
-    Injector injector =
-        Guice.createInjector(new InMemoryModule(cfg, NoteDbMode.newNotesMigrationFromEnv()));
+    Injector injector = Guice.createInjector(new InMemoryModule(cfg));
     injector.injectMembers(this);
     lifecycle = new LifecycleManager();
     lifecycle.add(injector);
diff --git a/java/com/google/gerrit/testing/NoteDbMode.java b/java/com/google/gerrit/testing/NoteDbMode.java
deleted file mode 100644
index f901cce..0000000
--- a/java/com/google/gerrit/testing/NoteDbMode.java
+++ /dev/null
@@ -1,81 +0,0 @@
-// Copyright (C) 2016 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.testing;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.common.base.Enums;
-import com.google.common.base.Strings;
-import com.google.gerrit.server.notedb.MutableNotesMigration;
-import com.google.gerrit.server.notedb.NotesMigrationState;
-
-public enum NoteDbMode {
-  /** NoteDb is disabled. */
-  OFF(NotesMigrationState.REVIEW_DB),
-
-  /** Writing data to NoteDb is enabled. */
-  WRITE(NotesMigrationState.WRITE),
-
-  /** Reading and writing all data to NoteDb is enabled. */
-  READ_WRITE(NotesMigrationState.READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY),
-
-  /** Changes are created with their primary storage as NoteDb. */
-  PRIMARY(NotesMigrationState.READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY),
-
-  /** All change tables are entirely disabled, and code/meta ref updates are fused. */
-  ON(NotesMigrationState.NOTE_DB);
-
-  private static final String ENV_VAR = "GERRIT_NOTEDB";
-  private static final String SYS_PROP = "gerrit.notedb";
-
-  public static NoteDbMode get() {
-    String value = System.getenv(ENV_VAR);
-    if (Strings.isNullOrEmpty(value)) {
-      value = System.getProperty(SYS_PROP);
-    }
-    if (Strings.isNullOrEmpty(value)) {
-      return ON;
-    }
-    value = value.toUpperCase().replace("-", "_");
-    NoteDbMode mode = Enums.getIfPresent(NoteDbMode.class, value).orNull();
-    if (!Strings.isNullOrEmpty(System.getenv(ENV_VAR))) {
-      checkArgument(
-          mode != null, "Invalid value for env variable %s: %s", ENV_VAR, System.getenv(ENV_VAR));
-    } else {
-      checkArgument(
-          mode != null,
-          "Invalid value for system property %s: %s",
-          SYS_PROP,
-          System.getProperty(SYS_PROP));
-    }
-    return mode;
-  }
-
-  public static MutableNotesMigration newNotesMigrationFromEnv() {
-    MutableNotesMigration m = MutableNotesMigration.newDisabled();
-    resetFromEnv(m);
-    return m;
-  }
-
-  public static void resetFromEnv(MutableNotesMigration migration) {
-    migration.setFrom(get().state);
-  }
-
-  private final NotesMigrationState state;
-
-  private NoteDbMode(NotesMigrationState state) {
-    this.state = state;
-  }
-}
diff --git a/java/com/google/gerrit/testing/TestChanges.java b/java/com/google/gerrit/testing/TestChanges.java
index 0c87b38..fe4f3e7 100644
--- a/java/com/google/gerrit/testing/TestChanges.java
+++ b/java/com/google/gerrit/testing/TestChanges.java
@@ -53,6 +53,7 @@
 
   public static Change newChange(Project.NameKey project, Account.Id userId, int id) {
     Change.Id changeId = new Change.Id(id);
+    // TODO(ekempin): Create NoteDb change.
     Change c =
         new Change(
             new Change.Key("Iabcd1234abcd1234abcd1234abcd1234abcd1234"),
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index aa1ad7b..87c63e1 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
@@ -715,4 +716,9 @@
       clear();
     }
   }
+
+  @Nullable
+  protected RevCommit getRemoteHead(String project, String branch) throws Exception {
+    return getRemoteHead(new Project.NameKey(project), branch);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index d2fd331..aa03d90 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -46,14 +46,13 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.git.receive.ReceiveCommitsAdvertiseRefsHook;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.testing.NoteDbMode;
+import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -63,6 +62,7 @@
 import java.util.function.Predicate;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -76,7 +76,6 @@
 public class RefAdvertisementIT extends AbstractDaemonTest {
   @Inject private PermissionBackend permissionBackend;
   @Inject private ChangeNoteUtil noteUtil;
-  @Inject @AnonymousCowardName private String anonymousCowardName;
   @Inject private AllUsersName allUsersName;
 
   private AccountGroup.UUID admins;
@@ -91,6 +90,13 @@
   private String r3;
   private String r4;
 
+  @ConfigSuite.Config
+  public static Config enableFullRefEvaluation() {
+    Config cfg = new Config();
+    cfg.setBoolean("auth", null, "skipFullRefEvaluationIfAllRefsAreVisible", false);
+    return cfg;
+  }
+
   @Before
   public void setUp() throws Exception {
     admins = adminGroupUuid();
@@ -253,7 +259,7 @@
   public void uploadPackSubsetOfBranchesVisibleWithEdit() throws Exception {
     allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
 
-    Change c = notesFactory.createChecked(db, project, c3.getId()).getChange();
+    Change c = notesFactory.createChecked(project, c3.getId()).getChange();
     String changeId = c.getKey().get();
 
     // Admin's edit is not visible.
@@ -281,9 +287,9 @@
     allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
     allow("refs/*", Permission.VIEW_PRIVATE_CHANGES, REGISTERED_USERS);
 
-    Change change3 = notesFactory.createChecked(db, project, c3.getId()).getChange();
+    Change change3 = notesFactory.createChecked(project, c3.getId()).getChange();
     String changeId3 = change3.getKey().get();
-    Change change4 = notesFactory.createChecked(db, project, c4.getId()).getChange();
+    Change change4 = notesFactory.createChecked(project, c4.getId()).getChange();
     String changeId4 = change4.getKey().get();
 
     // Admin's edit on change3 is visible.
@@ -369,7 +375,6 @@
 
   @Test
   public void uploadPackSequencesWithAccessDatabase() throws Exception {
-    assume().that(notesMigration.readChangeSequence()).isTrue();
     try (Repository repo = repoManager.openRepository(allProjects)) {
       assertRefs(repo, newFilter(allProjects, user), true);
 
@@ -379,6 +384,28 @@
   }
 
   @Test
+  public void uploadPackAllRefsAreVisibleOrphanedTag() throws Exception {
+    allow("refs/*", Permission.READ, REGISTERED_USERS);
+    // Delete the pending change on 'branch' and 'branch' itself so that the tag gets orphaned
+    gApi.changes().id(c4.getId().id).delete();
+    gApi.projects().name(project.get()).branch("refs/heads/branch").delete();
+
+    setApiUser(user);
+    assertUploadPackRefs(
+        "HEAD",
+        "refs/meta/config",
+        r1 + "1",
+        r1 + "meta",
+        r2 + "1",
+        r2 + "meta",
+        r3 + "1",
+        r3 + "meta",
+        "refs/heads/master",
+        "refs/tags/branch-tag",
+        "refs/tags/master-tag");
+  }
+
+  @Test
   public void receivePackListsOpenChangesAsAdditionalHaves() throws Exception {
     ReceiveCommitsAdvertiseRefsHook.Result r = getReceivePackRefs();
     assertThat(r.allRefs().keySet())
@@ -567,6 +594,9 @@
 
   @Test
   public void advertisedReferencesIncludePrivateChangesWhenAllRefsMayBeRead() throws Exception {
+    assume()
+        .that(baseConfig.getBoolean("auth", "skipFullRefEvaluationIfAllRefsAreVisible", true))
+        .isTrue();
     allow("refs/*", Permission.READ, REGISTERED_USERS);
 
     TestRepository<?> userTestRepository = cloneProject(project, user);
@@ -665,9 +695,7 @@
 
     List<String> expectedMetaRefs =
         new ArrayList<>(ImmutableList.of(mr.getPatchSetId().toRefName()));
-    if (NoteDbMode.get() != NoteDbMode.OFF) {
-      expectedMetaRefs.add(changeRefPrefix(mr.getChange().getId()) + "meta");
-    }
+    expectedMetaRefs.add(changeRefPrefix(mr.getChange().getId()) + "meta");
 
     List<String> expectedAllRefs = new ArrayList<>(expectedNonMetaRefs);
     expectedAllRefs.addAll(expectedMetaRefs);
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java b/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
index cf22a0a..dd9f08f 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
@@ -317,7 +317,7 @@
   }
 
   private PatchSetApproval getSubmitter(PatchSet.Id patchSetId) throws Exception {
-    ChangeNotes notes = notesFactory.createChecked(db, project, patchSetId.getParentKey()).load();
+    ChangeNotes notes = notesFactory.createChecked(project, patchSetId.getParentKey()).load();
     return approvalsUtil.getSubmitter(notes, patchSetId);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
index f72df99..0bbe769 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
@@ -839,13 +839,13 @@
 
     sub1.git().fetch().call();
     RevWalk rw1 = sub1.getRevWalk();
-    RevCommit master1 = rw1.parseCommit(getRemoteHead(subKey1.get(), "master"));
+    RevCommit master1 = rw1.parseCommit(getRemoteHead(subKey1, "master"));
     RevCommit change1Ps = parseCurrentRevision(rw1, changeId1);
     assertThat(rw1.isMergedInto(change1Ps, master1)).isTrue();
 
     sub2.git().fetch().call();
     RevWalk rw2 = sub2.getRevWalk();
-    RevCommit master2 = rw2.parseCommit(getRemoteHead(subKey2.get(), "master"));
+    RevCommit master2 = rw2.parseCommit(getRemoteHead(subKey2, "master"));
     RevCommit change2Ps = parseCurrentRevision(rw2, changeId2);
     assertThat(rw2.isMergedInto(change2Ps, master2)).isTrue();
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index a4d9a24..8bfcc84 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -1276,7 +1276,7 @@
 
   protected void assertSubmitter(String changeId, int psId, TestAccount user) throws Exception {
     Change c = getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).change();
-    ChangeNotes cn = notesFactory.createChecked(db, c);
+    ChangeNotes cn = notesFactory.createChecked(c);
     PatchSetApproval submitter =
         approvalsUtil.getSubmitter(cn, new PatchSet.Id(cn.getChangeId(), psId));
     assertThat(submitter).isNotNull();
@@ -1286,7 +1286,7 @@
 
   protected void assertNoSubmitter(String changeId, int psId) throws Exception {
     Change c = getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).change();
-    ChangeNotes cn = notesFactory.createChecked(db, c);
+    ChangeNotes cn = notesFactory.createChecked(c);
     PatchSetApproval submitter =
         approvalsUtil.getSubmitter(cn, new PatchSet.Id(cn.getChangeId(), psId));
     assertThat(submitter).isNull();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
index 2a397e4..7b302c4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
@@ -319,14 +320,14 @@
       input.state = state;
       gApi.changes().id(r.getChangeId()).addReviewer(input);
 
-      notesMigration.setFailOnLoadForTest(true);
+      Context oldCtx = disableDb();
       try {
         ChangeInfo info =
             Iterables.getOnlyElement(
                 gApi.changes().query(r.getChangeId()).withOption(DETAILED_LABELS).get());
         assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(acc)));
       } finally {
-        notesMigration.setFailOnLoadForTest(false);
+        enableDb(oldCtx);
       }
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index 6c0b707..14521cc 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -127,10 +127,7 @@
     assertThat(i.user.anonymousCowardName).isEqualTo("Unnamed User");
 
     // notedb
-    notesMigration.setReadChanges(true);
     assertThat(gApi.config().server().getInfo().noteDbEnabled).isTrue();
-    notesMigration.setReadChanges(false);
-    assertThat(gApi.config().server().getInfo().noteDbEnabled).isNull();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index a352baa..5d97347 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -950,7 +950,7 @@
     addComment(result.getChangeId(), "comment");
 
     Collection<com.google.gerrit.reviewdb.client.Comment> comments =
-        notesFactory.createChecked(db, project, changeId).getComments().values();
+        notesFactory.createChecked(project, changeId).getComments().values();
     assertThat(comments).hasSize(1);
     com.google.gerrit.reviewdb.client.Comment comment = comments.iterator().next();
     assertThat(comment.message).isEqualTo("comment");
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index 460cd30..9b19b22 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -261,7 +261,7 @@
             + rev
             + "\n");
     indexer.index(db, c.getProject(), c.getId());
-    ChangeNotes notes = changeNotesFactory.create(db, c.getProject(), c.getId());
+    ChangeNotes notes = changeNotesFactory.create(c.getProject(), c.getId());
 
     FixInput fix = new FixInput();
     fix.deletePatchSetIfCommitMissing = true;
@@ -760,7 +760,7 @@
               .setSendMail(false);
       bu.insertChange(ins).execute();
     }
-    return changeNotesFactory.create(db, project, ins.getChange().getId());
+    return changeNotesFactory.create(project, ins.getChange().getId());
   }
 
   private PatchSet.Id nextPatchSetId(ChangeNotes notes) throws Exception {
@@ -787,7 +787,7 @@
   }
 
   private ChangeNotes reload(ChangeNotes notes) throws Exception {
-    return changeNotesFactory.create(db, notes.getChange().getProject(), notes.getChangeId());
+    return changeNotesFactory.create(notes.getChange().getProject(), notes.getChangeId());
   }
 
   private RevCommit patchSetCommit(PatchSet.Id psId) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
index 5047b73..7af0ff8 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
@@ -189,8 +189,8 @@
   @Test
   public void missingChange() throws Exception {
     Change.Id changeId = new Change.Id(1234567);
-    assertNoSuchChangeException(() -> notesFactory.create(db, project, changeId));
-    assertNoSuchChangeException(() -> notesFactory.createChecked(db, project, changeId));
+    assertNoSuchChangeException(() -> notesFactory.create(project, changeId));
+    assertNoSuchChangeException(() -> notesFactory.createChecked(project, changeId));
   }
 
   private void assertNoSuchChangeException(Callable<?> callable) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/server/quota/DefaultQuotaBackendIT.java b/javatests/com/google/gerrit/acceptance/server/quota/DefaultQuotaBackendIT.java
index f60d301..dea83ca 100644
--- a/javatests/com/google/gerrit/acceptance/server/quota/DefaultQuotaBackendIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/quota/DefaultQuotaBackendIT.java
@@ -139,17 +139,13 @@
   }
 
   @Test
-  public void requestTokenPluginThrowsResultsInError() throws Exception {
+  public void requestTokenPluginThrowsAndRethrows() {
     QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
     expect(quotaEnforcer.requestTokens("testGroup", ctx, 1)).andThrow(new NullPointerException());
     replay(quotaEnforcer);
 
-    QuotaResponse.Aggregated result = quotaBackend.user(identifiedAdmin).requestToken("testGroup");
-    assertThat(result)
-        .isEqualTo(singletonAggregation(QuotaResponse.error("failed to request quota tokens")));
-    exception.expect(QuotaException.class);
-    exception.expectMessage("failed to request quota tokens");
-    result.throwOnError();
+    exception.expect(NullPointerException.class);
+    quotaBackend.user(identifiedAdmin).requestToken("testGroup");
   }
 
   private static QuotaResponse.Aggregated singletonAggregation(QuotaResponse response) {
diff --git a/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java b/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java
index ba69d7c..31a8808 100644
--- a/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java
@@ -19,6 +19,7 @@
 import static org.easymock.EasyMock.expectLastCall;
 import static org.easymock.EasyMock.replay;
 import static org.easymock.EasyMock.resetToStrict;
+import static org.easymock.EasyMock.verify;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -87,20 +88,24 @@
 
   @Test
   public void refillsOnException() {
+    NullPointerException exception = new NullPointerException();
     QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
-    expect(quotaEnforcerA.requestTokens("testGroup", ctx, 1)).andReturn(QuotaResponse.ok());
-    expect(quotaEnforcerB.requestTokens("testGroup", ctx, 1)).andThrow(new NullPointerException());
-    quotaEnforcerA.refill("testGroup", ctx, 1);
+    expect(quotaEnforcerA.requestTokens("testGroup", ctx, 1)).andThrow(exception);
+    expect(quotaEnforcerB.requestTokens("testGroup", ctx, 1)).andReturn(QuotaResponse.ok());
+    quotaEnforcerB.refill("testGroup", ctx, 1);
     expectLastCall();
 
     replay(quotaEnforcerA);
     replay(quotaEnforcerB);
 
-    assertThat(quotaBackend.user(identifiedAdmin).requestToken("testGroup"))
-        .isEqualTo(
-            QuotaResponse.Aggregated.create(
-                ImmutableList.of(
-                    QuotaResponse.ok(), QuotaResponse.error("failed to request quota tokens"))));
+    try {
+      quotaBackend.user(identifiedAdmin).requestToken("testGroup");
+      fail("expected a NullPointerException");
+    } catch (NullPointerException e) {
+      assertThat(exception).isEqualTo(e);
+    }
+
+    verify(quotaEnforcerA);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java
index c8ce54a..27868d2 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java
@@ -65,6 +65,6 @@
     String indicesPrefix = getSanitizedMethodName();
     ElasticTestUtils.configure(
         elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
index cfdfa98..2e4e22a 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
@@ -65,6 +65,6 @@
     String indicesPrefix = getSanitizedMethodName();
     ElasticTestUtils.configure(
         elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java
index 832a7bd..98c4321 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java
@@ -65,6 +65,6 @@
     String indicesPrefix = getSanitizedMethodName();
     ElasticTestUtils.configure(
         elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryProjectsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryProjectsTest.java
index 29d3fa4..6b4b58c 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryProjectsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryProjectsTest.java
@@ -65,6 +65,6 @@
     String indicesPrefix = getSanitizedMethodName();
     ElasticTestUtils.configure(
         elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
index 8833907..53593ef 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
@@ -64,6 +64,6 @@
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
index 8ba753c..6429431 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
@@ -64,6 +64,6 @@
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
index cecb085..de0af97 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
@@ -64,6 +64,6 @@
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryProjectsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryProjectsTest.java
index 47e9b10..0ce66e8 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryProjectsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryProjectsTest.java
@@ -64,6 +64,6 @@
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
index bddbbc9..6972a18 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
@@ -64,6 +64,6 @@
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
index 5dcf159..988abca 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
@@ -82,6 +82,6 @@
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
index 54be7b9..534bc36 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
@@ -64,6 +64,6 @@
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
index e8b4a2c..1f4653c 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
@@ -64,6 +64,6 @@
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java b/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
index d5add7d..e5578e1 100644
--- a/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
+++ b/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
@@ -47,7 +47,6 @@
 import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryDatabase;
 import com.google.gerrit.testing.InMemoryModule;
-import com.google.gerrit.testing.NoteDbMode;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
@@ -103,8 +102,7 @@
         ImmutableList.of(
             Fingerprint.toString(keyB().getPublicKey().getFingerprint()),
             Fingerprint.toString(keyD().getPublicKey().getFingerprint())));
-    Injector injector =
-        Guice.createInjector(new InMemoryModule(cfg, NoteDbMode.newNotesMigrationFromEnv()));
+    Injector injector = Guice.createInjector(new InMemoryModule(cfg));
 
     lifecycle = new LifecycleManager();
     lifecycle.add(injector);
diff --git a/javatests/com/google/gerrit/mail/AbstractParserTest.java b/javatests/com/google/gerrit/mail/AbstractParserTest.java
index c375aff..bcff6a7 100644
--- a/javatests/com/google/gerrit/mail/AbstractParserTest.java
+++ b/javatests/com/google/gerrit/mail/AbstractParserTest.java
@@ -41,7 +41,7 @@
       String message, MailComment comment, Comment inReplyTo) {
     assertThat(comment.fileName).isNull();
     assertThat(comment.message).isEqualTo(message);
-    assertThat(comment.inReplyTo).isEqualTo(inReplyTo);
+    assertThat(comment.inReplyTo.key).isEqualTo(inReplyTo.key);
     assertThat(comment.type).isEqualTo(MailComment.CommentType.INLINE_COMMENT);
   }
 
diff --git a/javatests/com/google/gerrit/proto/BUILD b/javatests/com/google/gerrit/proto/BUILD
index a249638..c7d3aca 100644
--- a/javatests/com/google/gerrit/proto/BUILD
+++ b/javatests/com/google/gerrit/proto/BUILD
@@ -4,15 +4,13 @@
     name = "proto_tests",
     srcs = glob(["*.java"]),
     deps = [
+        "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//lib/truth:truth-proto-extension",
-        "//proto:reviewdb_java_proto",
-
-        # TODO(dborowitz): These are already runtime_deps of
-        # truth-proto-extension, but either omitting them or adding them as
-        # runtime_deps to this target fails with:
-        #   class file for com.google.common.collect.Multimap not found
         "//lib:guava",
+        "//lib:protobuf",
         "//lib/truth",
+        "//lib/truth:truth-proto-extension",
+        "//proto:cache_java_proto",
+        "//proto:reviewdb_java_proto",
     ],
 )
diff --git a/javatests/com/google/gerrit/proto/ProtosTest.java b/javatests/com/google/gerrit/proto/ProtosTest.java
new file mode 100644
index 0000000..29e8fe0
--- /dev/null
+++ b/javatests/com/google/gerrit/proto/ProtosTest.java
@@ -0,0 +1,156 @@
+// 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.proto;
+
+import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+
+import com.google.gerrit.server.cache.proto.Cache.ChangeNotesKeyProto;
+import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
+import com.google.gerrit.testing.GerritBaseTests;
+import com.google.protobuf.ByteString;
+import java.util.Arrays;
+import org.junit.Test;
+
+public class ProtosTest extends GerritBaseTests {
+  @Test
+  public void parseUncheckedByteArrayWrongProtoType() {
+    ChangeNotesKeyProto proto =
+        ChangeNotesKeyProto.newBuilder()
+            .setProject("project")
+            .setChangeId(1234)
+            .setId(ByteString.copyFromUtf8("foo"))
+            .build();
+    byte[] bytes = Protos.toByteArray(proto);
+    try {
+      Protos.parseUnchecked(ChangeNotesStateProto.parser(), bytes);
+      assert_().fail("expected IllegalArgumentException");
+    } catch (IllegalArgumentException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void parseUncheckedByteArrayInvalidData() {
+    byte[] bytes = new byte[] {0x00};
+    try {
+      Protos.parseUnchecked(ChangeNotesStateProto.parser(), bytes);
+      assert_().fail("expected IllegalArgumentException");
+    } catch (IllegalArgumentException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void parseUncheckedByteArray() {
+    ChangeNotesKeyProto proto =
+        ChangeNotesKeyProto.newBuilder()
+            .setProject("project")
+            .setChangeId(1234)
+            .setId(ByteString.copyFromUtf8("foo"))
+            .build();
+    byte[] bytes = Protos.toByteArray(proto);
+    assertThat(Protos.parseUnchecked(ChangeNotesKeyProto.parser(), bytes)).isEqualTo(proto);
+  }
+
+  @Test
+  public void parseUncheckedSegmentOfByteArrayWrongProtoType() {
+    ChangeNotesKeyProto proto =
+        ChangeNotesKeyProto.newBuilder()
+            .setProject("project")
+            .setChangeId(1234)
+            .setId(ByteString.copyFromUtf8("foo"))
+            .build();
+    byte[] bytes = Protos.toByteArray(proto);
+    try {
+      Protos.parseUnchecked(ChangeNotesStateProto.parser(), bytes, 0, bytes.length);
+      assert_().fail("expected IllegalArgumentException");
+    } catch (IllegalArgumentException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void parseUncheckedSegmentOfByteArrayInvalidData() {
+    byte[] bytes = new byte[] {0x00};
+    try {
+      Protos.parseUnchecked(ChangeNotesStateProto.parser(), bytes, 0, bytes.length);
+      assert_().fail("expected IllegalArgumentException");
+    } catch (IllegalArgumentException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void parseUncheckedSegmentOfByteArray() {
+    ChangeNotesKeyProto proto =
+        ChangeNotesKeyProto.newBuilder()
+            .setProject("project")
+            .setChangeId(1234)
+            .setId(ByteString.copyFromUtf8("foo"))
+            .build();
+    byte[] protoBytes = Protos.toByteArray(proto);
+    int offset = 3;
+    int length = protoBytes.length;
+    byte[] bytes = new byte[length + 20];
+    Arrays.fill(bytes, (byte) 1);
+    System.arraycopy(protoBytes, 0, bytes, offset, length);
+
+    ChangeNotesKeyProto parsedProto =
+        Protos.parseUnchecked(ChangeNotesKeyProto.parser(), bytes, offset, length);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  @Test
+  public void parseUncheckedByteStringWrongProtoType() {
+    ChangeNotesKeyProto proto =
+        ChangeNotesKeyProto.newBuilder()
+            .setProject("project")
+            .setChangeId(1234)
+            .setId(ByteString.copyFromUtf8("foo"))
+            .build();
+    ByteString byteString = Protos.toByteString(proto);
+    try {
+      Protos.parseUnchecked(ChangeNotesStateProto.parser(), byteString);
+      assert_().fail("expected IllegalArgumentException");
+    } catch (IllegalArgumentException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void parseUncheckedByteStringInvalidData() {
+    ByteString byteString = ByteString.copyFrom(new byte[] {0x00});
+    try {
+      Protos.parseUnchecked(ChangeNotesStateProto.parser(), byteString);
+      assert_().fail("expected IllegalArgumentException");
+    } catch (IllegalArgumentException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void parseUncheckedByteString() {
+    ChangeNotesKeyProto proto =
+        ChangeNotesKeyProto.newBuilder()
+            .setProject("project")
+            .setChangeId(1234)
+            .setId(ByteString.copyFromUtf8("foo"))
+            .build();
+    ByteString byteString = Protos.toByteString(proto);
+    assertThat(Protos.parseUnchecked(ChangeNotesKeyProto.parser(), byteString)).isEqualTo(proto);
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/AccountIdProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/AccountIdProtoConverterTest.java
new file mode 100644
index 0000000..38d4195
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/AccountIdProtoConverterTest.java
@@ -0,0 +1,67 @@
+// 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.reviewdb.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.reviewdb.Reviewdb;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.protobuf.Parser;
+import org.junit.Test;
+
+public class AccountIdProtoConverterTest {
+  private final AccountIdProtoConverter accountIdProtoConverter = AccountIdProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    Account.Id accountId = new Account.Id(24);
+
+    Reviewdb.Account_Id proto = accountIdProtoConverter.toProto(accountId);
+
+    Reviewdb.Account_Id expectedProto = Reviewdb.Account_Id.newBuilder().setId(24).build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    Account.Id accountId = new Account.Id(34832);
+
+    Account.Id convertedAccountId =
+        accountIdProtoConverter.fromProto(accountIdProtoConverter.toProto(accountId));
+
+    assertThat(convertedAccountId).isEqualTo(accountId);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Reviewdb.Account_Id proto = Reviewdb.Account_Id.newBuilder().setId(24).build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Reviewdb.Account_Id> parser = accountIdProtoConverter.getParser();
+    Reviewdb.Account_Id parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void fieldsExistAsExpected() {
+    assertThatSerializedClass(Account.Id.class).hasFields(ImmutableMap.of("id", int.class));
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/BUILD b/javatests/com/google/gerrit/reviewdb/converter/BUILD
new file mode 100644
index 0000000..7c15910
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/BUILD
@@ -0,0 +1,33 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+COMPATIBLITY_TEST_SRCS = glob(["*CompatibilityTest.java"])
+
+junit_tests(
+    name = "proto_converter_tests",
+    srcs = glob(
+        ["*.java"],
+        exclude = COMPATIBLITY_TEST_SRCS,
+    ),
+    deps = [
+        "//java/com/google/gerrit/proto/testing",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//lib:guava",
+        "//lib:protobuf",
+        "//lib/truth",
+        "//lib/truth:truth-proto-extension",
+        "//proto:reviewdb_java_proto",
+    ],
+)
+
+junit_tests(
+    name = "compatibility_tests",
+    srcs = COMPATIBLITY_TEST_SRCS,
+    deps = [
+        "//java/com/google/gerrit/proto",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//lib:guava",
+        "//lib:gwtorm-client",
+        "//lib:protobuf",
+        "//lib/truth",
+    ],
+)
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverterTest.java
new file mode 100644
index 0000000..d5f055b
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverterTest.java
@@ -0,0 +1,67 @@
+// 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.reviewdb.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.reviewdb.Reviewdb;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.protobuf.Parser;
+import org.junit.Test;
+
+public class ChangeIdProtoConverterTest {
+  private final ChangeIdProtoConverter changeIdProtoConverter = ChangeIdProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    Change.Id changeId = new Change.Id(94);
+
+    Reviewdb.Change_Id proto = changeIdProtoConverter.toProto(changeId);
+
+    Reviewdb.Change_Id expectedProto = Reviewdb.Change_Id.newBuilder().setId(94).build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    Change.Id changeId = new Change.Id(2903482);
+
+    Change.Id convertedChangeId =
+        changeIdProtoConverter.fromProto(changeIdProtoConverter.toProto(changeId));
+
+    assertThat(convertedChangeId).isEqualTo(changeId);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Reviewdb.Change_Id proto = Reviewdb.Change_Id.newBuilder().setId(94).build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Reviewdb.Change_Id> parser = changeIdProtoConverter.getParser();
+    Reviewdb.Change_Id parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void fieldsExistAsExpected() {
+    assertThatSerializedClass(Change.Id.class).hasFields(ImmutableMap.of("id", int.class));
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageConverterCompatibilityTest.java b/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageConverterCompatibilityTest.java
new file mode 100644
index 0000000..a194ec6
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageConverterCompatibilityTest.java
@@ -0,0 +1,195 @@
+// 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.reviewdb.converter;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.Iterables.getOnlyElement;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gwtorm.protobuf.CodecFactory;
+import com.google.gwtorm.protobuf.ProtobufCodec;
+import com.google.gwtorm.server.OrmException;
+import com.google.protobuf.ByteString;
+import com.google.protobuf.CodedOutputStream;
+import com.google.protobuf.MessageLite;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Collection;
+import java.util.List;
+import org.junit.Test;
+
+// TODO(aliceks): Delete after proving binary compatibility.
+public class ChangeMessageConverterCompatibilityTest {
+
+  private final ProtobufCodec<ChangeMessage> changeMessageCodec =
+      CodecFactory.encoder(ChangeMessage.class);
+  private final ChangeMessageProtoConverter changeMessageProtoConverter =
+      ChangeMessageProtoConverter.INSTANCE;
+
+  @Test
+  public void changeIndexFieldWithAllValuesIsBinaryCompatible() throws Exception {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
+            new Account.Id(63),
+            new Timestamp(9876543),
+            new PatchSet.Id(new Change.Id(34), 13));
+    changeMessage.setMessage("This is a change message.");
+    changeMessage.setTag("An arbitrary tag.");
+    changeMessage.setRealAuthor(new Account.Id(10003));
+    ImmutableList<ChangeMessage> changeMessages = ImmutableList.of(changeMessage);
+
+    byte[] resultOfOldConverter =
+        getOnlyElement(convertToProtos_old(changeMessageCodec, changeMessages));
+    byte[] resultOfNewConverter =
+        getOnlyElement(convertToProtos_new(changeMessageProtoConverter, changeMessages));
+
+    assertThat(resultOfNewConverter).isEqualTo(resultOfOldConverter);
+  }
+
+  @Test
+  public void changeIndexFieldWithMandatoryValuesIsBinaryCompatible() throws Exception {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"), null, null, null);
+    ImmutableList<ChangeMessage> changeMessages = ImmutableList.of(changeMessage);
+
+    byte[] resultOfOldConverter =
+        getOnlyElement(convertToProtos_old(changeMessageCodec, changeMessages));
+    byte[] resultOfNewConverter =
+        getOnlyElement(convertToProtos_new(changeMessageProtoConverter, changeMessages));
+
+    assertThat(resultOfNewConverter).isEqualTo(resultOfOldConverter);
+  }
+
+  @Test
+  public void changeNotesFieldWithAllValuesIsBinaryCompatible() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
+            new Account.Id(63),
+            new Timestamp(9876543),
+            new PatchSet.Id(new Change.Id(34), 13));
+    changeMessage.setMessage("This is a change message.");
+    changeMessage.setTag("An arbitrary tag.");
+    changeMessage.setRealAuthor(new Account.Id(10003));
+
+    ByteString resultOfOldConverter = Protos.toByteString(changeMessage, changeMessageCodec);
+    ByteString resultOfNewConverter = toByteString(changeMessage, changeMessageProtoConverter);
+
+    assertThat(resultOfNewConverter).isEqualTo(resultOfOldConverter);
+  }
+
+  @Test
+  public void changeNotesFieldWithMainValuesIsBinaryCompatible() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
+            new Account.Id(63),
+            new Timestamp(9876543),
+            new PatchSet.Id(new Change.Id(34), 13));
+
+    ByteString resultOfOldConverter = Protos.toByteString(changeMessage, changeMessageCodec);
+    ByteString resultOfNewConverter = toByteString(changeMessage, changeMessageProtoConverter);
+
+    assertThat(resultOfNewConverter).isEqualTo(resultOfOldConverter);
+  }
+
+  @Test
+  public void changeNotesFieldWithoutRealAuthorButAuthorIsBinaryCompatible() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
+            new Account.Id(63),
+            null,
+            null);
+
+    ByteString resultOfOldConverter = Protos.toByteString(changeMessage, changeMessageCodec);
+    ByteString resultOfNewConverter = toByteString(changeMessage, changeMessageProtoConverter);
+
+    assertThat(resultOfNewConverter).isEqualTo(resultOfOldConverter);
+  }
+
+  @Test
+  public void changeNotesFieldWithoutSameRealAuthorAndAuthorIsBinaryCompatible() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
+            new Account.Id(63),
+            null,
+            null);
+    changeMessage.setRealAuthor(new Account.Id(63));
+
+    ByteString resultOfOldConverter = Protos.toByteString(changeMessage, changeMessageCodec);
+    ByteString resultOfNewConverter = toByteString(changeMessage, changeMessageProtoConverter);
+
+    assertThat(resultOfNewConverter).isEqualTo(resultOfOldConverter);
+  }
+
+  @Test
+  public void changeNotesFieldWithMandatoryValuesIsBinaryCompatible() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"), null, null, null);
+
+    ByteString resultOfOldConverter = Protos.toByteString(changeMessage, changeMessageCodec);
+    ByteString resultOfNewConverter = toByteString(changeMessage, changeMessageProtoConverter);
+
+    assertThat(resultOfNewConverter).isEqualTo(resultOfOldConverter);
+  }
+
+  // Copied from ChangeField.
+  private static <T> List<byte[]> convertToProtos_old(ProtobufCodec<T> codec, Collection<T> objs)
+      throws OrmException {
+    List<byte[]> result = Lists.newArrayListWithCapacity(objs.size());
+    ByteArrayOutputStream out = new ByteArrayOutputStream(256);
+    try {
+      for (T obj : objs) {
+        out.reset();
+        CodedOutputStream cos = CodedOutputStream.newInstance(out);
+        codec.encode(obj, cos);
+        cos.flush();
+        result.add(out.toByteArray());
+      }
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+    return result;
+  }
+
+  // Copied from ChangeField.
+  private static <T> List<byte[]> convertToProtos_new(
+      ProtoConverter<?, T> converter, Collection<T> objects) {
+    return objects
+        .stream()
+        .map(converter::toProto)
+        .map(Protos::toByteArray)
+        .collect(toImmutableList());
+  }
+
+  // Copied from ChangeNotesState.Serializer.
+  private static <T> ByteString toByteString(T object, ProtoConverter<?, T> converter) {
+    MessageLite message = converter.toProto(object);
+    return Protos.toByteString(message);
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverterTest.java
new file mode 100644
index 0000000..9874737
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverterTest.java
@@ -0,0 +1,83 @@
+// 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.reviewdb.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.reviewdb.Reviewdb;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.protobuf.Parser;
+import java.lang.reflect.Type;
+import org.junit.Test;
+
+public class ChangeMessageKeyProtoConverterTest {
+  private final ChangeMessageKeyProtoConverter messageKeyProtoConverter =
+      ChangeMessageKeyProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    ChangeMessage.Key messageKey = new ChangeMessage.Key(new Change.Id(704), "aabbcc");
+
+    Reviewdb.ChangeMessage_Key proto = messageKeyProtoConverter.toProto(messageKey);
+
+    Reviewdb.ChangeMessage_Key expectedProto =
+        Reviewdb.ChangeMessage_Key.newBuilder()
+            .setChangeId(Reviewdb.Change_Id.newBuilder().setId(704))
+            .setUuid("aabbcc")
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    ChangeMessage.Key messageKey = new ChangeMessage.Key(new Change.Id(704), "aabbcc");
+
+    ChangeMessage.Key convertedMessageKey =
+        messageKeyProtoConverter.fromProto(messageKeyProtoConverter.toProto(messageKey));
+
+    assertThat(convertedMessageKey).isEqualTo(messageKey);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Reviewdb.ChangeMessage_Key proto =
+        Reviewdb.ChangeMessage_Key.newBuilder()
+            .setChangeId(Reviewdb.Change_Id.newBuilder().setId(704))
+            .setUuid("aabbcc")
+            .build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Reviewdb.ChangeMessage_Key> parser = messageKeyProtoConverter.getParser();
+    Reviewdb.ChangeMessage_Key parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void fieldsExistAsExpected() {
+    assertThatSerializedClass(ChangeMessage.Key.class)
+        .hasFields(
+            ImmutableMap.<String, Type>builder()
+                .put("changeId", Change.Id.class)
+                .put("uuid", String.class)
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverterTest.java
new file mode 100644
index 0000000..f478deb
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverterTest.java
@@ -0,0 +1,214 @@
+// 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.reviewdb.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.reviewdb.Reviewdb;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.protobuf.Parser;
+import java.lang.reflect.Type;
+import java.sql.Timestamp;
+import org.junit.Test;
+
+public class ChangeMessageProtoConverterTest {
+  private final ChangeMessageProtoConverter changeMessageProtoConverter =
+      ChangeMessageProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
+            new Account.Id(63),
+            new Timestamp(9876543),
+            new PatchSet.Id(new Change.Id(34), 13));
+    changeMessage.setMessage("This is a change message.");
+    changeMessage.setTag("An arbitrary tag.");
+    changeMessage.setRealAuthor(new Account.Id(10003));
+
+    Reviewdb.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
+
+    Reviewdb.ChangeMessage expectedProto =
+        Reviewdb.ChangeMessage.newBuilder()
+            .setKey(
+                Reviewdb.ChangeMessage_Key.newBuilder()
+                    .setChangeId(Reviewdb.Change_Id.newBuilder().setId(543))
+                    .setUuid("change-message-21"))
+            .setAuthorId(Reviewdb.Account_Id.newBuilder().setId(63))
+            .setWrittenOn(9876543)
+            .setMessage("This is a change message.")
+            .setPatchset(
+                Reviewdb.PatchSet_Id.newBuilder()
+                    .setChangeId(Reviewdb.Change_Id.newBuilder().setId(34))
+                    .setPatchSetId(13))
+            .setTag("An arbitrary tag.")
+            .setRealAuthor(Reviewdb.Account_Id.newBuilder().setId(10003))
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void mainValuesConvertedToProto() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
+            new Account.Id(63),
+            new Timestamp(9876543),
+            new PatchSet.Id(new Change.Id(34), 13));
+
+    Reviewdb.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
+
+    Reviewdb.ChangeMessage expectedProto =
+        Reviewdb.ChangeMessage.newBuilder()
+            .setKey(
+                Reviewdb.ChangeMessage_Key.newBuilder()
+                    .setChangeId(Reviewdb.Change_Id.newBuilder().setId(543))
+                    .setUuid("change-message-21"))
+            .setAuthorId(Reviewdb.Account_Id.newBuilder().setId(63))
+            .setWrittenOn(9876543)
+            .setPatchset(
+                Reviewdb.PatchSet_Id.newBuilder()
+                    .setChangeId(Reviewdb.Change_Id.newBuilder().setId(34))
+                    .setPatchSetId(13))
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  // This test documents a special behavior which is necessary to ensure binary compatibility.
+  @Test
+  public void realAuthorIsNotAutomaticallySetToAuthorWhenConvertedToProto() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
+            new Account.Id(63),
+            null,
+            null);
+
+    Reviewdb.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
+
+    Reviewdb.ChangeMessage expectedProto =
+        Reviewdb.ChangeMessage.newBuilder()
+            .setKey(
+                Reviewdb.ChangeMessage_Key.newBuilder()
+                    .setChangeId(Reviewdb.Change_Id.newBuilder().setId(543))
+                    .setUuid("change-message-21"))
+            .setAuthorId(Reviewdb.Account_Id.newBuilder().setId(63))
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void mandatoryValuesConvertedToProto() {
+    // writtenOn may not be null according to the column definition but it's optional for the
+    // protobuf definition. -> assume as optional and hence test null
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"), null, null, null);
+
+    Reviewdb.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
+
+    Reviewdb.ChangeMessage expectedProto =
+        Reviewdb.ChangeMessage.newBuilder()
+            .setKey(
+                Reviewdb.ChangeMessage_Key.newBuilder()
+                    .setChangeId(Reviewdb.Change_Id.newBuilder().setId(543))
+                    .setUuid("change-message-21"))
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
+            new Account.Id(63),
+            new Timestamp(9876543),
+            new PatchSet.Id(new Change.Id(34), 13));
+    changeMessage.setMessage("This is a change message.");
+    changeMessage.setTag("An arbitrary tag.");
+    changeMessage.setRealAuthor(new Account.Id(10003));
+
+    ChangeMessage convertedChangeMessage =
+        changeMessageProtoConverter.fromProto(changeMessageProtoConverter.toProto(changeMessage));
+    assertThat(convertedChangeMessage).isEqualTo(changeMessage);
+  }
+
+  @Test
+  public void mainValuesConvertedToProtoAndBackAgain() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
+            new Account.Id(63),
+            new Timestamp(9876543),
+            new PatchSet.Id(new Change.Id(34), 13));
+
+    ChangeMessage convertedChangeMessage =
+        changeMessageProtoConverter.fromProto(changeMessageProtoConverter.toProto(changeMessage));
+    assertThat(convertedChangeMessage).isEqualTo(changeMessage);
+  }
+
+  @Test
+  public void mandatoryValuesConvertedToProtoAndBackAgain() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"), null, null, null);
+
+    ChangeMessage convertedChangeMessage =
+        changeMessageProtoConverter.fromProto(changeMessageProtoConverter.toProto(changeMessage));
+    assertThat(convertedChangeMessage).isEqualTo(changeMessage);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Reviewdb.ChangeMessage proto =
+        Reviewdb.ChangeMessage.newBuilder()
+            .setKey(
+                Reviewdb.ChangeMessage_Key.newBuilder()
+                    .setChangeId(Reviewdb.Change_Id.newBuilder().setId(543))
+                    .setUuid("change-message-21"))
+            .build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Reviewdb.ChangeMessage> parser = changeMessageProtoConverter.getParser();
+    Reviewdb.ChangeMessage parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void fieldsExistAsExpected() {
+    assertThatSerializedClass(ChangeMessage.class)
+        .hasFields(
+            ImmutableMap.<String, Type>builder()
+                .put("key", ChangeMessage.Key.class)
+                .put("author", Account.Id.class)
+                .put("writtenOn", Timestamp.class)
+                .put("message", String.class)
+                .put("patchset", PatchSet.Id.class)
+                .put("tag", String.class)
+                .put("realAuthor", Account.Id.class)
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/LabelIdProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/LabelIdProtoConverterTest.java
new file mode 100644
index 0000000..a6aebd2
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/LabelIdProtoConverterTest.java
@@ -0,0 +1,67 @@
+// 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.reviewdb.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.reviewdb.Reviewdb;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.protobuf.Parser;
+import org.junit.Test;
+
+public class LabelIdProtoConverterTest {
+  private final LabelIdProtoConverter labelIdProtoConverter = LabelIdProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    LabelId labelId = new LabelId("Label ID 42");
+
+    Reviewdb.LabelId proto = labelIdProtoConverter.toProto(labelId);
+
+    Reviewdb.LabelId expectedProto = Reviewdb.LabelId.newBuilder().setId("Label ID 42").build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    LabelId labelId = new LabelId("label-5");
+
+    LabelId convertedLabelId =
+        labelIdProtoConverter.fromProto(labelIdProtoConverter.toProto(labelId));
+
+    assertThat(convertedLabelId).isEqualTo(labelId);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Reviewdb.LabelId proto = Reviewdb.LabelId.newBuilder().setId("label-23").build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Reviewdb.LabelId> parser = labelIdProtoConverter.getParser();
+    Reviewdb.LabelId parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void fieldsExistAsExpected() {
+    assertThatSerializedClass(LabelId.class).hasFields(ImmutableMap.of("id", String.class));
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalConverterCompatibilityTest.java b/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalConverterCompatibilityTest.java
new file mode 100644
index 0000000..9da37da
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalConverterCompatibilityTest.java
@@ -0,0 +1,166 @@
+// 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.reviewdb.converter;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.Iterables.getOnlyElement;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gwtorm.protobuf.CodecFactory;
+import com.google.gwtorm.protobuf.ProtobufCodec;
+import com.google.gwtorm.server.OrmException;
+import com.google.protobuf.ByteString;
+import com.google.protobuf.CodedOutputStream;
+import com.google.protobuf.MessageLite;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Date;
+import java.util.List;
+import org.junit.Test;
+
+// TODO(aliceks): Delete after proving binary compatibility.
+public class PatchSetApprovalConverterCompatibilityTest {
+
+  private final ProtobufCodec<PatchSetApproval> patchSetApprovalCodec =
+      CodecFactory.encoder(PatchSetApproval.class);
+  private final PatchSetApprovalProtoConverter patchSetApprovalProtoConverter =
+      PatchSetApprovalProtoConverter.INSTANCE;
+
+  @Test
+  public void changeIndexFieldWithAllValuesIsBinaryCompatible() throws Exception {
+    PatchSetApproval patchSetApproval =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(
+                new PatchSet.Id(new Change.Id(42), 14),
+                new Account.Id(100013),
+                new LabelId("label-8")),
+            (short) 456,
+            new Date(987654L));
+    patchSetApproval.setTag("tag-21");
+    patchSetApproval.setRealAccountId(new Account.Id(612));
+    patchSetApproval.setPostSubmit(true);
+    ImmutableList<PatchSetApproval> patchSetApprovals = ImmutableList.of(patchSetApproval);
+
+    byte[] resultOfOldConverter =
+        getOnlyElement(convertToProtos_old(patchSetApprovalCodec, patchSetApprovals));
+    byte[] resultOfNewConverter =
+        getOnlyElement(convertToProtos_new(patchSetApprovalProtoConverter, patchSetApprovals));
+
+    assertThat(resultOfNewConverter).isEqualTo(resultOfOldConverter);
+  }
+
+  @Test
+  public void changeIndexFieldWithMandatoryValuesIsBinaryCompatible() throws Exception {
+    PatchSetApproval patchSetApproval =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(
+                new PatchSet.Id(new Change.Id(42), 14),
+                new Account.Id(100013),
+                new LabelId("label-8")),
+            (short) 456,
+            new Date(987654L));
+    ImmutableList<PatchSetApproval> patchSetApprovals = ImmutableList.of(patchSetApproval);
+
+    byte[] resultOfOldConverter =
+        getOnlyElement(convertToProtos_old(patchSetApprovalCodec, patchSetApprovals));
+    byte[] resultOfNewConverter =
+        getOnlyElement(convertToProtos_new(patchSetApprovalProtoConverter, patchSetApprovals));
+
+    assertThat(resultOfNewConverter).isEqualTo(resultOfOldConverter);
+  }
+
+  @Test
+  public void changeNotesFieldWithAllValuesIsBinaryCompatible() {
+    PatchSetApproval patchSetApproval =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(
+                new PatchSet.Id(new Change.Id(42), 14),
+                new Account.Id(100013),
+                new LabelId("label-8")),
+            (short) 456,
+            new Date(987654L));
+    patchSetApproval.setTag("tag-21");
+    patchSetApproval.setRealAccountId(new Account.Id(612));
+    patchSetApproval.setPostSubmit(true);
+
+    ByteString resultOfOldConverter = Protos.toByteString(patchSetApproval, patchSetApprovalCodec);
+    ByteString resultOfNewConverter =
+        toByteString(patchSetApproval, patchSetApprovalProtoConverter);
+
+    assertThat(resultOfNewConverter).isEqualTo(resultOfOldConverter);
+  }
+
+  @Test
+  public void changeNotesFieldWithMandatoryValuesIsBinaryCompatible() {
+    PatchSetApproval patchSetApproval =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(
+                new PatchSet.Id(new Change.Id(42), 14),
+                new Account.Id(100013),
+                new LabelId("label-8")),
+            (short) 456,
+            new Date(987654L));
+
+    ByteString resultOfOldConverter = Protos.toByteString(patchSetApproval, patchSetApprovalCodec);
+    ByteString resultOfNewConverter =
+        toByteString(patchSetApproval, patchSetApprovalProtoConverter);
+
+    assertThat(resultOfNewConverter).isEqualTo(resultOfOldConverter);
+  }
+
+  // Copied from ChangeField.
+  private static <T> List<byte[]> convertToProtos_old(ProtobufCodec<T> codec, Collection<T> objs)
+      throws OrmException {
+    List<byte[]> result = Lists.newArrayListWithCapacity(objs.size());
+    ByteArrayOutputStream out = new ByteArrayOutputStream(256);
+    try {
+      for (T obj : objs) {
+        out.reset();
+        CodedOutputStream cos = CodedOutputStream.newInstance(out);
+        codec.encode(obj, cos);
+        cos.flush();
+        result.add(out.toByteArray());
+      }
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+    return result;
+  }
+
+  // Copied from ChangeField.
+  private static <T> List<byte[]> convertToProtos_new(
+      ProtoConverter<?, T> converter, Collection<T> objects) {
+    return objects
+        .stream()
+        .map(converter::toProto)
+        .map(Protos::toByteArray)
+        .collect(toImmutableList());
+  }
+
+  // Copied from ChangeNotesState.Serializer.
+  private static <T> ByteString toByteString(T object, ProtoConverter<?, T> converter) {
+    MessageLite message = converter.toProto(object);
+    return Protos.toByteString(message);
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverterTest.java
new file mode 100644
index 0000000..0ed84fd
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverterTest.java
@@ -0,0 +1,98 @@
+// 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.reviewdb.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.reviewdb.Reviewdb;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.protobuf.Parser;
+import java.lang.reflect.Type;
+import org.junit.Test;
+
+public class PatchSetApprovalKeyProtoConverterTest {
+  private final PatchSetApprovalKeyProtoConverter protoConverter =
+      PatchSetApprovalKeyProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    PatchSetApproval.Key key =
+        new PatchSetApproval.Key(
+            new PatchSet.Id(new Change.Id(42), 14), new Account.Id(100013), new LabelId("label-8"));
+
+    Reviewdb.PatchSetApproval_Key proto = protoConverter.toProto(key);
+
+    Reviewdb.PatchSetApproval_Key expectedProto =
+        Reviewdb.PatchSetApproval_Key.newBuilder()
+            .setPatchSetId(
+                Reviewdb.PatchSet_Id.newBuilder()
+                    .setChangeId(Reviewdb.Change_Id.newBuilder().setId(42))
+                    .setPatchSetId(14))
+            .setAccountId(Reviewdb.Account_Id.newBuilder().setId(100013))
+            .setCategoryId(Reviewdb.LabelId.newBuilder().setId("label-8"))
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    PatchSetApproval.Key key =
+        new PatchSetApproval.Key(
+            new PatchSet.Id(new Change.Id(42), 14), new Account.Id(100013), new LabelId("label-8"));
+
+    PatchSetApproval.Key convertedKey = protoConverter.fromProto(protoConverter.toProto(key));
+
+    assertThat(convertedKey).isEqualTo(key);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Reviewdb.PatchSetApproval_Key proto =
+        Reviewdb.PatchSetApproval_Key.newBuilder()
+            .setPatchSetId(
+                Reviewdb.PatchSet_Id.newBuilder()
+                    .setChangeId(Reviewdb.Change_Id.newBuilder().setId(42))
+                    .setPatchSetId(14))
+            .setAccountId(Reviewdb.Account_Id.newBuilder().setId(100013))
+            .setCategoryId(Reviewdb.LabelId.newBuilder().setId("label-8"))
+            .build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Reviewdb.PatchSetApproval_Key> parser = protoConverter.getParser();
+    Reviewdb.PatchSetApproval_Key parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void fieldsExistAsExpected() {
+    assertThatSerializedClass(PatchSetApproval.Key.class)
+        .hasFields(
+            ImmutableMap.<String, Type>builder()
+                .put("patchSetId", PatchSet.Id.class)
+                .put("accountId", Account.Id.class)
+                .put("categoryId", LabelId.class)
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverterTest.java
new file mode 100644
index 0000000..831696c
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverterTest.java
@@ -0,0 +1,203 @@
+// 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.reviewdb.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.reviewdb.Reviewdb;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.protobuf.Parser;
+import java.lang.reflect.Type;
+import java.sql.Timestamp;
+import java.util.Date;
+import org.junit.Test;
+
+public class PatchSetApprovalProtoConverterTest {
+  private final PatchSetApprovalProtoConverter protoConverter =
+      PatchSetApprovalProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    PatchSetApproval patchSetApproval =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(
+                new PatchSet.Id(new Change.Id(42), 14),
+                new Account.Id(100013),
+                new LabelId("label-8")),
+            (short) 456,
+            new Date(987654L));
+    patchSetApproval.setTag("tag-21");
+    patchSetApproval.setRealAccountId(new Account.Id(612));
+    patchSetApproval.setPostSubmit(true);
+
+    Reviewdb.PatchSetApproval proto = protoConverter.toProto(patchSetApproval);
+
+    Reviewdb.PatchSetApproval expectedProto =
+        Reviewdb.PatchSetApproval.newBuilder()
+            .setKey(
+                Reviewdb.PatchSetApproval_Key.newBuilder()
+                    .setPatchSetId(
+                        Reviewdb.PatchSet_Id.newBuilder()
+                            .setChangeId(Reviewdb.Change_Id.newBuilder().setId(42))
+                            .setPatchSetId(14))
+                    .setAccountId(Reviewdb.Account_Id.newBuilder().setId(100013))
+                    .setCategoryId(Reviewdb.LabelId.newBuilder().setId("label-8")))
+            .setValue(456)
+            .setGranted(987654L)
+            .setTag("tag-21")
+            .setRealAccountId(Reviewdb.Account_Id.newBuilder().setId(612))
+            .setPostSubmit(true)
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void mandatoryValuesConvertedToProto() {
+    PatchSetApproval patchSetApproval =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(
+                new PatchSet.Id(new Change.Id(42), 14),
+                new Account.Id(100013),
+                new LabelId("label-8")),
+            (short) 456,
+            new Date(987654L));
+
+    Reviewdb.PatchSetApproval proto = protoConverter.toProto(patchSetApproval);
+
+    Reviewdb.PatchSetApproval expectedProto =
+        Reviewdb.PatchSetApproval.newBuilder()
+            .setKey(
+                Reviewdb.PatchSetApproval_Key.newBuilder()
+                    .setPatchSetId(
+                        Reviewdb.PatchSet_Id.newBuilder()
+                            .setChangeId(Reviewdb.Change_Id.newBuilder().setId(42))
+                            .setPatchSetId(14))
+                    .setAccountId(Reviewdb.Account_Id.newBuilder().setId(100013))
+                    .setCategoryId(Reviewdb.LabelId.newBuilder().setId("label-8")))
+            .setValue(456)
+            .setGranted(987654L)
+            // This value can't be unset when our entity class is given.
+            .setPostSubmit(false)
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    PatchSetApproval patchSetApproval =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(
+                new PatchSet.Id(new Change.Id(42), 14),
+                new Account.Id(100013),
+                new LabelId("label-8")),
+            (short) 456,
+            new Date(987654L));
+    patchSetApproval.setTag("tag-21");
+    patchSetApproval.setRealAccountId(new Account.Id(612));
+    patchSetApproval.setPostSubmit(true);
+
+    PatchSetApproval convertedPatchSetApproval =
+        protoConverter.fromProto(protoConverter.toProto(patchSetApproval));
+    assertThat(convertedPatchSetApproval).isEqualTo(patchSetApproval);
+  }
+
+  @Test
+  public void mandatoryValuesConvertedToProtoAndBackAgain() {
+    PatchSetApproval patchSetApproval =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(
+                new PatchSet.Id(new Change.Id(42), 14),
+                new Account.Id(100013),
+                new LabelId("label-8")),
+            (short) 456,
+            new Date(987654L));
+
+    PatchSetApproval convertedPatchSetApproval =
+        protoConverter.fromProto(protoConverter.toProto(patchSetApproval));
+    assertThat(convertedPatchSetApproval).isEqualTo(patchSetApproval);
+  }
+
+  // We need this special test as some values are only optional in the protobuf definition but can
+  // never be unset in our entity object.
+  @Test
+  public void protoWithOnlyRequiredValuesCanBeConvertedBack() {
+    Reviewdb.PatchSetApproval proto =
+        Reviewdb.PatchSetApproval.newBuilder()
+            .setKey(
+                Reviewdb.PatchSetApproval_Key.newBuilder()
+                    .setPatchSetId(
+                        Reviewdb.PatchSet_Id.newBuilder()
+                            .setChangeId(Reviewdb.Change_Id.newBuilder().setId(42))
+                            .setPatchSetId(14))
+                    .setAccountId(Reviewdb.Account_Id.newBuilder().setId(100013))
+                    .setCategoryId(Reviewdb.LabelId.newBuilder().setId("label-8")))
+            .build();
+    PatchSetApproval patchSetApproval = protoConverter.fromProto(proto);
+
+    assertThat(patchSetApproval.getPatchSetId()).isEqualTo(new PatchSet.Id(new Change.Id(42), 14));
+    assertThat(patchSetApproval.getAccountId()).isEqualTo(new Account.Id(100013));
+    assertThat(patchSetApproval.getLabelId()).isEqualTo(new LabelId("label-8"));
+    // Default values for unset protobuf fields which can't be unset in the entity object.
+    assertThat(patchSetApproval.getValue()).isEqualTo(0);
+    assertThat(patchSetApproval.getGranted()).isEqualTo(new Timestamp(0));
+    assertThat(patchSetApproval.isPostSubmit()).isEqualTo(false);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Reviewdb.PatchSetApproval proto =
+        Reviewdb.PatchSetApproval.newBuilder()
+            .setKey(
+                Reviewdb.PatchSetApproval_Key.newBuilder()
+                    .setPatchSetId(
+                        Reviewdb.PatchSet_Id.newBuilder()
+                            .setChangeId(Reviewdb.Change_Id.newBuilder().setId(42))
+                            .setPatchSetId(14))
+                    .setAccountId(Reviewdb.Account_Id.newBuilder().setId(100013))
+                    .setCategoryId(Reviewdb.LabelId.newBuilder().setId("label-8")))
+            .setValue(456)
+            .setGranted(987654L)
+            .build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Reviewdb.PatchSetApproval> parser = protoConverter.getParser();
+    Reviewdb.PatchSetApproval parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void fieldsExistAsExpected() {
+    assertThatSerializedClass(PatchSetApproval.class)
+        .hasFields(
+            ImmutableMap.<String, Type>builder()
+                .put("key", PatchSetApproval.Key.class)
+                .put("value", short.class)
+                .put("granted", Timestamp.class)
+                .put("tag", String.class)
+                .put("realAccountId", Account.Id.class)
+                .put("postSubmit", boolean.class)
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/PatchSetConverterCompatibilityTest.java b/javatests/com/google/gerrit/reviewdb/converter/PatchSetConverterCompatibilityTest.java
new file mode 100644
index 0000000..8d8960c
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/PatchSetConverterCompatibilityTest.java
@@ -0,0 +1,137 @@
+// 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.reviewdb.converter;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.Iterables.getOnlyElement;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gwtorm.protobuf.CodecFactory;
+import com.google.gwtorm.protobuf.ProtobufCodec;
+import com.google.gwtorm.server.OrmException;
+import com.google.protobuf.ByteString;
+import com.google.protobuf.CodedOutputStream;
+import com.google.protobuf.MessageLite;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Collection;
+import java.util.List;
+import org.junit.Test;
+
+// TODO(aliceks): Delete after proving binary compatibility.
+public class PatchSetConverterCompatibilityTest {
+
+  private final ProtobufCodec<PatchSet> patchSetCodec = CodecFactory.encoder(PatchSet.class);
+  private final PatchSetProtoConverter patchSetProtoConverter = PatchSetProtoConverter.INSTANCE;
+
+  @Test
+  public void changeIndexFieldWithAllValuesIsBinaryCompatible() throws Exception {
+    PatchSet patchSet = new PatchSet(new PatchSet.Id(new Change.Id(103), 73));
+    patchSet.setRevision(new RevId("aabbccddeeff"));
+    patchSet.setUploader(new Account.Id(452));
+    patchSet.setCreatedOn(new Timestamp(930349320L));
+    patchSet.setGroups(ImmutableList.of("group1, group2"));
+    patchSet.setPushCertificate("my push certificate");
+    patchSet.setDescription("This is a patch set description.");
+    ImmutableList<PatchSet> patchSets = ImmutableList.of(patchSet);
+
+    byte[] resultOfOldConverter = getOnlyElement(convertToProtos_old(patchSetCodec, patchSets));
+    byte[] resultOfNewConverter =
+        getOnlyElement(convertToProtos_new(patchSetProtoConverter, patchSets));
+
+    assertThat(resultOfNewConverter).isEqualTo(resultOfOldConverter);
+  }
+
+  @Test
+  public void changeIndexFieldWithMandatoryValuesIsBinaryCompatible() throws Exception {
+    PatchSet patchSet = new PatchSet(new PatchSet.Id(new Change.Id(103), 73));
+    ImmutableList<PatchSet> patchSets = ImmutableList.of(patchSet);
+
+    byte[] resultOfOldConverter = getOnlyElement(convertToProtos_old(patchSetCodec, patchSets));
+    byte[] resultOfNewConverter =
+        getOnlyElement(convertToProtos_new(patchSetProtoConverter, patchSets));
+
+    assertThat(resultOfNewConverter).isEqualTo(resultOfOldConverter);
+  }
+
+  @Test
+  public void changeNotesFieldWithAllValuesIsBinaryCompatible() {
+    PatchSet patchSet = new PatchSet(new PatchSet.Id(new Change.Id(103), 73));
+    patchSet.setRevision(new RevId("aabbccddeeff"));
+    patchSet.setUploader(new Account.Id(452));
+    patchSet.setCreatedOn(new Timestamp(930349320L));
+    patchSet.setGroups(ImmutableList.of("group1, group2"));
+    patchSet.setPushCertificate("my push certificate");
+    patchSet.setDescription("This is a patch set description.");
+
+    ByteString resultOfOldConverter = Protos.toByteString(patchSet, patchSetCodec);
+    ByteString resultOfNewConverter = toByteString(patchSet, patchSetProtoConverter);
+
+    assertThat(resultOfNewConverter).isEqualTo(resultOfOldConverter);
+  }
+
+  @Test
+  public void changeNotesFieldWithMandatoryValuesIsBinaryCompatible() {
+    PatchSet patchSet = new PatchSet(new PatchSet.Id(new Change.Id(103), 73));
+
+    ByteString resultOfOldConverter = Protos.toByteString(patchSet, patchSetCodec);
+    ByteString resultOfNewConverter = toByteString(patchSet, patchSetProtoConverter);
+
+    assertThat(resultOfNewConverter).isEqualTo(resultOfOldConverter);
+  }
+
+  // Copied from ChangeField.
+  private static <T> List<byte[]> convertToProtos_old(ProtobufCodec<T> codec, Collection<T> objs)
+      throws OrmException {
+    List<byte[]> result = Lists.newArrayListWithCapacity(objs.size());
+    ByteArrayOutputStream out = new ByteArrayOutputStream(256);
+    try {
+      for (T obj : objs) {
+        out.reset();
+        CodedOutputStream cos = CodedOutputStream.newInstance(out);
+        codec.encode(obj, cos);
+        cos.flush();
+        result.add(out.toByteArray());
+      }
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+    return result;
+  }
+
+  // Copied from ChangeField.
+  private static <T> List<byte[]> convertToProtos_new(
+      ProtoConverter<?, T> converter, Collection<T> objects) {
+    return objects
+        .stream()
+        .map(converter::toProto)
+        .map(Protos::toByteArray)
+        .collect(toImmutableList());
+  }
+
+  // Copied from ChangeNotesState.Serializer.
+  private static <T> ByteString toByteString(T object, ProtoConverter<?, T> converter) {
+    MessageLite message = converter.toProto(object);
+    return Protos.toByteString(message);
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverterTest.java
new file mode 100644
index 0000000..3869ab3
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverterTest.java
@@ -0,0 +1,83 @@
+// 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.reviewdb.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.reviewdb.Reviewdb;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.protobuf.Parser;
+import java.lang.reflect.Type;
+import org.junit.Test;
+
+public class PatchSetIdProtoConverterTest {
+  private final PatchSetIdProtoConverter patchSetIdProtoConverter =
+      PatchSetIdProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    PatchSet.Id patchSetId = new PatchSet.Id(new Change.Id(103), 73);
+
+    Reviewdb.PatchSet_Id proto = patchSetIdProtoConverter.toProto(patchSetId);
+
+    Reviewdb.PatchSet_Id expectedProto =
+        Reviewdb.PatchSet_Id.newBuilder()
+            .setChangeId(Reviewdb.Change_Id.newBuilder().setId(103))
+            .setPatchSetId(73)
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    PatchSet.Id patchSetId = new PatchSet.Id(new Change.Id(20), 13);
+
+    PatchSet.Id convertedPatchSetId =
+        patchSetIdProtoConverter.fromProto(patchSetIdProtoConverter.toProto(patchSetId));
+
+    assertThat(convertedPatchSetId).isEqualTo(patchSetId);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Reviewdb.PatchSet_Id proto =
+        Reviewdb.PatchSet_Id.newBuilder()
+            .setChangeId(Reviewdb.Change_Id.newBuilder().setId(103))
+            .setPatchSetId(73)
+            .build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Reviewdb.PatchSet_Id> parser = patchSetIdProtoConverter.getParser();
+    Reviewdb.PatchSet_Id parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void fieldsExistAsExpected() {
+    assertThatSerializedClass(PatchSet.Id.class)
+        .hasFields(
+            ImmutableMap.<String, Type>builder()
+                .put("changeId", Change.Id.class)
+                .put("patchSetId", int.class)
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/PatchSetProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/PatchSetProtoConverterTest.java
new file mode 100644
index 0000000..00ccf82
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/PatchSetProtoConverterTest.java
@@ -0,0 +1,137 @@
+// 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.reviewdb.converter;
+
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.truth.Truth;
+import com.google.gerrit.proto.reviewdb.Reviewdb;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.protobuf.Parser;
+import java.lang.reflect.Type;
+import java.sql.Timestamp;
+import org.junit.Test;
+
+public class PatchSetProtoConverterTest {
+  private final PatchSetProtoConverter patchSetProtoConverter = PatchSetProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    PatchSet patchSet = new PatchSet(new PatchSet.Id(new Change.Id(103), 73));
+    patchSet.setRevision(new RevId("aabbccddeeff"));
+    patchSet.setUploader(new Account.Id(452));
+    patchSet.setCreatedOn(new Timestamp(930349320L));
+    patchSet.setGroups(ImmutableList.of("group1, group2"));
+    patchSet.setPushCertificate("my push certificate");
+    patchSet.setDescription("This is a patch set description.");
+
+    Reviewdb.PatchSet proto = patchSetProtoConverter.toProto(patchSet);
+
+    Reviewdb.PatchSet expectedProto =
+        Reviewdb.PatchSet.newBuilder()
+            .setId(
+                Reviewdb.PatchSet_Id.newBuilder()
+                    .setChangeId(Reviewdb.Change_Id.newBuilder().setId(103))
+                    .setPatchSetId(73))
+            .setRevision(Reviewdb.RevId.newBuilder().setId("aabbccddeeff"))
+            .setUploaderAccountId(Reviewdb.Account_Id.newBuilder().setId(452))
+            .setCreatedOn(930349320L)
+            .setGroups("group1, group2")
+            .setPushCertificate("my push certificate")
+            .setDescription("This is a patch set description.")
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void mandatoryValuesConvertedToProto() {
+    PatchSet patchSet = new PatchSet(new PatchSet.Id(new Change.Id(103), 73));
+
+    Reviewdb.PatchSet proto = patchSetProtoConverter.toProto(patchSet);
+
+    Reviewdb.PatchSet expectedProto =
+        Reviewdb.PatchSet.newBuilder()
+            .setId(
+                Reviewdb.PatchSet_Id.newBuilder()
+                    .setChangeId(Reviewdb.Change_Id.newBuilder().setId(103))
+                    .setPatchSetId(73))
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    PatchSet patchSet = new PatchSet(new PatchSet.Id(new Change.Id(103), 73));
+    patchSet.setRevision(new RevId("aabbccddeeff"));
+    patchSet.setUploader(new Account.Id(452));
+    patchSet.setCreatedOn(new Timestamp(930349320L));
+    patchSet.setGroups(ImmutableList.of("group1, group2"));
+    patchSet.setPushCertificate("my push certificate");
+    patchSet.setDescription("This is a patch set description.");
+
+    PatchSet convertedPatchSet =
+        patchSetProtoConverter.fromProto(patchSetProtoConverter.toProto(patchSet));
+    Truth.assertThat(convertedPatchSet).isEqualTo(patchSet);
+  }
+
+  @Test
+  public void mandatoryValuesConvertedToProtoAndBackAgain() {
+    PatchSet patchSet = new PatchSet(new PatchSet.Id(new Change.Id(103), 73));
+
+    PatchSet convertedPatchSet =
+        patchSetProtoConverter.fromProto(patchSetProtoConverter.toProto(patchSet));
+    Truth.assertThat(convertedPatchSet).isEqualTo(patchSet);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Reviewdb.PatchSet proto =
+        Reviewdb.PatchSet.newBuilder()
+            .setId(
+                Reviewdb.PatchSet_Id.newBuilder()
+                    .setChangeId(Reviewdb.Change_Id.newBuilder().setId(103))
+                    .setPatchSetId(73))
+            .build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Reviewdb.PatchSet> parser = patchSetProtoConverter.getParser();
+    Reviewdb.PatchSet parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void fieldsExistAsExpected() {
+    assertThatSerializedClass(PatchSet.class)
+        .hasFields(
+            ImmutableMap.<String, Type>builder()
+                .put("id", PatchSet.Id.class)
+                .put("revision", RevId.class)
+                .put("uploader", Account.Id.class)
+                .put("createdOn", Timestamp.class)
+                .put("groups", String.class)
+                .put("pushCertificate", String.class)
+                .put("description", String.class)
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/RevIdProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/RevIdProtoConverterTest.java
new file mode 100644
index 0000000..2aa3a84
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/RevIdProtoConverterTest.java
@@ -0,0 +1,66 @@
+// 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.reviewdb.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.reviewdb.Reviewdb;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.protobuf.Parser;
+import org.junit.Test;
+
+public class RevIdProtoConverterTest {
+  private final RevIdProtoConverter revIdProtoConverter = RevIdProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    RevId revId = new RevId("9903402f303249e");
+
+    Reviewdb.RevId proto = revIdProtoConverter.toProto(revId);
+
+    Reviewdb.RevId expectedProto = Reviewdb.RevId.newBuilder().setId("9903402f303249e").build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    RevId revId = new RevId("ff3934a320bb");
+
+    RevId convertedRevId = revIdProtoConverter.fromProto(revIdProtoConverter.toProto(revId));
+
+    assertThat(convertedRevId).isEqualTo(revId);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Reviewdb.RevId proto = Reviewdb.RevId.newBuilder().setId("9903402f303249e").build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Reviewdb.RevId> parser = revIdProtoConverter.getParser();
+    Reviewdb.RevId parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void fieldsExistAsExpected() {
+    assertThatSerializedClass(RevId.class).hasFields(ImmutableMap.of("id", String.class));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 88edc2e..e705ec5 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -44,6 +44,8 @@
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/proto",
+        "//java/com/google/gerrit/proto/testing",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/cache/serialize",
diff --git a/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java b/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java
index edf6bdd..d757f71 100644
--- a/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java
+++ b/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java
@@ -16,8 +16,8 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
-import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSetMultimap;
diff --git a/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java b/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
index 81fd6d7..e4f8ba8 100644
--- a/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
+++ b/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
@@ -2,10 +2,11 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
-import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.gerrit.server.cache.proto.Cache.OAuthTokenProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import java.lang.reflect.Type;
@@ -56,10 +57,7 @@
     assertThat(s.deserialize(serializedWithEmptyString)).isEqualTo(tokenWithNull);
   }
 
-  /**
-   * See {@link com.google.gerrit.server.cache.testing.SerializedClassSubject} for background and
-   * what to do if this test fails.
-   */
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void oAuthTokenFields() throws Exception {
     assertThatSerializedClass(OAuthToken.class)
diff --git a/javatests/com/google/gerrit/server/cache/serialize/ProtoCacheSerializersTest.java b/javatests/com/google/gerrit/server/cache/serialize/ObjectIdConverterTest.java
similarity index 62%
rename from javatests/com/google/gerrit/server/cache/serialize/ProtoCacheSerializersTest.java
rename to javatests/com/google/gerrit/server/cache/serialize/ObjectIdConverterTest.java
index 8a02af2..c5ea2ea 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/ProtoCacheSerializersTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/ObjectIdConverterTest.java
@@ -16,18 +16,14 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
-import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
 import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
 
-import com.google.gerrit.server.cache.proto.Cache.ChangeNotesKeyProto;
-import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
-import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.testing.GerritBaseTests;
 import com.google.protobuf.ByteString;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
-public class ProtoCacheSerializersTest extends GerritBaseTests {
+public class ObjectIdConverterTest extends GerritBaseTests {
   @Test
   public void objectIdFromByteString() {
     ObjectIdConverter idConverter = ObjectIdConverter.create();
@@ -73,45 +69,4 @@
                 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb,
                 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb));
   }
-
-  @Test
-  public void parseUncheckedWrongProtoType() {
-    ChangeNotesKeyProto proto =
-        ChangeNotesKeyProto.newBuilder()
-            .setProject("project")
-            .setChangeId(1234)
-            .setId(ByteString.copyFromUtf8("foo"))
-            .build();
-    byte[] bytes = ProtoCacheSerializers.toByteArray(proto);
-    try {
-      ProtoCacheSerializers.parseUnchecked(ChangeNotesStateProto.parser(), bytes);
-      assert_().fail("expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      // Expected.
-    }
-  }
-
-  @Test
-  public void parseUncheckedInvalidData() {
-    byte[] bytes = new byte[] {0x00};
-    try {
-      ProtoCacheSerializers.parseUnchecked(ChangeNotesStateProto.parser(), bytes);
-      assert_().fail("expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      // Expected.
-    }
-  }
-
-  @Test
-  public void parseUnchecked() {
-    ChangeNotesKeyProto proto =
-        ChangeNotesKeyProto.newBuilder()
-            .setProject("project")
-            .setChangeId(1234)
-            .setId(ByteString.copyFromUtf8("foo"))
-            .build();
-    byte[] bytes = ProtoCacheSerializers.toByteArray(proto);
-    assertThat(ProtoCacheSerializers.parseUnchecked(ChangeNotesKeyProto.parser(), bytes))
-        .isEqualTo(proto);
-  }
 }
diff --git a/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
index 335ff12..fffb1da 100644
--- a/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
+++ b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
@@ -16,10 +16,11 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
-import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.gerrit.server.cache.proto.Cache.ChangeKindKeyProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.gerrit.server.change.ChangeKindCacheImpl.Key;
@@ -50,10 +51,7 @@
     assertThat(s.deserialize(serialized)).isEqualTo(key);
   }
 
-  /**
-   * See {@link com.google.gerrit.server.cache.testing.SerializedClassSubject} for background and
-   * what to do if this test fails.
-   */
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void keyFields() throws Exception {
     assertThatSerializedClass(ChangeKindCacheImpl.Key.class)
diff --git a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
index cc15b1b..b852bfb 100644
--- a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
+++ b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
@@ -135,7 +135,7 @@
     input.newBranch = true;
     input.subject = "Test change";
     ChangeInfo info = gApi.changes().create(input).get();
-    notes = changeNotesFactory.createChecked(db, allProjects, new Change.Id(info._number));
+    notes = changeNotesFactory.createChecked(allProjects, new Change.Id(info._number));
     change = notes.getChange();
   }
 
diff --git a/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java b/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java
index c5d35f6..46ddbc2 100644
--- a/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java
+++ b/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java
@@ -16,11 +16,12 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
-import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.gerrit.server.cache.proto.Cache.MergeabilityKeyProto;
 import com.google.gerrit.testing.GerritBaseTests;
 import org.eclipse.jgit.lib.ObjectId;
@@ -54,10 +55,7 @@
         .isEqualTo(key);
   }
 
-  /**
-   * See {@link com.google.gerrit.server.cache.testing.SerializedClassSubject} for background and
-   * what to do if this test fails.
-   */
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void keyFields() throws Exception {
     assertThatSerializedClass(MergeabilityCacheImpl.EntryKey.class)
diff --git a/javatests/com/google/gerrit/server/git/TagSetHolderTest.java b/javatests/com/google/gerrit/server/git/TagSetHolderTest.java
index 705139a..87ddc75 100644
--- a/javatests/com/google/gerrit/server/git/TagSetHolderTest.java
+++ b/javatests/com/google/gerrit/server/git/TagSetHolderTest.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
-import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.reviewdb.client.Project;
diff --git a/javatests/com/google/gerrit/server/git/TagSetTest.java b/javatests/com/google/gerrit/server/git/TagSetTest.java
index 1314ce6..3ac72be 100644
--- a/javatests/com/google/gerrit/server/git/TagSetTest.java
+++ b/javatests/com/google/gerrit/server/git/TagSetTest.java
@@ -17,8 +17,8 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
-import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSortedSet;
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index ed4aacb..138635e 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -161,10 +161,6 @@
                 bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
                 bind(MetricMaker.class).to(DisabledMetricMaker.class);
                 bind(ReviewDb.class).toProvider(Providers.<ReviewDb>of(null));
-                MutableNotesMigration migration = MutableNotesMigration.newDisabled();
-                migration.setFrom(NotesMigrationState.FINAL);
-                bind(MutableNotesMigration.class).toInstance(migration);
-                bind(NotesMigration.class).to(MutableNotesMigration.class);
 
                 // Tests don't support ReviewDb at all, but bindings are required via NoteDbModule.
                 bind(new TypeLiteral<SchemaFactory<ReviewDb>>() {})
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java
index 7b140b7..b4d9738 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java
@@ -16,8 +16,8 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
-import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.reviewdb.client.Change;
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 1bd6fbe..0ea1bea 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -16,11 +16,8 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
-import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.APPROVAL_CODEC;
-import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.MESSAGE_CODEC;
-import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.PATCH_SET_CODEC;
-import static com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.toByteString;
-import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+import static com.google.gerrit.server.notedb.ChangeNotesState.Serializer.toByteString;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
@@ -39,6 +36,9 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.converter.ChangeMessageProtoConverter;
+import com.google.gerrit.reviewdb.converter.PatchSetApprovalProtoConverter;
+import com.google.gerrit.reviewdb.converter.PatchSetProtoConverter;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
@@ -47,7 +47,7 @@
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerStatusUpdateProto;
-import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.notedb.ChangeNotesState.ChangeColumns;
 import com.google.gerrit.server.notedb.ChangeNotesState.Serializer;
 import com.google.gerrit.testing.GerritBaseTests;
@@ -340,14 +340,14 @@
     ps1.setUploader(new Account.Id(2000));
     ps1.setRevision(new RevId("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
     ps1.setCreatedOn(cols.createdOn());
-    ByteString ps1Bytes = toByteString(ps1, PATCH_SET_CODEC);
+    ByteString ps1Bytes = toByteString(ps1, PatchSetProtoConverter.INSTANCE);
     assertThat(ps1Bytes.size()).isEqualTo(66);
 
     PatchSet ps2 = new PatchSet(new PatchSet.Id(ID, 2));
     ps2.setUploader(new Account.Id(3000));
     ps2.setRevision(new RevId("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"));
     ps2.setCreatedOn(cols.lastUpdatedOn());
-    ByteString ps2Bytes = toByteString(ps2, PATCH_SET_CODEC);
+    ByteString ps2Bytes = toByteString(ps2, PatchSetProtoConverter.INSTANCE);
     assertThat(ps2Bytes.size()).isEqualTo(66);
     assertThat(ps2Bytes).isNotEqualTo(ps1Bytes);
 
@@ -372,7 +372,7 @@
                 new PatchSet.Id(ID, 1), new Account.Id(2001), new LabelId("Code-Review")),
             (short) 1,
             new Timestamp(1212L));
-    ByteString a1Bytes = toByteString(a1, APPROVAL_CODEC);
+    ByteString a1Bytes = toByteString(a1, PatchSetApprovalProtoConverter.INSTANCE);
     assertThat(a1Bytes.size()).isEqualTo(43);
 
     PatchSetApproval a2 =
@@ -381,7 +381,7 @@
                 new PatchSet.Id(ID, 1), new Account.Id(2002), new LabelId("Verified")),
             (short) -1,
             new Timestamp(3434L));
-    ByteString a2Bytes = toByteString(a2, APPROVAL_CODEC);
+    ByteString a2Bytes = toByteString(a2, PatchSetApprovalProtoConverter.INSTANCE);
     assertThat(a2Bytes.size()).isEqualTo(49);
     assertThat(a2Bytes).isNotEqualTo(a1Bytes);
 
@@ -639,7 +639,7 @@
             new Account.Id(1000),
             new Timestamp(1212L),
             new PatchSet.Id(ID, 1));
-    ByteString m1Bytes = toByteString(m1, MESSAGE_CODEC);
+    ByteString m1Bytes = toByteString(m1, ChangeMessageProtoConverter.INSTANCE);
     assertThat(m1Bytes.size()).isEqualTo(35);
 
     ChangeMessage m2 =
@@ -648,7 +648,7 @@
             new Account.Id(2000),
             new Timestamp(3434L),
             new PatchSet.Id(ID, 2));
-    ByteString m2Bytes = toByteString(m2, MESSAGE_CODEC);
+    ByteString m2Bytes = toByteString(m2, ChangeMessageProtoConverter.INSTANCE);
     assertThat(m2Bytes.size()).isEqualTo(35);
     assertThat(m2Bytes).isNotEqualTo(m1Bytes);
 
diff --git a/javatests/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java
index 660c1d8..e36b79e 100644
--- a/javatests/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java
@@ -44,6 +44,6 @@
   protected Injector createInjector() {
     Config luceneConfig = new Config(config);
     InMemoryModule.setDefaults(luceneConfig);
-    return Guice.createInjector(new InMemoryModule(luceneConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(luceneConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index fb20a05..7d8f3c6 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -1303,7 +1303,7 @@
     assertQuery("status:new", change2, change1);
 
     gApi.changes().id(change1.getId().get()).topic("new-topic");
-    change1 = notesFactory.create(db, change1.getProject(), change1.getId()).getChange();
+    change1 = notesFactory.create(change1.getProject(), change1.getId()).getChange();
 
     assertThat(lastUpdatedMs(change1)).isGreaterThan(lastUpdatedMs(change2));
     assertThat(lastUpdatedMs(change1) - lastUpdatedMs(change2))
@@ -2276,7 +2276,7 @@
     TestRepository<Repo> repo = createProject(project.get());
     Change change = insert(repo, newChange(repo));
     String changeId = change.getKey().get();
-    ChangeNotes notes = notesFactory.create(db, change.getProject(), change.getId());
+    ChangeNotes notes = notesFactory.create(change.getProject(), change.getId());
     PatchSet ps = psUtil.get(notes, change.currentPatchSetId());
 
     requestContext.setContext(newRequestContext(user));
@@ -2985,7 +2985,7 @@
 
     PatchSetInserter inserter =
         patchSetFactory
-            .create(changeNotesFactory.createChecked(db, c), new PatchSet.Id(c.getId(), n), commit)
+            .create(changeNotesFactory.createChecked(c), new PatchSet.Id(c.getId(), n), commit)
             .setNotify(NotifyHandling.NONE)
             .setFireRevisionCreated(false)
             .setValidate(false);
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
index c27be68..7419405 100644
--- a/javatests/com/google/gerrit/server/query/change/BUILD
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -61,6 +61,7 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/proto/testing",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/cache/testing",
diff --git a/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java b/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java
index 1683b56..e550f8e 100644
--- a/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java
+++ b/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java
@@ -18,11 +18,12 @@
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
 import static com.google.gerrit.extensions.client.SubmitType.FAST_FORWARD_ONLY;
 import static com.google.gerrit.extensions.client.SubmitType.MERGE_IF_NECESSARY;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
-import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.gerrit.server.cache.proto.Cache.ConflictKeyProto;
 import com.google.gerrit.testing.GerritBaseTests;
 import org.eclipse.jgit.lib.ObjectId;
@@ -82,10 +83,7 @@
     assertThat(ConflictKey.Serializer.INSTANCE.deserialize(serialized)).isEqualTo(key);
   }
 
-  /**
-   * See {@link com.google.gerrit.server.cache.testing.SerializedClassSubject} for background and
-   * what to do if this test fails.
-   */
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methods() throws Exception {
     assertThatSerializedClass(ConflictKey.class)
diff --git a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
index 1dfe7df..2ea198f 100644
--- a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
@@ -49,7 +49,7 @@
   protected Injector createInjector() {
     Config luceneConfig = new Config(config);
     InMemoryModule.setDefaults(luceneConfig);
-    return Guice.createInjector(new InMemoryModule(luceneConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(luceneConfig));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java
index 83835c1..2a453a0 100644
--- a/javatests/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java
@@ -43,6 +43,6 @@
   protected Injector createInjector() {
     Config luceneConfig = new Config(config);
     InMemoryModule.setDefaults(luceneConfig);
-    return Guice.createInjector(new InMemoryModule(luceneConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(luceneConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java
index 42964fa..77a56ed 100644
--- a/javatests/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java
+++ b/javatests/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java
@@ -45,6 +45,6 @@
   protected Injector createInjector() {
     Config luceneConfig = new Config(config);
     InMemoryModule.setDefaults(luceneConfig);
-    return Guice.createInjector(new InMemoryModule(luceneConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(luceneConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
index 6018d85..eeefd75 100644
--- a/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
@@ -29,9 +29,8 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.IntBlob;
-import com.google.gerrit.server.notedb.MutableNotesMigration;
 import com.google.gerrit.server.notedb.NoteDbSchemaVersionManager;
-import com.google.gerrit.server.notedb.NotesMigrationState;
+import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.notedb.RepoSequence;
 import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
@@ -117,8 +116,6 @@
       args = new NoteDbSchemaVersion.Arguments(repoManager, allProjectsName);
       NoteDbSchemaVersionManager versionManager =
           new NoteDbSchemaVersionManager(allProjectsName, repoManager);
-      MutableNotesMigration notesMigration = MutableNotesMigration.newDisabled();
-      notesMigration.setFrom(NotesMigrationState.NOTE_DB);
       updater =
           new NoteDbSchemaUpdater(
               cfg,
@@ -126,7 +123,7 @@
               allUsersName,
               repoManager,
               schemaCreator,
-              notesMigration,
+              new NotesMigration(),
               versionManager,
               args,
               ImmutableSortedMap.of(10, TestSchema_10.class, 11, TestSchema_11.class));
diff --git a/plugins/delete-project b/plugins/delete-project
index d8fdd55..5f3fe72 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit d8fdd5596181cc06707665051f0e03a49e5c3a97
+Subproject commit 5f3fe725b6f943f9acf63270cf8a432f9e7fd97a
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index fdbadf3..b98d892 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit fdbadf312d829990d3a4be3491d13a79d6c0cf5b
+Subproject commit b98d8920345aa6183fe98ea9bc15ae0fea5d9b58
diff --git a/proto/BUILD b/proto/BUILD
index 88445c1..7f02a81 100644
--- a/proto/BUILD
+++ b/proto/BUILD
@@ -11,14 +11,11 @@
 
 proto_library(
     name = "reviewdb_proto",
-    srcs = [":reviewdb.proto"],
+    srcs = ["reviewdb.proto"],
 )
 
 java_proto_library(
     name = "reviewdb_java_proto",
-    visibility = [
-        "//javatests/com/google/gerrit/proto:__pkg__",
-        "//tools/eclipse:__pkg__",
-    ],
+    visibility = ["//visibility:public"],
     deps = [":reviewdb_proto"],
 )