Merge branch 'stable-3.4'

* stable-3.4:
  RenameEmail: Preserve files which are not updated

Change-Id: If375fed498680dde896831fa23a3a7608404dbbe
diff --git a/BUILD b/BUILD
index 5333141..91a4e96 100644
--- a/BUILD
+++ b/BUILD
@@ -15,7 +15,7 @@
     srcs = glob(["java/com/google/gerrit/plugins/codeowners/module/*.java"]),
     manifest_entries = [
         "Gerrit-PluginName: code-owners",
-        "Gerrit-Module: com.google.gerrit.plugins.codeowners.module.Module",
+        "Gerrit-Module: com.google.gerrit.plugins.codeowners.module.PluginModule",
         "Gerrit-HttpModule: com.google.gerrit.plugins.codeowners.module.HttpModule",
         "Gerrit-BatchModule: com.google.gerrit.plugins.codeowners.module.BatchModule",
     ],
diff --git a/README.md b/README.md
index 02330f1..edb073e 100644
--- a/README.md
+++ b/README.md
@@ -9,3 +9,16 @@
 
 IMPORTANT: Before installing/enabling the plugin follow the instructions from
 the setup guide, see [resources/Documentation/setup-guide.md](./resources/Documentation/setup-guide.md).
+
+## JavaScript Plugin
+
+For testing the plugin with
+[Gerrit FE Dev Helper](https://gerrit.googlesource.com/gerrit-fe-dev-helper/)
+build the JavaScript bundle and copy it to the `plugins/` folder:
+
+    bazel build //plugins/code-owners/ui:code-owners
+    cp -f bazel-bin/plugins/code-owners/ui/code-owners.js plugins/
+
+and let the Dev Helper redirect from
+`.+/plugins/code-owners/static/code-owners.js` to
+`http://localhost:8081/plugins_/code-owners.js`.
diff --git a/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersIT.java b/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersIT.java
index e42501d..8ea5a5b 100644
--- a/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersIT.java
+++ b/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersIT.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.plugins.codeowners.api.impl.ProjectCodeOwnersFactory;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackendId;
 import com.google.gerrit.plugins.codeowners.backend.config.BackendConfig;
+import com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig;
 import com.google.gerrit.plugins.codeowners.backend.proto.ProtoBackend;
 import com.google.gerrit.testing.ConfigSuite;
 import java.util.Arrays;
@@ -49,7 +50,18 @@
    */
   @ConfigSuite.Default
   public static Config defaultConfig() {
-    return new Config();
+    Config cfg = new Config();
+
+    // Disable asynchronous posting of change messages during tests to avoid parallel updates to
+    // NoteDb and hence risking LOCK_FAILURES (especially needed since the test API does not retry
+    // on LOCK_FAILURES).
+    cfg.setBoolean(
+        "plugin",
+        "code-owners",
+        GeneralConfig.KEY_ENABLE_ASYNC_MESSAGE_ON_ADD_REVIEWER,
+        /* value= */ false);
+
+    return cfg;
   }
 
   /**
@@ -77,8 +89,7 @@
   protected CodeOwnersFactory codeOwnersApiFactory;
   protected ChangeCodeOwnersFactory changeCodeOwnersApiFactory;
   protected ProjectCodeOwnersFactory projectCodeOwnersApiFactory;
-
-  private BackendConfig backendConfig;
+  protected BackendConfig backendConfig;
 
   @Before
   public void baseSetup() throws Exception {
@@ -102,6 +113,11 @@
     assumeThatCodeOwnersBackendIsNotProtoBackend();
   }
 
+  protected void skipTestIfAnnotationsNotSupportedByCodeOwnersBackend() {
+    // the proto backend doesn't support annotations on code owners
+    assumeThatCodeOwnersBackendIsNotProtoBackend();
+  }
+
   protected void assumeThatCodeOwnersBackendIsNotProtoBackend() {
     assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
   }
diff --git a/java/com/google/gerrit/plugins/codeowners/acceptance/TestModule.java b/java/com/google/gerrit/plugins/codeowners/acceptance/TestModule.java
index 0394508..98987b5 100644
--- a/java/com/google/gerrit/plugins/codeowners/acceptance/TestModule.java
+++ b/java/com/google/gerrit/plugins/codeowners/acceptance/TestModule.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.CodeOwnerConfigOperations;
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.CodeOwnerConfigOperationsImpl;
-import com.google.gerrit.plugins.codeowners.module.Module;
+import com.google.gerrit.plugins.codeowners.module.PluginModule;
 import com.google.gerrit.plugins.codeowners.testing.backend.TestCodeOwnerConfigStorage;
 
 /**
@@ -27,7 +27,7 @@
 class TestModule extends FactoryModule {
   @Override
   public void configure() {
-    install(new Module());
+    install(new PluginModule());
 
     // Only add bindings here that are specifically required for tests, in order to keep the Guice
     // setup in tests as realistic as possible by delegating to the original module.
diff --git a/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite/TestPathExpressions.java b/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite/TestPathExpressions.java
index d2acaed..2637eda 100644
--- a/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite/TestPathExpressions.java
+++ b/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite/TestPathExpressions.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.plugins.codeowners.acceptance.testsuite;
 
+import com.google.gerrit.plugins.codeowners.backend.AbstractFileBasedCodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.FindOwnersGlobMatcher;
 import com.google.gerrit.plugins.codeowners.backend.GlobMatcher;
 import com.google.gerrit.plugins.codeowners.backend.PathExpressionMatcher;
+import com.google.gerrit.plugins.codeowners.backend.PathExpressions;
 import com.google.gerrit.plugins.codeowners.backend.SimplePathExpressionMatcher;
 import com.google.gerrit.plugins.codeowners.backend.config.BackendConfig;
 import com.google.inject.Inject;
@@ -83,8 +85,20 @@
 
   private PathExpressionMatcher getPathExpressionMatcher() {
     CodeOwnerBackend defaultBackend = backendConfig.getDefaultBackend();
+    if (defaultBackend instanceof AbstractFileBasedCodeOwnerBackend) {
+      return ((AbstractFileBasedCodeOwnerBackend) defaultBackend)
+          .getDefaultPathExpressions()
+          .map(PathExpressions::getMatcher)
+          .orElseThrow(
+              () ->
+                  new IllegalStateException(
+                      String.format(
+                          "code owner backend %s doesn't support path expressions",
+                          defaultBackend.getClass().getName())));
+    }
+
     return defaultBackend
-        .getPathExpressionMatcher()
+        .getPathExpressionMatcher(/* branchNameKey= */ null)
         .orElseThrow(
             () ->
                 new IllegalStateException(
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerCheckInfo.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerCheckInfo.java
index c96645c..19b02a0 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerCheckInfo.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerCheckInfo.java
@@ -131,6 +131,16 @@
   /** Whether the the specified path in the branch is owned by all users (aka {@code *}). */
   public boolean isOwnedByAllUsers;
 
+  /**
+   * Annotations that were set for the user.
+   *
+   * <p>Contains only supported annotations (unsupported annotations are reported in the {@link
+   * #debugLogs}).
+   *
+   * <p>Sorted alphabetically.
+   */
+  public List<String> annotations;
+
   /** Debug logs that may help to understand why the user is or isn't a code owner. */
   public List<String> debugLogs;
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerStatusInfo.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerStatusInfo.java
index e9ae34d..efe26d7 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerStatusInfo.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerStatusInfo.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.plugins.codeowners.api;
 
+import com.google.gerrit.extensions.common.AccountInfo;
 import java.util.List;
+import java.util.Map;
 
 /**
  * JSON entity that describes the response of the {@link
@@ -41,4 +43,12 @@
    * <p>Not set if {@code false}.
    */
   public Boolean more;
+
+  /**
+   * Accounts that are referenced in the reason messages that are returned with the {@link
+   * PathCodeOwnerStatusInfo}s in the {@link #fileCodeOwnerStatuses}.
+   *
+   * <p>Not set if no accounts are referenced from reasons.
+   */
+  public Map<Integer, AccountInfo> accounts;
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/OwnedChangedFileInfo.java b/java/com/google/gerrit/plugins/codeowners/api/OwnedChangedFileInfo.java
new file mode 100644
index 0000000..971a823
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/api/OwnedChangedFileInfo.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2021 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.plugins.codeowners.api;
+
+/**
+ * JSON representation of a file that was changed in a change for which the user owns the new path,
+ * the old path or both paths.
+ */
+public class OwnedChangedFileInfo {
+  /**
+   * Owner information for the new path.
+   *
+   * <p>Not set for deletions.
+   */
+  public OwnedPathInfo newPath;
+
+  /**
+   * Owner information for the old path.
+   *
+   * <p>Only set for deletions and renames.
+   */
+  public OwnedPathInfo oldPath;
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/api/OwnedPathInfo.java b/java/com/google/gerrit/plugins/codeowners/api/OwnedPathInfo.java
new file mode 100644
index 0000000..bda0e27
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/api/OwnedPathInfo.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2021 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.plugins.codeowners.api;
+
+/** JSON representation of a file path the may be owned by the user. */
+public class OwnedPathInfo {
+  /** The path of the file that may be owned by the user. */
+  public String path;
+
+  /**
+   * Whether the user owns this path.
+   *
+   * <p>Not set if {@code false}.
+   */
+  public Boolean owned;
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/api/OwnedPathsInfo.java b/java/com/google/gerrit/plugins/codeowners/api/OwnedPathsInfo.java
index ba41418..98e9e90 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/OwnedPathsInfo.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/OwnedPathsInfo.java
@@ -24,11 +24,25 @@
  */
 public class OwnedPathsInfo {
   /**
-   * List of the owned paths.
+   * List of files that were changed in a change for which the user owns the new path, the old path
+   * or both paths.
+   *
+   * <p>The entries are sorted alphabetically by new path, and by old path if new path is not
+   * present.
+   *
+   * <p>Contains at most as many entries as the limit that was specified on the request.
+   */
+  public List<OwnedChangedFileInfo> ownedChangedFiles;
+
+  /**
+   * The list of the owned new and old paths that are contained in {@link #ownedChangedFiles}.
    *
    * <p>The paths are returned as absolute paths.
    *
    * <p>The paths are sorted alphabetically.
+   *
+   * <p>May contain more entries than the limit that was specified on the request (if the users owns
+   * new and old path of renamed files).
    */
   public List<String> ownedPaths;
 
diff --git a/java/com/google/gerrit/plugins/codeowners/api/PathCodeOwnerStatusInfo.java b/java/com/google/gerrit/plugins/codeowners/api/PathCodeOwnerStatusInfo.java
index 7643e53..4e3db84 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/PathCodeOwnerStatusInfo.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/PathCodeOwnerStatusInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.plugins.codeowners.api;
 
 import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
+import java.util.List;
 
 /** JSON entity that describes the code owner status for a path that was touched in a change. */
 public class PathCodeOwnerStatusInfo {
@@ -27,4 +28,15 @@
 
   /** The code owner status for the path. */
   public CodeOwnerStatus status;
+
+  /**
+   * Reasons explaining the status.
+   *
+   * <p>The reasons may contain placeholders for accounts as {@code <GERRIT_ACCOUNT_XXXXXXX>} (where
+   * {@code XXXXXXX} is the account ID). The referenced accounts are returned in the {@link
+   * CodeOwnerStatusInfo} that contains the path code owner statuses.
+   *
+   * <p>Not set if there are no reasons.
+   */
+  public List<String> reasons;
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackend.java b/java/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackend.java
index 20a44c2..2bf9bff 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackend.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackend.java
@@ -16,11 +16,14 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Throwables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginProjectConfigSnapshot;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -76,9 +79,7 @@
 
   @Override
   public final Optional<CodeOwnerConfig> getCodeOwnerConfig(
-      CodeOwnerConfig.Key codeOwnerConfigKey,
-      @Nullable RevWalk revWalk,
-      @Nullable ObjectId revision) {
+      CodeOwnerConfig.Key codeOwnerConfigKey, @Nullable ObjectId revision) {
     String fileName =
         codeOwnerConfigKey.fileName().orElse(getFileName(codeOwnerConfigKey.project()));
 
@@ -95,33 +96,21 @@
       return Optional.empty();
     }
 
-    return loadCodeOwnerConfigFile(codeOwnerConfigKey, fileName, revWalk, revision)
+    return loadCodeOwnerConfigFile(codeOwnerConfigKey, fileName, revision)
         .getLoadedCodeOwnerConfig();
   }
 
   private CodeOwnerConfigFile loadCodeOwnerConfigFile(
-      CodeOwnerConfig.Key codeOwnerConfigKey,
-      String fileName,
-      @Nullable RevWalk revWalk,
-      @Nullable ObjectId revision) {
+      CodeOwnerConfig.Key codeOwnerConfigKey, String fileName, @Nullable ObjectId revision) {
     try (Repository repository = repoManager.openRepository(codeOwnerConfigKey.project())) {
       if (revision == null) {
         return codeOwnerConfigFileFactory.loadCurrent(
             fileName, codeOwnerConfigParser, repository, codeOwnerConfigKey);
       }
 
-      boolean closeRevWalk = false;
-      if (revWalk == null) {
-        closeRevWalk = true;
-        revWalk = new RevWalk(repository);
-      }
-      try {
+      try (RevWalk revWalk = new RevWalk(repository)) {
         return codeOwnerConfigFileFactory.load(
             fileName, codeOwnerConfigParser, revWalk, revision, codeOwnerConfigKey);
-      } finally {
-        if (closeRevWalk) {
-          revWalk.close();
-        }
       }
     } catch (IOException e) {
       throw new CodeOwnersInternalServerErrorException(
@@ -170,11 +159,12 @@
    * @return whether the given file name is code owner config file with an extension in the name
    */
   private boolean isCodeOwnerConfigFileWithExtension(Project.NameKey project, String fileName) {
+    CodeOwnersPluginProjectConfigSnapshot codeOwnersPluginProjectConfigSnapshot =
+        codeOwnersPluginConfiguration.getProjectConfig(project);
     String quotedDefaultFileName = Pattern.quote(defaultFileName);
     String quotedFileExtension =
         Pattern.quote(
-            codeOwnersPluginConfiguration
-                .getProjectConfig(project)
+            codeOwnersPluginProjectConfigSnapshot
                 .getFileExtension()
                 .map(ext -> "." + ext)
                 .orElse(""));
@@ -187,7 +177,12 @@
         || Pattern.compile(
                 "^" + nameExtension + "_" + quotedDefaultFileName + quotedFileExtension + "$")
             .matcher(fileName)
-            .matches();
+            .matches()
+        || (codeOwnersPluginProjectConfigSnapshot.enableCodeOwnerConfigFilesWithFileExtensions()
+            && Pattern.compile(
+                    "^" + quotedDefaultFileName + Pattern.quote(".") + nameExtension + "$")
+                .matcher(fileName)
+                .matches());
   }
 
   private String getFileName(Project.NameKey project) {
@@ -219,6 +214,28 @@
     }
   }
 
+  @Override
+  public final Optional<PathExpressionMatcher> getPathExpressionMatcher(
+      BranchNameKey branchNameKey) {
+    Optional<PathExpressions> pathExpressions =
+        codeOwnersPluginConfiguration
+            .getProjectConfig(branchNameKey.project())
+            .getPathExpressions(branchNameKey.branch());
+    boolean hasConfiguredPathExpressions = pathExpressions.isPresent();
+    if (!hasConfiguredPathExpressions) {
+      pathExpressions = getDefaultPathExpressions();
+    }
+    logger.atFine().log(
+        "using %s path expression syntax %s for project/branch %s",
+        (hasConfiguredPathExpressions ? "configured" : "default"),
+        pathExpressions.map(PathExpressions::name).orElse("<none>"),
+        branchNameKey);
+    return pathExpressions.map(PathExpressions::getMatcher);
+  }
+
+  @VisibleForTesting
+  public abstract Optional<PathExpressions> getDefaultPathExpressions();
+
   private Optional<CodeOwnerConfig> upsertCodeOwnerConfigInSourceBranch(
       @Nullable IdentifiedUser currentUser,
       CodeOwnerConfig.Key codeOwnerConfigKey,
@@ -261,10 +278,10 @@
         metaDataUpdate.getCommitBuilder().setAuthor(serverIdent);
       }
       return metaDataUpdate;
-    } catch (Throwable t) {
+    } catch (Exception e) {
+      throw new CodeOwnersInternalServerErrorException("Failed to create MetaDataUpdate", e);
+    } finally {
       metaDataUpdate.close();
-      Throwables.throwIfUnchecked(t);
-      throw new CodeOwnersInternalServerErrorException("Failed to create MetaDataUpdate", t);
     }
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java b/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
index e137ba7..1309f8f 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
@@ -19,6 +19,9 @@
 import com.google.gerrit.extensions.events.ReviewerAddedListener;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerApprovalHasOperand.CodeOwnerApprovalHasOperandModule;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerEnabledHasOperand.CodeOwnerEnabledHasOperandModule;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerSubmitRule.CodeOwnerSubmitRuleModule;
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfig;
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginGlobalConfigSnapshot;
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginProjectConfigSnapshot;
@@ -50,7 +53,9 @@
           .to(codeOwnerBackendId.getCodeOwnerBackendClass());
     }
 
-    install(new CodeOwnerSubmitRule.Module());
+    install(new CodeOwnerSubmitRuleModule());
+    install(new CodeOwnerApprovalHasOperandModule());
+    install(new CodeOwnerEnabledHasOperandModule());
 
     DynamicSet.bind(binder(), ExceptionHook.class).to(CodeOwnersExceptionHook.class);
     DynamicSet.bind(binder(), OnPostReview.class).to(OnCodeOwnerApproval.class);
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/ChangedFiles.java b/java/com/google/gerrit/plugins/codeowners/backend/ChangedFiles.java
index 2835935..81f31b3 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/ChangedFiles.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/ChangedFiles.java
@@ -19,10 +19,7 @@
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.metrics.Timer0;
@@ -31,285 +28,81 @@
 import com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy;
 import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.InMemoryInserter;
-import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.patch.AutoMerger;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.DiffOperations;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.List;
 import java.util.Map;
 import java.util.stream.Stream;
-import org.eclipse.jgit.diff.DiffEntry;
-import org.eclipse.jgit.diff.DiffFormatter;
-import org.eclipse.jgit.diff.RawTextComparator;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.util.io.DisabledOutputStream;
 
 /**
- * Class to get/compute the files that have been changed in a revision.
+ * Class to get the files that have been changed in a revision.
  *
- * <p>The {@link #getFromDiffCache(Project.NameKey, ObjectId)} method is retrieving the file diff
- * from the diff cache and has rename detection enabled.
- *
- * <p>In contrast to this, for the {@code compute} methods the file diff is newly computed on each
- * access and rename detection is disabled (as it's too expensive to do it on each access).
- *
- * <p>If possible, using {@link #getFromDiffCache(Project.NameKey, ObjectId)} is preferred, however
- * {@link #getFromDiffCache(Project.NameKey, ObjectId)} cannot be used for newly created commits
- * that are only available from a specific {@link RevWalk} instance since the {@link RevWalk}
- * instance cannot be passed in.
+ * <p>The {@link #getFromDiffCache(Project.NameKey, ObjectId, MergeCommitStrategy)} method is
+ * retrieving the file diff from the diff cache and has rename detection enabled.
  *
  * <p>The {@link com.google.gerrit.server.patch.PatchListCache} is deprecated, and hence it not
  * being used here.
  */
 @Singleton
 public class ChangedFiles {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private static int MAX_CHANGED_FILES_TO_LOG = 25;
-
   private final GitRepositoryManager repoManager;
   private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
   private final DiffOperations diffOperations;
-  private final Provider<AutoMerger> autoMergerProvider;
   private final CodeOwnerMetrics codeOwnerMetrics;
-  private final ThreeWayMergeStrategy mergeStrategy;
-  private final ExperimentFeatures experimentFeatures;
 
   @Inject
   public ChangedFiles(
-      @GerritServerConfig Config cfg,
       GitRepositoryManager repoManager,
       CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
       DiffOperations diffOperations,
-      Provider<AutoMerger> autoMergerProvider,
-      CodeOwnerMetrics codeOwnerMetrics,
-      ExperimentFeatures experimentFeatures) {
+      CodeOwnerMetrics codeOwnerMetrics) {
     this.repoManager = repoManager;
     this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
     this.diffOperations = diffOperations;
-    this.autoMergerProvider = autoMergerProvider;
     this.codeOwnerMetrics = codeOwnerMetrics;
-    this.experimentFeatures = experimentFeatures;
-    this.mergeStrategy = MergeUtil.getMergeStrategy(cfg);
-  }
-
-  /**
-   * Returns the changed files for the given revision.
-   *
-   * <p>By default the changed files are computed on access (see {@link #compute(Project.NameKey,
-   * ObjectId)}).
-   *
-   * <p>Only if enabled via the {@link CodeOwnersExperimentFeaturesConstants#USE_DIFF_CACHE}
-   * experiment feature flag the changed files are retrieved from the diff cache (see {@link
-   * #getFromDiffCache(Project.NameKey, ObjectId)}).
-   *
-   * @param project the project
-   * @param revision the revision for which the changed files should be computed
-   * @return the files that have been changed in the given revision, sorted alphabetically by path
-   */
-  public ImmutableList<ChangedFile> getOrCompute(Project.NameKey project, ObjectId revision)
-      throws IOException, PatchListNotAvailableException, DiffNotAvailableException {
-    if (experimentFeatures.isFeatureEnabled(CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)) {
-      if (isInitialCommit(project, revision)) {
-        // DiffOperations doesn't support getting the list of modified files for the initial commit.
-        return compute(project, revision);
-      }
-
-      return getFromDiffCache(project, revision);
-    }
-    return compute(project, revision);
-  }
-
-  /**
-   * Computes the files that have been changed in the given revision.
-   *
-   * <p>The diff is computed against the parent commit.
-   *
-   * <p>Rename detection is disabled.
-   *
-   * @param revisionResource the revision resource for which the changed files should be computed
-   * @return the files that have been changed in the given revision, sorted alphabetically by path
-   * @throws IOException thrown if the computation fails due to an I/O error
-   * @throws PatchListNotAvailableException thrown if getting the patch list for a merge commit
-   *     against the auto merge failed
-   */
-  public ImmutableList<ChangedFile> compute(RevisionResource revisionResource)
-      throws IOException, PatchListNotAvailableException {
-    requireNonNull(revisionResource, "revisionResource");
-    return compute(revisionResource.getProject(), revisionResource.getPatchSet().commitId());
-  }
-
-  /**
-   * Computes the files that have been changed in the given revision.
-   *
-   * <p>The diff is computed against the parent commit.
-   *
-   * <p>Rename detection is disabled.
-   *
-   * @param project the project
-   * @param revision the revision for which the changed files should be computed
-   * @return the files that have been changed in the given revision, sorted alphabetically by path
-   * @throws IOException thrown if the computation fails due to an I/O error
-   * @throws PatchListNotAvailableException thrown if getting the patch list for a merge commit
-   *     against the auto merge failed
-   */
-  public ImmutableList<ChangedFile> compute(Project.NameKey project, ObjectId revision)
-      throws IOException, PatchListNotAvailableException {
-    requireNonNull(project, "project");
-    requireNonNull(revision, "revision");
-
-    try (Repository repository = repoManager.openRepository(project);
-        RevWalk revWalk = new RevWalk(repository)) {
-      RevCommit revCommit = revWalk.parseCommit(revision);
-      return compute(project, repository.getConfig(), revWalk, revCommit);
-    }
-  }
-
-  public ImmutableList<ChangedFile> compute(
-      Project.NameKey project, Config repoConfig, RevWalk revWalk, RevCommit revCommit)
-      throws IOException {
-    return compute(
-        project,
-        repoConfig,
-        revWalk,
-        revCommit,
-        codeOwnersPluginConfiguration.getProjectConfig(project).getMergeCommitStrategy());
-  }
-
-  public ImmutableList<ChangedFile> compute(
-      Project.NameKey project,
-      Config repoConfig,
-      RevWalk revWalk,
-      RevCommit revCommit,
-      MergeCommitStrategy mergeCommitStrategy)
-      throws IOException {
-    requireNonNull(project, "project");
-    requireNonNull(repoConfig, "repoConfig");
-    requireNonNull(revWalk, "revWalk");
-    requireNonNull(revCommit, "revCommit");
-    requireNonNull(mergeCommitStrategy, "mergeCommitStrategy");
-
-    logger.atFine().log(
-        "computing changed files for revision %s in project %s", revCommit.name(), project);
-
-    if (revCommit.getParentCount() > 1
-        && MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION.equals(mergeCommitStrategy)) {
-      RevCommit autoMergeCommit = getAutoMergeCommit(project, revCommit);
-      return compute(repoConfig, revWalk, revCommit, autoMergeCommit);
-    }
-
-    RevCommit baseCommit = revCommit.getParentCount() > 0 ? revCommit.getParent(0) : null;
-    return compute(repoConfig, revWalk, revCommit, baseCommit);
-  }
-
-  private RevCommit getAutoMergeCommit(Project.NameKey project, RevCommit mergeCommit)
-      throws IOException {
-    try (Timer0.Context ctx = codeOwnerMetrics.getAutoMerge.start();
-        Repository repository = repoManager.openRepository(project);
-        InMemoryInserter inserter = new InMemoryInserter(repository);
-        ObjectReader reader = inserter.newReader();
-        RevWalk revWalk = new RevWalk(reader)) {
-      return autoMergerProvider
-          .get()
-          .lookupFromGitOrMergeInMemory(repository, revWalk, inserter, mergeCommit, mergeStrategy);
-    }
-  }
-
-  /**
-   * Computes the changed files by comparing the given commit against the given base commit.
-   *
-   * <p>The computation also works if the commit doesn't have any parent.
-   *
-   * <p>Rename detection is disabled.
-   *
-   * @param repoConfig the repository configuration
-   * @param revWalk the rev walk
-   * @param commit the commit for which the changed files should be computed
-   * @param baseCommit the base commit against which the given commit should be compared, {@code
-   *     null} if the commit doesn't have any parent commit
-   * @return the changed files for the given commit, sorted alphabetically by path
-   */
-  private ImmutableList<ChangedFile> compute(
-      Config repoConfig, RevWalk revWalk, RevCommit commit, @Nullable RevCommit baseCommit)
-      throws IOException {
-    logger.atFine().log("baseCommit = %s", baseCommit != null ? baseCommit.name() : "n/a");
-    try (Timer0.Context ctx = codeOwnerMetrics.computeChangedFiles.start()) {
-      // Detecting renames is expensive (since it requires Git to load and compare file contents of
-      // added and deleted files) and can significantly increase the latency for changes that touch
-      // large files. To avoid this latency we do not enable the rename detection on the
-      // DiffFormater. As a result of this renamed files will be returned as 2 ChangedFile's, one
-      // for the deletion of the old path and one for the addition of the new path.
-      try (DiffFormatter diffFormatter = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
-        diffFormatter.setReader(revWalk.getObjectReader(), repoConfig);
-        diffFormatter.setDiffComparator(RawTextComparator.DEFAULT);
-        List<DiffEntry> diffEntries = diffFormatter.scan(baseCommit, commit);
-        ImmutableList<ChangedFile> changedFiles =
-            diffEntries.stream().map(ChangedFile::create).collect(toImmutableList());
-        if (changedFiles.size() <= MAX_CHANGED_FILES_TO_LOG) {
-          logger.atFine().log("changed files = %s", changedFiles);
-        } else {
-          logger.atFine().log(
-              "changed files = %s (and %d more)",
-              changedFiles.asList().subList(0, MAX_CHANGED_FILES_TO_LOG),
-              changedFiles.size() - MAX_CHANGED_FILES_TO_LOG);
-        }
-        return changedFiles;
-      }
-    }
   }
 
   /**
    * Gets the changed files from the diff cache.
    *
-   * <p>Doesn't support getting changed files for an initial revision. This is because the diff
-   * cache doesn't support getting changed files for commits that don't have any parent.
-   *
    * <p>Rename detection is enabled.
    *
-   * @throws IllegalStateException thrown if invoked for an initial revision
+   * @param project the project
+   * @param revision the revision for which the changed files should be retrieved
+   * @param mergeCommitStrategy the merge commit strategy that should be used to compute the changed
+   *     files for merge commits
+   * @return the files that have been changed in the given revision, sorted alphabetically by path
    */
-  @VisibleForTesting
-  ImmutableList<ChangedFile> getFromDiffCache(Project.NameKey project, ObjectId revision)
+  public ImmutableList<ChangedFile> getFromDiffCache(
+      Project.NameKey project, ObjectId revision, MergeCommitStrategy mergeCommitStrategy)
       throws IOException, DiffNotAvailableException {
     requireNonNull(project, "project");
     requireNonNull(revision, "revision");
-
-    checkState(!isInitialCommit(project, revision), "diff cache doesn't support initial commits");
-
-    MergeCommitStrategy mergeCommitStrategy =
-        codeOwnersPluginConfiguration.getProjectConfig(project).getMergeCommitStrategy();
+    requireNonNull(mergeCommitStrategy, "mergeCommitStrategy");
 
     try (Timer0.Context ctx = codeOwnerMetrics.getChangedFiles.start()) {
       Map<String, FileDiffOutput> fileDiffOutputs;
-      if (mergeCommitStrategy.equals(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION)) {
-        // Use parentNum=null to do the comparison against the default base.
+      if (mergeCommitStrategy.equals(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION)
+          || isInitialCommit(project, revision)) {
+        // Use parentNum=0 to do the comparison against the default base.
         // For non-merge commits the default base is the only parent (aka parent 1, initial commits
         // are not supported).
         // For merge commits the default base is the auto-merge commit which should be used as base
         // if the merge commit strategy is FILES_WITH_CONFLICT_RESOLUTION.
         fileDiffOutputs =
-            diffOperations.listModifiedFilesAgainstParent(project, revision, /* parentNum=*/ null);
+            diffOperations.listModifiedFilesAgainstParent(project, revision, /* parentNum=*/ 0);
       } else {
         checkState(mergeCommitStrategy.equals(MergeCommitStrategy.ALL_CHANGED_FILES));
         // Always use parent 1 to do the comparison.
-        // Non-merge commits should always be compared against against the first parent (initial
-        // commits are not supported).
+        // Non-merge commits should always be compared against the first parent (initial commits are
+        // handled above).
         // For merge commits also the first parent should be used if the merge commit strategy is
         // ALL_CHANGED_FILES.
         fileDiffOutputs = diffOperations.listModifiedFilesAgainstParent(project, revision, 1);
@@ -319,6 +112,47 @@
     }
   }
 
+  /**
+   * Gets the changed files from the diff cache.
+   *
+   * <p>Rename detection is enabled.
+   *
+   * <p>Uses the configured merge commit strategy.
+   *
+   * @param project the project
+   * @param revision the revision for which the changed files should be retrieved
+   * @return the files that have been changed in the given revision, sorted alphabetically by path
+   * @throws IOException thrown if the computation fails due to an I/O error
+   */
+  public ImmutableList<ChangedFile> getFromDiffCache(Project.NameKey project, ObjectId revision)
+      throws IOException, DiffNotAvailableException {
+    requireNonNull(project, "project");
+    requireNonNull(revision, "revision");
+    return getFromDiffCache(
+        project,
+        revision,
+        codeOwnersPluginConfiguration.getProjectConfig(project).getMergeCommitStrategy());
+  }
+
+  /**
+   * Gets the changed files from the diff cache.
+   *
+   * <p>Rename detection is enabled.
+   *
+   * <p>Uses the configured merge commit strategy.
+   *
+   * @param revisionResource the revision resource for which the changed files should be retrieved
+   * @return the files that have been changed in the given revision, sorted alphabetically by path
+   * @throws IOException thrown if the computation fails due to an I/O error
+   * @see #getFromDiffCache(Project.NameKey, ObjectId, MergeCommitStrategy)
+   */
+  public ImmutableList<ChangedFile> getFromDiffCache(RevisionResource revisionResource)
+      throws IOException, DiffNotAvailableException {
+    requireNonNull(revisionResource, "revisionResource");
+    return getFromDiffCache(
+        revisionResource.getProject(), revisionResource.getPatchSet().commitId());
+  }
+
   private boolean isInitialCommit(Project.NameKey project, ObjectId objectId) throws IOException {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk revWalk = new RevWalk(repo)) {
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerAnnotation.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerAnnotation.java
new file mode 100644
index 0000000..bcb4d8c
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerAnnotation.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2021 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.plugins.codeowners.backend;
+
+import com.google.auto.value.AutoValue;
+
+/**
+ * An annotation on a {@link CodeOwnerReference} in a {@link CodeOwnerConfig}.
+ *
+ * <p>This is a class rather than string so that we can easily support values on annotations later.
+ */
+@AutoValue
+public abstract class CodeOwnerAnnotation {
+  public abstract String key();
+
+  public static CodeOwnerAnnotation create(String key) {
+    return new AutoValue_CodeOwnerAnnotation(key);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerAnnotations.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerAnnotations.java
new file mode 100644
index 0000000..ea1417d
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerAnnotations.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2021 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.plugins.codeowners.backend;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/** Class that defines all known/supported {@link CodeOwnerAnnotation}s on code owners. */
+public class CodeOwnerAnnotations {
+  /**
+   * Code owners with this annotation are omitted when suggesting code owners (see {@link
+   * com.google.gerrit.plugins.codeowners.restapi.GetCodeOwnersForPathInChange}).
+   */
+  public static final CodeOwnerAnnotation LAST_RESORT_SUGGESTION_ANNOTATION =
+      CodeOwnerAnnotation.create("LAST_RESORT_SUGGESTION");
+
+  private static final List<String> KEYS_ALL;
+
+  static {
+    KEYS_ALL = new ArrayList<>();
+    KEYS_ALL.add(LAST_RESORT_SUGGESTION_ANNOTATION.key());
+  }
+
+  /** Whether the given annotation is known and supported. */
+  public static boolean isSupported(String annotation) {
+    return KEYS_ALL.contains(annotation);
+  }
+
+  /**
+   * Private constructor to prevent instantiation of this class.
+   *
+   * <p>This class contains only static method and hence never needs to be instantiated.
+   */
+  private CodeOwnerAnnotations() {}
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
index 65f4d78..f015cb0 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
@@ -27,9 +27,11 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.metrics.Timer0;
@@ -39,16 +41,16 @@
 import com.google.gerrit.plugins.codeowners.common.ChangedFile;
 import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
 import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.PureRevertCache;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -90,7 +92,7 @@
   private final ChangedFiles changedFiles;
   private final PureRevertCache pureRevertCache;
   private final Provider<CodeOwnerConfigHierarchy> codeOwnerConfigHierarchyProvider;
-  private final Provider<CodeOwnerResolver> codeOwnerResolver;
+  private final Provider<CodeOwnerResolver> codeOwnerResolverProvider;
   private final ApprovalsUtil approvalsUtil;
   private final CodeOwnerMetrics codeOwnerMetrics;
 
@@ -102,7 +104,7 @@
       ChangedFiles changedFiles,
       PureRevertCache pureRevertCache,
       Provider<CodeOwnerConfigHierarchy> codeOwnerConfigHierarchyProvider,
-      Provider<CodeOwnerResolver> codeOwnerResolver,
+      Provider<CodeOwnerResolver> codeOwnerResolverProvider,
       ApprovalsUtil approvalsUtil,
       CodeOwnerMetrics codeOwnerMetrics) {
     this.permissionBackend = permissionBackend;
@@ -111,7 +113,7 @@
     this.changedFiles = changedFiles;
     this.pureRevertCache = pureRevertCache;
     this.codeOwnerConfigHierarchyProvider = codeOwnerConfigHierarchyProvider;
-    this.codeOwnerResolver = codeOwnerResolver;
+    this.codeOwnerResolverProvider = codeOwnerResolverProvider;
     this.approvalsUtil = approvalsUtil;
     this.codeOwnerMetrics = codeOwnerMetrics;
   }
@@ -127,7 +129,7 @@
    * @return the paths of the files in the given patch set that are owned by the specified account
    * @throws ResourceConflictException if the destination branch of the change no longer exists
    */
-  public ImmutableList<Path> getOwnedPaths(
+  public ImmutableList<OwnedChangedFile> getOwnedPaths(
       ChangeNotes changeNotes, PatchSet patchSet, Account.Id accountId, int start, int limit)
       throws ResourceConflictException {
     try (Timer0.Context ctx = codeOwnerMetrics.computeOwnedPaths.start()) {
@@ -140,26 +142,46 @@
           patchSet.id().get(),
           start,
           limit);
-      Stream<Path> ownedPaths =
+
+      Stream<FileCodeOwnerStatus> fileStatuses =
           getFileStatusesForAccount(changeNotes, patchSet, accountId)
-              .flatMap(
-                  fileCodeOwnerStatus ->
-                      Stream.of(
-                              fileCodeOwnerStatus.newPathStatus(),
-                              fileCodeOwnerStatus.oldPathStatus())
-                          .filter(Optional::isPresent)
-                          .map(Optional::get))
               .filter(
-                  pathCodeOwnerStatus -> pathCodeOwnerStatus.status() == CodeOwnerStatus.APPROVED)
-              .map(PathCodeOwnerStatus::path);
+                  fileStatus ->
+                      (fileStatus.newPathStatus().isPresent()
+                              && fileStatus.newPathStatus().get().status()
+                                  == CodeOwnerStatus.APPROVED)
+                          || (fileStatus.oldPathStatus().isPresent()
+                              && fileStatus.oldPathStatus().get().status()
+                                  == CodeOwnerStatus.APPROVED));
       if (start > 0) {
-        ownedPaths = ownedPaths.skip(start);
+        fileStatuses = fileStatuses.skip(start);
       }
       if (limit > 0) {
-        ownedPaths = ownedPaths.limit(limit);
+        fileStatuses = fileStatuses.limit(limit);
       }
-      return ownedPaths.collect(toImmutableList());
-    } catch (IOException | PatchListNotAvailableException | DiffNotAvailableException e) {
+
+      return fileStatuses
+          .map(
+              fileStatus ->
+                  OwnedChangedFile.create(
+                      fileStatus
+                          .newPathStatus()
+                          .map(
+                              newPathStatus ->
+                                  OwnedPath.create(
+                                      newPathStatus.path(),
+                                      newPathStatus.status() == CodeOwnerStatus.APPROVED))
+                          .orElse(null),
+                      fileStatus
+                          .oldPathStatus()
+                          .map(
+                              oldPathStatus ->
+                                  OwnedPath.create(
+                                      oldPathStatus.path(),
+                                      oldPathStatus.status() == CodeOwnerStatus.APPROVED))
+                          .orElse(null)))
+          .collect(toImmutableList());
+    } catch (IOException | DiffNotAvailableException e) {
       throw new CodeOwnersInternalServerErrorException(
           String.format(
               "failed to compute owned paths of patch set %s for account %d",
@@ -175,16 +197,16 @@
    * @return whether the given change has sufficient code owner approvals to be submittable
    */
   public boolean isSubmittable(ChangeNotes changeNotes)
-      throws ResourceConflictException, IOException, PatchListNotAvailableException,
-          DiffNotAvailableException {
+      throws ResourceConflictException, IOException, DiffNotAvailableException {
     requireNonNull(changeNotes, "changeNotes");
     logger.atFine().log(
         "checking if change %d in project %s is submittable",
         changeNotes.getChangeId().get(), changeNotes.getProjectName());
     CodeOwnerConfigHierarchy codeOwnerConfigHierarchy = codeOwnerConfigHierarchyProvider.get();
+    CodeOwnerResolver codeOwnerResolver = codeOwnerResolverProvider.get().enforceVisibility(false);
     try {
       boolean isSubmittable =
-          !getFileStatuses(codeOwnerConfigHierarchy, changeNotes)
+          !getFileStatuses(codeOwnerConfigHierarchy, codeOwnerResolver, changeNotes)
               .anyMatch(
                   fileStatus ->
                       (fileStatus.newPathStatus().isPresent()
@@ -204,6 +226,10 @@
           codeOwnerConfigHierarchy.getCodeOwnerConfigCounters().getBackendReadCount());
       codeOwnerMetrics.codeOwnerConfigCacheReadsPerChange.record(
           codeOwnerConfigHierarchy.getCodeOwnerConfigCounters().getCacheReadCount());
+      codeOwnerMetrics.codeOwnerResolutionsPerChange.record(
+          codeOwnerResolver.getCodeOwnerCounters().getResolutionCount());
+      codeOwnerMetrics.codeOwnerConfigCacheReadsPerChange.record(
+          codeOwnerResolver.getCodeOwnerCounters().getCacheReadCount());
     }
   }
 
@@ -213,19 +239,21 @@
    *
    * @param start number of file statuses to skip
    * @param limit the max number of file statuses that should be returned (0 = unlimited)
-   * @see #getFileStatuses(CodeOwnerConfigHierarchy, ChangeNotes)
+   * @see #getFileStatuses(CodeOwnerConfigHierarchy, CodeOwnerResolver, ChangeNotes)
    */
   public ImmutableSet<FileCodeOwnerStatus> getFileStatusesAsSet(
       ChangeNotes changeNotes, int start, int limit)
-      throws ResourceConflictException, IOException, PatchListNotAvailableException,
-          DiffNotAvailableException {
+      throws ResourceConflictException, IOException, DiffNotAvailableException {
     requireNonNull(changeNotes, "changeNotes");
     try (Timer0.Context ctx = codeOwnerMetrics.computeFileStatuses.start()) {
       logger.atFine().log(
           "compute file statuses (project = %s, change = %d, start = %d, limit = %d)",
           changeNotes.getProjectName(), changeNotes.getChangeId().get(), start, limit);
       Stream<FileCodeOwnerStatus> fileStatuses =
-          getFileStatuses(codeOwnerConfigHierarchyProvider.get(), changeNotes);
+          getFileStatuses(
+              codeOwnerConfigHierarchyProvider.get(),
+              codeOwnerResolverProvider.get().enforceVisibility(false),
+              changeNotes);
       if (start > 0) {
         fileStatuses = fileStatuses.skip(start);
       }
@@ -265,9 +293,10 @@
    *     returned
    */
   private Stream<FileCodeOwnerStatus> getFileStatuses(
-      CodeOwnerConfigHierarchy codeOwnerConfigHierarchy, ChangeNotes changeNotes)
-      throws ResourceConflictException, IOException, PatchListNotAvailableException,
-          DiffNotAvailableException {
+      CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
+      CodeOwnerResolver codeOwnerResolver,
+      ChangeNotes changeNotes)
+      throws ResourceConflictException, IOException, DiffNotAvailableException {
     requireNonNull(changeNotes, "changeNotes");
     try (Timer0.Context ctx = codeOwnerMetrics.prepareFileStatusComputation.start()) {
       logger.atFine().log(
@@ -285,7 +314,12 @@
         logger.atFine().log(
             "patch set uploader %d is exempted from requiring code owner approvals",
             patchSetUploader.get());
-        return getAllPathsAsApproved(changeNotes, changeNotes.getCurrentPatchSet());
+        return getAllPathsAsApproved(
+            changeNotes,
+            changeNotes.getCurrentPatchSet(),
+            String.format(
+                "patch set uploader %s is exempted from requiring code owner approvals",
+                AccountTemplateUtil.getAccountTemplate(patchSetUploader)));
       }
 
       boolean arePureRevertsExempted = codeOwnersConfig.arePureRevertsExempted();
@@ -293,7 +327,10 @@
       if (arePureRevertsExempted && isPureRevert(changeNotes)) {
         logger.atFine().log(
             "change is a pure revert and is exempted from requiring code owner approvals");
-        return getAllPathsAsApproved(changeNotes, changeNotes.getCurrentPatchSet());
+        return getAllPathsAsApproved(
+            changeNotes,
+            changeNotes.getCurrentPatchSet(),
+            "change is a pure revert and is exempted from requiring code owner approvals");
       }
 
       boolean implicitApprovalConfig = codeOwnersConfig.areImplicitApprovalsEnabled();
@@ -313,28 +350,26 @@
       logger.atFine().log("requiredApproval = %s", requiredApproval);
 
       ImmutableSet<RequiredApproval> overrideApprovals = codeOwnersConfig.getOverrideApprovals();
-      boolean hasOverride =
-          hasOverride(currentPatchSetApprovals, overrideApprovals, patchSetUploader);
+      ImmutableSet<PatchSetApproval> overrides =
+          getOverride(currentPatchSetApprovals, overrideApprovals, patchSetUploader);
       logger.atFine().log(
-          "hasOverride = %s (overrideApprovals = %s)",
-          hasOverride,
+          "hasOverride = %s (overrideApprovals = %s, overrides = %s)",
+          !overrides.isEmpty(),
           overrideApprovals.stream()
               .map(
                   overrideApproval ->
                       String.format(
                           "%s (ignoreSelfApproval = %s)",
                           overrideApproval, overrideApproval.labelType().isIgnoreSelfApproval()))
-              .collect(toImmutableList()));
+              .collect(toImmutableList()),
+          overrides);
 
       BranchNameKey branch = changeNotes.getChange().getDest();
       ObjectId revision = getDestBranchRevision(changeNotes.getChange());
       logger.atFine().log("dest branch %s has revision %s", branch.branch(), revision.name());
 
       CodeOwnerResolverResult globalCodeOwners =
-          codeOwnerResolver
-              .get()
-              .enforceVisibility(false)
-              .resolveGlobalCodeOwners(changeNotes.getProjectName());
+          codeOwnerResolver.resolveGlobalCodeOwners(changeNotes.getProjectName());
       logger.atFine().log("global code owners = %s", globalCodeOwners);
 
       ImmutableSet<Account.Id> reviewerAccountIds =
@@ -346,12 +381,14 @@
       FallbackCodeOwners fallbackCodeOwners = codeOwnersConfig.getFallbackCodeOwners();
 
       return changedFiles
-          .getOrCompute(changeNotes.getProjectName(), changeNotes.getCurrentPatchSet().commitId())
+          .getFromDiffCache(
+              changeNotes.getProjectName(), changeNotes.getCurrentPatchSet().commitId())
           .stream()
           .map(
               changedFile ->
                   getFileStatus(
                       codeOwnerConfigHierarchy,
+                      codeOwnerResolver,
                       branch,
                       revision,
                       globalCodeOwners,
@@ -359,7 +396,7 @@
                       reviewerAccountIds,
                       approverAccountIds,
                       fallbackCodeOwners,
-                      hasOverride,
+                      overrides,
                       changedFile));
     }
   }
@@ -380,8 +417,7 @@
   @VisibleForTesting
   public Stream<FileCodeOwnerStatus> getFileStatusesForAccount(
       ChangeNotes changeNotes, PatchSet patchSet, Account.Id accountId)
-      throws ResourceConflictException, IOException, PatchListNotAvailableException,
-          DiffNotAvailableException {
+      throws ResourceConflictException, IOException, DiffNotAvailableException {
     requireNonNull(changeNotes, "changeNotes");
     requireNonNull(patchSet, "patchSet");
     requireNonNull(accountId, "accountId");
@@ -408,16 +444,17 @@
       FallbackCodeOwners fallbackCodeOwners = codeOwnersConfig.getFallbackCodeOwners();
       logger.atFine().log(
           "fallbackCodeOwner = %s, isProjectOwner = %s", fallbackCodeOwners, isProjectOwner);
-      if (fallbackCodeOwners.equals(FallbackCodeOwners.PROJECT_OWNERS) && isProjectOwner) {
-        return getAllPathsAsApproved(changeNotes, patchSet);
-      }
 
       CodeOwnerConfigHierarchy codeOwnerConfigHierarchy = codeOwnerConfigHierarchyProvider.get();
-      return changedFiles.getOrCompute(changeNotes.getProjectName(), patchSet.commitId()).stream()
+      CodeOwnerResolver codeOwnerResolver =
+          codeOwnerResolverProvider.get().enforceVisibility(false);
+      return changedFiles.getFromDiffCache(changeNotes.getProjectName(), patchSet.commitId())
+          .stream()
           .map(
               changedFile ->
                   getFileStatus(
                       codeOwnerConfigHierarchy,
+                      codeOwnerResolver,
                       branch,
                       revision,
                       /* globalCodeOwners= */ CodeOwnerResolverResult.createEmpty(),
@@ -430,7 +467,7 @@
                       // Assume an explicit approval of the given account.
                       /* approverAccountIds= */ ImmutableSet.of(accountId),
                       fallbackCodeOwners,
-                      /* hasOverride= */ false,
+                      /* overrides= */ ImmutableSet.of(),
                       changedFile));
     }
   }
@@ -449,9 +486,10 @@
   }
 
   private Stream<FileCodeOwnerStatus> getAllPathsAsApproved(
-      ChangeNotes changeNotes, PatchSet patchSet)
-      throws IOException, PatchListNotAvailableException, DiffNotAvailableException {
-    return changedFiles.getOrCompute(changeNotes.getProjectName(), patchSet.commitId()).stream()
+      ChangeNotes changeNotes, PatchSet patchSet, String reason)
+      throws IOException, DiffNotAvailableException {
+    logger.atFine().log("all paths are approved (reason = %s)", reason);
+    return changedFiles.getFromDiffCache(changeNotes.getProjectName(), patchSet.commitId()).stream()
         .map(
             changedFile ->
                 FileCodeOwnerStatus.create(
@@ -460,16 +498,19 @@
                         .newPath()
                         .map(
                             newPath ->
-                                PathCodeOwnerStatus.create(newPath, CodeOwnerStatus.APPROVED)),
+                                PathCodeOwnerStatus.create(
+                                    newPath, CodeOwnerStatus.APPROVED, reason)),
                     changedFile
                         .oldPath()
                         .map(
                             oldPath ->
-                                PathCodeOwnerStatus.create(oldPath, CodeOwnerStatus.APPROVED))));
+                                PathCodeOwnerStatus.create(
+                                    oldPath, CodeOwnerStatus.APPROVED, reason))));
   }
 
   private FileCodeOwnerStatus getFileStatus(
       CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
+      CodeOwnerResolver codeOwnerResolver,
       BranchNameKey branch,
       ObjectId revision,
       CodeOwnerResolverResult globalCodeOwners,
@@ -477,7 +518,7 @@
       ImmutableSet<Account.Id> reviewerAccountIds,
       ImmutableSet<Account.Id> approverAccountIds,
       FallbackCodeOwners fallbackCodeOwners,
-      boolean hasOverride,
+      ImmutableSet<PatchSetApproval> overrides,
       ChangedFile changedFile) {
     try (Timer0.Context ctx = codeOwnerMetrics.computeFileStatus.start()) {
       logger.atFine().log("computing file status for %s", changedFile);
@@ -490,6 +531,7 @@
                   newPath ->
                       getPathCodeOwnerStatus(
                           codeOwnerConfigHierarchy,
+                          codeOwnerResolver,
                           branch,
                           revision,
                           globalCodeOwners,
@@ -497,7 +539,7 @@
                           reviewerAccountIds,
                           approverAccountIds,
                           fallbackCodeOwners,
-                          hasOverride,
+                          overrides,
                           newPath));
 
       // Compute the code owner status for the old path, if the file was deleted or renamed.
@@ -512,6 +554,7 @@
             Optional.of(
                 getPathCodeOwnerStatus(
                     codeOwnerConfigHierarchy,
+                    codeOwnerResolver,
                     branch,
                     revision,
                     globalCodeOwners,
@@ -519,7 +562,7 @@
                     reviewerAccountIds,
                     approverAccountIds,
                     fallbackCodeOwners,
-                    hasOverride,
+                    overrides,
                     changedFile.oldPath().get()));
       }
 
@@ -532,6 +575,7 @@
 
   private PathCodeOwnerStatus getPathCodeOwnerStatus(
       CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
+      CodeOwnerResolver codeOwnerResolver,
       BranchNameKey branch,
       ObjectId revision,
       CodeOwnerResolverResult globalCodeOwners,
@@ -539,28 +583,41 @@
       ImmutableSet<Account.Id> reviewerAccountIds,
       ImmutableSet<Account.Id> approverAccountIds,
       FallbackCodeOwners fallbackCodeOwners,
-      boolean hasOverride,
+      ImmutableSet<PatchSetApproval> overrides,
       Path absolutePath) {
     logger.atFine().log("computing path status for %s", absolutePath);
 
-    if (hasOverride) {
+    if (!overrides.isEmpty()) {
       logger.atFine().log(
-          "the status for path %s is %s since an override is present",
-          absolutePath, CodeOwnerStatus.APPROVED.name());
-      return PathCodeOwnerStatus.create(absolutePath, CodeOwnerStatus.APPROVED);
+          "the status for path %s is %s since an override is present (overrides = %s)",
+          absolutePath, CodeOwnerStatus.APPROVED.name(), overrides);
+      Optional<PatchSetApproval> override = overrides.stream().findAny();
+      checkState(override.isPresent(), "no override found");
+      return PathCodeOwnerStatus.create(
+          absolutePath,
+          CodeOwnerStatus.APPROVED,
+          String.format(
+              "override approval %s by %s is present",
+              override.get().label() + LabelValue.formatValue(override.get().value()),
+              AccountTemplateUtil.getAccountTemplate(override.get().accountId())));
     }
 
     AtomicReference<CodeOwnerStatus> codeOwnerStatus =
         new AtomicReference<>(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    AtomicReference<String> reason = new AtomicReference<>(/* initialValue= */ null);
 
-    if (isApproved(absolutePath, globalCodeOwners, approverAccountIds, implicitApprover)) {
-      logger.atFine().log("%s was approved by a global code owner", absolutePath);
+    if (isApproved(
+        globalCodeOwners,
+        CodeOwnerKind.GLOBAL_CODE_OWNER,
+        approverAccountIds,
+        implicitApprover,
+        reason)) {
       codeOwnerStatus.set(CodeOwnerStatus.APPROVED);
     } else {
       logger.atFine().log("%s was not approved by a global code owner", absolutePath);
 
-      if (isPending(absolutePath, globalCodeOwners, reviewerAccountIds)) {
-        logger.atFine().log("%s is owned by a reviewer who is a global owner", absolutePath);
+      if (isPending(
+          globalCodeOwners, CodeOwnerKind.GLOBAL_CODE_OWNER, reviewerAccountIds, reason)) {
         codeOwnerStatus.set(CodeOwnerStatus.PENDING);
       }
 
@@ -572,10 +629,18 @@
           absolutePath,
           (PathCodeOwnersVisitor)
               pathCodeOwners -> {
-                CodeOwnerResolverResult codeOwners = resolveCodeOwners(pathCodeOwners);
+                CodeOwnerKind codeOwnerKind =
+                    RefNames.REFS_CONFIG.equals(pathCodeOwners.getCodeOwnerConfig().key().ref())
+                        ? CodeOwnerKind.DEFAULT_CODE_OWNER
+                        : CodeOwnerKind.REGULAR_CODE_OWNER;
+
+                CodeOwnerResolverResult codeOwners =
+                    resolveCodeOwners(codeOwnerResolver, pathCodeOwners);
                 logger.atFine().log(
-                    "code owners = %s (code owner config folder path = %s, file name = %s)",
+                    "code owners = %s (code owner kind = %s, code owner config folder path = %s,"
+                        + " file name = %s)",
                     codeOwners,
+                    codeOwnerKind,
                     pathCodeOwners.getCodeOwnerConfig().key().folderPath(),
                     pathCodeOwners.getCodeOwnerConfig().key().fileName().orElse("<default>"));
 
@@ -583,10 +648,11 @@
                   hasRevelantCodeOwnerDefinitions.set(true);
                 }
 
-                if (isApproved(absolutePath, codeOwners, approverAccountIds, implicitApprover)) {
+                if (isApproved(
+                    codeOwners, codeOwnerKind, approverAccountIds, implicitApprover, reason)) {
                   codeOwnerStatus.set(CodeOwnerStatus.APPROVED);
                   return false;
-                } else if (isPending(absolutePath, codeOwners, reviewerAccountIds)) {
+                } else if (isPending(codeOwners, codeOwnerKind, reviewerAccountIds, reason)) {
                   codeOwnerStatus.set(CodeOwnerStatus.PENDING);
 
                   // We need to continue to check if any of the higher-level code owners approved
@@ -611,21 +677,46 @@
       if (codeOwnerStatus.get() != CodeOwnerStatus.APPROVED
           && !hasRevelantCodeOwnerDefinitions.get()
           && !parentCodeOwnersAreIgnored.get()) {
-        codeOwnerStatus.set(
+        CodeOwnerStatus codeOwnerStatusForFallbackCodeOwners =
             getCodeOwnerStatusForFallbackCodeOwners(
                 codeOwnerStatus.get(),
                 branch,
-                globalCodeOwners,
                 implicitApprover,
                 reviewerAccountIds,
                 approverAccountIds,
                 fallbackCodeOwners,
-                absolutePath));
+                absolutePath,
+                reason);
+        // Merge codeOwnerStatusForFallbackCodeOwners into codeOwnerStatus:
+        // * codeOwnerStatus is the code owner status without taking fallback code owners into
+        //   account
+        // * codeOwnerStatusForFallbackCodeOwners is the code owner status for fallback code owners
+        //   only
+        // When merging both the "better" code owner status should take precedence (APPROVED is
+        // better than PENDING which is better than INSUFFICIENT_REVIEWERS):
+        // * if codeOwnerStatus == APPROVED we do not compute the code owner status for the fallback
+        //   code owners and never reach this point. Hence we can ignore this case below.
+        // * if codeOwnerStatus == PENDING (e.g. because a global code owner is a reviewer) we must
+        //   override it if codeOwnerStatusForFallbackCodeOwners is APPROVED
+        // * if codeOwnerStatus == INSUFFICIENT_REVIEWERS we must override it if
+        //   codeOwnerStatusForFallbackCodeOwners is PENDING or APPROVED
+        // This means if codeOwnerStatusForFallbackCodeOwners is INSUFFICIENT_REVIEWERS it is never
+        // "better" than codeOwnerStatus, hence in this case we do not override codeOwnerStatus.
+        // On the other hand if codeOwnerStatusForFallbackCodeOwners is PENDING or APPROVED (aka not
+        // INSUFFICIENT_REVIEWERS) it is always as good or "better" than codeOwnerStatus (which can
+        // only be INSUFFICIENT_REVIEWERS or PENDING at this point), hence in this case we can/must
+        // override codeOwnerStatus.
+        if (!codeOwnerStatusForFallbackCodeOwners.equals(CodeOwnerStatus.INSUFFICIENT_REVIEWERS)) {
+          codeOwnerStatus.set(codeOwnerStatusForFallbackCodeOwners);
+        }
       }
     }
 
+    logger.atFine().log(
+        "%s has code owner status %s (reason = %s)",
+        absolutePath, codeOwnerStatus.get(), reason.get() != null ? reason.get() : "n/a");
     PathCodeOwnerStatus pathCodeOwnerStatus =
-        PathCodeOwnerStatus.create(absolutePath, codeOwnerStatus.get());
+        PathCodeOwnerStatus.create(absolutePath, codeOwnerStatus.get(), reason.get());
     logger.atFine().log("pathCodeOwnerStatus = %s", pathCodeOwnerStatus);
     return pathCodeOwnerStatus;
   }
@@ -636,107 +727,91 @@
    */
   private CodeOwnerStatus getCodeOwnerStatusForProjectOwnersAsFallbackCodeOwners(
       BranchNameKey branch,
-      CodeOwnerResolverResult globalCodeOwners,
       @Nullable Account.Id implicitApprover,
       ImmutableSet<Account.Id> reviewerAccountIds,
       ImmutableSet<Account.Id> approverAccountIds,
-      Path absolutePath) {
+      Path absolutePath,
+      AtomicReference<String> reason) {
     logger.atFine().log(
         "computing code owner status for %s with project owners as fallback code owners",
         absolutePath);
 
     CodeOwnerStatus codeOwnerStatus = CodeOwnerStatus.INSUFFICIENT_REVIEWERS;
-    if (isApprovedByProjectOwnerOrGlobalOwner(
-        branch.project(), absolutePath, globalCodeOwners, approverAccountIds, implicitApprover)) {
+    if (isApprovedByProjectOwner(branch.project(), approverAccountIds, implicitApprover, reason)) {
       codeOwnerStatus = CodeOwnerStatus.APPROVED;
-    } else if (isPendingByProjectOwnerOrGlobalOwner(
-        branch.project(), absolutePath, globalCodeOwners, reviewerAccountIds)) {
+    } else if (isPendingByProjectOwner(branch.project(), reviewerAccountIds, reason)) {
       codeOwnerStatus = CodeOwnerStatus.PENDING;
     }
 
-    logger.atFine().log("codeOwnerStatus = %s", codeOwnerStatus);
     return codeOwnerStatus;
   }
 
-  private boolean isApprovedByProjectOwnerOrGlobalOwner(
+  private boolean isApprovedByProjectOwner(
       Project.NameKey projectName,
-      Path absolutePath,
-      CodeOwnerResolverResult globalCodeOwners,
       ImmutableSet<Account.Id> approverAccountIds,
-      @Nullable Account.Id implicitApprover) {
+      @Nullable Account.Id implicitApprover,
+      AtomicReference<String> reason) {
     return (implicitApprover != null
-            && isImplicitlyApprovedByProjectOwnerOrGlobalOwner(
-                projectName, absolutePath, globalCodeOwners, implicitApprover))
-        || isExplicitlyApprovedByProjectOwnerOrGlobalOwner(
-            projectName, absolutePath, globalCodeOwners, approverAccountIds);
+            && isImplicitlyApprovedByProjectOwner(projectName, implicitApprover, reason))
+        || isExplicitlyApprovedByProjectOwner(projectName, approverAccountIds, reason);
   }
 
-  private boolean isImplicitlyApprovedByProjectOwnerOrGlobalOwner(
-      Project.NameKey projectName,
-      Path absolutePath,
-      CodeOwnerResolverResult globalCodeOwners,
-      Account.Id implicitApprover) {
+  private boolean isImplicitlyApprovedByProjectOwner(
+      Project.NameKey projectName, Account.Id implicitApprover, AtomicReference<String> reason) {
     requireNonNull(implicitApprover, "implicitApprover");
     if (isProjectOwner(projectName, implicitApprover)) {
       // The uploader of the patch set is a project owner and thus a code owner. This means there
       // is an implicit code owner approval from the patch set uploader so that the path is
       // automatically approved.
-      logger.atFine().log(
-          "%s was implicitly approved by the patch set uploader who is a project owner",
-          absolutePath);
+      reason.set(
+          String.format(
+              "implicitly approved by the patch set uploader %s who is a %s"
+                  + " (all project owners are %ss)",
+              AccountTemplateUtil.getAccountTemplate(implicitApprover),
+              CodeOwnerKind.FALLBACK_CODE_OWNER.getDisplayName(),
+              CodeOwnerKind.FALLBACK_CODE_OWNER.getDisplayName()));
       return true;
     }
-
-    if (globalCodeOwners.ownedByAllUsers()
-        || globalCodeOwners.codeOwnersAccountIds().contains(implicitApprover)) {
-      // If the uploader of the patch set is a global code owner, there is an implicit code owner
-      // approval from the patch set uploader so that the path is automatically approved.
-      logger.atFine().log(
-          "%s was implicitly approved by the patch set uploader who is a global owner",
-          absolutePath);
-      return true;
-    }
-
     return false;
   }
 
-  private boolean isExplicitlyApprovedByProjectOwnerOrGlobalOwner(
+  private boolean isExplicitlyApprovedByProjectOwner(
       Project.NameKey projectName,
-      Path absolutePath,
-      CodeOwnerResolverResult globalCodeOwners,
-      ImmutableSet<Account.Id> approverAccountIds) {
-    if (!Collections.disjoint(approverAccountIds, globalCodeOwners.codeOwnersAccountIds())
-        || (globalCodeOwners.ownedByAllUsers() && !approverAccountIds.isEmpty())) {
-      // At least one of the global code owners approved the change.
-      logger.atFine().log("%s was approved by a global code owner", absolutePath);
-      return true;
-    }
-
-    if (approverAccountIds.stream()
-        .anyMatch(approverAccountId -> isProjectOwner(projectName, approverAccountId))) {
+      ImmutableSet<Account.Id> approverAccountIds,
+      AtomicReference<String> reason) {
+    Optional<Account.Id> approver =
+        approverAccountIds.stream()
+            .filter(approverAccountId -> isProjectOwner(projectName, approverAccountId))
+            .findAny();
+    if (approver.isPresent()) {
       // At least one of the approvers is a project owner and thus a code owner.
-      logger.atFine().log("%s was approved by a project owner", absolutePath);
+      reason.set(
+          String.format(
+              "approved by %s who is a %s (all project owners are %ss)",
+              AccountTemplateUtil.getAccountTemplate(approver.get()),
+              CodeOwnerKind.FALLBACK_CODE_OWNER.getDisplayName(),
+              CodeOwnerKind.FALLBACK_CODE_OWNER.getDisplayName()));
       return true;
     }
-
     return false;
   }
 
-  private boolean isPendingByProjectOwnerOrGlobalOwner(
+  private boolean isPendingByProjectOwner(
       Project.NameKey projectName,
-      Path absolutePath,
-      CodeOwnerResolverResult globalCodeOwners,
-      ImmutableSet<Account.Id> reviewerAccountIds) {
-    if (reviewerAccountIds.stream()
-        .anyMatch(reviewerAccountId -> isProjectOwner(projectName, reviewerAccountId))) {
+      ImmutableSet<Account.Id> reviewerAccountIds,
+      AtomicReference<String> reason) {
+    Optional<Account.Id> reviewer =
+        reviewerAccountIds.stream()
+            .filter(reviewerAccountId -> isProjectOwner(projectName, reviewerAccountId))
+            .findAny();
+    if (reviewer.isPresent()) {
       // At least one of the reviewers is a project owner and thus a code owner.
-      logger.atFine().log("%s is owned by a reviewer who is project owner", absolutePath);
-      return true;
-    }
-
-    if (isPending(absolutePath, globalCodeOwners, reviewerAccountIds)) {
-      // At least one of the reviewers is a global code owner.
-      logger.atFine().log("%s is owned by a reviewer who is a global owner", absolutePath);
+      reason.set(
+          String.format(
+              "reviewer %s is a %s (all project owners are %ss)",
+              AccountTemplateUtil.getAccountTemplate(reviewer.get()),
+              CodeOwnerKind.FALLBACK_CODE_OWNER.getDisplayName(),
+              CodeOwnerKind.FALLBACK_CODE_OWNER.getDisplayName()));
       return true;
     }
 
@@ -749,12 +824,12 @@
   private CodeOwnerStatus getCodeOwnerStatusForFallbackCodeOwners(
       CodeOwnerStatus codeOwnerStatus,
       BranchNameKey branch,
-      CodeOwnerResolverResult globalCodeOwners,
       @Nullable Account.Id implicitApprover,
       ImmutableSet<Account.Id> reviewerAccountIds,
       ImmutableSet<Account.Id> approverAccountIds,
       FallbackCodeOwners fallbackCodeOwners,
-      Path absolutePath) {
+      Path absolutePath,
+      AtomicReference<String> reason) {
     logger.atFine().log(
         "getting code owner status for fallback code owners (fallback code owners = %s)",
         fallbackCodeOwners);
@@ -764,15 +839,10 @@
         return codeOwnerStatus;
       case PROJECT_OWNERS:
         return getCodeOwnerStatusForProjectOwnersAsFallbackCodeOwners(
-            branch,
-            globalCodeOwners,
-            implicitApprover,
-            reviewerAccountIds,
-            approverAccountIds,
-            absolutePath);
+            branch, implicitApprover, reviewerAccountIds, approverAccountIds, absolutePath, reason);
       case ALL_USERS:
         return getCodeOwnerStatusIfAllUsersAreCodeOwners(
-            implicitApprover != null, reviewerAccountIds, approverAccountIds, absolutePath);
+            implicitApprover, reviewerAccountIds, approverAccountIds, absolutePath, reason);
     }
 
     throw new CodeOwnersInternalServerErrorException(
@@ -781,26 +851,44 @@
 
   /** Computes the code owner status for the given path assuming that all users are code owners. */
   private CodeOwnerStatus getCodeOwnerStatusIfAllUsersAreCodeOwners(
-      boolean enableImplicitApprovalFromUploader,
+      @Nullable Account.Id implicitApprover,
       ImmutableSet<Account.Id> reviewerAccountIds,
       ImmutableSet<Account.Id> approverAccountIds,
-      Path absolutePath) {
+      Path absolutePath,
+      AtomicReference<String> reason) {
     logger.atFine().log(
         "getting code owner status for fallback code owners (all users are fallback code owners)");
 
-    if (enableImplicitApprovalFromUploader) {
-      logger.atFine().log(
-          "%s was implicitly approved by the patch set uploader since the uploader is a fallback"
-              + " code owner",
-          absolutePath);
+    if (implicitApprover != null) {
+      reason.set(
+          String.format(
+              "implicitly approved by the patch set uploader %s who is a %s"
+                  + " (all users are %ss)",
+              AccountTemplateUtil.getAccountTemplate(implicitApprover),
+              CodeOwnerKind.FALLBACK_CODE_OWNER.getDisplayName(),
+              CodeOwnerKind.FALLBACK_CODE_OWNER.getDisplayName()));
       return CodeOwnerStatus.APPROVED;
     }
 
     if (!approverAccountIds.isEmpty()) {
-      logger.atFine().log("%s was approved by a fallback code owner", absolutePath);
+      Optional<Account.Id> approver = approverAccountIds.stream().findAny();
+      checkState(approver.isPresent(), "no approver found");
+      reason.set(
+          String.format(
+              "approved by %s who is a %s (all users are %ss)",
+              AccountTemplateUtil.getAccountTemplate(approver.get()),
+              CodeOwnerKind.FALLBACK_CODE_OWNER.getDisplayName(),
+              CodeOwnerKind.FALLBACK_CODE_OWNER.getDisplayName()));
       return CodeOwnerStatus.APPROVED;
     } else if (!reviewerAccountIds.isEmpty()) {
-      logger.atFine().log("%s has a fallback code owner as reviewer", absolutePath);
+      Optional<Account.Id> reviewer = reviewerAccountIds.stream().findAny();
+      checkState(reviewer.isPresent(), "no reviewer found");
+      reason.set(
+          String.format(
+              "reviewer %s is a %s (all users are %ss)",
+              AccountTemplateUtil.getAccountTemplate(reviewer.get()),
+              CodeOwnerKind.FALLBACK_CODE_OWNER.getDisplayName(),
+              CodeOwnerKind.FALLBACK_CODE_OWNER.getDisplayName()));
       return CodeOwnerStatus.PENDING;
     }
 
@@ -808,38 +896,96 @@
     return CodeOwnerStatus.INSUFFICIENT_REVIEWERS;
   }
 
+  /**
+   * Checks whether the given path was implicitly or explicitly approved.
+   *
+   * @param codeOwners users that own the path
+   * @param codeOwnerKind the kind of the given {@code codeOwners}
+   * @param approverAccountIds the IDs of the accounts that have approved the change
+   * @param implicitApprover the ID of the account the could be an implicit approver (aka last patch
+   *     set uploader)
+   * @param reason {@link AtomicReference} on which the reason is being set if the path is approved
+   * @return whether the path was approved
+   */
   private boolean isApproved(
-      Path absolutePath,
       CodeOwnerResolverResult codeOwners,
+      CodeOwnerKind codeOwnerKind,
       ImmutableSet<Account.Id> approverAccountIds,
-      @Nullable Account.Id implicitApprover) {
+      @Nullable Account.Id implicitApprover,
+      AtomicReference<String> reason) {
     if (implicitApprover != null) {
       if (codeOwners.codeOwnersAccountIds().contains(implicitApprover)
           || codeOwners.ownedByAllUsers()) {
         // If the uploader of the patch set owns the path, there is an implicit code owner
         // approval from the patch set uploader so that the path is automatically approved.
-        logger.atFine().log("%s was implicitly approved by the patch set uploader", absolutePath);
+        reason.set(
+            String.format(
+                "implicitly approved by the patch set uploader %s who is a %s%s",
+                AccountTemplateUtil.getAccountTemplate(implicitApprover),
+                codeOwnerKind.getDisplayName(),
+                codeOwners.ownedByAllUsers()
+                    ? String.format(" (all users are %ss)", codeOwnerKind.getDisplayName())
+                    : ""));
         return true;
       }
     }
 
     if (!Collections.disjoint(approverAccountIds, codeOwners.codeOwnersAccountIds())
         || (codeOwners.ownedByAllUsers() && !approverAccountIds.isEmpty())) {
-      // At least one of the global code owners approved the change.
-      logger.atFine().log("%s was explicitly approved by a code owner", absolutePath);
+      // At least one of the code owners approved the change.
+      Optional<Account.Id> approver =
+          codeOwners.ownedByAllUsers()
+              ? approverAccountIds.stream().findAny()
+              : approverAccountIds.stream()
+                  .filter(accountId -> codeOwners.codeOwnersAccountIds().contains(accountId))
+                  .findAny();
+      checkState(approver.isPresent(), "no approver found");
+      reason.set(
+          String.format(
+              "approved by %s who is a %s%s",
+              AccountTemplateUtil.getAccountTemplate(approver.get()),
+              codeOwnerKind.getDisplayName(),
+              codeOwners.ownedByAllUsers()
+                  ? String.format(" (all users are %ss)", codeOwnerKind.getDisplayName())
+                  : ""));
       return true;
     }
 
     return false;
   }
 
+  /**
+   * Checks whether any of the reviewers is a code owner of the path.
+   *
+   * @param codeOwners users that own the path
+   * @param codeOwnerKind the kind of the given {@code codeOwners}
+   * @param reviewerAccountIds the IDs of the accounts that are reviewer of the change
+   * @param reason {@link AtomicReference} on which the reason is being set if the status for the
+   *     path is {@code PENDING}
+   * @return whether the path was approved
+   */
   private boolean isPending(
-      Path absolutePath,
       CodeOwnerResolverResult codeOwners,
-      ImmutableSet<Account.Id> reviewerAccountIds) {
+      CodeOwnerKind codeOwnerKind,
+      ImmutableSet<Account.Id> reviewerAccountIds,
+      AtomicReference<String> reason) {
     if (!Collections.disjoint(codeOwners.codeOwnersAccountIds(), reviewerAccountIds)
         || (codeOwners.ownedByAllUsers() && !reviewerAccountIds.isEmpty())) {
-      logger.atFine().log("%s is owned by a reviewer", absolutePath);
+      Optional<Account.Id> reviewer =
+          codeOwners.ownedByAllUsers()
+              ? reviewerAccountIds.stream().findAny()
+              : reviewerAccountIds.stream()
+                  .filter(accountId -> codeOwners.codeOwnersAccountIds().contains(accountId))
+                  .findAny();
+      checkState(reviewer.isPresent(), "no reviewer found");
+      reason.set(
+          String.format(
+              "reviewer %s is a %s%s",
+              AccountTemplateUtil.getAccountTemplate(reviewer.get()),
+              codeOwnerKind.getDisplayName(),
+              codeOwners.ownedByAllUsers()
+                  ? String.format(" (all users are %ss)", codeOwnerKind.getDisplayName())
+                  : ""));
       return true;
     }
 
@@ -870,10 +1016,13 @@
   /**
    * Resolves the given path code owners.
    *
+   * @param codeOwnerResolver the {@code CodeOwnerResolver} that should be used to resolve code
+   *     owners
    * @param pathCodeOwners the path code owners that should be resolved
    */
-  private CodeOwnerResolverResult resolveCodeOwners(PathCodeOwners pathCodeOwners) {
-    return codeOwnerResolver.get().enforceVisibility(false).resolvePathCodeOwners(pathCodeOwners);
+  private CodeOwnerResolverResult resolveCodeOwners(
+      CodeOwnerResolver codeOwnerResolver, PathCodeOwners pathCodeOwners) {
+    return codeOwnerResolver.resolvePathCodeOwners(pathCodeOwners);
   }
 
   /**
@@ -946,13 +1095,13 @@
   }
 
   /**
-   * Checks whether the given change has an override approval.
+   * Gets the overrides that were applied on the change.
    *
    * @param overrideApprovals approvals that count as override for the code owners submit check.
    * @param patchSetUploader account ID of the patch set uploader
-   * @return whether the given change has an override approval
+   * @return the overrides that were applied on the change
    */
-  private boolean hasOverride(
+  private ImmutableSet<PatchSetApproval> getOverride(
       ImmutableList<PatchSetApproval> currentPatchSetApprovals,
       ImmutableSet<RequiredApproval> overrideApprovals,
       Account.Id patchSetUploader) {
@@ -980,10 +1129,11 @@
               }
               return true;
             })
-        .anyMatch(
+        .filter(
             patchSetApproval ->
                 overrideApprovals.stream()
-                    .anyMatch(overrideApproval -> overrideApproval.isApprovedBy(patchSetApproval)));
+                    .anyMatch(overrideApproval -> overrideApproval.isApprovedBy(patchSetApproval)))
+        .collect(toImmutableSet());
   }
 
   /**
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalHasOperand.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalHasOperand.java
new file mode 100644
index 0000000..ada921e
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalHasOperand.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2021 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.plugins.codeowners.backend;
+
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder.ChangeHasOperandFactory;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/** A class contributing a "approval_code-owners" operand to the "has" predicate. */
+@Singleton
+public class CodeOwnerApprovalHasOperand implements ChangeHasOperandFactory {
+  static final String OPERAND = "approval";
+
+  public static class CodeOwnerApprovalHasOperandModule extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(ChangeHasOperandFactory.class)
+          .annotatedWith(Exports.named(OPERAND))
+          .to(CodeOwnerApprovalHasOperand.class);
+    }
+  }
+
+  private final CodeOwnerApprovalPredicate codeOwnerApprovalPredicate;
+
+  @Inject
+  public CodeOwnerApprovalHasOperand(CodeOwnerApprovalPredicate codeOwnerApprovalPredicate) {
+    this.codeOwnerApprovalPredicate = codeOwnerApprovalPredicate;
+  }
+
+  @Override
+  public Predicate<ChangeData> create(ChangeQueryBuilder builder) throws QueryParseException {
+    return codeOwnerApprovalPredicate;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalPredicate.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalPredicate.java
new file mode 100644
index 0000000..7ca70b1
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalPredicate.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2021 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.plugins.codeowners.backend;
+
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Optional;
+
+/**
+ * A predicate that checks if a given change has all necessary code owner approvals. Matches with
+ * changes that have a code owner approval or a code owner override. This predicate wraps the
+ * existing {@link CodeOwnerSubmitRule} to perform the logic.
+ *
+ * <p>We implement the {@link SubmitRequirementPredicate} interface to make this predicate available
+ * for submit requirement expressions. As a consequence, this predicate does not work with search
+ * queries. We do that since the computation of code owner approvals is expensive.
+ *
+ * <p>TODO(ghareeb): exclude code owner overrides from this predicate.
+ */
+@Singleton
+public class CodeOwnerApprovalPredicate extends SubmitRequirementPredicate {
+  private final CodeOwnerSubmitRule codeOwnerSubmitRule;
+
+  @Inject
+  public CodeOwnerApprovalPredicate(
+      @PluginName String pluginName, CodeOwnerSubmitRule codeOwnerSubmitRule) {
+    super("has", CodeOwnerApprovalHasOperand.OPERAND + "_" + pluginName);
+    this.codeOwnerSubmitRule = codeOwnerSubmitRule;
+  }
+
+  @Override
+  public boolean match(ChangeData changeData) {
+    Optional<SubmitRecord> submitRecord = codeOwnerSubmitRule.evaluate(changeData);
+    return submitRecord.isPresent() && submitRecord.get().status == SubmitRecord.Status.OK;
+  }
+
+  @Override
+  public int getCost() {
+    // Running the code owner approval predicate is expensive
+    return 10;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerBackend.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerBackend.java
index 3775f89..22d91bc 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerBackend.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerBackend.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.plugins.codeowners.backend;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.server.IdentifiedUser;
 import java.nio.file.Path;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
  * Interface for code owner backends.
@@ -51,25 +51,8 @@
    *     {@code null} the code owner config is loaded from the current revision of the branch
    * @return code owner config for the given key if it exists, otherwise {@link Optional#empty()}
    */
-  default Optional<CodeOwnerConfig> getCodeOwnerConfig(
-      CodeOwnerConfig.Key codeOwnerConfigKey, @Nullable ObjectId revision) {
-    return getCodeOwnerConfig(codeOwnerConfigKey, /* revWalk= */ null, revision);
-  }
-
-  /**
-   * Gets the code owner config for the given key if it exists.
-   *
-   * @param codeOwnerConfigKey the code owner config key for which the code owner config should be
-   *     returned
-   * @param revWalk optional rev walk, if given this rev walk is used to load the given revision
-   * @param revision the branch revision from which the code owner config should be loaded, if
-   *     {@code null} the code owner config is loaded from the current revision of the branch
-   * @return code owner config for the given key if it exists, otherwise {@link Optional#empty()}
-   */
   Optional<CodeOwnerConfig> getCodeOwnerConfig(
-      CodeOwnerConfig.Key codeOwnerConfigKey,
-      @Nullable RevWalk revWalk,
-      @Nullable ObjectId revision);
+      CodeOwnerConfig.Key codeOwnerConfigKey, @Nullable ObjectId revision);
 
   /**
    * Returns the absolute file path of the specified code owner config.
@@ -111,8 +94,11 @@
    * <p>May return {@link Optional#empty()} if path expressions are not supported by the code owner
    * backend. It this case all {@link CodeOwnerSet}s that have path expressions are ignored and will
    * not have any effect.
+   *
+   * @param branchNameKey project and branch for which the path expression matcher should be
+   *     returned
    */
-  Optional<PathExpressionMatcher> getPathExpressionMatcher();
+  Optional<PathExpressionMatcher> getPathExpressionMatcher(BranchNameKey branchNameKey);
 
   /**
    * Replaces the old email in the given code owner config file content with the new email.
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerEnabledHasOperand.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerEnabledHasOperand.java
new file mode 100644
index 0000000..e2c7ab9
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerEnabledHasOperand.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2021 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.plugins.codeowners.backend;
+
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder.ChangeHasOperandFactory;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/** A class contributing a "enabled_code-owners" operand to the "has" predicate. */
+@Singleton
+public class CodeOwnerEnabledHasOperand implements ChangeHasOperandFactory {
+  static final String OPERAND = "enabled";
+
+  public static class CodeOwnerEnabledHasOperandModule extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(ChangeHasOperandFactory.class)
+          .annotatedWith(Exports.named(OPERAND))
+          .to(CodeOwnerEnabledHasOperand.class);
+    }
+  }
+
+  private final CodeOwnerEnabledPredicate codeOwnerEnabledPredicate;
+
+  @Inject
+  public CodeOwnerEnabledHasOperand(CodeOwnerEnabledPredicate codeOwnerEnabledPredicate) {
+    this.codeOwnerEnabledPredicate = codeOwnerEnabledPredicate;
+  }
+
+  @Override
+  public Predicate<ChangeData> create(ChangeQueryBuilder builder) throws QueryParseException {
+    return codeOwnerEnabledPredicate;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerEnabledPredicate.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerEnabledPredicate.java
new file mode 100644
index 0000000..6c9acec
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerEnabledPredicate.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2021 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.plugins.codeowners.backend;
+
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/**
+ * A predicate that returns true if the code-owners functionality is enabled for a given change.
+ *
+ * <p>We implement the {@link SubmitRequirementPredicate} interface to make this predicate available
+ * for submit requirement expressions. As a consequence, this predicate does not work with search
+ * queries. We do that since the computation of code owner approvals is expensive.
+ */
+@Singleton
+public class CodeOwnerEnabledPredicate extends SubmitRequirementPredicate {
+  private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
+
+  @Inject
+  public CodeOwnerEnabledPredicate(
+      @PluginName String pluginName, CodeOwnersPluginConfiguration codeOwnersPluginConfiguration) {
+    super("has", CodeOwnerEnabledHasOperand.OPERAND + "_" + pluginName);
+    this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
+  }
+
+  @Override
+  public boolean match(ChangeData changeData) {
+    return !codeOwnersPluginConfiguration
+        .getProjectConfig(changeData.project())
+        .isDisabled(changeData.change().getDest().branch());
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerKind.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerKind.java
new file mode 100644
index 0000000..ca5ceba
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerKind.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2021 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.plugins.codeowners.backend;
+
+/** Code owner kind, describing how the code ownership was assigned to a user. */
+public enum CodeOwnerKind {
+  /**
+   * The user is a global code owner (defined by {@code plugin.code-owners.globalCodeOwner} in
+   * {@code gerrit.config} or {@code codeOwners.globalCodeOwner} in {@code code-owners.config}).
+   */
+  GLOBAL_CODE_OWNER("global code owner"),
+
+  /**
+   * The user is a default code owner (defined in the code owner config file in {@code
+   * refs/meta/config}).
+   */
+  DEFAULT_CODE_OWNER("default code owner"),
+
+  /**
+   * The user is a fallback code owner (according the the fallback code owner policy that is defined
+   * by {@code plugin.code-owners.fallbackCodeOwners} in {@code gerrit.config} or {@code
+   * codeOwners.fallbackCodeOwners} in {@code code-owners.config} if no code owner is defined).
+   */
+  FALLBACK_CODE_OWNER("fallback code owner"),
+
+  /**
+   * Regular folder or per-file code owner (define in a code owner config file in the repository).
+   */
+  REGULAR_CODE_OWNER("code owner");
+
+  private final String displayName;
+
+  private CodeOwnerKind(String displayName) {
+    this.displayName = displayName;
+  }
+
+  public String getDisplayName() {
+    return displayName;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java
index f78804f..254b3a6 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java
@@ -15,13 +15,17 @@
 package com.google.gerrit.plugins.codeowners.backend;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
@@ -42,13 +46,57 @@
 import com.google.inject.Provider;
 import java.io.IOException;
 import java.nio.file.Path;
-import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.stream.Stream;
 
-/** Class to resolve {@link CodeOwnerReference}s to {@link CodeOwner}s. */
+/**
+ * Class to resolve {@link CodeOwnerReference}s to {@link CodeOwner}s.
+ *
+ * <p>Code owners are defined by {@link CodeOwnerReference}s (e.g. emails) that need to be resolved
+ * to accounts. The accounts are wrapped in {@link CodeOwner}s so that we can support different kind
+ * of code owners later (e.g. groups).
+ *
+ * <p>Code owners that cannot be resolved are filtered out:
+ *
+ * <ul>
+ *   <li>Emails that have a non-allowed email domain (see config parameter {@code
+ *       plugin.code-owners.allowedEmailDomain}).
+ *   <li>Emails for which no account exists: If no account exists, we cannot return any account.
+ *       It's fine to filter them out as it just means nobody can claim the ownership that was
+ *       assigned for this email.
+ *   <li>Emails for which multiple accounts exist: If an email is ambiguous it is treated the same
+ *       way as if there was no account for the email. That's because we can't tell which account
+ *       was meant to have the ownership. This behaviour is consistent with the behaviour in Gerrit
+ *       core that also treats ambiguous identifiers as non-resolveable.
+ * </ul>
+ *
+ * <p>Unless {@link CodeOwnerResolver#enforceVisibility} is {@code false} it is checked whether the
+ * {@link #user} or the calling user (if {@link #user} is unset) can see the accounts of the code
+ * owners and code owners whose accounts are not visible are filtered out.
+ *
+ * <p>In addition code owners that are referenced by a secondary email are filtered out if the
+ * {@link #user} or the calling user (if {@link #user} is unset) cannot see the secondary email:
+ *
+ * <ul>
+ *   <li>every user can see their own secondary emails
+ *   <li>users with the {@code Modify Account} global capability can see the secondary emails of all
+ *       accounts
+ * </ul>
+ *
+ * <p>Resolved code owners are cached within this class so that each email needs to be resolved only
+ * once. To take advantage of this caching callers should reuse {@link CodeOwnerResolver} instances
+ * where possible.
+ */
 public class CodeOwnerResolver {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -63,6 +111,7 @@
   private final PathCodeOwners.Factory pathCodeOwnersFactory;
   private final CodeOwnerMetrics codeOwnerMetrics;
   private final UnresolvedImportFormatter unresolvedImportFormatter;
+  private final TransientCodeOwnerCache transientCodeOwnerCache;
 
   // Enforce visibility by default.
   private boolean enforceVisibility = true;
@@ -82,7 +131,8 @@
       AccountControl.Factory accountControlFactory,
       PathCodeOwners.Factory pathCodeOwnersFactory,
       CodeOwnerMetrics codeOwnerMetrics,
-      UnresolvedImportFormatter unresolvedImportFormatter) {
+      UnresolvedImportFormatter unresolvedImportFormatter,
+      TransientCodeOwnerCache transientCodeOwnerCache) {
     this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
     this.permissionBackend = permissionBackend;
     this.currentUser = currentUser;
@@ -92,6 +142,7 @@
     this.pathCodeOwnersFactory = pathCodeOwnersFactory;
     this.codeOwnerMetrics = codeOwnerMetrics;
     this.unresolvedImportFormatter = unresolvedImportFormatter;
+    this.transientCodeOwnerCache = transientCodeOwnerCache;
   }
 
   /**
@@ -104,6 +155,7 @@
   public CodeOwnerResolver enforceVisibility(boolean enforceVisibility) {
     logger.atFine().log("enforceVisibility = %s", enforceVisibility);
     this.enforceVisibility = enforceVisibility;
+    transientCodeOwnerCache.clear();
     return this;
   }
 
@@ -125,6 +177,7 @@
   public CodeOwnerResolver forUser(IdentifiedUser user) {
     logger.atFine().log("user = %s", user.getLoggableName());
     this.user = user;
+    transientCodeOwnerCache.clear();
     return this;
   }
 
@@ -179,6 +232,7 @@
           pathCodeOwners.resolveCodeOwnerConfig();
       return resolve(
           pathCodeOwnersResult.get().getPathCodeOwners(),
+          pathCodeOwnersResult.get().getAnnotations(),
           pathCodeOwnersResult.get().unresolvedImports(),
           pathCodeOwnersResult.messages());
     }
@@ -200,11 +254,11 @@
    *
    * @param codeOwnerReferences the code owner references that should be resolved
    * @return the {@link CodeOwner} for the given code owner references
-   * @see #resolve(CodeOwnerReference)
    */
   public CodeOwnerResolverResult resolve(Set<CodeOwnerReference> codeOwnerReferences) {
     return resolve(
         codeOwnerReferences,
+        /* annotations= */ ImmutableMultimap.of(),
         /* unresolvedImports= */ ImmutableList.of(),
         /* pathCodeOwnersMessages= */ ImmutableList.of());
   }
@@ -212,14 +266,18 @@
   /**
    * Resolves the given {@link CodeOwnerReference}s to {@link CodeOwner}s.
    *
+   * <p>The accounts for the given {@link CodeOwnerReference}s are loaded from the account cache in
+   * parallel (via {@link AccountCache#get(Set)}.
+   *
    * @param codeOwnerReferences the code owner references that should be resolved
+   * @param annotationsByCodeOwnerReference annotations by code owner reference
    * @param unresolvedImports list of unresolved imports
    * @param pathCodeOwnersMessages messages that were collected when resolving path code owners
-   * @return the {@link CodeOwner} for the given code owner references
-   * @see #resolve(CodeOwnerReference)
+   * @return the resolved code owner references as a {@link CodeOwnerResolverResult}
    */
   private CodeOwnerResolverResult resolve(
       Set<CodeOwnerReference> codeOwnerReferences,
+      ImmutableMultimap<CodeOwnerReference, CodeOwnerAnnotation> annotationsByCodeOwnerReference,
       List<UnresolvedImport> unresolvedImports,
       ImmutableList<String> pathCodeOwnersMessages) {
     requireNonNull(codeOwnerReferences, "codeOwnerReferences");
@@ -227,78 +285,42 @@
     requireNonNull(pathCodeOwnersMessages, "pathCodeOwnersMessages");
 
     try (Timer0.Context ctx = codeOwnerMetrics.resolveCodeOwnerReferences.start()) {
+      ImmutableList.Builder<String> messageBuilder = ImmutableList.builder();
+      messageBuilder.addAll(pathCodeOwnersMessages);
+      unresolvedImports.forEach(
+          unresolvedImport ->
+              messageBuilder.add(unresolvedImportFormatter.format(unresolvedImport)));
+
       AtomicBoolean ownedByAllUsers = new AtomicBoolean(false);
       AtomicBoolean hasUnresolvedCodeOwners = new AtomicBoolean(false);
-      List<String> messages = new ArrayList<>(pathCodeOwnersMessages);
-      unresolvedImports.forEach(
-          unresolvedImport -> messages.add(unresolvedImportFormatter.format(unresolvedImport)));
-      ImmutableSet<CodeOwner> codeOwners =
-          codeOwnerReferences.stream()
-              .filter(
-                  codeOwnerReference -> {
-                    if (ALL_USERS_WILDCARD.equals(codeOwnerReference.email())) {
-                      ownedByAllUsers.set(true);
-                      return false;
-                    }
-                    return true;
-                  })
-              .map(this::resolveWithMessages)
-              .filter(
-                  resolveResult -> {
-                    messages.addAll(resolveResult.messages());
-                    if (!resolveResult.isPresent()) {
-                      hasUnresolvedCodeOwners.set(true);
-                      return false;
-                    }
-                    return true;
-                  })
-              .map(OptionalResultWithMessages::get)
-              .collect(toImmutableSet());
+      ImmutableMap<CodeOwner, ImmutableSet<CodeOwnerAnnotation>> codeOwnersWithAnnotations =
+          resolve(
+              messageBuilder,
+              ownedByAllUsers,
+              hasUnresolvedCodeOwners,
+              codeOwnerReferences,
+              annotationsByCodeOwnerReference);
+
+      ImmutableMultimap.Builder<CodeOwner, CodeOwnerAnnotation> annotationsByCodeOwner =
+          ImmutableMultimap.builder();
+      codeOwnersWithAnnotations.forEach(
+          (codeOwner, annotations) -> annotationsByCodeOwner.putAll(codeOwner, annotations));
+
       CodeOwnerResolverResult codeOwnerResolverResult =
           CodeOwnerResolverResult.create(
-              codeOwners,
+              codeOwnersWithAnnotations.keySet(),
+              annotationsByCodeOwner.build(),
               ownedByAllUsers.get(),
               hasUnresolvedCodeOwners.get(),
               !unresolvedImports.isEmpty(),
-              messages);
+              messageBuilder.build());
       logger.atFine().log("resolve result = %s", codeOwnerResolverResult);
       return codeOwnerResolverResult;
     }
   }
 
   /**
-   * Resolves a {@link CodeOwnerReference} to {@link CodeOwner}s.
-   *
-   * <p>Code owners are defined by {@link CodeOwnerReference}s (e.g. emails) that need to be
-   * resolved to accounts. The accounts are wrapped in {@link CodeOwner}s so that we can support
-   * different kind of code owners later (e.g. groups).
-   *
-   * <p>Code owners that cannot be resolved are filtered out:
-   *
-   * <ul>
-   *   <li>Emails that have a non-allowed email domain (see config parameter {@code
-   *       plugin.code-owners.allowedEmailDomain}).
-   *   <li>Emails for which no account exists: If no account exists, we cannot return any account.
-   *       It's fine to filter them out as it just means nobody can claim the ownership that was
-   *       assigned for this email.
-   *   <li>Emails for which multiple accounts exist: If an email is ambiguous it is treated the same
-   *       way as if there was no account for the email. That's because we can't tell which account
-   *       was meant to have the ownership. This behaviour is consistent with the behaviour in
-   *       Gerrit core that also treats ambiguous identifiers as non-resolveable.
-   * </ul>
-   *
-   * <p>This methods checks whether the {@link #user} or the calling user (if {@link #user} is
-   * unset) can see the accounts of the code owners and returns code owners whose accounts are
-   * visible.
-   *
-   * <p>In addition code owners that are referenced by a secondary email are only returned if the
-   * {@link #user} or the calling user (if {@link #user} is unset) can see the secondary email:
-   *
-   * <ul>
-   *   <li>every user can see the own secondary emails
-   *   <li>users with the {@code Modify Account} global capability can see the secondary emails of
-   *       all accounts
-   * </ul>
+   * Resolves a {@link CodeOwnerReference} to a {@link CodeOwner}.
    *
    * <p>This method does not resolve {@link CodeOwnerReference}s that assign the code ownership to
    * all user by using {@link #ALL_USERS_WILDCARD} as email.
@@ -313,40 +335,445 @@
     return resolveResult.result();
   }
 
+  /**
+   * Resolves a {@link CodeOwnerReference} to a {@link CodeOwner}.
+   *
+   * <p>This method does not resolve {@link CodeOwnerReference}s that assign the code ownership to
+   * all user by using {@link #ALL_USERS_WILDCARD} as email.
+   *
+   * <p>Debug messages are returned with the result.
+   *
+   * @param codeOwnerReference the code owner reference that should be resolved
+   * @return the result of resolving the given code owner reference with debug messages
+   */
   public OptionalResultWithMessages<CodeOwner> resolveWithMessages(
       CodeOwnerReference codeOwnerReference) {
-    String email = requireNonNull(codeOwnerReference, "codeOwnerReference").email();
+    requireNonNull(codeOwnerReference, "codeOwnerReference");
 
-    List<String> messages = new ArrayList<>();
-    messages.add(String.format("resolving code owner reference %s", codeOwnerReference));
-
-    OptionalResultWithMessages<Boolean> emailDomainAllowedResult = isEmailDomainAllowed(email);
-    messages.addAll(emailDomainAllowedResult.messages());
-    if (!emailDomainAllowedResult.get()) {
-      return OptionalResultWithMessages.createEmpty(messages);
+    if (CodeOwnerResolver.ALL_USERS_WILDCARD.equals(codeOwnerReference.email())) {
+      return OptionalResultWithMessages.createEmpty(
+          String.format(
+              "cannot resolve code owner email %s: no account with this email exists",
+              CodeOwnerResolver.ALL_USERS_WILDCARD));
     }
 
-    OptionalResultWithMessages<AccountState> activeAccountResult =
-        lookupActiveAccountForEmail(email);
-    messages.addAll(activeAccountResult.messages());
-    if (activeAccountResult.isEmpty()) {
+    ImmutableList.Builder<String> messageBuilder = ImmutableList.builder();
+    AtomicBoolean ownedByAllUsers = new AtomicBoolean(false);
+    AtomicBoolean hasUnresolvedCodeOwners = new AtomicBoolean(false);
+    ImmutableMap<CodeOwner, ImmutableSet<CodeOwnerAnnotation>> codeOwnersWithAnnotations =
+        resolve(
+            messageBuilder,
+            ownedByAllUsers,
+            hasUnresolvedCodeOwners,
+            ImmutableSet.of(codeOwnerReference),
+            /* annotations= */ ImmutableMultimap.of());
+    ImmutableList<String> messages = messageBuilder.build();
+    if (codeOwnersWithAnnotations.isEmpty()) {
       return OptionalResultWithMessages.createEmpty(messages);
     }
+    return OptionalResultWithMessages.create(
+        Iterables.getOnlyElement(codeOwnersWithAnnotations.keySet()), messages);
+  }
 
-    AccountState accountState = activeAccountResult.get();
+  /**
+   * Resolves the given {@link CodeOwnerReference}s to {@link CodeOwner}s.
+   *
+   * <p>The accounts for the given {@link CodeOwnerReference}s are loaded from the account cache in
+   * parallel (via {@link AccountCache#get(Set)}.
+   *
+   * @param messages a builder to which debug messages are added
+   * @param ownedByAllUsers a flag that is set if any of the given {@link CodeOwnerReference}s
+   *     assigns code ownership to all users
+   * @param hasUnresolvedCodeOwners a flag that is set any of the given {@link CodeOwnerReference}s
+   *     cannot be resolved
+   * @param codeOwnerReferences the code owner references that should be resolved
+   * @param annotations annotations by code owner reference
+   * @return map that maps the resolved {@link CodeOwner}s to their annotations (note: we cannot
+   *     return a {@code Multimap<CodeOwner, CodeOwnerAnnotation>} here since there may be code
+   *     owners without annotations and Multimap doesn't store keys for which no values are stored)
+   */
+  private ImmutableMap<CodeOwner, ImmutableSet<CodeOwnerAnnotation>> resolve(
+      ImmutableList.Builder<String> messages,
+      AtomicBoolean ownedByAllUsers,
+      AtomicBoolean hasUnresolvedCodeOwners,
+      Set<CodeOwnerReference> codeOwnerReferences,
+      ImmutableMultimap<CodeOwnerReference, CodeOwnerAnnotation> annotations) {
+    requireNonNull(codeOwnerReferences, "codeOwnerReferences");
+
+    ImmutableSet<String> emailsToResolve =
+        codeOwnerReferences.stream()
+            .map(CodeOwnerReference::email)
+            .filter(filterOutAllUsersWildCard(ownedByAllUsers))
+            .collect(toImmutableSet());
+
+    ImmutableMap<String, Optional<CodeOwner>> cachedCodeOwnersByEmail =
+        transientCodeOwnerCache.get(emailsToResolve);
+
+    ImmutableSet<String> emailsToLookup =
+        emailsToResolve.stream()
+            .filter(email -> !cachedCodeOwnersByEmail.containsKey(email))
+            .filter(filterOutEmailsWithNonAllowedDomains(messages))
+            .collect(toImmutableSet());
+
+    ImmutableMap<String, Collection<ExternalId>> externalIdsByEmail =
+        lookupExternalIds(messages, emailsToLookup);
+
+    Stream<Pair<String, AccountState>> accountsByEmail =
+        lookupAccounts(messages, externalIdsByEmail)
+            .map(removeInactiveAccounts(messages))
+            .filter(filterOutEmailsWithoutAccounts(messages))
+            .filter(filterOutAmbiguousEmails(messages))
+            .map(mapToOnlyAccount(messages));
+
     if (enforceVisibility) {
-      OptionalResultWithMessages<Boolean> isVisibleResult = isVisible(accountState, email);
-      messages.addAll(isVisibleResult.messages());
-      if (!isVisibleResult.get()) {
-        return OptionalResultWithMessages.createEmpty(messages);
-      }
+      accountsByEmail =
+          accountsByEmail
+              .filter(filterOutEmailsOfNonVisibleAccounts(messages))
+              .filter(filterOutNonVisibleSecondaryEmails(messages));
     } else {
       messages.add("code owner visibility is not checked");
     }
 
-    CodeOwner codeOwner = CodeOwner.create(accountState.account().id());
-    messages.add(String.format("resolved to account %s", codeOwner.accountId()));
-    return OptionalResultWithMessages.create(codeOwner, messages);
+    ImmutableMap<String, CodeOwner> codeOwnersByEmail =
+        accountsByEmail.map(mapToCodeOwner()).collect(toImmutableMap(Pair::key, Pair::value));
+
+    if (codeOwnersByEmail.keySet().size() < emailsToResolve.size()) {
+      hasUnresolvedCodeOwners.set(true);
+    }
+
+    Map<CodeOwner, Set<CodeOwnerAnnotation>> codeOwnersWithAnnotations = new HashMap<>();
+
+    // Merge code owners that have been newly resolved with code owners which have been looked up
+    // from cache and return them with their annotations.
+    Stream<Pair<String, CodeOwner>> newlyResolvedCodeOwnersStream =
+        codeOwnersByEmail.entrySet().stream().map(e -> Pair.of(e.getKey(), e.getValue()));
+    Stream<Pair<String, CodeOwner>> cachedCodeOwnersStream =
+        cachedCodeOwnersByEmail.entrySet().stream()
+            .filter(e -> e.getValue().isPresent())
+            .map(e -> Pair.of(e.getKey(), e.getValue().get()));
+    Streams.concat(newlyResolvedCodeOwnersStream, cachedCodeOwnersStream)
+        .forEach(
+            p -> {
+              ImmutableSet.Builder<CodeOwnerAnnotation> annotationBuilder = ImmutableSet.builder();
+
+              annotationBuilder.addAll(annotations.get(CodeOwnerReference.create(p.key())));
+
+              // annotations for the all users wildcard (aka '*') apply to all code owners
+              annotationBuilder.addAll(
+                  annotations.get(CodeOwnerReference.create(ALL_USERS_WILDCARD)));
+
+              if (!codeOwnersWithAnnotations.containsKey(p.value())) {
+                codeOwnersWithAnnotations.put(p.value(), new HashSet<>());
+              }
+              codeOwnersWithAnnotations.get(p.value()).addAll(annotationBuilder.build());
+            });
+
+    return codeOwnersWithAnnotations.entrySet().stream()
+        .collect(toImmutableMap(Map.Entry::getKey, e -> ImmutableSet.copyOf(e.getValue())));
+  }
+
+  /**
+   * Creates a predicate to filter out emails that are all users wild card (aka {@code *}).
+   *
+   * @param ownedByAllUsers flag that is set if any of the emails is the all users wild card (aka
+   *     {@code *})
+   */
+  private Predicate<String> filterOutAllUsersWildCard(AtomicBoolean ownedByAllUsers) {
+    return email -> {
+      if (ALL_USERS_WILDCARD.equals(email)) {
+        ownedByAllUsers.set(true);
+        return false;
+      }
+      return true;
+    };
+  }
+
+  /**
+   * Creates a predicate to filter out emails that have a non-allowed email domain.
+   *
+   * <p>Which emails domains are allowed is controlled via the plugin configuration (see {@link
+   * com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginGlobalConfigSnapshot#getAllowedEmailDomains()}
+   *
+   * @param messages builder to which debug messages are added
+   */
+  private Predicate<String> filterOutEmailsWithNonAllowedDomains(
+      ImmutableList.Builder<String> messages) {
+    return email -> {
+      boolean isEmailDomainAllowed = isEmailDomainAllowed(messages, email);
+      if (!isEmailDomainAllowed) {
+        transientCodeOwnerCache.cacheNonResolvable(email);
+      }
+      return isEmailDomainAllowed;
+    };
+  }
+
+  /**
+   * Whether the domain of the given email is allowed for code owners.
+   *
+   * <p>Which emails domains are allowed is controlled via the plugin configuration (see {@link
+   * com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginGlobalConfigSnapshot#getAllowedEmailDomains()}
+   *
+   * <p>Debug messages are returned with the result.
+   *
+   * @param email the email for which the domain should be checked
+   * @return a {@link OptionalResultWithMessages} that contains {@code true} if the domain of the
+   *     given email is allowed for code owners, otherwise {@link OptionalResultWithMessages} that
+   *     contains {@code false}
+   */
+  public OptionalResultWithMessages<Boolean> isEmailDomainAllowed(String email) {
+    ImmutableList.Builder<String> messages = ImmutableList.builder();
+    boolean isEmailDomainAllowed = isEmailDomainAllowed(messages, email);
+    return OptionalResultWithMessages.create(isEmailDomainAllowed, messages.build());
+  }
+
+  /**
+   * Whether the domain of the given email is allowed for code owners.
+   *
+   * <p>Which emails domains are allowed is controlled via the plugin configuration (see {@link
+   * com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginGlobalConfigSnapshot#getAllowedEmailDomains()}
+   *
+   * @param messages builder to which debug messages are added
+   * @param email the email for which the domain should be checked
+   * @return {@code true} if the domain of the given email is allowed for code owners, otherwise
+   *     {@code false}
+   */
+  private boolean isEmailDomainAllowed(ImmutableList.Builder<String> messages, String email) {
+    requireNonNull(messages, "messages");
+    requireNonNull(email, "email");
+
+    ImmutableSet<String> allowedEmailDomains =
+        codeOwnersPluginConfiguration.getGlobalConfig().getAllowedEmailDomains();
+    if (allowedEmailDomains.isEmpty()) {
+      messages.add("all domains are allowed");
+      return true;
+    }
+
+    if (email.equals(ALL_USERS_WILDCARD)) {
+      messages.add("all users wildcard is allowed");
+      return true;
+    }
+
+    int emailAtIndex = email.lastIndexOf('@');
+    if (emailAtIndex >= 0 && emailAtIndex < email.length() - 1) {
+      String emailDomain = email.substring(emailAtIndex + 1);
+      boolean isEmailDomainAllowed = allowedEmailDomains.contains(emailDomain);
+      messages.add(
+          String.format(
+              "domain %s of email %s is %s",
+              emailDomain, email, isEmailDomainAllowed ? "allowed" : "not allowed"));
+      return isEmailDomainAllowed;
+    }
+
+    messages.add(String.format("email %s has no domain", email));
+    return false;
+  }
+
+  /**
+   * Looks up the external IDs for the given emails.
+   *
+   * <p>Looks up all emails from the external ID cache at once, which is more efficient than looking
+   * up external IDs for emails one by one (see {@link ExternalIds#byEmails(String...)}).
+   *
+   * @param messages builder to which debug messages are added
+   * @param emails the emails for which the external IDs should be looked up
+   * @return external IDs per email
+   */
+  private ImmutableMap<String, Collection<ExternalId>> lookupExternalIds(
+      ImmutableList.Builder<String> messages, ImmutableSet<String> emails) {
+    try {
+      ImmutableMap<String, Collection<ExternalId>> extIdsByEmail =
+          externalIds.byEmails(emails.toArray(new String[0])).asMap();
+      emails.stream()
+          .filter(email -> !extIdsByEmail.containsKey(email))
+          .forEach(
+              email -> {
+                transientCodeOwnerCache.cacheNonResolvable(email);
+                messages.add(
+                    String.format(
+                        "cannot resolve code owner email %s: no account with this email exists",
+                        email));
+              });
+      return extIdsByEmail;
+    } catch (IOException e) {
+      throw new CodeOwnersInternalServerErrorException(
+          String.format("cannot resolve code owner emails: %s", emails), e);
+    }
+  }
+
+  /**
+   * Looks up the accounts for the given external IDs.
+   *
+   * <p>Looks up all accounts from the account cache at once, which is more efficient than looking
+   * up accounts one by one (see {@link AccountCache#get(Set)}).
+   *
+   * @param messages builder to which debug messages are added
+   * @param externalIdsByEmail external IDs for which the accounts should be looked up
+   * @return account states per email
+   */
+  private Stream<Pair<String, Collection<AccountState>>> lookupAccounts(
+      ImmutableList.Builder<String> messages,
+      ImmutableMap<String, Collection<ExternalId>> externalIdsByEmail) {
+    ImmutableSet<Account.Id> accountIds =
+        externalIdsByEmail.values().stream()
+            .flatMap(Collection::stream)
+            .map(ExternalId::accountId)
+            .collect(toImmutableSet());
+    Map<Account.Id, AccountState> accounts = accountCache.get(accountIds);
+    return externalIdsByEmail.entrySet().stream()
+        .map(
+            e ->
+                Pair.of(
+                    e.getKey(),
+                    e.getValue().stream()
+                        .map(
+                            extId -> {
+                              Account.Id accountId = extId.accountId();
+                              AccountState accountState = accounts.get(accountId);
+                              if (accountState == null) {
+                                messages.add(
+                                    String.format(
+                                        "cannot resolve account %s for email %s: account does not"
+                                            + " exists",
+                                        accountId, e.getKey()));
+                              }
+                              return accountState;
+                            })
+                        .filter(Objects::nonNull)
+                        .collect(toImmutableSet())));
+  }
+
+  /**
+   * Creates a map function that removes inactive accounts from a {@code Pair<String,
+   * Collection<AccountState>>}.
+   *
+   * <p>The pair which is provided as input to the function maps an email to a collection of account
+   * states.
+   *
+   * @param messages builder to which debug messages are added
+   */
+  private Function<Pair<String, Collection<AccountState>>, Pair<String, Collection<AccountState>>>
+      removeInactiveAccounts(ImmutableList.Builder<String> messages) {
+    return e -> Pair.of(e.key(), removeInactiveAccounts(messages, e.key(), e.value()));
+  }
+
+  /**
+   * Removes inactive accounts from the given collection of account states.
+   *
+   * @param messages builder to which debug messages are added
+   * @param email email to which the accounts belong
+   * @param accountStates the set of account states from which inactive accounts should be removed
+   * @return the account states that belong to active accounts
+   */
+  private ImmutableSet<AccountState> removeInactiveAccounts(
+      ImmutableList.Builder<String> messages,
+      String email,
+      Collection<AccountState> accountStates) {
+    return accountStates.stream()
+        .filter(
+            accountState -> {
+              if (!accountState.account().isActive()) {
+                messages.add(
+                    String.format(
+                        "ignoring inactive account %s for email %s",
+                        accountState.account().id(), email));
+                return false;
+              }
+              return true;
+            })
+        .collect(toImmutableSet());
+  }
+
+  /**
+   * Creates a predicate to filter out emails without accounts.
+   *
+   * <p>The pair which is provided as input to the predicate maps an email to a collection of
+   * account states. If the collection of account states is empty, the email is filtered out.
+   *
+   * @param messages builder to which debug messages are added
+   */
+  private Predicate<Pair<String, Collection<AccountState>>> filterOutEmailsWithoutAccounts(
+      ImmutableList.Builder<String> messages) {
+    return e -> {
+      if (e.value().isEmpty()) {
+        String email = e.key();
+        transientCodeOwnerCache.cacheNonResolvable(email);
+        messages.add(
+            String.format(
+                "cannot resolve code owner email %s: no active account with this email found",
+                email));
+        return false;
+      }
+      return true;
+    };
+  }
+
+  /**
+   * Creates a predicate to filter out ambiguous emails (emails that belong to multiple accounts).
+   *
+   * <p>The pair which is provided as input to the predicate maps an email to a collection of
+   * account states. If the collection of account states contains more than 1 entry, the email is
+   * filtered out.
+   *
+   * @param messages builder to which debug messages are added
+   */
+  private Predicate<Pair<String, Collection<AccountState>>> filterOutAmbiguousEmails(
+      ImmutableList.Builder<String> messages) {
+    return e -> {
+      if (e.value().size() > 1) {
+        String email = e.key();
+        transientCodeOwnerCache.cacheNonResolvable(email);
+        messages.add(
+            String.format("cannot resolve code owner email %s: email is ambiguous", email));
+        return false;
+      }
+      return true;
+    };
+  }
+
+  /**
+   * Creates a map function that maps a {@code Pair<String, Collection<AccountState>>} to a {@code
+   * Pair<String, AccountState>}.
+   *
+   * <p>The pair which is provided as input to the function maps an email to a collection of account
+   * states, which must contain exactly one entry. As output the function returns a pair that maps
+   * the email to the only account state.
+   *
+   * @param messages builder to which debug messages are added
+   */
+  private Function<Pair<String, Collection<AccountState>>, Pair<String, AccountState>>
+      mapToOnlyAccount(ImmutableList.Builder<String> messages) {
+    return e -> {
+      String email = e.key();
+      AccountState accountState = Iterables.getOnlyElement(e.value());
+      messages.add(
+          String.format("resolved email %s to account %s", email, accountState.account().id()));
+      return Pair.of(email, accountState);
+    };
+  }
+
+  /**
+   * Creates a predicate to filter out emails that belong to non-visible accounts.
+   *
+   * @param messages builder to which debug messages are added
+   */
+  private Predicate<Pair<String, AccountState>> filterOutEmailsOfNonVisibleAccounts(
+      ImmutableList.Builder<String> messages) {
+    return e -> {
+      String email = e.key();
+      AccountState accountState = e.value();
+      if (!canSee(accountState)) {
+        transientCodeOwnerCache.cacheNonResolvable(email);
+        messages.add(
+            String.format(
+                "cannot resolve code owner email %s: account %s is not visible to user %s",
+                email,
+                accountState.account().id(),
+                user != null ? user.getLoggableName() : currentUser.get().getLoggableName()));
+        return false;
+      }
+
+      return true;
+    };
   }
 
   /** Whether the given account can be seen. */
@@ -357,100 +784,9 @@
   }
 
   /**
-   * Looks up an email and returns the ID of the active account to which it belongs.
+   * Creates a predicate to filter out non-visible secondary emails.
    *
-   * <p>If the email is ambiguous (it belongs to multiple active accounts) it is considered as
-   * non-resolvable and empty result is returned.
-   *
-   * @param email the email that should be looked up
-   * @return the ID of the account to which the email belongs if was found
-   */
-  private OptionalResultWithMessages<AccountState> lookupActiveAccountForEmail(String email) {
-    ImmutableSet<ExternalId> extIds;
-    try {
-      extIds = externalIds.byEmail(email);
-    } catch (IOException e) {
-      throw new CodeOwnersInternalServerErrorException(
-          String.format("cannot resolve code owner email %s", email), e);
-    }
-
-    if (extIds.isEmpty()) {
-      return OptionalResultWithMessages.createEmpty(
-          String.format(
-              "cannot resolve code owner email %s: no account with this email exists", email));
-    }
-
-    List<String> messages = new ArrayList<>();
-    OptionalResultWithMessages<ImmutableSet<AccountState>> activeAccountsResult =
-        lookupActiveAccounts(extIds, email);
-    ImmutableSet<AccountState> activeAccounts = activeAccountsResult.get();
-    messages.addAll(activeAccountsResult.messages());
-
-    if (activeAccounts.isEmpty()) {
-      messages.add(
-          String.format(
-              "cannot resolve code owner email %s: no active account with this email found",
-              email));
-      return OptionalResultWithMessages.createEmpty(messages);
-    }
-
-    if (activeAccounts.size() > 1) {
-      messages.add(String.format("cannot resolve code owner email %s: email is ambiguous", email));
-      return OptionalResultWithMessages.createEmpty(messages);
-    }
-
-    return OptionalResultWithMessages.create(Iterables.getOnlyElement(activeAccounts));
-  }
-
-  private OptionalResultWithMessages<ImmutableSet<AccountState>> lookupActiveAccounts(
-      ImmutableSet<ExternalId> extIds, String email) {
-    ImmutableSet<OptionalResultWithMessages<AccountState>> accountStateResults =
-        extIds.stream()
-            .map(externalId -> lookupAccount(externalId.accountId(), externalId.email()))
-            .collect(toImmutableSet());
-
-    ImmutableSet.Builder<AccountState> activeAccounts = ImmutableSet.builder();
-    List<String> messages = new ArrayList<>();
-    for (OptionalResultWithMessages<AccountState> accountStateResult : accountStateResults) {
-      messages.addAll(accountStateResult.messages());
-      if (accountStateResult.isPresent()) {
-        AccountState accountState = accountStateResult.get();
-        if (accountState.account().isActive()) {
-          activeAccounts.add(accountState);
-        } else {
-          messages.add(
-              String.format(
-                  "account %s for email %s is inactive", accountState.account().id(), email));
-        }
-      }
-    }
-    return OptionalResultWithMessages.create(activeAccounts.build(), messages);
-  }
-
-  /**
-   * Looks up an account by account ID and returns the corresponding {@link AccountState} if it is
-   * found.
-   *
-   * @param accountId the ID of the account that should be looked up
-   * @param email the email that was resolved to the account ID
-   * @return the {@link AccountState} of the account with the given account ID, if it exists
-   */
-  private OptionalResultWithMessages<AccountState> lookupAccount(
-      Account.Id accountId, String email) {
-    Optional<AccountState> accountState = accountCache.get(accountId);
-    if (!accountState.isPresent()) {
-      return OptionalResultWithMessages.createEmpty(
-          String.format(
-              "cannot resolve account %s for email %s: account does not exists", accountId, email));
-    }
-    return OptionalResultWithMessages.create(accountState.get());
-  }
-
-  /**
-   * Checks whether the given account and email are visible to the {@link #user} or the calling user
-   * (if {@link #user} is unset).
-   *
-   * <p>If the email is a secondary email it is only visible if
+   * <p>A secondary email is only visible if
    *
    * <ul>
    *   <li>it is owned by the {@link #user} or the calling user (if {@link #user} is unset)
@@ -458,44 +794,42 @@
    *       Modify Account} global capability
    * </ul>
    *
-   * @param accountState the account for which it should be checked whether it's visible to the user
-   * @param email email that was used to reference the account
-   * @return {@code true} if the given account and email are visible to the user, otherwise {@code
-   *     false}
+   * @param messages builder to which debug messages are added
    */
-  private OptionalResultWithMessages<Boolean> isVisible(AccountState accountState, String email) {
-    if (!canSee(accountState)) {
-      return OptionalResultWithMessages.create(
-          false,
-          String.format(
-              "cannot resolve code owner email %s: account %s is not visible to user %s",
-              email,
-              accountState.account().id(),
-              user != null ? user.getLoggableName() : currentUser.get().getLoggableName()));
-    }
-
-    if (!email.equals(accountState.account().preferredEmail())) {
-      // the email is a secondary email of the account
+  private Predicate<Pair<String, AccountState>> filterOutNonVisibleSecondaryEmails(
+      ImmutableList.Builder<String> messages) {
+    return e -> {
+      String email = e.key();
+      AccountState accountState = e.value();
+      if (email.equals(accountState.account().preferredEmail())) {
+        // the email is a primary email of the account
+        messages.add(
+            String.format(
+                "account %s is visible to user %s",
+                accountState.account().id(),
+                user != null ? user.getLoggableName() : currentUser.get().getLoggableName()));
+        return true;
+      }
 
       if (user != null) {
         if (user.hasEmailAddress(email)) {
-          return OptionalResultWithMessages.create(
-              true,
+          messages.add(
               String.format(
                   "email %s is visible to user %s: email is a secondary email that is owned by this"
                       + " user",
                   email, user.getLoggableName()));
+          return true;
         }
       } else if (currentUser.get().isIdentifiedUser()
           && currentUser.get().asIdentifiedUser().hasEmailAddress(email)) {
         // it's a secondary email of the calling user, users can always see their own secondary
         // emails
-        return OptionalResultWithMessages.create(
-            true,
+        messages.add(
             String.format(
                 "email %s is visible to the calling user %s: email is a secondary email that is"
                     + " owned by this user",
                 email, currentUser.get().getLoggableName()));
+        return true;
       }
 
       // the email is a secondary email of another account, check if the user can see secondary
@@ -503,80 +837,61 @@
       try {
         if (user != null) {
           if (!permissionBackend.user(user).test(GlobalPermission.MODIFY_ACCOUNT)) {
-            return OptionalResultWithMessages.create(
-                false,
+            transientCodeOwnerCache.cacheNonResolvable(email);
+            messages.add(
                 String.format(
                     "cannot resolve code owner email %s: account %s is referenced by secondary email"
                         + " but user %s cannot see secondary emails",
                     email, accountState.account().id(), user.getLoggableName()));
+            return false;
           }
-          return OptionalResultWithMessages.create(
-              true,
+          messages.add(
               String.format(
                   "resolved code owner email %s: account %s is referenced by secondary email"
                       + " and user %s can see secondary emails",
                   email, accountState.account().id(), user.getLoggableName()));
+          return true;
         } else if (!permissionBackend.currentUser().test(GlobalPermission.MODIFY_ACCOUNT)) {
-          return OptionalResultWithMessages.create(
-              false,
+          transientCodeOwnerCache.cacheNonResolvable(email);
+          messages.add(
               String.format(
                   "cannot resolve code owner email %s: account %s is referenced by secondary email"
                       + " but the calling user %s cannot see secondary emails",
                   email, accountState.account().id(), currentUser.get().getLoggableName()));
+          return false;
         } else {
-          return OptionalResultWithMessages.create(
-              true,
+          messages.add(
               String.format(
                   "resolved code owner email %s: account %s is referenced by secondary email"
                       + " and the calling user %s can see secondary emails",
                   email, accountState.account().id(), currentUser.get().getLoggableName()));
+          return true;
         }
-      } catch (PermissionBackendException e) {
+      } catch (PermissionBackendException ex) {
         throw new CodeOwnersInternalServerErrorException(
             String.format(
                 "failed to test the %s global capability", GlobalPermission.MODIFY_ACCOUNT),
-            e);
+            ex);
       }
-    }
-    return OptionalResultWithMessages.create(
-        true,
-        String.format(
-            "account %s is visible to user %s",
-            accountState.account().id(),
-            user != null ? user.getLoggableName() : currentUser.get().getLoggableName()));
+    };
   }
 
   /**
-   * Whether the domain of the given email is allowed for code owners.
+   * Creates a map function that maps a {@code Pair<String, AccountState>} to a code owner.
    *
-   * @param email the email for which the domain should be checked
-   * @return {@code true} if the domain of the given email is allowed for code owners, otherwise
-   *     {@code false}
+   * <p>The pair which is provided as input to the function maps an email to an account states.
    */
-  public OptionalResultWithMessages<Boolean> isEmailDomainAllowed(String email) {
-    requireNonNull(email, "email");
+  private Function<Pair<String, AccountState>, Pair<String, CodeOwner>> mapToCodeOwner() {
+    return e -> {
+      String email = e.key();
+      CodeOwner codeOwner = CodeOwner.create(e.value().account().id());
+      transientCodeOwnerCache.cache(email, codeOwner);
+      return Pair.of(email, codeOwner);
+    };
+  }
 
-    ImmutableSet<String> allowedEmailDomains =
-        codeOwnersPluginConfiguration.getGlobalConfig().getAllowedEmailDomains();
-    if (allowedEmailDomains.isEmpty()) {
-      return OptionalResultWithMessages.create(true, "all domains are allowed");
-    }
-
-    if (email.equals(ALL_USERS_WILDCARD)) {
-      return OptionalResultWithMessages.create(true, "all users wildcard is allowed");
-    }
-
-    int emailAtIndex = email.lastIndexOf('@');
-    if (emailAtIndex >= 0 && emailAtIndex < email.length() - 1) {
-      String emailDomain = email.substring(emailAtIndex + 1);
-      boolean isEmailDomainAllowed = allowedEmailDomains.contains(emailDomain);
-      return OptionalResultWithMessages.create(
-          isEmailDomainAllowed,
-          String.format(
-              "domain %s of email %s is %s",
-              emailDomain, email, isEmailDomainAllowed ? "allowed" : "not allowed"));
-    }
-
-    return OptionalResultWithMessages.create(false, String.format("email %s has no domain", email));
+  /** Returns the counters for resolutions and cache reads of code owners. */
+  public TransientCodeOwnerCache.Counters getCodeOwnerCounters() {
+    return transientCodeOwnerCache.getCounters();
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResult.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResult.java
index 0744e5b..7dd9e96 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResult.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResult.java
@@ -19,6 +19,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import java.util.List;
@@ -39,6 +40,9 @@
     return codeOwners().stream().map(CodeOwner::accountId).collect(toImmutableSet());
   }
 
+  /** Returns the annotations for the {@link #codeOwners()}. */
+  public abstract ImmutableMultimap<CodeOwner, CodeOwnerAnnotation> annotations();
+
   /**
    * Whether the code ownership was assigned to all users by using the {@link
    * CodeOwnerResolver#ALL_USERS_WILDCARD}.
@@ -69,6 +73,7 @@
   public final String toString() {
     return MoreObjects.toStringHelper(this)
         .add("codeOwners", codeOwners())
+        .add("annotations", annotations())
         .add("ownedByAllUsers", ownedByAllUsers())
         .add("hasUnresolvedCodeOwners", hasUnresolvedCodeOwners())
         .add("hasUnresolvedImports", hasUnresolvedImports())
@@ -79,12 +84,14 @@
   /** Creates a {@link CodeOwnerResolverResult} instance. */
   public static CodeOwnerResolverResult create(
       ImmutableSet<CodeOwner> codeOwners,
+      ImmutableMultimap<CodeOwner, CodeOwnerAnnotation> annotations,
       boolean ownedByAllUsers,
       boolean hasUnresolvedCodeOwners,
       boolean hasUnresolvedImports,
       List<String> messages) {
     return new AutoValue_CodeOwnerResolverResult(
         codeOwners,
+        annotations,
         ownedByAllUsers,
         hasUnresolvedCodeOwners,
         hasUnresolvedImports,
@@ -95,6 +102,7 @@
   public static CodeOwnerResolverResult createEmpty() {
     return new AutoValue_CodeOwnerResolverResult(
         /* codeOwners= */ ImmutableSet.of(),
+        /* annotations= */ ImmutableMultimap.of(),
         /* ownedByAllUsers= */ false,
         /* hasUnresolvedCodeOwners= */ false,
         /* hasUnresolvedImports= */ false,
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScore.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScore.java
index 523c2ee..40a4a3b 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScore.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScore.java
@@ -53,7 +53,21 @@
    * <p>The IS_REVIEWER score has a higher weight than the {@link #DISTANCE} score so that it takes
    * precedence and code owners that are reviewers are always returned first.
    */
-  IS_REVIEWER(Kind.GREATER_VALUE_IS_BETTER, /* weight= */ 2, /* maxValue= */ 1);
+  IS_REVIEWER(Kind.GREATER_VALUE_IS_BETTER, /* weight= */ 2, /* maxValue= */ 1),
+
+  /**
+   * Score to take into account when a user is explicitly mentioned as a code owner
+   *
+   * <p>Users that are explicitly mentioned as code owner in a code owner config file get scored
+   * with 1 (see {@link #IS_EXPLICITLY_MENTIONED_SCORING_VALUE}), while users that are not
+   * explicitly mentioned as code owners in the code owner config file, and are only code owners
+   * because the code ownership is assigned to all users aka {@code *}, get scored with 0 (see
+   * {@link #NOT_EXPLICITLY_MENTIONED_SCORING_VALUE}).
+   *
+   * <p>The IS_EXPLICITLY_MENTIONED score has a lower weight than the {@link #DISTANCE} score so
+   * that the {@link #DISTANCE} score takes precedence.
+   */
+  IS_EXPLICITLY_MENTIONED(Kind.GREATER_VALUE_IS_BETTER, /* weight= */ 0.5, /* maxValue= */ 1);
 
   /**
    * Scoring value for the {@link #IS_REVIEWER} score for users that are not a reviewer of the
@@ -67,6 +81,19 @@
   public static int IS_REVIEWER_SCORING_VALUE = 1;
 
   /**
+   * Scoring value for the {@link #IS_EXPLICITLY_MENTIONED} score for users that are not explicitly
+   * mentioned as code owners in the code owner config file and are only code owners because the
+   * code ownership is assigned to all users aka {@code *}.
+   */
+  public static int NOT_EXPLICITLY_MENTIONED_SCORING_VALUE = 0;
+
+  /**
+   * Scoring value for the {@link #IS_EXPLICITLY_MENTIONED} score for users that are explicitly
+   * mentioned as code owners in the code owner config file.
+   */
+  public static int IS_EXPLICITLY_MENTIONED_SCORING_VALUE = 1;
+
+  /**
    * Score kind.
    *
    * <p>Whether a greater value as scoring is better than a lower value ({@code
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSet.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSet.java
index 02b6524..43f49d5 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSet.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSet.java
@@ -19,8 +19,10 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ImmutableSet;
 import java.util.Arrays;
+import java.util.Set;
 
 /**
  * A code owner set defines a set of code owners for a set of path expressions.
@@ -71,6 +73,9 @@
   /** Gets the code owners of this code owner set. */
   public abstract ImmutableSet<CodeOwnerReference> codeOwners();
 
+  /** Gets the annotations of the {@link #codeOwners()}. */
+  public abstract ImmutableMultimap<CodeOwnerReference, CodeOwnerAnnotation> annotations();
+
   /**
    * Creates a builder from this code owner set.
    *
@@ -199,6 +204,37 @@
       return this;
     }
 
+    /** Gets a builder to add code owner annotations. */
+    abstract ImmutableMultimap.Builder<CodeOwnerReference, CodeOwnerAnnotation>
+        annotationsBuilder();
+
+    /**
+     * Adds an annotation for a code owner.
+     *
+     * @param email email of the code owner for which the annotation should be added
+     * @param annotation annotation that should be added
+     * @return the Builder instance for chaining calls
+     */
+    public Builder addAnnotation(String email, CodeOwnerAnnotation annotation) {
+      return addAnnotations(CodeOwnerReference.create(email), ImmutableSet.of(annotation));
+    }
+
+    /**
+     * Adds annotations for a code owner.
+     *
+     * @param codeOwnerReference reference to the code owner for which the annotations should be
+     *     added
+     * @param annotations annotations that should be added
+     * @return the Builder instance for chaining calls
+     */
+    public Builder addAnnotations(
+        CodeOwnerReference codeOwnerReference, Set<CodeOwnerAnnotation> annotations) {
+      requireNonNull(codeOwnerReference, "codeOwnerReference");
+      requireNonNull(annotations, "annotations");
+      annotationsBuilder().putAll(codeOwnerReference, annotations);
+      return this;
+    }
+
     /**
      * Adds a code owner for the given email.
      *
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSubmitRule.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSubmitRule.java
index df96cc4..4053413 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSubmitRule.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSubmitRule.java
@@ -25,10 +25,10 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.InvalidPluginConfigurationException;
 import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.inject.AbstractModule;
@@ -43,7 +43,7 @@
 class CodeOwnerSubmitRule implements SubmitRule {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static class Module extends AbstractModule {
+  public static class CodeOwnerSubmitRuleModule extends AbstractModule {
     @Override
     public void configure() {
       bind(SubmitRule.class)
@@ -103,13 +103,13 @@
               "Couldn't evaluate code owner statuses for patch set %d of change %d.",
               changeData.currentPatchSet().id().get(), changeData.change().getId().get()));
       return Optional.of(notReady());
-    } catch (Throwable t) {
+    } catch (Exception e) {
       // Whether the exception should be treated as RULE_ERROR.
       // RULE_ERROR must only be returned if the exception is caused by user misconfiguration (e.g.
       // an invalid OWNERS file), but not for internal server errors.
       boolean isRuleError = false;
 
-      String cause = t.getClass().getSimpleName();
+      String cause = e.getClass().getSimpleName();
       String errorMessage = "Failed to evaluate code owner statuses";
       if (changeData != null) {
         errorMessage +=
@@ -118,13 +118,20 @@
                 changeData.currentPatchSet().id().get(), changeData.change().getId().get());
       }
       Optional<InvalidPathException> invalidPathException =
-          CodeOwnersExceptionHook.getInvalidPathException(t);
+          CodeOwnersExceptionHook.getInvalidPathException(e);
+      Optional<InvalidPluginConfigurationException> invalidPluginConfigurationException =
+          CodeOwnersExceptionHook.getInvalidPluginConfigurationCause(e);
       Optional<InvalidCodeOwnerConfigException> invalidCodeOwnerConfigException =
-          CodeOwners.getInvalidCodeOwnerConfigCause(t);
+          CodeOwners.getInvalidCodeOwnerConfigCause(e);
       if (invalidPathException.isPresent()) {
         isRuleError = true;
         cause = "invalid_path";
         errorMessage += String.format(" (cause: %s)", invalidPathException.get().getMessage());
+      } else if (invalidPluginConfigurationException.isPresent()) {
+        isRuleError = true;
+        cause = "invalid_plugin_configuration";
+        errorMessage +=
+            String.format(" (cause: %s)", invalidPluginConfigurationException.get().getMessage());
       } else if (invalidCodeOwnerConfigException.isPresent()) {
         isRuleError = true;
         codeOwnerMetrics.countInvalidCodeOwnerConfigFiles.increment(
@@ -153,13 +160,12 @@
         logger.atWarning().log(errorMessage);
         return Optional.of(ruleError(errorMessage));
       }
-      throw new CodeOwnersInternalServerErrorException(errorMessage, t);
+      throw new CodeOwnersInternalServerErrorException(errorMessage, e);
     }
   }
 
   private SubmitRecord getSubmitRecord(ChangeNotes changeNotes)
-      throws ResourceConflictException, IOException, PatchListNotAvailableException,
-          DiffNotAvailableException {
+      throws ResourceConflictException, IOException, DiffNotAvailableException {
     requireNonNull(changeNotes, "changeNotes");
     return codeOwnerApprovalCheck.isSubmittable(changeNotes) ? ok() : notReady();
   }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersExceptionHook.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersExceptionHook.java
index 468f5bb..c7da676 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersExceptionHook.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersExceptionHook.java
@@ -119,7 +119,7 @@
     return getInvalidPluginConfigurationCause(throwable).isPresent();
   }
 
-  private static Optional<InvalidPluginConfigurationException> getInvalidPluginConfigurationCause(
+  public static Optional<InvalidPluginConfigurationException> getInvalidPluginConfigurationCause(
       Throwable throwable) {
     return getCause(InvalidPluginConfigurationException.class, throwable);
   }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersExperimentFeaturesConstants.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersExperimentFeaturesConstants.java
deleted file mode 100644
index a0e82ad..0000000
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersExperimentFeaturesConstants.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2021 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.plugins.codeowners.backend;
-
-/**
- * Constants for {@link com.google.gerrit.server.experiments.ExperimentFeatures} in the code-owners
- * plugin.
- */
-public final class CodeOwnersExperimentFeaturesConstants {
-  /**
-   * Whether {@link com.google.gerrit.server.patch.DiffOperations}, and thus the diff cache, should
-   * be used to get changed files, instead of computing the changed files on our own.
-   *
-   * @see ChangedFiles#getOrCompute(com.google.gerrit.entities.Project.NameKey,
-   *     org.eclipse.jgit.lib.ObjectId)
-   */
-  public static final String USE_DIFF_CACHE =
-      "GerritBackendRequestFeature__code_owners_use_diff_cache";
-
-  /**
-   * Private constructor to prevent instantiation of this class.
-   *
-   * <p>The class only contains static fields, hence the class never needs to be instantiated.
-   */
-  private CodeOwnersExperimentFeaturesConstants() {}
-}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnAddReviewer.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnAddReviewer.java
index 1aa9660..a694ce5 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnAddReviewer.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnAddReviewer.java
@@ -20,29 +20,31 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.events.ReviewerAddedListener;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginProjectConfigSnapshot;
 import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
 import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gerrit.server.util.AccountTemplateUtil;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.nio.file.Path;
+import java.sql.Timestamp;
 import java.util.List;
 import java.util.Optional;
 import java.util.stream.Stream;
@@ -60,31 +62,34 @@
   private static final String TAG_ADD_REVIEWER =
       ChangeMessagesUtil.AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "code-owners:addReviewer";
 
+  private final WorkQueue workQueue;
+  private final OneOffRequestContext oneOffRequestContext;
   private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
   private final CodeOwnerApprovalCheck codeOwnerApprovalCheck;
   private final Provider<CurrentUser> userProvider;
   private final RetryHelper retryHelper;
   private final ChangeNotes.Factory changeNotesFactory;
-  private final AccountCache accountCache;
   private final ChangeMessagesUtil changeMessageUtil;
   private final CodeOwnerMetrics codeOwnerMetrics;
 
   @Inject
   CodeOwnersOnAddReviewer(
+      WorkQueue workQueue,
+      OneOffRequestContext oneOffRequestContext,
       CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
       CodeOwnerApprovalCheck codeOwnerApprovalCheck,
       Provider<CurrentUser> userProvider,
       RetryHelper retryHelper,
       ChangeNotes.Factory changeNotesFactory,
-      AccountCache accountCache,
       ChangeMessagesUtil changeMessageUtil,
       CodeOwnerMetrics codeOwnerMetrics) {
+    this.workQueue = workQueue;
+    this.oneOffRequestContext = oneOffRequestContext;
     this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
     this.codeOwnerApprovalCheck = codeOwnerApprovalCheck;
     this.userProvider = userProvider;
     this.retryHelper = retryHelper;
     this.changeNotesFactory = changeNotesFactory;
-    this.accountCache = accountCache;
     this.changeMessageUtil = changeMessageUtil;
     this.codeOwnerMetrics = codeOwnerMetrics;
   }
@@ -101,15 +106,60 @@
       return;
     }
 
-    try (Timer0.Context ctx = codeOwnerMetrics.addChangeMessageOnAddReviewer.start()) {
+    CurrentUser user = userProvider.get();
+    if (codeOwnersConfig.enableAsyncMessageOnAddReviewer()) {
+      // post change message asynchronously to avoid adding latency to PostReviewers and PostReview
+      logger.atFine().log("schedule asynchronous posting of the change message");
+      @SuppressWarnings("unused")
+      WorkQueue.Task<?> possiblyIgnoredError =
+          (WorkQueue.Task<?>)
+              workQueue
+                  .getDefaultQueue()
+                  .submit(
+                      () -> {
+                        try (ManualRequestContext ignored =
+                            oneOffRequestContext.openAs(user.getAccountId())) {
+                          postChangeMessage(
+                              user,
+                              projectName,
+                              changeId,
+                              event.getReviewers(),
+                              event.getWhen(),
+                              maxPathsInChangeMessages,
+                              /* asynchronous= */ true);
+                        }
+                      });
+    } else {
+      logger.atFine().log("post change message synchronously");
+      postChangeMessage(
+          user,
+          projectName,
+          changeId,
+          event.getReviewers(),
+          event.getWhen(),
+          maxPathsInChangeMessages,
+          /* asynchronous= */ false);
+    }
+  }
+
+  private void postChangeMessage(
+      CurrentUser currentUser,
+      Project.NameKey projectName,
+      Change.Id changeId,
+      List<AccountInfo> reviewers,
+      Timestamp when,
+      int maxPathsInChangeMessages,
+      boolean asynchronous) {
+    try (Timer1.Context<String> ctx =
+        codeOwnerMetrics.addChangeMessageOnAddReviewer.start(
+            asynchronous ? "asynchronous" : "synchronous")) {
       retryHelper
           .changeUpdate(
               "addCodeOwnersMessageOnAddReviewer",
               updateFactory -> {
                 try (BatchUpdate batchUpdate =
-                    updateFactory.create(projectName, userProvider.get(), TimeUtil.nowTs())) {
-                  batchUpdate.addOp(
-                      changeId, new Op(event.getReviewers(), maxPathsInChangeMessages));
+                    updateFactory.create(projectName, currentUser, when)) {
+                  batchUpdate.addOp(changeId, new Op(reviewers, maxPathsInChangeMessages));
                   batchUpdate.execute();
                 }
                 return null;
@@ -149,9 +199,7 @@
         return false;
       }
 
-      ChangeMessage changeMessage = ChangeMessagesUtil.newMessage(ctx, message, TAG_ADD_REVIEWER);
-      changeMessageUtil.addChangeMessage(
-          ctx.getUpdate(ctx.getChange().currentPatchSetId()), changeMessage);
+      changeMessageUtil.setChangeMessage(ctx, message, TAG_ADD_REVIEWER);
       return true;
     }
 
@@ -163,12 +211,13 @@
       try {
         // limit + 1, so that we can show an indicator if there are more than <limit> files.
         ownedPaths =
-            codeOwnerApprovalCheck.getOwnedPaths(
-                changeNotes,
-                changeNotes.getCurrentPatchSet(),
-                reviewerAccountId,
-                /* start= */ 0,
-                limit + 1);
+            OwnedChangedFile.getOwnedPaths(
+                codeOwnerApprovalCheck.getOwnedPaths(
+                    changeNotes,
+                    changeNotes.getCurrentPatchSet(),
+                    reviewerAccountId,
+                    /* start= */ 0,
+                    limit + 1));
       } catch (RestApiException e) {
         logger.atFine().withCause(e).log(
             "Couldn't compute owned paths of change %s for account %s",
@@ -181,13 +230,11 @@
         return Optional.empty();
       }
 
-      Account reviewerAccount = accountCache.getEvenIfMissing(reviewerAccountId).account();
-
       StringBuilder message = new StringBuilder();
       message.append(
           String.format(
-              "%s who was added as reviewer owns the following files:\n",
-              reviewerAccount.getName()));
+              "%s, who was added as reviewer owns the following files:\n",
+              AccountTemplateUtil.getAccountTemplate(reviewerAccountId)));
 
       if (ownedPaths.size() <= limit) {
         appendPaths(message, ownedPaths.stream());
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/FileCodeOwnerStatus.java b/java/com/google/gerrit/plugins/codeowners/backend/FileCodeOwnerStatus.java
index 330917d..86fc426 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/FileCodeOwnerStatus.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/FileCodeOwnerStatus.java
@@ -17,6 +17,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.plugins.codeowners.common.ChangedFile;
 import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
 import com.google.gerrit.plugins.codeowners.util.JgitPath;
@@ -63,45 +64,70 @@
   }
 
   public static FileCodeOwnerStatus addition(String path, CodeOwnerStatus codeOwnerStatus) {
+    return addition(path, codeOwnerStatus, /* reason= */ null);
+  }
+
+  public static FileCodeOwnerStatus addition(
+      String path, CodeOwnerStatus codeOwnerStatus, @Nullable String reason) {
     requireNonNull(path, "path");
 
-    return addition(JgitPath.of(path).getAsAbsolutePath(), codeOwnerStatus);
+    return addition(JgitPath.of(path).getAsAbsolutePath(), codeOwnerStatus, reason);
   }
 
   public static FileCodeOwnerStatus addition(Path path, CodeOwnerStatus codeOwnerStatus) {
+    return addition(path, codeOwnerStatus, /* reason= */ null);
+  }
+
+  public static FileCodeOwnerStatus addition(
+      Path path, CodeOwnerStatus codeOwnerStatus, @Nullable String reason) {
     requireNonNull(path, "path");
     requireNonNull(codeOwnerStatus, "codeOwnerStatus");
 
     return create(
         ChangedFile.addition(path),
-        Optional.of(PathCodeOwnerStatus.create(path, codeOwnerStatus)),
+        Optional.of(PathCodeOwnerStatus.create(path, codeOwnerStatus, reason)),
         Optional.empty());
   }
 
   public static FileCodeOwnerStatus modification(Path path, CodeOwnerStatus codeOwnerStatus) {
+    return modification(path, codeOwnerStatus, /* reason= */ null);
+  }
+
+  public static FileCodeOwnerStatus modification(
+      Path path, CodeOwnerStatus codeOwnerStatus, @Nullable String reason) {
     requireNonNull(path, "path");
     requireNonNull(codeOwnerStatus, "codeOwnerStatus");
 
     return create(
         ChangedFile.modification(path),
-        Optional.of(PathCodeOwnerStatus.create(path, codeOwnerStatus)),
+        Optional.of(PathCodeOwnerStatus.create(path, codeOwnerStatus, reason)),
         Optional.empty());
   }
 
   public static FileCodeOwnerStatus deletion(String path, CodeOwnerStatus codeOwnerStatus) {
-    requireNonNull(path, "path");
-
-    return deletion(JgitPath.of(path).getAsAbsolutePath(), codeOwnerStatus);
+    return deletion(path, codeOwnerStatus, /* reason= */ null);
   }
 
   public static FileCodeOwnerStatus deletion(Path path, CodeOwnerStatus codeOwnerStatus) {
+    return deletion(path, codeOwnerStatus, /* reason= */ null);
+  }
+
+  public static FileCodeOwnerStatus deletion(
+      String path, CodeOwnerStatus codeOwnerStatus, @Nullable String reason) {
+    requireNonNull(path, "path");
+
+    return deletion(JgitPath.of(path).getAsAbsolutePath(), codeOwnerStatus, reason);
+  }
+
+  public static FileCodeOwnerStatus deletion(
+      Path path, CodeOwnerStatus codeOwnerStatus, @Nullable String reason) {
     requireNonNull(path, "path");
     requireNonNull(codeOwnerStatus, "codeOwnerStatus");
 
     return create(
         ChangedFile.deletion(path),
         Optional.empty(),
-        Optional.of(PathCodeOwnerStatus.create(path, codeOwnerStatus)));
+        Optional.of(PathCodeOwnerStatus.create(path, codeOwnerStatus, reason)));
   }
 
   public static FileCodeOwnerStatus rename(
@@ -109,14 +135,13 @@
       CodeOwnerStatus oldPathCodeOwnerStatus,
       String newPath,
       CodeOwnerStatus newPathCodeOwnerStatus) {
-    requireNonNull(oldPath, "oldPath");
-    requireNonNull(newPath, "newPath");
-
     return rename(
-        JgitPath.of(oldPath).getAsAbsolutePath(),
+        oldPath,
         oldPathCodeOwnerStatus,
-        JgitPath.of(newPath).getAsAbsolutePath(),
-        newPathCodeOwnerStatus);
+        /* reasonOldPath= */ null,
+        newPath,
+        newPathCodeOwnerStatus,
+        /* reasonNewPath= */ null);
   }
 
   public static FileCodeOwnerStatus rename(
@@ -124,6 +149,41 @@
       CodeOwnerStatus oldPathCodeOwnerStatus,
       Path newPath,
       CodeOwnerStatus newPathCodeOwnerStatus) {
+    return rename(
+        oldPath,
+        oldPathCodeOwnerStatus,
+        /* reasonOldPath= */ null,
+        newPath,
+        newPathCodeOwnerStatus,
+        /* reasonNewPath= */ null);
+  }
+
+  public static FileCodeOwnerStatus rename(
+      String oldPath,
+      CodeOwnerStatus oldPathCodeOwnerStatus,
+      @Nullable String reasonOldPath,
+      String newPath,
+      CodeOwnerStatus newPathCodeOwnerStatus,
+      @Nullable String reasonNewPath) {
+    requireNonNull(oldPath, "oldPath");
+    requireNonNull(newPath, "newPath");
+
+    return rename(
+        JgitPath.of(oldPath).getAsAbsolutePath(),
+        oldPathCodeOwnerStatus,
+        reasonOldPath,
+        JgitPath.of(newPath).getAsAbsolutePath(),
+        newPathCodeOwnerStatus,
+        reasonNewPath);
+  }
+
+  public static FileCodeOwnerStatus rename(
+      Path oldPath,
+      CodeOwnerStatus oldPathCodeOwnerStatus,
+      @Nullable String reasonOldPath,
+      Path newPath,
+      CodeOwnerStatus newPathCodeOwnerStatus,
+      @Nullable String reasonNewPath) {
     requireNonNull(oldPath, "oldPath");
     requireNonNull(oldPathCodeOwnerStatus, "oldPathCodeOwnerStatus");
     requireNonNull(newPath, "newPath");
@@ -131,7 +191,7 @@
 
     return create(
         ChangedFile.rename(newPath, oldPath),
-        Optional.of(PathCodeOwnerStatus.create(newPath, newPathCodeOwnerStatus)),
-        Optional.of(PathCodeOwnerStatus.create(oldPath, oldPathCodeOwnerStatus)));
+        Optional.of(PathCodeOwnerStatus.create(newPath, newPathCodeOwnerStatus, reasonNewPath)),
+        Optional.of(PathCodeOwnerStatus.create(oldPath, oldPathCodeOwnerStatus, reasonOldPath)));
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/GlobMatcher.java b/java/com/google/gerrit/plugins/codeowners/backend/GlobMatcher.java
index 23a1132..4d4f6a7 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/GlobMatcher.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/GlobMatcher.java
@@ -17,6 +17,7 @@
 import com.google.common.flogger.FluentLogger;
 import java.nio.file.FileSystems;
 import java.nio.file.Path;
+import java.util.regex.PatternSyntaxException;
 
 /**
  * Matcher that checks for a given path expression as Java NIO glob if it matches a given path.
@@ -47,9 +48,15 @@
 
   @Override
   public boolean matches(String glob, Path relativePath) {
-    boolean isMatching =
-        FileSystems.getDefault().getPathMatcher("glob:" + glob).matches(relativePath);
-    logger.atFine().log("path %s %s matching %s", relativePath, isMatching ? "is" : "is not", glob);
-    return isMatching;
+    try {
+      boolean isMatching =
+          FileSystems.getDefault().getPathMatcher("glob:" + glob).matches(relativePath);
+      logger.atFine().log(
+          "path %s %s matching %s", relativePath, isMatching ? "is" : "is not", glob);
+      return isMatching;
+    } catch (PatternSyntaxException e) {
+      logger.atFine().log("glob %s is invalid: %s", glob, e.getMessage());
+      return false;
+    }
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerApproval.java b/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerApproval.java
index d6beb5f..5b6f6dd 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerApproval.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerApproval.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.restapi.change.OnPostReview;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -123,12 +124,13 @@
     try {
       // limit + 1, so that we can show an indicator if there are more than <limit> files.
       ownedPaths =
-          codeOwnerApprovalCheck.getOwnedPaths(
-              changeNotes,
-              changeNotes.getCurrentPatchSet(),
-              user.getAccountId(),
-              /* start= */ 0,
-              limit + 1);
+          OwnedChangedFile.getOwnedPaths(
+              codeOwnerApprovalCheck.getOwnedPaths(
+                  changeNotes,
+                  changeNotes.getCurrentPatchSet(),
+                  user.getAccountId(),
+                  /* start= */ 0,
+                  limit + 1));
     } catch (RestApiException e) {
       logger.atFine().withCause(e).log(
           "Couldn't compute owned paths of change %s for account %s",
@@ -166,12 +168,12 @@
         message.append(
             String.format(
                 "By voting %s the following files are now explicitly code-owner approved by %s:\n",
-                newVote, user.getName()));
+                newVote, AccountTemplateUtil.getAccountTemplate(user.getAccountId())));
       } else {
         message.append(
             String.format(
                 "By voting %s the following files are now code-owner approved by %s:\n",
-                newVote, user.getName()));
+                newVote, AccountTemplateUtil.getAccountTemplate(user.getAccountId())));
       }
     } else if (isCodeOwnerApprovalRemoved(requiredApproval, oldApprovals, newVote)) {
       if (newVote.value() == 0) {
@@ -181,13 +183,13 @@
               String.format(
                   "By removing the %s vote the following files are no longer explicitly code-owner"
                       + " approved by %s:\n",
-                  newVote.label(), user.getName()));
+                  newVote.label(), AccountTemplateUtil.getAccountTemplate(user.getAccountId())));
         } else {
           message.append(
               String.format(
                   "By removing the %s vote the following files are no longer code-owner approved"
                       + " by %s:\n",
-                  newVote.label(), user.getName()));
+                  newVote.label(), AccountTemplateUtil.getAccountTemplate(user.getAccountId())));
         }
       } else {
         if (hasImplicitApprovalByUser) {
@@ -196,12 +198,12 @@
               String.format(
                   "By voting %s the following files are no longer explicitly code-owner approved by"
                       + " %s:\n",
-                  newVote, user.getName()));
+                  newVote, AccountTemplateUtil.getAccountTemplate(user.getAccountId())));
         } else {
           message.append(
               String.format(
                   "By voting %s the following files are no longer code-owner approved by %s:\n",
-                  newVote, user.getName()));
+                  newVote, AccountTemplateUtil.getAccountTemplate(user.getAccountId())));
         }
       }
     } else if (isCodeOwnerApprovalUpOrDowngraded(requiredApproval, oldApprovals, newVote)) {
@@ -210,12 +212,12 @@
             String.format(
                 "By voting %s the following files are still explicitly code-owner approved by"
                     + " %s:\n",
-                newVote, user.getName()));
+                newVote, AccountTemplateUtil.getAccountTemplate(user.getAccountId())));
       } else {
         message.append(
             String.format(
                 "By voting %s the following files are still code-owner approved by %s:\n",
-                newVote, user.getName()));
+                newVote, AccountTemplateUtil.getAccountTemplate(user.getAccountId())));
       }
     } else {
       // non-approval was downgraded (e.g. -1 to -2)
@@ -232,7 +234,8 @@
     if (hasImplicitApprovalByUser && noLongerExplicitlyApproved) {
       message.append(
           String.format(
-              "\nThe listed files are still implicitly approved by %s.\n", user.getName()));
+              "\nThe listed files are still implicitly approved by %s.\n",
+              AccountTemplateUtil.getAccountTemplate(user.getAccountId())));
     }
 
     return Optional.of(message.toString());
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerOverride.java b/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerOverride.java
index 09e3624..ea9fa59 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerOverride.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerOverride.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.restapi.change.OnPostReview;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -127,24 +128,24 @@
       return Optional.of(
           String.format(
               "By voting %s the code-owners submit requirement is overridden by %s",
-              newVote, user.getName()));
+              newVote, AccountTemplateUtil.getAccountTemplate(user.getAccountId())));
     } else if (isCodeOwnerOverrideRemoved(overrideApproval, oldApprovals, newVote)) {
       if (newVote.value() == 0) {
         return Optional.of(
             String.format(
                 "By removing the %s vote the code-owners submit requirement is no longer overridden"
                     + " by %s",
-                newVote.label(), user.getName()));
+                newVote.label(), AccountTemplateUtil.getAccountTemplate(user.getAccountId())));
       }
       return Optional.of(
           String.format(
               "By voting %s the code-owners submit requirement is no longer overridden by %s",
-              newVote, user.getName()));
+              newVote, AccountTemplateUtil.getAccountTemplate(user.getAccountId())));
     } else if (isCodeOwnerOverrideUpOrDowngraded(overrideApproval, oldApprovals, newVote)) {
       return Optional.of(
           String.format(
               "By voting %s the code-owners submit requirement is still overridden by %s",
-              newVote, user.getName()));
+              newVote, AccountTemplateUtil.getAccountTemplate(user.getAccountId())));
     }
     // non-approval was downgraded (e.g. -1 to -2)
     return Optional.empty();
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/OptionalResultWithMessages.java b/java/com/google/gerrit/plugins/codeowners/backend/OptionalResultWithMessages.java
index 66fce67..6775d5b 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/OptionalResultWithMessages.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/OptionalResultWithMessages.java
@@ -80,4 +80,12 @@
     return new AutoValue_OptionalResultWithMessages<>(
         Optional.of(result), ImmutableList.copyOf(messages));
   }
+
+  /** Creates a {@link OptionalResultWithMessages} instance with messages. */
+  public static <T> OptionalResultWithMessages<T> create(
+      Optional<T> result, List<String> messages) {
+    requireNonNull(result, "result");
+    requireNonNull(messages, "messages");
+    return new AutoValue_OptionalResultWithMessages<>(result, ImmutableList.copyOf(messages));
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/OwnedChangedFile.java b/java/com/google/gerrit/plugins/codeowners/backend/OwnedChangedFile.java
new file mode 100644
index 0000000..edb3894
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/OwnedChangedFile.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2021 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.plugins.codeowners.backend;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import java.nio.file.Path;
+import java.util.Comparator;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+/**
+ * Representation of a file that was changed in the revision for which the user owns the new path,
+ * the old path or both paths.
+ */
+@AutoValue
+public abstract class OwnedChangedFile {
+  /**
+   * Owner information for the new path.
+   *
+   * <p>{@link Optional#empty()} for deletions.
+   */
+  public abstract Optional<OwnedPath> newPath();
+
+  /**
+   * Owner information for the old path.
+   *
+   * <p>Present only for deletions and renames.
+   */
+  public abstract Optional<OwnedPath> oldPath();
+
+  public static OwnedChangedFile create(@Nullable OwnedPath newPath, @Nullable OwnedPath oldPath) {
+    return new AutoValue_OwnedChangedFile(
+        Optional.ofNullable(newPath), Optional.ofNullable(oldPath));
+  }
+
+  /**
+   * Returns the owned paths that are contained in the given {@link OwnedChangedFile}s as new or old
+   * path, as a sorted list.
+   *
+   * <p>New or old paths that are not owned by the user are filtered out.
+   */
+  public static ImmutableList<Path> getOwnedPaths(
+      ImmutableList<OwnedChangedFile> ownedChangedFiles) {
+    return asPathStream(ownedChangedFiles.stream()).collect(toImmutableList());
+  }
+
+  /**
+   * Returns the owned paths that are contained in the given {@link OwnedChangedFile}s as new or old
+   * path, as a sorted stream.
+   *
+   * <p>New or old paths that are not owned by the user are filtered out.
+   */
+  public static Stream<Path> asPathStream(Stream<OwnedChangedFile> ownedChangedFiles) {
+    return ownedChangedFiles
+        .flatMap(
+            ownedChangedFile -> Stream.of(ownedChangedFile.newPath(), ownedChangedFile.oldPath()))
+        .filter(Optional::isPresent)
+        .map(Optional::get)
+        .filter(OwnedPath::owned)
+        .map(ownedPath -> ownedPath.path())
+        .sorted(Comparator.comparing(path -> path.toString()));
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/OwnedPath.java b/java/com/google/gerrit/plugins/codeowners/backend/OwnedPath.java
new file mode 100644
index 0000000..c6283a3
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/OwnedPath.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2021 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.plugins.codeowners.backend;
+
+import com.google.auto.value.AutoValue;
+import java.nio.file.Path;
+
+/** Representation of a file path the may be owned by the user. */
+@AutoValue
+public abstract class OwnedPath {
+  /** The path of the file that may be owned by the user. */
+  public abstract Path path();
+
+  /** Whether the user owns this path. */
+  public abstract boolean owned();
+
+  public static OwnedPath create(Path path, boolean owned) {
+    return new AutoValue_OwnedPath(path, owned);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/Pair.java b/java/com/google/gerrit/plugins/codeowners/backend/Pair.java
new file mode 100644
index 0000000..ecd0a2e
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/Pair.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2021 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.plugins.codeowners.backend;
+
+import com.google.auto.value.AutoValue;
+
+/**
+ * Key-value pair.
+ *
+ * @param <K> the type of the key
+ * @param <V> the type of the value
+ */
+@AutoValue
+public abstract class Pair<K, V> {
+
+  public abstract K key();
+
+  public abstract V value();
+
+  public static <K, V> Pair<K, V> of(K key, V value) {
+    return new AutoValue_Pair<>(key, value);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnerStatus.java b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnerStatus.java
index b5f0976..2de39cd 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnerStatus.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnerStatus.java
@@ -17,6 +17,8 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
 import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import java.nio.file.Path;
@@ -35,6 +37,24 @@
   public abstract CodeOwnerStatus status();
 
   /**
+   * Message explaining the reason for {@link #status()}.
+   *
+   * <p>A reason may contain one or several placeholders for accounts (see {@link
+   * com.google.gerrit.server.util.AccountTemplateUtil#ACCOUNT_TEMPLATE}).
+   */
+  public abstract ImmutableList<String> reasons();
+
+  /** Creates a builder for a {@link PathCodeOwnerStatus}. */
+  public static PathCodeOwnerStatus.Builder builder(Path path, CodeOwnerStatus codeOwnerStatus) {
+    return new AutoValue_PathCodeOwnerStatus.Builder().path(path).status(codeOwnerStatus);
+  }
+
+  /** Creates a builder for a {@link PathCodeOwnerStatus}. */
+  public static PathCodeOwnerStatus.Builder builder(String path, CodeOwnerStatus codeOwnerStatus) {
+    return builder(JgitPath.of(path).getAsAbsolutePath(), codeOwnerStatus);
+  }
+
+  /**
    * Creates a {@link PathCodeOwnerStatus} instance.
    *
    * @param path the path to which the code owner status belongs
@@ -42,7 +62,24 @@
    * @return the created {@link PathCodeOwnerStatus} instance
    */
   public static PathCodeOwnerStatus create(Path path, CodeOwnerStatus codeOwnerStatus) {
-    return new AutoValue_PathCodeOwnerStatus(path, codeOwnerStatus);
+    return builder(path, codeOwnerStatus).build();
+  }
+
+  /**
+   * Creates a {@link PathCodeOwnerStatus} instance.
+   *
+   * @param path the path to which the code owner status belongs
+   * @param codeOwnerStatus the code owner status
+   * @param reason for the status
+   * @return the created {@link PathCodeOwnerStatus} instance
+   */
+  public static PathCodeOwnerStatus create(
+      Path path, CodeOwnerStatus codeOwnerStatus, @Nullable String reason) {
+    Builder builder = builder(path, codeOwnerStatus);
+    if (reason != null) {
+      builder.addReason(reason);
+    }
+    return builder.build();
   }
 
   /**
@@ -58,4 +95,30 @@
 
     return create(JgitPath.of(path).getAsAbsolutePath(), codeOwnerStatus);
   }
+
+  /** Builder for a {@link PathCodeOwnerStatus}. */
+  @AutoValue.Builder
+  public abstract static class Builder {
+    /**
+     * Sets the path to which the status belongs.
+     *
+     * @param path absolute path to which the status belongs
+     */
+    public abstract Builder path(Path path);
+
+    /** Sets the code owner status of the path. */
+    public abstract Builder status(CodeOwnerStatus codeOwnerStatus);
+
+    /** Gets a builder for adding reasons for this status. */
+    abstract ImmutableList.Builder<String> reasonsBuilder();
+
+    /** Adds a reason for this status. */
+    public Builder addReason(String reason) {
+      reasonsBuilder().add(reason);
+      return this;
+    }
+
+    /** Builds the {@link PathCodeOwnerStatus} instance. */
+    public abstract PathCodeOwnerStatus build();
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwners.java b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwners.java
index 8272bb6..a61c913 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwners.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwners.java
@@ -131,7 +131,7 @@
               .getProjectConfig(codeOwnerConfigKey.project())
               .getBackend(codeOwnerConfigKey.branchNameKey().branch());
       return codeOwnerBackend
-          .getPathExpressionMatcher()
+          .getPathExpressionMatcher(codeOwnerConfigKey.branchNameKey())
           .orElse((pathExpression, relativePath) -> false);
     }
   }
@@ -377,7 +377,7 @@
             codeOwnerConfigImport.referenceToImportedCodeOwnerConfig();
         CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
             createKeyForImportedCodeOwnerConfig(
-                keyOfImportingCodeOwnerConfig, codeOwnerConfigReference);
+                codeOwnerConfigImport.importingCodeOwnerConfig(), codeOwnerConfigReference);
 
         try (Timer0.Context ctx2 = codeOwnerMetrics.resolveCodeOwnerConfigImport.start()) {
           logger.atFine().log(
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResult.java b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResult.java
index bc45ded..93a36be 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResult.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResult.java
@@ -19,6 +19,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import java.nio.file.Path;
@@ -52,7 +53,7 @@
    */
   public ImmutableSet<CodeOwnerReference> getPathCodeOwners() {
     logger.atFine().log(
-        "computing path code owners for %s from %s", path(), codeOwnerConfig().key());
+        "retrieving path code owners for %s from %s", path(), codeOwnerConfig().key());
     ImmutableSet<CodeOwnerReference> pathCodeOwners =
         codeOwnerConfig().codeOwnerSets().stream()
             .flatMap(codeOwnerSet -> codeOwnerSet.codeOwners().stream())
@@ -62,6 +63,34 @@
   }
 
   /**
+   * Gets the annotations for all path code owners that are returned by {@link
+   * #getPathCodeOwners()}.
+   *
+   * @return annotations by code owner
+   */
+  public ImmutableMultimap<CodeOwnerReference, CodeOwnerAnnotation> getAnnotations() {
+    logger.atFine().log(
+        "retrieving path code owner annotations for %s from %s", path(), codeOwnerConfig().key());
+    ImmutableMultimap.Builder<CodeOwnerReference, CodeOwnerAnnotation> annotationsBuilder =
+        ImmutableMultimap.builder();
+    codeOwnerConfig()
+        .codeOwnerSets()
+        .forEach(codeOwnerSet -> annotationsBuilder.putAll(codeOwnerSet.annotations()));
+
+    ImmutableMultimap<CodeOwnerReference, CodeOwnerAnnotation> annotations =
+        annotationsBuilder.build();
+    logger.atFine().log("annotations = %s", annotations);
+    return annotations;
+  }
+
+  /** Gets the annotations for the given email. */
+  public ImmutableSet<String> getAnnotationsFor(String email) {
+    return getAnnotations().get(CodeOwnerReference.create(email)).stream()
+        .map(CodeOwnerAnnotation::key)
+        .collect(toImmutableSet());
+  }
+
+  /**
    * Whether parent code owners should be ignored for the path.
    *
    * @return whether parent code owners should be ignored for the path
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/PathExpressions.java b/java/com/google/gerrit/plugins/codeowners/backend/PathExpressions.java
new file mode 100644
index 0000000..d378a7e
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/PathExpressions.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2021 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.plugins.codeowners.backend;
+
+import com.google.gerrit.common.Nullable;
+import java.util.Locale;
+import java.util.Optional;
+
+/** Enum listing the supported options for path expressions syntaxes. */
+public enum PathExpressions {
+  /** Simple path expressions as implemented by {@link SimplePathExpressionMatcher}. */
+  SIMPLE(SimplePathExpressionMatcher.INSTANCE),
+
+  /** Plain glob path expressions as implemented by {@link GlobMatcher}. */
+  GLOB(GlobMatcher.INSTANCE),
+
+  /**
+   * Find-owners compatible glob path expressions as implemented by {@link FindOwnersGlobMatcher}.
+   */
+  FIND_OWNERS_GLOB(FindOwnersGlobMatcher.INSTANCE);
+
+  private final PathExpressionMatcher matcher;
+
+  private PathExpressions(PathExpressionMatcher matcher) {
+    this.matcher = matcher;
+  }
+
+  /** Gets the path expression matcher. */
+  public PathExpressionMatcher getMatcher() {
+    return matcher;
+  }
+
+  /**
+   * Tries to parse a string as a {@link PathExpressions} enum.
+   *
+   * @param value the string value to be parsed
+   * @return the parsed {@link PathExpressions} enum, {@link Optional#empty()} if the given value
+   *     couldn't be parsed as {@link PathExpressions} enum
+   */
+  public static Optional<PathExpressions> tryParse(@Nullable String value) {
+    if (value == null) {
+      return Optional.empty();
+    }
+    try {
+      return Optional.of(PathExpressions.valueOf(value.toUpperCase(Locale.US)));
+    } catch (IllegalArgumentException e) {
+      return Optional.empty();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/TransientCodeOwnerCache.java b/java/com/google/gerrit/plugins/codeowners/backend/TransientCodeOwnerCache.java
new file mode 100644
index 0000000..0229860
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/TransientCodeOwnerCache.java
@@ -0,0 +1,117 @@
+// Copyright (C) 2021 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.plugins.codeowners.backend;
+
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
+import com.google.inject.Inject;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Class to cache resolved {@link CodeOwner}s within a request.
+ *
+ * <p>This cache is transient, which means the code owners stay cached only for the lifetime of the
+ * {@code TransientCodeOwnerCache} instance.
+ *
+ * <p><strong>Note</strong>: This class is not thread-safe.
+ */
+public class TransientCodeOwnerCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final Optional<Integer> maxCacheSize;
+  private final Counters counters;
+  private final HashMap<String, Optional<CodeOwner>> cache = new HashMap<>();
+
+  @Inject
+  TransientCodeOwnerCache(
+      CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
+      CodeOwnerMetrics codeOwnerMetrics) {
+    this.maxCacheSize =
+        codeOwnersPluginConfiguration.getGlobalConfig().getMaxCodeOwnerConfigCacheSize();
+    this.counters = new Counters(codeOwnerMetrics);
+  }
+
+  public ImmutableMap<String, Optional<CodeOwner>> get(Set<String> emails) {
+    ImmutableMap<String, Optional<CodeOwner>> cachedCodeOwnersByEmail =
+        cache.entrySet().stream()
+            .filter(e -> emails.contains(e.getKey()))
+            .collect(toImmutableMap(Map.Entry::getKey, Map.Entry::getValue));
+    counters.incrementCacheReads(cachedCodeOwnersByEmail.size());
+    return cachedCodeOwnersByEmail;
+  }
+
+  public void clear() {
+    cache.clear();
+  }
+
+  public void cacheNonResolvable(String email) {
+    cache(email, Optional.empty());
+  }
+
+  public void cache(String email, CodeOwner codeOwner) {
+    cache(email, Optional.of(codeOwner));
+  }
+
+  private void cache(String email, Optional<CodeOwner> codeOwner) {
+    counters.incrementResolutions();
+    if (!maxCacheSize.isPresent() || cache.size() < maxCacheSize.get()) {
+      cache.put(email, codeOwner);
+    } else if (maxCacheSize.isPresent()) {
+      logger.atWarning().atMostEvery(1, TimeUnit.DAYS).log(
+          "exceeded limit of %s", getClass().getSimpleName());
+    }
+  }
+
+  public Counters getCounters() {
+    return counters;
+  }
+
+  public static class Counters {
+    private final CodeOwnerMetrics codeOwnerMetrics;
+
+    private int resolutionCount;
+    private int cacheReadCount;
+
+    private Counters(CodeOwnerMetrics codeOwnerMetrics) {
+      this.codeOwnerMetrics = codeOwnerMetrics;
+    }
+
+    private void incrementCacheReads(long value) {
+      codeOwnerMetrics.countCodeOwnerCacheReads.incrementBy(value);
+      cacheReadCount++;
+    }
+
+    private void incrementResolutions() {
+      codeOwnerMetrics.countCodeOwnerResolutions.increment();
+      resolutionCount++;
+    }
+
+    public int getResolutionCount() {
+      return resolutionCount;
+    }
+
+    public int getCacheReadCount() {
+      return cacheReadCount;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/BackendConfig.java b/java/com/google/gerrit/plugins/codeowners/backend/config/BackendConfig.java
index cbeb83d..05c9f0e 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/config/BackendConfig.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/BackendConfig.java
@@ -18,6 +18,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
@@ -26,6 +27,7 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackendId;
+import com.google.gerrit.plugins.codeowners.backend.PathExpressions;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.git.validators.ValidationMessage;
@@ -57,6 +59,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   @VisibleForTesting public static final String KEY_BACKEND = "backend";
+  @VisibleForTesting public static final String KEY_PATH_EXPRESSIONS = "pathExpressions";
 
   private final String pluginName;
   private final DynamicMap<CodeOwnerBackend> codeOwnerBackends;
@@ -64,6 +67,9 @@
   /** The name of the configured code owners default backend. */
   private final String defaultBackendName;
 
+  /** The configured default path expressions. */
+  private final Optional<PathExpressions> defaultPathExpressions;
+
   @Inject
   BackendConfig(
       @PluginName String pluginName,
@@ -76,6 +82,19 @@
         pluginConfigFactory
             .getFromGerritConfig(pluginName)
             .getString(KEY_BACKEND, CodeOwnerBackendId.FIND_OWNERS.getBackendId());
+
+    String defaultPathExpressionsName =
+        pluginConfigFactory
+            .getFromGerritConfig(pluginName)
+            .getString(KEY_PATH_EXPRESSIONS, /* defaultValue= */ null);
+    this.defaultPathExpressions = PathExpressions.tryParse(defaultPathExpressionsName);
+    if (!Strings.isNullOrEmpty(defaultPathExpressionsName)
+        && !this.defaultPathExpressions.isPresent()) {
+      logger.atWarning().log(
+          "Path expressions '%s' that are configured in gerrit.config"
+              + " (parameter plugin.%s.%s) not found.",
+          defaultPathExpressionsName, pluginName, KEY_PATH_EXPRESSIONS);
+    }
   }
 
   /**
@@ -105,6 +124,18 @@
       }
     }
 
+    String pathExpressionsName =
+        projectLevelConfig.getString(SECTION_CODE_OWNERS, null, KEY_PATH_EXPRESSIONS);
+    if (!Strings.isNullOrEmpty(pathExpressionsName)
+        && !PathExpressions.tryParse(pathExpressionsName).isPresent()) {
+      validationMessages.add(
+          new CommitValidationMessage(
+              String.format(
+                  "Path expressions '%s' that are configured in %s (parameter %s.%s) not found.",
+                  pathExpressionsName, fileName, SECTION_CODE_OWNERS, KEY_PATH_EXPRESSIONS),
+              ValidationMessage.Type.ERROR));
+    }
+
     for (String subsection : projectLevelConfig.getSubsections(SECTION_CODE_OWNERS)) {
       backendName = projectLevelConfig.getString(SECTION_CODE_OWNERS, subsection, KEY_BACKEND);
       if (backendName != null) {
@@ -117,6 +148,22 @@
                   ValidationMessage.Type.ERROR));
         }
       }
+
+      pathExpressionsName =
+          projectLevelConfig.getString(SECTION_CODE_OWNERS, subsection, KEY_PATH_EXPRESSIONS);
+      if (!Strings.isNullOrEmpty(pathExpressionsName)
+          && !PathExpressions.tryParse(pathExpressionsName).isPresent()) {
+        validationMessages.add(
+            new CommitValidationMessage(
+                String.format(
+                    "Path expressions '%s' that are configured in %s (parameter %s.%s.%s) not found.",
+                    pathExpressionsName,
+                    fileName,
+                    SECTION_CODE_OWNERS,
+                    subsection,
+                    KEY_PATH_EXPRESSIONS),
+                ValidationMessage.Type.ERROR));
+      }
     }
 
     return ImmutableList.copyOf(validationMessages);
@@ -211,7 +258,6 @@
   }
 
   /** Gets the default code owner backend. */
-  @VisibleForTesting
   public CodeOwnerBackend getDefaultBackend() {
     return lookupBackend(defaultBackendName)
         .orElseThrow(
@@ -233,4 +279,93 @@
     // plugin name.
     return Optional.ofNullable(codeOwnerBackends.get("gerrit", backendName));
   }
+
+  /**
+   * Gets the path expressions that are configured for the given branch.
+   *
+   * <p>The path expressions configuration is evaluated in the following order:
+   *
+   * <ul>
+   *   <li>path expressions for branch by full name (with inheritance)
+   *   <li>path expressions for branch by short name (with inheritance)
+   * </ul>
+   *
+   * @param pluginConfig the plugin config from which the path expressions should be read.
+   * @param branch the project and branch for which the configured path expressions should be read
+   * @return the path expressions that are configured for the given branch, {@link Optional#empty()}
+   *     if there is no branch-specific path expressions configuration
+   */
+  Optional<PathExpressions> getPathExpressionsForBranch(Config pluginConfig, BranchNameKey branch) {
+    requireNonNull(pluginConfig, "pluginConfig");
+    requireNonNull(branch, "branch");
+
+    // check for branch specific path expressions by full branch name
+    Optional<PathExpressions> pathExpressions =
+        getPathExpressionsForBranch(pluginConfig, branch.project(), branch.branch());
+    if (!pathExpressions.isPresent()) {
+      // check for branch specific path expressions by short branch name
+      pathExpressions =
+          getPathExpressionsForBranch(pluginConfig, branch.project(), branch.shortName());
+    }
+    return pathExpressions;
+  }
+
+  private Optional<PathExpressions> getPathExpressionsForBranch(
+      Config pluginConfig, Project.NameKey project, String branch) {
+    String pathExpressionsName =
+        pluginConfig.getString(SECTION_CODE_OWNERS, branch, KEY_PATH_EXPRESSIONS);
+    if (Strings.isNullOrEmpty(pathExpressionsName)) {
+      return Optional.empty();
+    }
+
+    Optional<PathExpressions> pathExpressions = PathExpressions.tryParse(pathExpressionsName);
+    if (!pathExpressions.isPresent()) {
+      logger.atWarning().log(
+          "Path expressions '%s' that are configured for project %s in"
+              + " %s.config (parameter %s.%s.%s) not found. Falling back to default path"
+              + " expressions.",
+          pathExpressionsName,
+          project,
+          pluginName,
+          SECTION_CODE_OWNERS,
+          branch,
+          KEY_PATH_EXPRESSIONS);
+    }
+    return pathExpressions;
+  }
+
+  /**
+   * Gets the path expressions that are configured for the given project.
+   *
+   * @param pluginConfig the plugin config from which the path expressions should be read.
+   * @param project the project for which the configured path expressions should be read
+   * @return the path expressions that are configured for the given project, {@link
+   *     Optional#empty()} if there is no project-specific path expression configuration
+   */
+  Optional<PathExpressions> getPathExpressionsForProject(
+      Config pluginConfig, Project.NameKey project) {
+    requireNonNull(pluginConfig, "pluginConfig");
+    requireNonNull(project, "project");
+
+    String pathExpressionsName =
+        pluginConfig.getString(SECTION_CODE_OWNERS, null, KEY_PATH_EXPRESSIONS);
+    if (Strings.isNullOrEmpty(pathExpressionsName)) {
+      return Optional.empty();
+    }
+
+    Optional<PathExpressions> pathExpressions = PathExpressions.tryParse(pathExpressionsName);
+    if (!pathExpressions.isPresent()) {
+      logger.atWarning().log(
+          "Path expressions '%s' that are configured for project %s in"
+              + " %s.config (parameter %s.%s) not found. Falling back to default path"
+              + " expressions.",
+          pathExpressionsName, project, pluginName, SECTION_CODE_OWNERS, KEY_PATH_EXPRESSIONS);
+    }
+    return pathExpressions;
+  }
+
+  /** Gets the default path expressions. */
+  public Optional<PathExpressions> getDefaultPathExpressions() {
+    return defaultPathExpressions;
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigValidator.java b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigValidator.java
index c6181b5..97029e0 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigValidator.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigValidator.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectLevelConfig;
 import com.google.gerrit.server.project.ProjectState;
@@ -97,7 +98,7 @@
             exceptionMessage(fileName, cfg.getRevision()), validationMessages);
       }
       return ImmutableList.of();
-    } catch (IOException | ConfigInvalidException e) {
+    } catch (IOException | DiffNotAvailableException | ConfigInvalidException e) {
       String errorMessage =
           String.format(
               "failed to validate file %s for revision %s in ref %s of project %s",
@@ -124,12 +125,10 @@
    * @param fileName the name of the file
    */
   private boolean isFileChanged(CommitReceivedEvent receiveEvent, String fileName)
-      throws IOException {
+      throws IOException, DiffNotAvailableException {
     return changedFiles
-        .compute(
+        .getFromDiffCache(
             receiveEvent.project.getNameKey(),
-            receiveEvent.repoConfig,
-            receiveEvent.revWalk,
             receiveEvent.commit,
             MergeCommitStrategy.ALL_CHANGED_FILES)
         .stream()
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfiguration.java b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfiguration.java
index a216eb1..cb565c5 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfiguration.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfiguration.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.cache.PerThreadCache;
+import com.google.gerrit.server.cache.PerThreadProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -69,8 +70,9 @@
    */
   public CodeOwnersPluginProjectConfigSnapshot getProjectConfig(Project.NameKey projectName) {
     requireNonNull(projectName, "projectName");
-    return PerThreadCache.getOrCompute(
-        PerThreadCache.Key.create(CodeOwnersPluginProjectConfigSnapshot.class, projectName),
+    return PerThreadProjectCache.getOrCompute(
+        PerThreadCache.Key.create(
+            Project.NameKey.class, projectName, "CodeOwnersPluginConfiguration"),
         () -> codeOwnersPluginProjectConfigSnapshotFactory.create(projectName));
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginGlobalConfigSnapshot.java b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginGlobalConfigSnapshot.java
index c466b8a..472df1a 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginGlobalConfigSnapshot.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginGlobalConfigSnapshot.java
@@ -32,8 +32,10 @@
   static final String KEY_ENABLE_EXPERIMENTAL_REST_ENDPOINTS = "enableExperimentalRestEndpoints";
 
   @VisibleForTesting static final int DEFAULT_MAX_CODE_OWNER_CONFIG_CACHE_SIZE = 10000;
+  @VisibleForTesting static final int DEFAULT_MAX_CODE_OWNER_CACHE_SIZE = 10000;
 
   private static final String KEY_MAX_CODE_OWNER_CONFIG_CACHE_SIZE = "maxCodeOwnerConfigCacheSize";
+  private static final String KEY_MAX_CODE_OWNER_CACHE_SIZE = "maxCodeOwnerCacheSize";
 
   public interface Factory {
     CodeOwnersPluginGlobalConfigSnapshot create();
@@ -115,30 +117,39 @@
    */
   public Optional<Integer> getMaxCodeOwnerConfigCacheSize() {
     if (maxCodeOwnerConfigCacheSize == null) {
-      maxCodeOwnerConfigCacheSize = readMaxCodeOwnerConfigCacheSize();
+      maxCodeOwnerConfigCacheSize =
+          readMaxCacheSize(
+              KEY_MAX_CODE_OWNER_CONFIG_CACHE_SIZE, DEFAULT_MAX_CODE_OWNER_CONFIG_CACHE_SIZE);
     }
     return maxCodeOwnerConfigCacheSize;
   }
 
-  private Optional<Integer> readMaxCodeOwnerConfigCacheSize() {
+  /**
+   * Gets the maximum size for the {@link
+   * com.google.gerrit.plugins.codeowners.backend.TransientCodeOwnerConfigCache}.
+   *
+   * @return the maximum cache size, {@link Optional#empty()} if the cache size is not limited
+   */
+  public Optional<Integer> getMaxCodeOwnerCacheSize() {
+    if (maxCodeOwnerConfigCacheSize == null) {
+      maxCodeOwnerConfigCacheSize =
+          readMaxCacheSize(KEY_MAX_CODE_OWNER_CACHE_SIZE, DEFAULT_MAX_CODE_OWNER_CACHE_SIZE);
+    }
+    return maxCodeOwnerConfigCacheSize;
+  }
+
+  private Optional<Integer> readMaxCacheSize(String key, int defaultCacheSize) {
     try {
       int maxCodeOwnerConfigCacheSize =
-          pluginConfigFactory
-              .getFromGerritConfig(pluginName)
-              .getInt(
-                  KEY_MAX_CODE_OWNER_CONFIG_CACHE_SIZE, DEFAULT_MAX_CODE_OWNER_CONFIG_CACHE_SIZE);
+          pluginConfigFactory.getFromGerritConfig(pluginName).getInt(key, defaultCacheSize);
       return maxCodeOwnerConfigCacheSize > 0
           ? Optional.of(maxCodeOwnerConfigCacheSize)
           : Optional.empty();
     } catch (IllegalArgumentException e) {
       logger.atWarning().withCause(e).log(
           "Value '%s' in gerrit.config (parameter plugin.%s.%s) is invalid.",
-          pluginConfigFactory
-              .getFromGerritConfig(pluginName)
-              .getString(KEY_MAX_CODE_OWNER_CONFIG_CACHE_SIZE),
-          pluginName,
-          KEY_MAX_CODE_OWNER_CONFIG_CACHE_SIZE);
-      return Optional.empty();
+          pluginConfigFactory.getFromGerritConfig(pluginName).getString(key), pluginName, key);
+      return Optional.of(defaultCacheSize);
     }
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshot.java b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshot.java
index ea7c88b..96e2685 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshot.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshot.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnersInternalServerErrorException;
 import com.google.gerrit.plugins.codeowners.backend.EnableImplicitApprovals;
 import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
+import com.google.gerrit.plugins.codeowners.backend.PathExpressions;
 import com.google.gerrit.plugins.codeowners.common.CodeOwnerConfigValidationPolicy;
 import com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy;
 import com.google.gerrit.server.account.Emails;
@@ -66,6 +67,7 @@
   private final Config pluginConfig;
 
   @Nullable private Optional<String> fileExtension;
+  @Nullable private Boolean enableCodeOwnerConfigFilesWithFileExtensions;
   @Nullable private Boolean codeOwnerConfigsReadOnly;
   @Nullable private Boolean exemptPureReverts;
   @Nullable private Boolean rejectNonResolvableCodeOwners;
@@ -78,6 +80,7 @@
   @Nullable private MergeCommitStrategy mergeCommitStrategy;
   @Nullable private FallbackCodeOwners fallbackCodeOwners;
   @Nullable private Integer maxPathsInChangeMessages;
+  @Nullable private Boolean enableAsyncMessageOnAddReviewer;
   @Nullable private ImmutableSet<CodeOwnerReference> globalCodeOwners;
   @Nullable private ImmutableSet<Account.Id> exemptedAccounts;
   @Nullable private Optional<String> overrideInfoUrl;
@@ -86,6 +89,8 @@
   @Nullable private Boolean isDisabled;
   private Map<String, CodeOwnerBackend> backendByBranch = new HashMap<>();
   @Nullable private CodeOwnerBackend backend;
+  private Map<String, Optional<PathExpressions>> pathExpressionsByBranch = new HashMap<>();
+  @Nullable private Optional<PathExpressions> pathExpressions;
   @Nullable private Boolean implicitApprovalsEnabled;
   @Nullable private RequiredApproval requiredApproval;
   @Nullable private ImmutableSortedSet<RequiredApproval> overrideApprovals;
@@ -120,6 +125,15 @@
     return fileExtension;
   }
 
+  /** Whether file extensions for code owner config files are enabled. */
+  public boolean enableCodeOwnerConfigFilesWithFileExtensions() {
+    if (enableCodeOwnerConfigFilesWithFileExtensions == null) {
+      enableCodeOwnerConfigFilesWithFileExtensions =
+          generalConfig.enableCodeOwnerConfigFilesWithFileExtensions(projectName, pluginConfig);
+    }
+    return enableCodeOwnerConfigFilesWithFileExtensions;
+  }
+
   /** Whether code owner configs are read-only. */
   public boolean areCodeOwnerConfigsReadOnly() {
     if (codeOwnerConfigsReadOnly == null) {
@@ -273,6 +287,18 @@
     return maxPathsInChangeMessages;
   }
 
+  /**
+   * Gets whether code owner change messages that are added when a code owner is added as a reviewer
+   * should be posted asynchronously.
+   */
+  public boolean enableAsyncMessageOnAddReviewer() {
+    if (enableAsyncMessageOnAddReviewer == null) {
+      enableAsyncMessageOnAddReviewer =
+          generalConfig.enableAsyncMessageOnAddReviewer(projectName, pluginConfig);
+    }
+    return enableAsyncMessageOnAddReviewer;
+  }
+
   /** Gets the global code owners. */
   public ImmutableSet<CodeOwnerReference> getGlobalCodeOwners() {
     if (globalCodeOwners == null) {
@@ -451,6 +477,74 @@
   }
 
   /**
+   * Returns the configured {@link PathExpressions} for the given branch.
+   *
+   * <p>The path expression configuration is evaluated in the following order:
+   *
+   * <ul>
+   *   <li>path expression configuration for branch (with inheritance, first by full branch name,
+   *       then by short branch name)
+   *   <li>path expressions configuration for project (with inheritance)
+   * </ul>
+   *
+   * <p>The first path expressions configuration that exists counts and the evaluation is stopped.
+   *
+   * @param branchName the branch for which the configured path expressions should be returned
+   * @return the {@link PathExpressions} that should be used for the branch, {@link
+   *     Optional#empty()} if no path expressions are configured for the branch
+   */
+  public Optional<PathExpressions> getPathExpressions(String branchName) {
+    requireNonNull(branchName, "branchName");
+
+    BranchNameKey branchNameKey = BranchNameKey.create(projectName, branchName);
+    return pathExpressionsByBranch.computeIfAbsent(
+        branchNameKey.branch(),
+        b -> {
+          Optional<PathExpressions> pathExpressions =
+              backendConfig.getPathExpressionsForBranch(
+                  pluginConfig, BranchNameKey.create(projectName, branchName));
+          if (pathExpressions.isPresent()) {
+            return pathExpressions;
+          }
+          return getPathExpressions();
+        });
+  }
+
+  /**
+   * Returns the configured {@link PathExpressions}.
+   *
+   * <p>The path expression configuration is evaluated in the following order:
+   *
+   * <ul>
+   *   <li>path expression configuration for project (with inheritance)
+   *   <li>default path expressions (globally configured path expressions)
+   * </ul>
+   *
+   * <p>The first path expression configuration that exists counts and the evaluation is stopped.
+   *
+   * @return the {@link PathExpressions} that should be used, {@link Optional#empty()} if no path
+   *     expressions are configured
+   */
+  public Optional<PathExpressions> getPathExpressions() {
+    if (pathExpressions == null) {
+      pathExpressions = readPathExpressions();
+    }
+    return pathExpressions;
+  }
+
+  private Optional<PathExpressions> readPathExpressions() {
+    // check if project specific path expressions are configured
+    Optional<PathExpressions> pathExpressions =
+        backendConfig.getPathExpressionsForProject(pluginConfig, projectName);
+    if (pathExpressions.isPresent()) {
+      return pathExpressions;
+    }
+
+    // fall back to the default path expressions
+    return backendConfig.getDefaultPathExpressions();
+  }
+
+  /**
    * Checks whether implicit code owner approvals are enabled.
    *
    * <p>If enabled, an implict code owner approval from the change owner is assumed if the last
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java b/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java
index 7ab6706..7e71f09 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java
@@ -65,6 +65,10 @@
   public static final String SECTION_VALIDATION = "validation";
 
   public static final String KEY_FILE_EXTENSION = "fileExtension";
+  public static final String KEY_ENABLE_ASYNC_MESSAGE_ON_ADD_REVIEWER =
+      "enableAsyncMessageOnAddReviewer";
+  public static final String KEY_ENABLE_CODE_OWNER_CONFIG_FILES_WITH_FILE_EXTENSIONS =
+      "enableCodeOwnerConfigFilesWithFileExtensions";
   public static final String KEY_READ_ONLY = "readOnly";
   public static final String KEY_EXEMPT_PURE_REVERTS = "exemptPureReverts";
   public static final String KEY_FALLBACK_CODE_OWNERS = "fallbackCodeOwners";
@@ -186,6 +190,27 @@
   }
 
   /**
+   * Whether file extensions for code owner config files are enabled.
+   *
+   * <p>If enabled, code owner config files with file extensions are treated as regular code owner
+   * config files. This means they are validated on push/submit (if validation is enabled) and can
+   * be imported by other code owner config files (regardless of whether they have the same file
+   * extension or not).
+   *
+   * @param project the project for which the configuration should be read
+   * @param pluginConfig the plugin config from which the configuration should be read.
+   * @return whether file extensions for code owner config files are enabled
+   */
+  boolean enableCodeOwnerConfigFilesWithFileExtensions(
+      Project.NameKey project, Config pluginConfig) {
+    return getBooleanConfig(
+        project,
+        pluginConfig,
+        KEY_ENABLE_CODE_OWNER_CONFIG_FILES_WITH_FILE_EXTENSIONS,
+        /* defaultValue= */ false);
+  }
+
+  /**
    * Returns the email domains that are allowed to be used for code owners.
    *
    * @return the email domains that are allowed to be used for code owners, an empty set if all
@@ -434,6 +459,22 @@
   }
 
   /**
+   * Gets whether code owner change messages that are added when a code owner is added as a reviewer
+   * should be posted asynchronously.
+   *
+   * @param project the project for which the enable async message on add reviewer configuration
+   *     should be read
+   * @param pluginConfig the plugin config from which the enable async message on add reviewer
+   *     configuration should be read
+   * @return whether code owner change messages that are added when a code owner is added as a
+   *     reviewer should be posted asynchronously
+   */
+  boolean enableAsyncMessageOnAddReviewer(Project.NameKey project, Config pluginConfig) {
+    return getBooleanConfig(
+        project, pluginConfig, KEY_ENABLE_ASYNC_MESSAGE_ON_ADD_REVIEWER, /* defaultValue= */ true);
+  }
+
+  /**
    * Gets the enable validation on commit received configuration from the given plugin config for
    * the specified project with fallback to {@code gerrit.config} and default to {@code true}.
    *
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackend.java b/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackend.java
index 0e923d7..513bd12 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackend.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackend.java
@@ -18,8 +18,7 @@
 import com.google.gerrit.plugins.codeowners.backend.AbstractFileBasedCodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigFile;
-import com.google.gerrit.plugins.codeowners.backend.FindOwnersGlobMatcher;
-import com.google.gerrit.plugins.codeowners.backend.PathExpressionMatcher;
+import com.google.gerrit.plugins.codeowners.backend.PathExpressions;
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -63,8 +62,8 @@
   }
 
   @Override
-  public Optional<PathExpressionMatcher> getPathExpressionMatcher() {
-    return Optional.of(FindOwnersGlobMatcher.INSTANCE);
+  public Optional<PathExpressions> getDefaultPathExpressions() {
+    return Optional.of(PathExpressions.FIND_OWNERS_GLOB);
   }
 
   @Override
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParser.java b/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParser.java
index 5ddbb9a..01ab9b9 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParser.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParser.java
@@ -15,7 +15,10 @@
 package com.google.gerrit.plugins.codeowners.backend.findowners;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.collect.ImmutableSortedSet.toImmutableSortedSet;
+import static java.util.Comparator.naturalOrder;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.joining;
 
@@ -25,7 +28,12 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SortedSetMultimap;
+import com.google.common.collect.TreeMultimap;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerAnnotation;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigImportMode;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigParseException;
@@ -37,7 +45,11 @@
 import com.google.inject.Singleton;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
+import java.util.SortedSet;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Stream;
@@ -126,7 +138,8 @@
     return Joiner.on("\n").join(updatedLines);
   }
 
-  private static class Parser implements ValidationError.Sink {
+  @VisibleForTesting
+  static class Parser implements ValidationError.Sink {
     private static final String COMMA = "[\\s]*,[\\s]*";
 
     // Separator for project and file paths in an include line.
@@ -160,6 +173,7 @@
     // Simple input lines with 0 or 1 sub-pattern.
     private static final Pattern PAT_COMMENT = Pattern.compile(BOL + EOL);
     private static final Pattern PAT_EMAIL = Pattern.compile(BOL + EMAIL_OR_STAR + EOL);
+    private static final Pattern PAT_ANNOTATION = Pattern.compile("#\\{([A-Za-z_]+)\\}");
     private static final Pattern PAT_INCLUDE =
         Pattern.compile(BOL + INCLUDE_OR_FILE + PROJECT_BRANCH_AND_FILE + EOL);
     private static final Pattern PAT_NO_PARENT = Pattern.compile(BOL + SET_NOPARENT + EOL);
@@ -205,16 +219,18 @@
         CodeOwnerSet.Builder globalCodeOwnerSetBuilder,
         List<CodeOwnerSet> perFileCodeOwnerSets,
         String line) {
-      String email;
+      ParsedEmailLine parsedEmailLine;
       CodeOwnerSet codeOwnerSet;
       CodeOwnerConfigReference codeOwnerConfigReference;
       if (isNoParent(line)) {
         codeOwnerConfigBuilder.setIgnoreParentCodeOwners();
       } else if (isComment(line)) {
         // ignore comment lines and empty lines
-      } else if ((email = parseEmail(line)) != null) {
-        globalCodeOwnerSetBuilder.addCodeOwner(CodeOwnerReference.create(email));
-      } else if ((codeOwnerSet = parsePerFile(line)) != null) {
+      } else if ((parsedEmailLine = parseEmailLine(line)) != null) {
+        globalCodeOwnerSetBuilder.addCodeOwner(parsedEmailLine.codeOwnerReference());
+        globalCodeOwnerSetBuilder.addAnnotations(
+            parsedEmailLine.codeOwnerReference(), parsedEmailLine.annotations());
+      } else if ((codeOwnerSet = parsePerFileLine(line)) != null) {
         perFileCodeOwnerSets.add(codeOwnerSet);
       } else if ((codeOwnerConfigReference = parseInclude(line)) != null) {
         codeOwnerConfigBuilder.addImport(codeOwnerConfigReference);
@@ -223,25 +239,32 @@
       }
     }
 
-    private static CodeOwnerSet parsePerFile(String line) {
-      Matcher m = PAT_PER_FILE.matcher(line);
-      if (!m.matches() || !isGlobs(m.group(1).trim())) {
+    private CodeOwnerSet parsePerFileLine(String line) {
+      Matcher perFileMatcher = PAT_PER_FILE.matcher(line);
+      if (!perFileMatcher.matches() || !isGlobs(perFileMatcher.group(1).trim())) {
         return null;
       }
 
-      String matchedGroup2 = m.group(2).trim();
+      String matchedGroup2 = perFileMatcher.group(2).trim();
       if (!PAT_PER_FILE_OWNERS.matcher(matchedGroup2).matches()) {
-        checkState(
-            !PAT_PER_FILE_INCLUDE.matcher(matchedGroup2).matches(),
-            "import mode %s is unsupported for per file import: %s",
-            CodeOwnerConfigImportMode.ALL.name(),
-            line);
+        if (PAT_PER_FILE_INCLUDE.matcher(matchedGroup2).matches()) {
+          error(
+              ValidationError.create(
+                  String.format(
+                      "keyword 'include' is not supported for per file imports: %s", line)));
+
+          // return an empty code owner set to avoid that the line will be reported as invalid once
+          // more
+          return CodeOwnerSet.builder().build();
+        }
         return null;
       }
 
       String[] globsAndOwners =
-          new String[] {removeExtraSpaces(m.group(1)), removeExtraSpaces(m.group(2))};
-      String[] dirGlobs = globsAndOwners[0].split(COMMA, -1);
+          new String[] {
+            removeExtraSpaces(perFileMatcher.group(1)), removeExtraSpaces(perFileMatcher.group(2))
+          };
+      String[] dirGlobs = splitGlobs(globsAndOwners[0]);
       String directive = globsAndOwners[1];
       if (directive.equals(TOK_SET_NOPARENT)) {
         return CodeOwnerSet.builder()
@@ -259,11 +282,79 @@
       }
 
       List<String> ownerEmails = Arrays.asList(directive.split(COMMA, -1));
-      return CodeOwnerSet.builder()
-          .setPathExpressions(ImmutableSet.copyOf(dirGlobs))
-          .setCodeOwners(
-              ownerEmails.stream().map(CodeOwnerReference::create).collect(toImmutableSet()))
-          .build();
+
+      // Get the comment part of the line (the first '#' and everything that follows).
+      String comment = perFileMatcher.group(3);
+      Set<CodeOwnerAnnotation> annotations = new HashSet<>();
+      if (comment != null) {
+        Matcher annotationMatcher = PAT_ANNOTATION.matcher(comment);
+        while (annotationMatcher.find()) {
+          String annotation = annotationMatcher.group(1);
+          annotations.add(CodeOwnerAnnotation.create(annotation));
+        }
+      }
+
+      CodeOwnerSet.Builder codeOwnerSet =
+          CodeOwnerSet.builder()
+              .setPathExpressions(ImmutableSet.copyOf(dirGlobs))
+              .setCodeOwners(
+                  ownerEmails.stream().map(CodeOwnerReference::create).collect(toImmutableSet()));
+      ownerEmails.stream()
+          .forEach(
+              email -> codeOwnerSet.addAnnotations(CodeOwnerReference.create(email), annotations));
+      return codeOwnerSet.build();
+    }
+
+    /**
+     * Splits the given glob string by the commas that separate the globs.
+     *
+     * <p>Commas that appear within a glob do not cause the string to be split at this position:
+     *
+     * <ul>
+     *   <li>commas that are used as separator when matching choices via {@code {choice1,choice2}}
+     *   <li>commas that appears as part of a character class via {@code
+     *       [<any-chars-including-comma>]}
+     * </ul>
+     *
+     * @param commaSeparatedGlobs globs as comma-separated list
+     * @return the globs as array
+     */
+    @VisibleForTesting
+    static String[] splitGlobs(String commaSeparatedGlobs) {
+      ArrayList<String> globList = new ArrayList<>();
+      StringBuilder nextGlob = new StringBuilder();
+      int curlyBracesIndentionLevel = 0;
+      int squareBracesIndentionLevel = 0;
+      for (int i = 0; i < commaSeparatedGlobs.length(); i++) {
+        char c = commaSeparatedGlobs.charAt(i);
+        if (c == ',') {
+          if (curlyBracesIndentionLevel == 0 && squareBracesIndentionLevel == 0) {
+            globList.add(nextGlob.toString());
+            nextGlob = new StringBuilder();
+          } else {
+            nextGlob.append(c);
+          }
+        } else {
+          nextGlob.append(c);
+          if (c == '{') {
+            curlyBracesIndentionLevel++;
+          } else if (c == '}') {
+            if (curlyBracesIndentionLevel > 0) {
+              curlyBracesIndentionLevel--;
+            }
+          } else if (c == '[') {
+            squareBracesIndentionLevel++;
+          } else if (c == ']') {
+            if (squareBracesIndentionLevel > 0) {
+              squareBracesIndentionLevel--;
+            }
+          }
+        }
+      }
+      if (nextGlob.length() > 0) {
+        globList.add(nextGlob.toString());
+      }
+      return globList.toArray(new String[globList.size()]);
     }
 
     private static boolean isComment(String line) {
@@ -274,9 +365,25 @@
       return PAT_NO_PARENT.matcher(line).matches();
     }
 
-    private static String parseEmail(String line) {
-      Matcher m = PAT_EMAIL.matcher(line);
-      return m.matches() ? m.group(1).trim() : null;
+    private static ParsedEmailLine parseEmailLine(String line) {
+      Matcher emailMatcher = PAT_EMAIL.matcher(line);
+      if (!emailMatcher.matches()) {
+        return null;
+      }
+      String email = emailMatcher.group(1).trim();
+      ParsedEmailLine.Builder parsedEmailLine = ParsedEmailLine.builder(email);
+
+      // Get the comment part of the line (the first '#' and everything that follows).
+      String comment = emailMatcher.group(2);
+      if (comment != null) {
+        Matcher annotationMatcher = PAT_ANNOTATION.matcher(comment);
+        while (annotationMatcher.find()) {
+          String annotation = annotationMatcher.group(1);
+          parsedEmailLine.addAnnotation(annotation);
+        }
+      }
+
+      return parsedEmailLine.build();
     }
 
     private static CodeOwnerConfigReference parseInclude(String line) {
@@ -350,7 +457,7 @@
     static String formatAsString(CodeOwnerConfig codeOwnerConfig) {
       return formatIgnoreParentCodeOwners(codeOwnerConfig)
           + formatImports(codeOwnerConfig)
-          + formatGlobalCodeOwners(codeOwnerConfig)
+          + formatFolderCodeOwners(codeOwnerConfig)
           + formatPerFileCodeOwners(codeOwnerConfig);
     }
 
@@ -358,22 +465,37 @@
       return codeOwnerConfig.ignoreParentCodeOwners() ? SET_NOPARENT_LINE : "";
     }
 
-    private static String formatGlobalCodeOwners(CodeOwnerConfig codeOwnerConfig) {
-      String emails =
+    private static String formatFolderCodeOwners(CodeOwnerConfig codeOwnerConfig) {
+      ImmutableSet<CodeOwnerSet> folderCodeOwnerSets =
           codeOwnerConfig.codeOwnerSets().stream()
               // Filter out code owner sets with path expressions. If path expressions are present
               // the code owner set defines per-file code owners and is handled in
               // formatPerFileCodeOwners(CodeOwnerConfig).
               .filter(codeOwnerSet -> codeOwnerSet.pathExpressions().isEmpty())
+              .collect(toImmutableSet());
+      ImmutableList<String> emails =
+          folderCodeOwnerSets.stream()
               .flatMap(codeOwnerSet -> codeOwnerSet.codeOwners().stream())
               .map(CodeOwnerReference::email)
               .sorted()
               .distinct()
-              .collect(joining("\n"));
-      if (!emails.isEmpty()) {
-        return emails + "\n";
+              .collect(toImmutableList());
+      SortedSetMultimap<String, String> annotations = TreeMultimap.create();
+      folderCodeOwnerSets.forEach(
+          codeOwnerSet ->
+              codeOwnerSet
+                  .annotations()
+                  .forEach(
+                      (codeOwnerReference, annotation) ->
+                          annotations.put(codeOwnerReference.email(), annotation.key())));
+
+      StringBuilder b = new StringBuilder();
+      for (String email : emails) {
+        b.append(email);
+        annotations.get(email).forEach(annotation -> b.append(" #{" + annotation + "}"));
+        b.append('\n');
       }
-      return emails;
+      return b.toString();
     }
 
     private static String formatPerFileCodeOwners(CodeOwnerConfig codeOwnerConfig) {
@@ -405,20 +527,48 @@
       }
 
       if (!codeOwnerSet.codeOwners().isEmpty()) {
-        b.append(
-            String.format(
-                PER_FILE_LINE_FORMAT,
-                formattedPathExpressions,
-                formatCodeOwnerReferencesAsList(codeOwnerSet.codeOwners())));
+        // group code owners that have the same annotations
+        ListMultimap<SortedSet<String>, CodeOwnerReference> codeOwnersByAnnotations =
+            MultimapBuilder.hashKeys().arrayListValues().build();
+        codeOwnerSet
+            .codeOwners()
+            .forEach(
+                codeOwnerReference ->
+                    codeOwnersByAnnotations.put(
+                        codeOwnerSet.annotations().get(codeOwnerReference).stream()
+                            .map(CodeOwnerAnnotation::key)
+                            .collect(toImmutableSortedSet(naturalOrder())),
+                        codeOwnerReference));
+
+        codeOwnersByAnnotations
+            .asMap()
+            .forEach(
+                (annotations, codeOwners) ->
+                    b.append(
+                        String.format(
+                            PER_FILE_LINE_FORMAT,
+                            formattedPathExpressions,
+                            formatCodeOwnerReferencesAsList(codeOwners)
+                                + formatAnnotations(annotations))));
       }
       return b.toString();
     }
 
     private static String formatCodeOwnerReferencesAsList(
-        ImmutableSet<CodeOwnerReference> codeOwnerReferences) {
+        Collection<CodeOwnerReference> codeOwnerReferences) {
       return formatValuesAsList(codeOwnerReferences.stream().map(CodeOwnerReference::email));
     }
 
+    private static String formatAnnotations(SortedSet<String> annotations) {
+      if (annotations.isEmpty()) {
+        return "";
+      }
+
+      return annotations.stream()
+          .map(annotation -> "#{" + annotation + "}")
+          .collect(joining(" ", " ", ""));
+    }
+
     private static String formatValuesAsList(ImmutableSet<String> values) {
       return formatValuesAsList(values.stream());
     }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/findowners/ParsedEmailLine.java b/java/com/google/gerrit/plugins/codeowners/backend/findowners/ParsedEmailLine.java
new file mode 100644
index 0000000..19d5004
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/findowners/ParsedEmailLine.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2021 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.plugins.codeowners.backend.findowners;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerAnnotation;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
+
+/**
+ * A parsed email line of an {@code OWNERS} file, consisting out of an email and optionally
+ * annotations for that email.
+ */
+@AutoValue
+abstract class ParsedEmailLine {
+  abstract CodeOwnerReference codeOwnerReference();
+
+  abstract ImmutableSet<CodeOwnerAnnotation> annotations();
+
+  static ParsedEmailLine.Builder builder(String email) {
+    requireNonNull(email, "email");
+    return new AutoValue_ParsedEmailLine.Builder()
+        .codeOwnerReference(CodeOwnerReference.create(email));
+  }
+
+  @AutoValue.Builder
+  abstract static class Builder {
+    abstract Builder codeOwnerReference(CodeOwnerReference codeOwnerReference);
+
+    abstract ImmutableSet.Builder<CodeOwnerAnnotation> annotationsBuilder();
+
+    Builder addAnnotation(String annotation) {
+      requireNonNull(annotation, "annotation");
+      annotationsBuilder().add(CodeOwnerAnnotation.create(annotation));
+      return this;
+    }
+
+    abstract ParsedEmailLine build();
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/proto/ProtoBackend.java b/java/com/google/gerrit/plugins/codeowners/backend/proto/ProtoBackend.java
index b273ab2..ccb59df 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/proto/ProtoBackend.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/proto/ProtoBackend.java
@@ -18,8 +18,7 @@
 import com.google.gerrit.plugins.codeowners.backend.AbstractFileBasedCodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigFile;
-import com.google.gerrit.plugins.codeowners.backend.PathExpressionMatcher;
-import com.google.gerrit.plugins.codeowners.backend.SimplePathExpressionMatcher;
+import com.google.gerrit.plugins.codeowners.backend.PathExpressions;
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -67,7 +66,7 @@
   }
 
   @Override
-  public Optional<PathExpressionMatcher> getPathExpressionMatcher() {
-    return Optional.of(SimplePathExpressionMatcher.INSTANCE);
+  public Optional<PathExpressions> getDefaultPathExpressions() {
+    return Optional.of(PathExpressions.SIMPLE);
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/metrics/CodeOwnerMetrics.java b/java/com/google/gerrit/plugins/codeowners/metrics/CodeOwnerMetrics.java
index 89fac59..d6ac638 100644
--- a/java/com/google/gerrit/plugins/codeowners/metrics/CodeOwnerMetrics.java
+++ b/java/com/google/gerrit/plugins/codeowners/metrics/CodeOwnerMetrics.java
@@ -32,14 +32,12 @@
 @Singleton
 public class CodeOwnerMetrics {
   // latency metrics
-  public final Timer0 addChangeMessageOnAddReviewer;
-  public final Timer0 computeChangedFiles;
+  public final Timer1<String> addChangeMessageOnAddReviewer;
   public final Timer0 computeFileStatus;
   public final Timer0 computeFileStatuses;
   public final Timer0 computeOwnedPaths;
   public final Timer0 computePatchSetApprovals;
   public final Timer0 extendChangeMessageOnPostReview;
-  public final Timer0 getAutoMerge;
   public final Timer0 getChangedFiles;
   public final Timer0 prepareFileStatusComputation;
   public final Timer0 prepareFileStatusComputationForAccount;
@@ -51,17 +49,21 @@
   public final Timer0 runCodeOwnerSubmitRule;
 
   // code owner config metrics
+  public final Histogram0 codeOwnerCacheReadsPerChange;
   public final Histogram0 codeOwnerConfigBackendReadsPerChange;
   public final Histogram0 codeOwnerConfigCacheReadsPerChange;
+  public final Histogram0 codeOwnerResolutionsPerChange;
   public final Timer1<String> loadCodeOwnerConfig;
   public final Timer0 readCodeOwnerConfig;
   public final Timer1<String> parseCodeOwnerConfig;
 
   // counter metrics
+  public final Counter0 countCodeOwnerCacheReads;
   public final Counter0 countCodeOwnerConfigReads;
   public final Counter0 countCodeOwnerConfigCacheReads;
   public final Counter3<ValidationTrigger, ValidationResult, Boolean>
       countCodeOwnerConfigValidations;
+  public final Counter0 countCodeOwnerResolutions;
   public final Counter1<String> countCodeOwnerSubmitRuleErrors;
   public final Counter0 countCodeOwnerSubmitRuleRuns;
   public final Counter1<Boolean> countCodeOwnerSuggestions;
@@ -75,67 +77,64 @@
 
     // latency metrics
     this.addChangeMessageOnAddReviewer =
-        createLatencyTimer(
+        createTimer(
             "add_change_message_on_add_reviewer",
             "Latency for adding a change message with the owned path when a code owner is added as"
-                + " a reviewer");
-    this.computeChangedFiles =
-        createLatencyTimer("compute_changed_files", "Latency for computing changed files");
+                + " a reviewer",
+            Field.ofString("post_type", (metadataBuilder, fieldValue) -> {})
+                .description(
+                    "Whether the change message was posted synchronously or asynchronously.")
+                .build());
     this.computeFileStatus =
-        createLatencyTimer(
-            "compute_file_status", "Latency for computing the file status of one file");
+        createTimer("compute_file_status", "Latency for computing the file status of one file");
     this.computeFileStatuses =
-        createLatencyTimer(
+        createTimer(
             "compute_file_statuses",
             "Latency for computing file statuses for all files in a change");
     this.computeOwnedPaths =
-        createLatencyTimer(
+        createTimer(
             "compute_owned_paths",
             "Latency for computing the files in a change that are owned by a user");
     this.computePatchSetApprovals =
-        createLatencyTimer(
+        createTimer(
             "compute_patch_set_approvals",
             "Latency for computing the approvals of the current patch set");
     this.extendChangeMessageOnPostReview =
-        createLatencyTimer(
+        createTimer(
             "extend_change_message_on_post_review",
             "Latency for extending the change message with the owned path when a code owner"
                 + " approval is applied");
-    this.getAutoMerge =
-        createLatencyTimer(
-            "get_auto_merge", "Latency for getting the auto merge commit of a merge commit");
     this.getChangedFiles =
-        createLatencyTimer(
-            "get_changed_files", "Latency for getting changed files from diff cache");
+        createTimer("get_changed_files", "Latency for getting changed files from diff cache");
     this.prepareFileStatusComputation =
-        createLatencyTimer(
+        createTimer(
             "prepare_file_status_computation", "Latency for preparing the file status computation");
     this.prepareFileStatusComputationForAccount =
-        createLatencyTimer(
+        createTimer(
             "compute_file_statuses_for_account",
             "Latency for computing file statuses for an account");
     this.resolveCodeOwnerConfig =
-        createLatencyTimer(
-            "resolve_code_owner_config", "Latency for resolving a code owner config file");
+        createTimer("resolve_code_owner_config", "Latency for resolving a code owner config file");
     this.resolveCodeOwnerConfigImport =
-        createLatencyTimer(
+        createTimer(
             "resolve_code_owner_config_import",
             "Latency for resolving an import of a code owner config file");
     this.resolveCodeOwnerConfigImports =
-        createLatencyTimer(
+        createTimer(
             "resolve_code_owner_config_imports",
             "Latency for resolving all imports of a code owner config file");
     this.resolveCodeOwnerReferences =
-        createLatencyTimer(
+        createTimer(
             "resolve_code_owner_references", "Latency for resolving the code owner references");
     this.resolvePathCodeOwners =
-        createLatencyTimer(
-            "resolve_path_code_owners", "Latency for resolving the code owners of a path");
+        createTimer("resolve_path_code_owners", "Latency for resolving the code owners of a path");
     this.runCodeOwnerSubmitRule =
-        createLatencyTimer(
-            "run_code_owner_submit_rule", "Latency for running the code owner submit rule");
+        createTimer("run_code_owner_submit_rule", "Latency for running the code owner submit rule");
 
     // code owner config metrics
+    this.codeOwnerCacheReadsPerChange =
+        createHistogram(
+            "code_owner_cache_reads_per_change", "Number of code owner cache reads per change");
     this.codeOwnerConfigBackendReadsPerChange =
         createHistogram(
             "code_owner_config_backend_reads_per_change",
@@ -144,6 +143,9 @@
         createHistogram(
             "code_owner_config_cache_reads_per_change",
             "Number of code owner config cache reads per change");
+    this.codeOwnerResolutionsPerChange =
+        createHistogram(
+            "code_owner_resolutions_per_change", "Number of code owner resolutions per change");
     this.loadCodeOwnerConfig =
         createTimerWithClassField(
             "load_code_owner_config",
@@ -153,10 +155,12 @@
         createTimerWithClassField(
             "parse_code_owner_config", "Latency for parsing a code owner config file", "parser");
     this.readCodeOwnerConfig =
-        createLatencyTimer(
-            "read_code_owner_config", "Latency for reading a code owner config file");
+        createTimer("read_code_owner_config", "Latency for reading a code owner config file");
 
     // counter metrics
+    this.countCodeOwnerCacheReads =
+        createCounter(
+            "count_code_owner_cache_reads", "Total number of code owner reads from cache");
     this.countCodeOwnerConfigReads =
         createCounter(
             "count_code_owner_config_reads",
@@ -179,6 +183,8 @@
             Field.ofBoolean("dry_run", (metadataBuilder, resolveAllUsers) -> {})
                 .description("Whether the validation was a dry run.")
                 .build());
+    this.countCodeOwnerResolutions =
+        createCounter("count_code_owner_resolutions", "Total number of code owner resolutions");
     this.countCodeOwnerSubmitRuleErrors =
         createCounter1(
             "count_code_owner_submit_rule_errors",
@@ -216,22 +222,25 @@
                 .build());
   }
 
-  private Timer0 createLatencyTimer(String name, String description) {
+  private Timer0 createTimer(String name, String description) {
     return metricMaker.newTimer(
         name, new Description(description).setCumulative().setUnit(Units.MILLISECONDS));
   }
 
+  private <F1> Timer1<F1> createTimer(String name, String description, Field<F1> field) {
+    return metricMaker.newTimer(
+        name,
+        new Description(description).setCumulative().setUnit(Description.Units.MILLISECONDS),
+        field);
+  }
+
   private Timer1<String> createTimerWithClassField(
       String name, String description, String fieldName) {
     Field<String> CODE_OWNER_BACKEND_FIELD =
         Field.ofString(
                 fieldName, (metadataBuilder, fieldValue) -> metadataBuilder.className(fieldValue))
             .build();
-
-    return metricMaker.newTimer(
-        name,
-        new Description(description).setCumulative().setUnit(Description.Units.MILLISECONDS),
-        CODE_OWNER_BACKEND_FIELD);
+    return createTimer(name, description, CODE_OWNER_BACKEND_FIELD);
   }
 
   private Counter0 createCounter(String name, String description) {
diff --git a/java/com/google/gerrit/plugins/codeowners/module/Module.java b/java/com/google/gerrit/plugins/codeowners/module/PluginModule.java
similarity index 96%
rename from java/com/google/gerrit/plugins/codeowners/module/Module.java
rename to java/com/google/gerrit/plugins/codeowners/module/PluginModule.java
index c5447f6..96f5e1e 100644
--- a/java/com/google/gerrit/plugins/codeowners/module/Module.java
+++ b/java/com/google/gerrit/plugins/codeowners/module/PluginModule.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.plugins.codeowners.validation.ValidationModule;
 
 /** Guice module that registers the extensions of the code-owners plugin. */
-public class Module extends FactoryModule {
+public class PluginModule extends FactoryModule {
   @Override
   protected void configure() {
     install(new ApiModule());
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/AbstractGetCodeOwnersForPath.java b/java/com/google/gerrit/plugins/codeowners/restapi/AbstractGetCodeOwnersForPath.java
index 85db8e3..56aa627 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/AbstractGetCodeOwnersForPath.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/AbstractGetCodeOwnersForPath.java
@@ -16,11 +16,16 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.gerrit.plugins.codeowners.backend.CodeOwnerScore.IS_EXPLICITLY_MENTIONED_SCORING_VALUE;
+import static com.google.gerrit.plugins.codeowners.backend.CodeOwnerScore.NOT_EXPLICITLY_MENTIONED_SCORING_VALUE;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
@@ -34,6 +39,7 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnersInfo;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwner;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerAnnotation;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigHierarchy;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerResolver;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerResolverResult;
@@ -200,10 +206,13 @@
     int maxDistance = globalOwnersDistance;
 
     CodeOwnerScoring.Builder distanceScoring = CodeOwnerScore.DISTANCE.createScoring(maxDistance);
+    CodeOwnerScoring.Builder isExplicitlyMentionedScoring =
+        CodeOwnerScore.IS_EXPLICITLY_MENTIONED.createScoring();
 
     Set<CodeOwner> codeOwners = new HashSet<>();
+    ListMultimap<CodeOwner, CodeOwnerAnnotation> annotations = LinkedListMultimap.create();
     AtomicBoolean ownedByAllUsers = new AtomicBoolean(false);
-    List<String> debugLogs = new ArrayList<>();
+    ImmutableList.Builder<String> debugLogsBuilder = ImmutableList.builder();
     codeOwnerConfigHierarchy.visit(
         rsrc.getBranch(),
         rsrc.getRevision(),
@@ -212,11 +221,9 @@
           CodeOwnerResolverResult pathCodeOwners =
               codeOwnerResolver.get().resolvePathCodeOwners(codeOwnerConfig, rsrc.getPath());
 
-          if (debug) {
-            debugLogs.addAll(pathCodeOwners.messages());
-          }
-
-          codeOwners.addAll(filterCodeOwners(rsrc, pathCodeOwners.codeOwners()));
+          debugLogsBuilder.addAll(pathCodeOwners.messages());
+          codeOwners.addAll(pathCodeOwners.codeOwners());
+          annotations.putAll(pathCodeOwners.annotations());
 
           int distance =
               codeOwnerConfig.key().branchNameKey().branch().equals(RefNames.REFS_CONFIG)
@@ -225,14 +232,21 @@
           pathCodeOwners
               .codeOwners()
               .forEach(
-                  localCodeOwner -> distanceScoring.putValueForCodeOwner(localCodeOwner, distance));
+                  localCodeOwner -> {
+                    distanceScoring.putValueForCodeOwner(localCodeOwner, distance);
+                    isExplicitlyMentionedScoring.putValueForCodeOwner(
+                        localCodeOwner, IS_EXPLICITLY_MENTIONED_SCORING_VALUE);
+                  });
 
           if (pathCodeOwners.ownedByAllUsers()) {
             ownedByAllUsers.set(true);
-            ImmutableSet<CodeOwner> addedCodeOwners =
-                fillUpWithRandomUsers(rsrc, codeOwners, limit);
+            ImmutableSet<CodeOwner> addedCodeOwners = fillUpWithRandomUsers(codeOwners, limit);
             addedCodeOwners.forEach(
-                localCodeOwner -> distanceScoring.putValueForCodeOwner(localCodeOwner, distance));
+                localCodeOwner -> {
+                  distanceScoring.putValueForCodeOwner(localCodeOwner, distance);
+                  isExplicitlyMentionedScoring.putValueForCodeOwner(
+                      localCodeOwner, NOT_EXPLICITLY_MENTIONED_SCORING_VALUE);
+                });
 
             if (codeOwners.size() < limit) {
               logger.atFine().log(
@@ -251,33 +265,48 @@
           return true;
         });
 
-    if (codeOwners.size() < limit || !ownedByAllUsers.get()) {
+    if (!ownedByAllUsers.get()) {
       CodeOwnerResolverResult globalCodeOwners = getGlobalCodeOwners(rsrc.getBranch().project());
 
-      if (debug) {
-        debugLogs.add("resolve global code owners");
-        debugLogs.addAll(globalCodeOwners.messages());
-      }
+      debugLogsBuilder.add("resolve global code owners");
+      debugLogsBuilder.addAll(globalCodeOwners.messages());
 
       globalCodeOwners
           .codeOwners()
           .forEach(
-              codeOwner -> distanceScoring.putValueForCodeOwner(codeOwner, globalOwnersDistance));
-      codeOwners.addAll(filterCodeOwners(rsrc, globalCodeOwners.codeOwners()));
+              codeOwner -> {
+                distanceScoring.putValueForCodeOwner(codeOwner, globalOwnersDistance);
+                isExplicitlyMentionedScoring.putValueForCodeOwner(
+                    codeOwner, IS_EXPLICITLY_MENTIONED_SCORING_VALUE);
+              });
+      codeOwners.addAll(globalCodeOwners.codeOwners());
 
       if (globalCodeOwners.ownedByAllUsers()) {
         ownedByAllUsers.set(true);
-        ImmutableSet<CodeOwner> addedCodeOwners = fillUpWithRandomUsers(rsrc, codeOwners, limit);
+        ImmutableSet<CodeOwner> addedCodeOwners = fillUpWithRandomUsers(codeOwners, limit);
         addedCodeOwners.forEach(
-            codeOwner -> distanceScoring.putValueForCodeOwner(codeOwner, globalOwnersDistance));
+            codeOwner -> {
+              distanceScoring.putValueForCodeOwner(codeOwner, globalOwnersDistance);
+              isExplicitlyMentionedScoring.putValueForCodeOwner(
+                  codeOwner, NOT_EXPLICITLY_MENTIONED_SCORING_VALUE);
+            });
       }
     }
 
-    ImmutableSet<CodeOwner> immutableCodeOwners = ImmutableSet.copyOf(codeOwners);
+    ImmutableSet<CodeOwner> filteredCodeOwners =
+        filterCodeOwners(
+            rsrc,
+            ImmutableMultimap.copyOf(annotations),
+            ImmutableSet.copyOf(codeOwners),
+            debugLogsBuilder);
     CodeOwnerScorings codeOwnerScorings =
-        createScorings(rsrc, immutableCodeOwners, distanceScoring.build());
+        createScorings(
+            rsrc,
+            filteredCodeOwners,
+            distanceScoring.build(),
+            isExplicitlyMentionedScoring.build());
     ImmutableMap<CodeOwner, Double> scoredCodeOwners =
-        codeOwnerScorings.getScorings(immutableCodeOwners);
+        codeOwnerScorings.getScorings(filteredCodeOwners);
 
     ImmutableList<CodeOwner> sortedAndLimitedCodeOwners = sortAndLimit(rsrc, scoredCodeOwners);
 
@@ -296,14 +325,18 @@
     codeOwnersInfo.codeOwners =
         codeOwnerJsonFactory.create(getFillOptions()).format(sortedAndLimitedCodeOwners);
     codeOwnersInfo.ownedByAllUsers = ownedByAllUsers.get() ? true : null;
+
+    ImmutableList<String> debugLogs = debugLogsBuilder.build();
     codeOwnersInfo.debugLogs = debug ? debugLogs : null;
+    logger.atFine().log("debug logs: %s", debugLogs);
+
     return Response.ok(codeOwnersInfo);
   }
 
   private CodeOwnerScorings createScorings(
-      R rsrc, ImmutableSet<CodeOwner> codeOwners, CodeOwnerScoring distanceScoring) {
+      R rsrc, ImmutableSet<CodeOwner> codeOwners, CodeOwnerScoring... scorings) {
     ImmutableSet.Builder<CodeOwnerScoring> codeOwnerScorings = ImmutableSet.builder();
-    codeOwnerScorings.add(distanceScoring);
+    codeOwnerScorings.addAll(ImmutableSet.copyOf(scorings));
     codeOwnerScorings.addAll(getCodeOwnerScorings(rsrc, codeOwners));
     return CodeOwnerScorings.create(codeOwnerScorings.build());
   }
@@ -343,18 +376,29 @@
    *       normally doesn't make sense since they will not react to review requests.
    * </ul>
    */
-  private ImmutableSet<CodeOwner> filterCodeOwners(R rsrc, ImmutableSet<CodeOwner> codeOwners) {
-    return filterCodeOwners(rsrc, getVisibleCodeOwners(rsrc, codeOwners)).collect(toImmutableSet());
+  private ImmutableSet<CodeOwner> filterCodeOwners(
+      R rsrc,
+      ImmutableMultimap<CodeOwner, CodeOwnerAnnotation> annotations,
+      ImmutableSet<CodeOwner> codeOwners,
+      ImmutableList.Builder<String> debugLogs) {
+    return filterCodeOwners(rsrc, annotations, getVisibleCodeOwners(rsrc, codeOwners), debugLogs)
+        .collect(toImmutableSet());
   }
 
   /**
    * To be overridden by subclasses to filter out additional code owners.
    *
    * @param rsrc resource on which the request is being performed
+   * @param annotations annotations that were set on the code owners
    * @param codeOwners stream of code owners that should be filtered
+   * @param debugLogs builder to collect debug logs that may be returned to the caller
    * @return the filtered stream of code owners
    */
-  protected Stream<CodeOwner> filterCodeOwners(R rsrc, Stream<CodeOwner> codeOwners) {
+  protected Stream<CodeOwner> filterCodeOwners(
+      R rsrc,
+      ImmutableMultimap<CodeOwner, CodeOwnerAnnotation> annotations,
+      Stream<CodeOwner> codeOwners,
+      ImmutableList.Builder<String> debugLogs) {
     return codeOwners;
   }
 
@@ -484,8 +528,7 @@
    *
    * @return the added code owners
    */
-  private ImmutableSet<CodeOwner> fillUpWithRandomUsers(
-      R rsrc, Set<CodeOwner> codeOwners, int limit) {
+  private ImmutableSet<CodeOwner> fillUpWithRandomUsers(Set<CodeOwner> codeOwners, int limit) {
     if (!resolveAllUsers || codeOwners.size() >= limit) {
       // code ownership for all users should not be resolved or the limit has already been reached
       // so that we don't need to add further suggestions
@@ -494,17 +537,12 @@
 
     logger.atFine().log("filling up with random users");
     ImmutableSet<CodeOwner> codeOwnersToAdd =
-        filterCodeOwners(
-                rsrc,
-                // ask for 2 times the number of users that we need so that we still have enough
-                // suggestions when some users are removed by the filterCodeOwners call or if the
-                // returned users were already present in codeOwners
-                getRandomVisibleUsers(2 * limit - codeOwners.size())
-                    .map(CodeOwner::create)
-                    .collect(toImmutableSet()))
-            .stream()
+        // ask for 2 times the number of users that we need so that we still have enough
+        // suggestions when some users are removed on the filter step later or if the returned users
+        // were already present in codeOwners
+        getRandomVisibleUsers(2 * limit - codeOwners.size()).map(CodeOwner::create)
+            .collect(toImmutableSet()).stream()
             .filter(codeOwner -> !codeOwners.contains(codeOwner))
-            .limit(limit - codeOwners.size())
             .collect(toImmutableSet());
     codeOwners.addAll(codeOwnersToAdd);
     return codeOwnersToAdd;
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwner.java b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwner.java
index f4e5c0f..9c4e144 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwner.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwner.java
@@ -14,8 +14,12 @@
 
 package com.google.gerrit.plugins.codeowners.restapi;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -27,6 +31,7 @@
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerCheckInfo;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwner;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerAnnotations;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigHierarchy;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerResolver;
@@ -57,8 +62,10 @@
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Optional;
+import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -157,6 +164,7 @@
     AtomicBoolean isDefaultCodeOwner = new AtomicBoolean(false);
     AtomicBoolean hasRevelantCodeOwnerDefinitions = new AtomicBoolean(false);
     AtomicBoolean parentCodeOwnersAreIgnored = new AtomicBoolean(false);
+    Set<String> annotations = new HashSet<>();
     codeOwnerConfigHierarchy.visit(
         branchResource.getBranchKey(),
         ObjectId.fromString(branchResource.getRevision()),
@@ -187,15 +195,23 @@
             if (RefNames.isConfigRef(codeOwnerConfig.key().ref())) {
               messages.add(
                   String.format(
-                      "found email %s as code owner in default code owner config", email));
+                      "found email %s as a code owner in the default code owner config", email));
               isDefaultCodeOwner.set(true);
             } else {
               Path codeOwnerConfigFilePath = codeOwners.getFilePath(codeOwnerConfig.key());
               messages.add(
                   String.format(
-                      "found email %s as code owner in %s", email, codeOwnerConfigFilePath));
+                      "found email %s as a code owner in %s", email, codeOwnerConfigFilePath));
               codeOwnerConfigFilePaths.add(codeOwnerConfigFilePath);
             }
+
+            ImmutableSet<String> localAnnotations =
+                pathCodeOwnersResult.get().getAnnotationsFor(email);
+            if (!localAnnotations.isEmpty()) {
+              messages.add(
+                  String.format("email %s is annotated with %s", email, sort(localAnnotations)));
+              annotations.addAll(localAnnotations);
+            }
           }
 
           if (pathCodeOwnersResult.get().getPathCodeOwners().stream()
@@ -205,19 +221,31 @@
             if (RefNames.isConfigRef(codeOwnerConfig.key().ref())) {
               messages.add(
                   String.format(
-                      "found email %s as code owner in default code owner config",
-                      CodeOwnerResolver.ALL_USERS_WILDCARD));
+                      "found the all users wildcard ('%s') as a code owner in the default code"
+                          + " owner config which makes %s a code owner",
+                      CodeOwnerResolver.ALL_USERS_WILDCARD, email));
               isDefaultCodeOwner.set(true);
             } else {
               Path codeOwnerConfigFilePath = codeOwners.getFilePath(codeOwnerConfig.key());
               messages.add(
                   String.format(
-                      "found email %s as code owner in %s",
-                      CodeOwnerResolver.ALL_USERS_WILDCARD, codeOwnerConfigFilePath));
+                      "found the all users wildcard ('%s') as a code owner in %s which makes %s a"
+                          + " code owner",
+                      CodeOwnerResolver.ALL_USERS_WILDCARD, codeOwnerConfigFilePath, email));
               if (!codeOwnerConfigFilePaths.contains(codeOwnerConfigFilePath)) {
                 codeOwnerConfigFilePaths.add(codeOwnerConfigFilePath);
               }
             }
+
+            ImmutableSet<String> localAnnotations =
+                pathCodeOwnersResult.get().getAnnotationsFor(CodeOwnerResolver.ALL_USERS_WILDCARD);
+            if (!localAnnotations.isEmpty()) {
+              messages.add(
+                  String.format(
+                      "found annotations for the all users wildcard ('%s') which apply to %s: %s",
+                      CodeOwnerResolver.ALL_USERS_WILDCARD, email, sort(localAnnotations)));
+              annotations.addAll(localAnnotations);
+            }
           }
 
           if (codeOwnerResolverProvider
@@ -282,6 +310,17 @@
       }
     }
 
+    ImmutableSet<String> unsupportedAnnotations =
+        annotations.stream()
+            .filter(annotation -> !CodeOwnerAnnotations.isSupported(annotation))
+            .collect(toImmutableSet());
+    if (!unsupportedAnnotations.isEmpty()) {
+      messages.add(
+          String.format(
+              "dropping unsupported annotations for %s: %s", email, sort(unsupportedAnnotations)));
+      annotations.removeAll(unsupportedAnnotations);
+    }
+
     boolean isFallbackCodeOwner =
         !isCodeOwnershipAssignedToEmail.get()
             && !isCodeOwnershipAssignedToAllUsers.get()
@@ -305,6 +344,7 @@
     codeOwnerCheckInfo.isDefaultCodeOwner = isDefaultCodeOwner.get();
     codeOwnerCheckInfo.isGlobalCodeOwner = isGlobalCodeOwner;
     codeOwnerCheckInfo.isOwnedByAllUsers = isCodeOwnershipAssignedToAllUsers.get();
+    codeOwnerCheckInfo.annotations = sort(annotations);
     codeOwnerCheckInfo.debugLogs = messages;
     return Response.ok(codeOwnerCheckInfo);
   }
@@ -408,4 +448,8 @@
     }
     return OptionalResultWithMessages.createEmpty(messages);
   }
+
+  private ImmutableList<String> sort(Set<String> set) {
+    return set.stream().sorted().collect(toImmutableList());
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFiles.java b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFiles.java
index 1eaaed6..1b931ba 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFiles.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFiles.java
@@ -41,7 +41,6 @@
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.plugins.codeowners.validation.CodeOwnerConfigValidator;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -55,8 +54,6 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
  * REST endpoint that checks/validates the code owner config files in a project.
@@ -75,7 +72,6 @@
 
   private final Provider<CurrentUser> currentUser;
   private final PermissionBackend permissionBackend;
-  private final GitRepositoryManager repoManager;
   private final Provider<ListBranches> listBranches;
   private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
   private final CodeOwnerConfigScanner.Factory codeOwnerConfigScannerFactory;
@@ -85,14 +81,12 @@
   public CheckCodeOwnerConfigFiles(
       Provider<CurrentUser> currentUser,
       PermissionBackend permissionBackend,
-      GitRepositoryManager repoManager,
       Provider<ListBranches> listBranches,
       CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
       CodeOwnerConfigScanner.Factory codeOwnerConfigScannerFactory,
       CodeOwnerConfigValidator codeOwnerConfigValidator) {
     this.currentUser = currentUser;
     this.permissionBackend = permissionBackend;
-    this.repoManager = repoManager;
     this.listBranches = listBranches;
     this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
     this.codeOwnerConfigScannerFactory = codeOwnerConfigScannerFactory;
@@ -126,25 +120,22 @@
 
     validateInput(projectResource.getNameKey(), branches, input);
 
-    try (Repository repo = repoManager.openRepository(projectResource.getNameKey());
-        RevWalk revWalk = new RevWalk(repo)) {
-      ImmutableMap.Builder<String, Map<String, List<ConsistencyProblemInfo>>>
-          resultsByBranchBuilder = ImmutableMap.builder();
-      branches.stream()
-          .filter(branchNameKey -> shouldValidateBranch(input, branchNameKey))
-          .filter(
-              branchNameKey ->
-                  validateDisabledBranches(input)
-                      || !codeOwnersPluginConfiguration
-                          .getProjectConfig(branchNameKey.project())
-                          .isDisabled(branchNameKey.branch()))
-          .forEach(
-              branchNameKey ->
-                  resultsByBranchBuilder.put(
-                      branchNameKey.branch(),
-                      checkBranch(revWalk, input.path, branchNameKey, input.verbosity)));
-      return Response.ok(resultsByBranchBuilder.build());
-    }
+    ImmutableMap.Builder<String, Map<String, List<ConsistencyProblemInfo>>> resultsByBranchBuilder =
+        ImmutableMap.builder();
+    branches.stream()
+        .filter(branchNameKey -> shouldValidateBranch(input, branchNameKey))
+        .filter(
+            branchNameKey ->
+                validateDisabledBranches(input)
+                    || !codeOwnersPluginConfiguration
+                        .getProjectConfig(branchNameKey.project())
+                        .isDisabled(branchNameKey.branch()))
+        .forEach(
+            branchNameKey ->
+                resultsByBranchBuilder.put(
+                    branchNameKey.branch(),
+                    checkBranch(input.path, branchNameKey, input.verbosity)));
+    return Response.ok(resultsByBranchBuilder.build());
   }
 
   private ImmutableSet<BranchNameKey> branches(ProjectResource projectResource)
@@ -156,7 +147,6 @@
   }
 
   private Map<String, List<ConsistencyProblemInfo>> checkBranch(
-      RevWalk revWalk,
       String pathGlob,
       BranchNameKey branchNameKey,
       @Nullable ConsistencyProblemInfo.Status verbosity) {
@@ -177,7 +167,7 @@
               problemsByPath.putAll(
                   codeOwnerBackend.getFilePath(codeOwnerConfig.key()).toString(),
                   checkCodeOwnerConfig(
-                      branchNameKey, revWalk, codeOwnerBackend, codeOwnerConfig, verbosity));
+                      branchNameKey, codeOwnerBackend, codeOwnerConfig, verbosity));
               return true;
             },
             (codeOwnerConfigFilePath, configInvalidException) -> {
@@ -193,17 +183,12 @@
 
   private ImmutableList<ConsistencyProblemInfo> checkCodeOwnerConfig(
       BranchNameKey branchNameKey,
-      RevWalk revWalk,
       CodeOwnerBackend codeOwnerBackend,
       CodeOwnerConfig codeOwnerConfig,
       @Nullable ConsistencyProblemInfo.Status verbosity) {
     return codeOwnerConfigValidator
         .validateCodeOwnerConfig(
-            branchNameKey,
-            revWalk,
-            currentUser.get().asIdentifiedUser(),
-            codeOwnerBackend,
-            codeOwnerConfig)
+            branchNameKey, currentUser.get().asIdentifiedUser(), codeOwnerBackend, codeOwnerConfig)
         .map(
             commitValidationMessage ->
                 createConsistencyProblemInfo(commitValidationMessage, verbosity))
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFilesInRevision.java b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFilesInRevision.java
index db939c4..af4b97f 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFilesInRevision.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFilesInRevision.java
@@ -29,7 +29,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -76,7 +76,7 @@
   @Override
   public Response<Map<String, List<ConsistencyProblemInfo>>> apply(
       RevisionResource revisionResource, CheckCodeOwnerConfigFilesInRevisionInput input)
-      throws IOException, PatchListNotAvailableException {
+      throws IOException, DiffNotAvailableException {
     logger.atFine().log(
         "checking code owner config files for revision %d of change %d (path = %s)",
         revisionResource.getPatchSet().number(),
@@ -95,7 +95,7 @@
         RevWalk rw = new RevWalk(repository)) {
       RevCommit commit = rw.parseCommit(revisionResource.getPatchSet().commitId());
       return Response.ok(
-          changedFiles.compute(revisionResource.getProject(), commit).stream()
+          changedFiles.getFromDiffCache(revisionResource.getProject(), commit).stream()
               // filter out deletions (files without new path)
               .filter(changedFile -> changedFile.newPath().isPresent())
               // filter out non code owner config files
@@ -122,7 +122,6 @@
                                   codeOwnerBackend,
                                   revisionResource.getChange().getDest(),
                                   changedFile,
-                                  rw,
                                   commit)
                               .map(
                                   commitValidationMessage ->
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerStatusInfoJson.java b/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerStatusInfoJson.java
index bba3dbe..6872371 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerStatusInfoJson.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerStatusInfoJson.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.plugins.codeowners.restapi;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 
@@ -22,6 +23,9 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Maps;
+import com.google.common.collect.Streams;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.common.ChangeType;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatusInfo;
@@ -30,11 +34,23 @@
 import com.google.gerrit.plugins.codeowners.backend.FileCodeOwnerStatus;
 import com.google.gerrit.plugins.codeowners.backend.PathCodeOwnerStatus;
 import com.google.gerrit.plugins.codeowners.util.JgitPath;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.util.AccountTemplateUtil;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.util.Comparator;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.regex.Matcher;
+import java.util.stream.Stream;
 import org.eclipse.jgit.diff.DiffEntry;
 
 /** Collection of routines to populate {@link CodeOwnerStatusInfo}. */
+@Singleton
 public class CodeOwnerStatusInfoJson {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   /** Comparator that sorts {@link FileCodeOwnerStatus} by new path and then old path. */
   private static final Comparator<FileCodeOwnerStatus> FILE_CODE_OWNER_STATUS_COMPARATOR =
       comparing(
@@ -60,6 +76,13 @@
               .put(DiffEntry.ChangeType.COPY, ChangeType.COPIED)
               .build());
 
+  private final AccountLoader.Factory accountLoaderFactory;
+
+  @Inject
+  public CodeOwnerStatusInfoJson(AccountLoader.Factory accountLoaderFactory) {
+    this.accountLoaderFactory = accountLoaderFactory;
+  }
+
   /**
    * Formats a {@link CodeOwnerStatusInfo} from the provided file code owner statuses.
    *
@@ -68,10 +91,12 @@
    *     CodeOwnerStatusInfo}
    * @return the created {@link CodeOwnerStatusInfo}
    */
-  public static CodeOwnerStatusInfo format(
-      PatchSet.Id patchSetId, ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses) {
+  public CodeOwnerStatusInfo format(
+      PatchSet.Id patchSetId, ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses)
+      throws PermissionBackendException {
     requireNonNull(patchSetId, "patchSetId");
     requireNonNull(fileCodeOwnerStatuses, "fileCodeOwnerStatuses");
+
     CodeOwnerStatusInfo info = new CodeOwnerStatusInfo();
     info.patchSetNumber = patchSetId.get();
     info.fileCodeOwnerStatuses =
@@ -79,9 +104,55 @@
             .sorted(FILE_CODE_OWNER_STATUS_COMPARATOR)
             .map(CodeOwnerStatusInfoJson::format)
             .collect(toImmutableList());
+
+    AccountLoader accountLoader = accountLoaderFactory.create(/* detailed= */ true);
+    ImmutableSet<Account.Id> referencedAccounts = getReferencedAccounts(patchSetId, info);
+    info.accounts =
+        !referencedAccounts.isEmpty()
+            ? referencedAccounts.stream()
+                .map(accountLoader::get)
+                .collect(toImmutableMap(accountInfo -> accountInfo._accountId, Function.identity()))
+            : null;
+    accountLoader.fill();
+
     return info;
   }
 
+  private ImmutableSet<Account.Id> getReferencedAccounts(
+      PatchSet.Id patchSetId, CodeOwnerStatusInfo codeOwnerStatusInfo) {
+    ImmutableSet.Builder<Account.Id> referencedAccounts = ImmutableSet.builder();
+
+    codeOwnerStatusInfo.fileCodeOwnerStatuses.stream()
+        .flatMap(
+            fileCodeOwnerStatus ->
+                Streams.concat(
+                    fileCodeOwnerStatus.newPathStatus != null
+                            && fileCodeOwnerStatus.newPathStatus.reasons != null
+                        ? fileCodeOwnerStatus.newPathStatus.reasons.stream()
+                        : Stream.empty(),
+                    fileCodeOwnerStatus.oldPathStatus != null
+                            && fileCodeOwnerStatus.oldPathStatus.reasons != null
+                        ? fileCodeOwnerStatus.oldPathStatus.reasons.stream()
+                        : Stream.empty()))
+        .forEach(
+            reason -> {
+              Matcher matcher = AccountTemplateUtil.ACCOUNT_TEMPLATE_PATTERN.matcher(reason);
+              while (matcher.find()) {
+                String accountIdString = matcher.group(1);
+                Optional<Account.Id> accountId = Account.Id.tryParse(accountIdString);
+                if (accountId.isPresent()) {
+                  referencedAccounts.add(accountId.get());
+                } else {
+                  logger.atWarning().log(
+                      "reason that is returned for patchset %s of change %s references invalid"
+                          + " account ID %s (reason = \"%s\")",
+                      patchSetId.get(), patchSetId.changeId(), accountIdString, reason);
+                }
+              }
+            });
+    return referencedAccounts.build();
+  }
+
   /**
    * Formats the provided {@link FileCodeOwnerStatus} as {@link FileCodeOwnerStatusInfo}.
    *
@@ -119,13 +190,7 @@
     PathCodeOwnerStatusInfo info = new PathCodeOwnerStatusInfo();
     info.path = JgitPath.of(pathCodeOwnerStatus.path()).get();
     info.status = pathCodeOwnerStatus.status();
+    info.reasons = !pathCodeOwnerStatus.reasons().isEmpty() ? pathCodeOwnerStatus.reasons() : null;
     return info;
   }
-
-  /**
-   * Private constructor to prevent instantiation of this class.
-   *
-   * <p>The class only contains static methods, hence the class never needs to be instantiated.
-   */
-  private CodeOwnerStatusInfoJson() {}
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnersInChangeCollection.java b/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnersInChangeCollection.java
index ad0b1ad..20e8dfa 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnersInChangeCollection.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnersInChangeCollection.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.plugins.codeowners.restapi.CodeOwnersInChangeCollection.PathResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -68,7 +69,8 @@
 
   @Override
   public PathResource parse(RevisionResource revisionResource, IdString id)
-      throws RestApiException, IOException, PatchListNotAvailableException {
+      throws RestApiException, IOException, PatchListNotAvailableException,
+          DiffNotAvailableException {
     // Check if the file exists in the revision only after creating the path resource. This way we
     // get a more specific error response for invalid paths ('400 Bad Request' instead of a '404 Not
     // Found').
@@ -100,8 +102,8 @@
 
   private void checkThatFileExists(
       RevisionResource revisionResource, PathResource pathResource, IdString id)
-      throws RestApiException, IOException, PatchListNotAvailableException {
-    if (!changedFiles.compute(revisionResource).stream()
+      throws RestApiException, IOException, DiffNotAvailableException {
+    if (!changedFiles.getFromDiffCache(revisionResource).stream()
         .anyMatch(
             changedFile ->
                 // Check whether the path matches any file in the change.
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerStatus.java b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerStatus.java
index 1952965..c1984e6 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerStatus.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerStatus.java
@@ -56,6 +56,7 @@
   private static final int UNLIMITED = 0;
 
   private final CodeOwnerApprovalCheck codeOwnerApprovalCheck;
+  private final CodeOwnerStatusInfoJson codeOwnerStatusInfoJson;
 
   private int start;
   private int limit;
@@ -79,8 +80,11 @@
   }
 
   @Inject
-  public GetCodeOwnerStatus(CodeOwnerApprovalCheck codeOwnerApprovalCheck) {
+  public GetCodeOwnerStatus(
+      CodeOwnerApprovalCheck codeOwnerApprovalCheck,
+      CodeOwnerStatusInfoJson codeOwnerStatusInfoJson) {
     this.codeOwnerApprovalCheck = codeOwnerApprovalCheck;
+    this.codeOwnerStatusInfoJson = codeOwnerStatusInfoJson;
   }
 
   @Override
@@ -93,7 +97,7 @@
         codeOwnerApprovalCheck.getFileStatusesAsSet(
             changeResource.getNotes(), start, limit == UNLIMITED ? UNLIMITED : limit + 1);
     CodeOwnerStatusInfo codeOwnerStatusInfo =
-        CodeOwnerStatusInfoJson.format(
+        codeOwnerStatusInfoJson.format(
             changeResource.getNotes().getCurrentPatchSet().id(),
             limit == UNLIMITED
                 ? fileCodeOwnerStatuses
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInBranch.java b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInBranch.java
index 446b91e..e0a9145 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInBranch.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInBranch.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 
-import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -29,19 +29,20 @@
 import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.Accounts;
-import com.google.gerrit.server.change.IncludedInResolver;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
+import java.util.Optional;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.ReachabilityChecker;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.kohsuke.args4j.Option;
@@ -119,7 +120,13 @@
           branchNameKey.branch(),
           branchNameKey.project());
       RevCommit commit = rw.parseCommit(revisionId);
-      if (!IncludedInResolver.includedInAny(repository, rw, commit, ImmutableSet.of(ref))) {
+      RevCommit refCommit = rw.parseCommit(ref.getObjectId());
+
+      ReachabilityChecker checker = rw.getObjectReader().createReachabilityChecker(rw);
+      Optional<RevCommit> unreachable =
+          checker.areAllReachable(ImmutableList.of(commit), ImmutableList.of(refCommit).stream());
+
+      if (unreachable.isPresent()) {
         throw new BadRequestException("unknown revision");
       }
     } catch (InvalidObjectIdException | IncorrectObjectTypeException e) {
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInChange.java b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInChange.java
index e2aac82..3ed9a51 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInChange.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInChange.java
@@ -14,17 +14,22 @@
 
 package com.google.gerrit.plugins.codeowners.restapi;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.plugins.codeowners.backend.CodeOwnerScore.IS_REVIEWER_SCORING_VALUE;
 import static com.google.gerrit.plugins.codeowners.backend.CodeOwnerScore.NO_REVIEWER_SCORING_VALUE;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.flogger.FluentLogger;
+import com.google.common.hash.Hashing;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnersInfo;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwner;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerAnnotation;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerAnnotations;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigHierarchy;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerResolver;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerScore;
@@ -53,7 +58,6 @@
  */
 public class GetCodeOwnersForPathInChange
     extends AbstractGetCodeOwnersForPath<CodeOwnersInChangeCollection.PathResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final ServiceUserClassifier serviceUserClassifier;
 
@@ -92,8 +96,13 @@
 
   @Override
   protected Optional<Long> getDefaultSeed(CodeOwnersInChangeCollection.PathResource rsrc) {
-    // use the change number as seed so that the sort order for a change is always stable
-    return Optional.of(Long.valueOf(rsrc.getRevisionResource().getChange().getId().get()));
+    // We are using a hash of the change number as a seed so that the sort order for a change is
+    // always stable.
+    // Using a hash of the change number instead of the change number itself ensures that the seeds
+    // for changes in a change series are very distant. This is important because java.util.Random
+    // is prone to produce the same random numbers for seeds that are nearby.
+    return Optional.of(
+        Hashing.sha256().hashInt(rsrc.getRevisionResource().getChange().getId().get()).asLong());
   }
 
   /**
@@ -123,31 +132,101 @@
 
   @Override
   protected Stream<CodeOwner> filterCodeOwners(
-      CodeOwnersInChangeCollection.PathResource rsrc, Stream<CodeOwner> codeOwners) {
-    return codeOwners.filter(filterOutChangeOwner(rsrc)).filter(filterOutServiceUsers());
+      CodeOwnersInChangeCollection.PathResource rsrc,
+      ImmutableMultimap<CodeOwner, CodeOwnerAnnotation> annotations,
+      Stream<CodeOwner> codeOwners,
+      ImmutableList.Builder<String> debugLogs) {
+
+    // The change owner and service users should never be suggested, hence filter them out.
+    ImmutableList<CodeOwner> filteredCodeOwners =
+        codeOwners
+            .filter(filterOutChangeOwner(rsrc, debugLogs))
+            .filter(filterOutServiceUsers(debugLogs))
+            .collect(toImmutableList());
+
+    // Code owners that are annotated with #{LAST_RESORT_SUGGESTION} should be dropped from the
+    // suggestion, but only if it doesn't make the result empty. In other words this means that
+    // those code owners should be suggested if there are no other code owners.
+    ImmutableList<CodeOwner>
+        filteredCodeOwnersWithoutCodeOwnersThatAreAnnotatedWithLastResortSuggestion =
+            filteredCodeOwners.stream()
+                .filter(
+                    filterOutCodeOwnersThatAreAnnotatedWithLastResortSuggestion(
+                        rsrc, annotations, debugLogs))
+                .collect(toImmutableList());
+    if (filteredCodeOwnersWithoutCodeOwnersThatAreAnnotatedWithLastResortSuggestion.isEmpty()) {
+      // The result would be empty, hence return code owners that are annotated with
+      // #{LAST_RESORT_SUGGESTION}.
+      return filteredCodeOwners.stream();
+    }
+    return filteredCodeOwnersWithoutCodeOwnersThatAreAnnotatedWithLastResortSuggestion.stream();
   }
 
   private Predicate<CodeOwner> filterOutChangeOwner(
-      CodeOwnersInChangeCollection.PathResource rsrc) {
+      CodeOwnersInChangeCollection.PathResource rsrc, ImmutableList.Builder<String> debugLogs) {
     return codeOwner -> {
       if (!codeOwner.accountId().equals(rsrc.getRevisionResource().getChange().getOwner())) {
         // Returning true from the Predicate here means that the code owner should be kept.
         return true;
       }
-      logger.atFine().log(
-          "Filtering out %s because this code owner is the change owner", codeOwner);
+      debugLogs.add(
+          String.format("filtering out %s because this code owner is the change owner", codeOwner));
       // Returning false from the Predicate here means that the code owner should be filtered out.
       return false;
     };
   }
 
-  private Predicate<CodeOwner> filterOutServiceUsers() {
+  private Predicate<CodeOwner> filterOutCodeOwnersThatAreAnnotatedWithLastResortSuggestion(
+      CodeOwnersInChangeCollection.PathResource rsrc,
+      ImmutableMultimap<CodeOwner, CodeOwnerAnnotation> annotations,
+      ImmutableList.Builder<String> debugLogs) {
+    return codeOwner -> {
+      boolean lastResortSuggestion =
+          annotations.containsEntry(
+              codeOwner, CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION);
+
+      // If the code owner is already a reviewer, the code owner should always be suggested, even
+      // if annotated with LAST_RESORT_SUGGESTION_ANNOTATION.
+      if (isReviewer(rsrc, codeOwner)) {
+        if (lastResortSuggestion) {
+          debugLogs.add(
+              String.format(
+                  "ignoring %s annotation for %s because this code owner is a reviewer",
+                  CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION.key(), codeOwner));
+        }
+
+        // Returning true from the Predicate here means that the code owner should be kept.
+        return true;
+      }
+      if (!lastResortSuggestion) {
+        // Returning true from the Predicate here means that the code owner should be kept.
+        return true;
+      }
+      debugLogs.add(
+          String.format(
+              "filtering out %s because this code owner is annotated with %s",
+              codeOwner, CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION.key()));
+      // Returning false from the Predicate here means that the code owner should be filtered out.
+      return false;
+    };
+  }
+
+  private boolean isReviewer(CodeOwnersInChangeCollection.PathResource rsrc, CodeOwner codeOwner) {
+    return rsrc.getRevisionResource()
+        .getNotes()
+        .getReviewers()
+        .byState(ReviewerStateInternal.REVIEWER)
+        .contains(codeOwner.accountId());
+  }
+
+  private Predicate<CodeOwner> filterOutServiceUsers(ImmutableList.Builder<String> debugLogs) {
     return codeOwner -> {
       if (!isServiceUser(codeOwner)) {
         // Returning true from the Predicate here means that the code owner should be kept.
         return true;
       }
-      logger.atFine().log("Filtering out %s because this code owner is a service user", codeOwner);
+      debugLogs.add(
+          String.format("filtering out %s because this code owner is a service user", codeOwner));
       // Returning false from the Predicate here means that the code owner should be filtered out.
       return false;
     };
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/GetOwnedPaths.java b/java/com/google/gerrit/plugins/codeowners/restapi/GetOwnedPaths.java
index 1f2298b..c6a447b 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/GetOwnedPaths.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/GetOwnedPaths.java
@@ -24,8 +24,12 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.plugins.codeowners.api.OwnedChangedFileInfo;
+import com.google.gerrit.plugins.codeowners.api.OwnedPathInfo;
 import com.google.gerrit.plugins.codeowners.api.OwnedPathsInfo;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerApprovalCheck;
+import com.google.gerrit.plugins.codeowners.backend.OwnedChangedFile;
+import com.google.gerrit.plugins.codeowners.backend.OwnedPath;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
 import com.google.gerrit.server.change.RevisionResource;
@@ -89,7 +93,7 @@
 
     Account.Id accountId = resolveAccount();
 
-    ImmutableList<Path> ownedPaths =
+    ImmutableList<OwnedChangedFile> ownedChangedFiles =
         codeOwnerApprovalCheck.getOwnedPaths(
             revisionResource.getNotes(),
             revisionResource.getPatchSet(),
@@ -98,9 +102,16 @@
             limit + 1);
 
     OwnedPathsInfo ownedPathsInfo = new OwnedPathsInfo();
-    ownedPathsInfo.more = ownedPaths.size() > limit ? true : null;
+    ownedPathsInfo.more = ownedChangedFiles.size() > limit ? true : null;
+    ownedPathsInfo.ownedChangedFiles =
+        ownedChangedFiles.stream()
+            .limit(limit)
+            .map(GetOwnedPaths::toOwnedChangedFileInfo)
+            .collect(toImmutableList());
     ownedPathsInfo.ownedPaths =
-        ownedPaths.stream().limit(limit).map(Path::toString).collect(toImmutableList());
+        OwnedChangedFile.asPathStream(ownedChangedFiles.stream().limit(limit))
+            .map(Path::toString)
+            .collect(toImmutableList());
     return Response.ok(ownedPathsInfo);
   }
 
@@ -122,4 +133,22 @@
       throw new BadRequestException("limit must be positive");
     }
   }
+
+  private static OwnedChangedFileInfo toOwnedChangedFileInfo(OwnedChangedFile ownedChangedFile) {
+    OwnedChangedFileInfo info = new OwnedChangedFileInfo();
+    if (ownedChangedFile.newPath().isPresent()) {
+      info.newPath = toOwnedPathInfo(ownedChangedFile.newPath().get());
+    }
+    if (ownedChangedFile.oldPath().isPresent()) {
+      info.oldPath = toOwnedPathInfo(ownedChangedFile.oldPath().get());
+    }
+    return info;
+  }
+
+  private static OwnedPathInfo toOwnedPathInfo(OwnedPath ownedPath) {
+    OwnedPathInfo info = new OwnedPathInfo();
+    info.path = ownedPath.path().toString();
+    info.owned = ownedPath.owned() ? true : null;
+    return info;
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/BUILD b/java/com/google/gerrit/plugins/codeowners/testing/BUILD
index 202e994..c4a40d3 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/BUILD
+++ b/java/com/google/gerrit/plugins/codeowners/testing/BUILD
@@ -9,6 +9,7 @@
     name = "testing",
     srcs = glob(["*.java"]),
     deps = [
+        "//java/com/google/gerrit/acceptance:lib",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/truth",
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerCheckInfoSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerCheckInfoSubject.java
index 31fa4e0..3128b7f 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerCheckInfoSubject.java
+++ b/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerCheckInfoSubject.java
@@ -133,6 +133,10 @@
     check("isOwnedByAllUsers").that(codeOwnerCheckInfo().isOwnedByAllUsers).isFalse();
   }
 
+  public IterableSubject hasAnnotationsThat() {
+    return check("annotations").that(codeOwnerCheckInfo().annotations);
+  }
+
   public void hasDebugLogsThatContainAllOf(String... expectedMessages) {
     for (String expectedMessage : expectedMessages) {
       check("debugLogs").that(codeOwnerCheckInfo().debugLogs).contains(expectedMessage);
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerSetSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerSetSubject.java
index 974289a..8c7d014 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerSetSubject.java
+++ b/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerSetSubject.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.plugins.codeowners.testing;
 
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertAbout;
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerConfigReferenceSubject.codeOwnerConfigReferences;
 import static com.google.gerrit.truth.ListSubject.elements;
@@ -22,7 +24,9 @@
 import com.google.common.truth.Correspondence;
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.IterableSubject;
+import com.google.common.truth.MapSubject;
 import com.google.common.truth.Subject;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerAnnotation;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigReference;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerSet;
@@ -85,6 +89,19 @@
     return hasCodeOwnersThat().comparingElementsUsing(hasEmail());
   }
 
+  public MapSubject hasAnnotationsThat() {
+    return check("codeOwners()")
+        .that(
+            codeOwnerSet().annotations().asMap().entrySet().stream()
+                .collect(
+                    toImmutableMap(
+                        e -> e.getKey().email(),
+                        e ->
+                            e.getValue().stream()
+                                .map(CodeOwnerAnnotation::key)
+                                .collect(toImmutableSet()))));
+  }
+
   /**
    * Returns an {@link ListSubject} for the imports in the code owner set.
    *
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerStatusInfoSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerStatusInfoSubject.java
index a0b3452..fe782f4 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerStatusInfoSubject.java
+++ b/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerStatusInfoSubject.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.plugins.codeowners.testing;
 
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.common.truth.Truth.assertAbout;
 import static com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusInfoSubject.fileCodeOwnerStatusInfos;
 import static com.google.gerrit.truth.ListSubject.elements;
@@ -21,10 +22,14 @@
 import com.google.common.truth.BooleanSubject;
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.IntegerSubject;
+import com.google.common.truth.MapSubject;
 import com.google.common.truth.Subject;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatusInfo;
 import com.google.gerrit.plugins.codeowners.api.FileCodeOwnerStatusInfo;
 import com.google.gerrit.truth.ListSubject;
+import java.util.stream.Stream;
 
 /** {@link Subject} for doing assertions on {@link CodeOwnerStatusInfo}s. */
 public class CodeOwnerStatusInfoSubject extends Subject {
@@ -63,6 +68,20 @@
         .thatCustom(codeOwnerStatusInfo().fileCodeOwnerStatuses, fileCodeOwnerStatusInfos());
   }
 
+  public MapSubject hasAccountsThat() {
+    return check("accounts()").that(codeOwnerStatusInfo().accounts);
+  }
+
+  public void hasAccounts(TestAccount... accounts) {
+    hasAccountsThat()
+        .containsExactlyEntriesIn(
+            Stream.of(accounts)
+                .collect(
+                    toImmutableMap(
+                        account -> account.id().get(),
+                        CodeOwnerStatusInfoSubject::createAccountInfo)));
+  }
+
   public BooleanSubject hasMoreThat() {
     return check("more()").that(codeOwnerStatusInfo().more);
   }
@@ -71,4 +90,13 @@
     isNotNull();
     return codeOwnerStatusInfo;
   }
+
+  private static AccountInfo createAccountInfo(TestAccount testAccount) {
+    AccountInfo accountInfo = new AccountInfo(testAccount.id().get());
+    accountInfo.email = testAccount.email();
+    accountInfo.name = testAccount.fullName();
+    accountInfo.username = testAccount.username();
+    accountInfo.displayName = testAccount.displayName();
+    return accountInfo;
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/FileCodeOwnerStatusInfoSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/FileCodeOwnerStatusInfoSubject.java
index 5f885e7..6a630d7 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/FileCodeOwnerStatusInfoSubject.java
+++ b/java/com/google/gerrit/plugins/codeowners/testing/FileCodeOwnerStatusInfoSubject.java
@@ -80,7 +80,12 @@
   private static PathCodeOwnerStatus toPathCodeOwnerStatus(
       PathCodeOwnerStatusInfo pathCodeOwnerStatusInfo) {
     requireNonNull(pathCodeOwnerStatusInfo, "pathCodeOwnerStatusInfo");
-    return PathCodeOwnerStatus.create(pathCodeOwnerStatusInfo.path, pathCodeOwnerStatusInfo.status);
+    PathCodeOwnerStatus.Builder pathCodeOwnerStatus =
+        PathCodeOwnerStatus.builder(pathCodeOwnerStatusInfo.path, pathCodeOwnerStatusInfo.status);
+    if (pathCodeOwnerStatusInfo.reasons != null) {
+      pathCodeOwnerStatusInfo.reasons.forEach(reason -> pathCodeOwnerStatus.addReason(reason));
+    }
+    return pathCodeOwnerStatus.build();
   }
 
   /**
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/OwnedChangedFileInfoSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/OwnedChangedFileInfoSubject.java
new file mode 100644
index 0000000..7897058
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/testing/OwnedChangedFileInfoSubject.java
@@ -0,0 +1,88 @@
+// Copyright (C) 2021 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.plugins.codeowners.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+import com.google.gerrit.plugins.codeowners.api.OwnedChangedFileInfo;
+
+/** {@link Subject} for doing assertions on {@link OwnedChangedFileInfo}s. */
+public class OwnedChangedFileInfoSubject extends Subject {
+  /**
+   * Starts fluent chain to do assertions on a {@link OwnedChangedFileInfo}.
+   *
+   * @param ownedChangedFileInfo the owned changed file info on which assertions should be done
+   * @return the created {@link OwnedChangedFileInfoSubject}
+   */
+  public static OwnedChangedFileInfoSubject assertThat(OwnedChangedFileInfo ownedChangedFileInfo) {
+    return assertAbout(ownedChangedFileInfos()).that(ownedChangedFileInfo);
+  }
+
+  private static Factory<OwnedChangedFileInfoSubject, OwnedChangedFileInfo>
+      ownedChangedFileInfos() {
+    return OwnedChangedFileInfoSubject::new;
+  }
+
+  private final OwnedChangedFileInfo ownedChangedFileInfo;
+
+  private OwnedChangedFileInfoSubject(
+      FailureMetadata metadata, OwnedChangedFileInfo ownedChangedFileInfo) {
+    super(metadata, ownedChangedFileInfo);
+    this.ownedChangedFileInfo = ownedChangedFileInfo;
+  }
+
+  public void hasEmptyNewPath() {
+    check("ownedNewPath").that(ownedChangedFileInfo().newPath).isNull();
+  }
+
+  public void hasOwnedNewPath(String expectedOwnedNewPath) {
+    check("ownedNewPath").that(ownedChangedFileInfo().newPath).isNotNull();
+    check("ownedNewPath").that(ownedChangedFileInfo().newPath.path).isEqualTo(expectedOwnedNewPath);
+    check("ownedNewPath").that(ownedChangedFileInfo().newPath.owned).isTrue();
+  }
+
+  public void hasNonOwnedNewPath(String expectedNonOwnedNewPath) {
+    check("ownedNewPath").that(ownedChangedFileInfo().newPath).isNotNull();
+    check("ownedNewPath")
+        .that(ownedChangedFileInfo().newPath.path)
+        .isEqualTo(expectedNonOwnedNewPath);
+    check("ownedNewPath").that(ownedChangedFileInfo().newPath.owned).isNull();
+  }
+
+  public void hasEmptyOldPath() {
+    check("ownedOldPath").that(ownedChangedFileInfo().oldPath).isNull();
+  }
+
+  public void hasOwnedOldPath(String expectedOwnedOldPath) {
+    check("ownedOldPath").that(ownedChangedFileInfo().oldPath).isNotNull();
+    check("ownedOldPath").that(ownedChangedFileInfo().oldPath.path).isEqualTo(expectedOwnedOldPath);
+    check("ownedOldPath").that(ownedChangedFileInfo().oldPath.owned).isTrue();
+  }
+
+  public void hasNonOwnedOldPath(String expectedNonOwnedOldPath) {
+    check("ownedOldPath").that(ownedChangedFileInfo().oldPath).isNotNull();
+    check("ownedOldPath")
+        .that(ownedChangedFileInfo().oldPath.path)
+        .isEqualTo(expectedNonOwnedOldPath);
+    check("ownedOldPath").that(ownedChangedFileInfo().oldPath.owned).isNull();
+  }
+
+  private OwnedChangedFileInfo ownedChangedFileInfo() {
+    isNotNull();
+    return ownedChangedFileInfo;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/OwnedPathsInfoSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/OwnedPathsInfoSubject.java
index d6a1072..08b762a 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/OwnedPathsInfoSubject.java
+++ b/java/com/google/gerrit/plugins/codeowners/testing/OwnedPathsInfoSubject.java
@@ -49,6 +49,10 @@
     return check("ownedPaths()").that(ownedPathsInfo().ownedPaths);
   }
 
+  public IterableSubject hasOwnedChangedFilesThat() {
+    return check("ownedChangedFiles()").that(ownedPathsInfo().ownedChangedFiles);
+  }
+
   public BooleanSubject hasMoreThat() {
     return check("more()").that(ownedPathsInfo().more);
   }
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/PathCodeOwnerStatusInfoSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/PathCodeOwnerStatusInfoSubject.java
index 0ed2f75..d3ba4b6 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/PathCodeOwnerStatusInfoSubject.java
+++ b/java/com/google/gerrit/plugins/codeowners/testing/PathCodeOwnerStatusInfoSubject.java
@@ -18,6 +18,7 @@
 
 import com.google.common.truth.ComparableSubject;
 import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IterableSubject;
 import com.google.common.truth.Subject;
 import com.google.gerrit.plugins.codeowners.api.PathCodeOwnerStatusInfo;
 import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
@@ -63,6 +64,11 @@
     return check("status()").that(pathCodeOwnerStatusInfo().status);
   }
 
+  /** Returns an {@link IterableSubject} for the reasons. */
+  public IterableSubject hasReasonsThat() {
+    return check("reasons()").that(pathCodeOwnerStatusInfo().reasons);
+  }
+
   private PathCodeOwnerStatusInfo pathCodeOwnerStatusInfo() {
     isNotNull();
     return pathCodeOwnerStatusInfo;
diff --git a/java/com/google/gerrit/plugins/codeowners/validation/CodeOwnerConfigValidator.java b/java/com/google/gerrit/plugins/codeowners/validation/CodeOwnerConfigValidator.java
index 6292194..136ffcb 100644
--- a/java/com/google/gerrit/plugins/codeowners/validation/CodeOwnerConfigValidator.java
+++ b/java/com/google/gerrit/plugins/codeowners/validation/CodeOwnerConfigValidator.java
@@ -66,6 +66,7 @@
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
@@ -82,7 +83,6 @@
 import java.util.Optional;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -204,8 +204,6 @@
           validationResult =
               validateCodeOwnerConfig(
                   receiveEvent.getBranchNameKey(),
-                  receiveEvent.repoConfig,
-                  receiveEvent.revWalk,
                   receiveEvent.commit,
                   receiveEvent.user,
                   codeOwnerConfigValidationPolicy.isForced(),
@@ -294,8 +292,6 @@
           validationResult =
               validateCodeOwnerConfig(
                   branchNameKey,
-                  repository.getConfig(),
-                  revWalk,
                   commit,
                   patchSetUploader,
                   codeOwnerConfigValidationPolicy.isForced(),
@@ -337,7 +333,6 @@
    *
    * @param branchNameKey the project and branch that contains the provided commit or for which the
    *     commit is being pushed
-   * @param revWalk the rev walk that should be used to load revCommit
    * @param revCommit the commit for which newly added and modified code owner configs should be
    *     validated
    * @param user user for which the code owner visibility checks should be performed
@@ -348,8 +343,6 @@
    */
   private Optional<ValidationResult> validateCodeOwnerConfig(
       BranchNameKey branchNameKey,
-      Config repoConfig,
-      RevWalk revWalk,
       RevCommit revCommit,
       IdentifiedUser user,
       boolean force,
@@ -417,12 +410,8 @@
       // MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION is configured.
       ImmutableList<ChangedFile> modifiedCodeOwnerConfigFiles =
           changedFiles
-              .compute(
-                  branchNameKey.project(),
-                  repoConfig,
-                  revWalk,
-                  revCommit,
-                  MergeCommitStrategy.ALL_CHANGED_FILES)
+              .getFromDiffCache(
+                  branchNameKey.project(), revCommit, MergeCommitStrategy.ALL_CHANGED_FILES)
               .stream()
               // filter out deletions (files without new path)
               .filter(changedFile -> changedFile.newPath().isPresent())
@@ -448,12 +437,7 @@
                   .flatMap(
                       changedFile ->
                           validateCodeOwnerConfig(
-                              user,
-                              codeOwnerBackend,
-                              branchNameKey,
-                              changedFile,
-                              revWalk,
-                              revCommit))));
+                              user, codeOwnerBackend, branchNameKey, changedFile, revCommit))));
     } catch (InvalidPluginConfigurationException e) {
       // If the code-owners plugin configuration is invalid we cannot get the code owners backend
       // and hence we are not able to detect and validate code owner config files. Instead of
@@ -473,7 +457,7 @@
                   "code-owners plugin configuration is invalid,"
                       + " cannot validate code owner config files",
                   ValidationMessage.Type.WARNING)));
-    } catch (IOException e) {
+    } catch (IOException | DiffNotAvailableException e) {
       String errorMessage =
           String.format(
               "failed to validate code owner config files in revision %s"
@@ -491,7 +475,6 @@
    * @param codeOwnerBackend the code owner backend from which the code owner config can be loaded
    * @param branchNameKey the project and branch of the code owner config
    * @param changedFile the changed file that represents the code owner config
-   * @param revWalk the rev walk that should be used to load revCommit
    * @param revCommit the commit from which the code owner config should be loaded
    * @return a stream of validation messages that describe issues with the code owner config, an
    *     empty stream if there are no issues
@@ -501,13 +484,11 @@
       CodeOwnerBackend codeOwnerBackend,
       BranchNameKey branchNameKey,
       ChangedFile changedFile,
-      RevWalk revWalk,
       RevCommit revCommit) {
     requireNonNull(user, "user");
     requireNonNull(codeOwnerBackend, "codeOwnerBackend");
     requireNonNull(branchNameKey, "branchNameKey");
     requireNonNull(changedFile, "changedFile");
-    requireNonNull(revWalk, "revWalk");
     requireNonNull(revCommit, "revCommit");
 
     if (!changedFile.newPath().isPresent()) {
@@ -524,7 +505,7 @@
           createCodeOwnerConfigKey(branchNameKey, changedFile.newPath().get());
       codeOwnerConfig =
           codeOwnerBackend
-              .getCodeOwnerConfig(codeOwnerConfigKey, revWalk, revCommit)
+              .getCodeOwnerConfig(codeOwnerConfigKey, revCommit)
               // We already know that the path exists, so either the code owner config is
               // successfully loaded (this case) or the loading fails with an exception because the
               // code owner config is not parseable (catch block below), but it cannot happen that
@@ -553,7 +534,7 @@
           new CommitValidationMessage(
               invalidCodeOwnerConfigException.get().getMessage(),
               getValidationMessageTypeForParsingError(
-                  codeOwnerBackend, branchNameKey, changedFile, revWalk, revCommit)));
+                  codeOwnerBackend, branchNameKey, changedFile, revCommit)));
     }
 
     // The code owner config was successfully loaded and parsed.
@@ -564,15 +545,14 @@
     Optional<CodeOwnerConfig> baseCodeOwnerConfig;
     try {
       baseCodeOwnerConfig =
-          getBaseCodeOwnerConfig(codeOwnerBackend, branchNameKey, changedFile, revWalk, revCommit);
+          getBaseCodeOwnerConfig(codeOwnerBackend, branchNameKey, changedFile, revCommit);
     } catch (CodeOwnersInternalServerErrorException codeOwnersInternalServerErrorException) {
       if (getInvalidCodeOwnerConfigCause(codeOwnersInternalServerErrorException).isPresent()) {
         // The base code owner config is non-parseable. Since the update makes the code owner
         // config parseable, it is a good update even if the code owner config still contains
         // issues. Hence in this case we downgrade all validation errors in the new version to
         // warnings so that the update is not blocked.
-        return validateCodeOwnerConfig(
-                branchNameKey, revWalk, user, codeOwnerBackend, codeOwnerConfig)
+        return validateCodeOwnerConfig(branchNameKey, user, codeOwnerBackend, codeOwnerConfig)
             .map(CodeOwnerConfigValidator::downgradeErrorToWarning);
       }
 
@@ -583,14 +563,9 @@
     // Validate the parsed code owner config.
     if (baseCodeOwnerConfig.isPresent()) {
       return validateCodeOwnerConfig(
-          branchNameKey,
-          revWalk,
-          user,
-          codeOwnerBackend,
-          codeOwnerConfig,
-          baseCodeOwnerConfig.get());
+          branchNameKey, user, codeOwnerBackend, codeOwnerConfig, baseCodeOwnerConfig.get());
     }
-    return validateCodeOwnerConfig(branchNameKey, revWalk, user, codeOwnerBackend, codeOwnerConfig);
+    return validateCodeOwnerConfig(branchNameKey, user, codeOwnerBackend, codeOwnerConfig);
   }
 
   /**
@@ -621,7 +596,6 @@
    * @param branchNameKey the project and branch of the base code owner config
    * @param changedFile the changed file of the code owner config that contains the path of the base
    *     code owner config as old path
-   * @param revWalk rev walk that should be used to load the base code owner config
    * @param revCommit the commit of the code owner config for which the base code owner config
    *     should be loaded
    * @return the loaded base code owner config, {@link Optional#empty()} if no base code owner
@@ -631,15 +605,13 @@
       CodeOwnerBackend codeOwnerBackend,
       BranchNameKey branchNameKey,
       ChangedFile changedFile,
-      RevWalk revWalk,
       RevCommit revCommit) {
     if (changedFile.oldPath().isPresent()) {
       Optional<ObjectId> parentRevision = getParentRevision(branchNameKey.project(), revCommit);
       if (parentRevision.isPresent()) {
         CodeOwnerConfig.Key baseCodeOwnerConfigKey =
             createCodeOwnerConfigKey(branchNameKey, changedFile.oldPath().get());
-        return codeOwnerBackend.getCodeOwnerConfig(
-            baseCodeOwnerConfigKey, revWalk, parentRevision.get());
+        return codeOwnerBackend.getCodeOwnerConfig(baseCodeOwnerConfigKey, parentRevision.get());
       }
     }
     return Optional.empty();
@@ -665,7 +637,6 @@
    * @param codeOwnerBackend the code owner backend from which the code owner config can be loaded
    * @param branchNameKey the project and branch of the code owner config
    * @param changedFile the changed file that represents the code owner config
-   * @param revWalk rev walk that should be used to load the code owner config
    * @param revCommit the commit from which the code owner config should be loaded
    * @return the {@link com.google.gerrit.server.git.validators.ValidationMessage.Type} (ERROR or
    *     WARNING) that should be used for parsing error of a code owner config file
@@ -674,7 +645,6 @@
       CodeOwnerBackend codeOwnerBackend,
       BranchNameKey branchNameKey,
       ChangedFile changedFile,
-      RevWalk revWalk,
       RevCommit revCommit) {
     //
     if (changedFile.oldPath().isPresent()) {
@@ -693,7 +663,7 @@
         // there.
         CodeOwnerConfig.Key baseCodeOwnerConfigKey =
             createCodeOwnerConfigKey(branchNameKey, changedFile.oldPath().get());
-        codeOwnerBackend.getCodeOwnerConfig(baseCodeOwnerConfigKey, revWalk, parentRevision);
+        codeOwnerBackend.getCodeOwnerConfig(baseCodeOwnerConfigKey, parentRevision);
         // The code owner config at the parent revision is parseable. This means the parsing error
         // is introduced by the new commit and we should block uploading it, which we achieve by
         // setting the validation message type to fatal.
@@ -757,7 +727,6 @@
    * they are not newly introduced by the given code owner config).
    *
    * @param branchNameKey the branch and the project
-   * @param revWalk rev walk that should be used to load the code owner configs
    * @param user user for which the code owner visibility checks should be performed
    * @param codeOwnerBackend the code owner backend from which the code owner configs were loaded
    * @param codeOwnerConfig the code owner config that should be validated
@@ -767,7 +736,6 @@
    */
   private Stream<CommitValidationMessage> validateCodeOwnerConfig(
       BranchNameKey branchNameKey,
-      RevWalk revWalk,
       IdentifiedUser user,
       CodeOwnerBackend codeOwnerBackend,
       CodeOwnerConfig codeOwnerConfig,
@@ -776,9 +744,9 @@
     requireNonNull(baseCodeOwnerConfig, "baseCodeOwnerConfig");
 
     ImmutableSet<CommitValidationMessage> issuesInBaseVersion =
-        validateCodeOwnerConfig(branchNameKey, revWalk, user, codeOwnerBackend, baseCodeOwnerConfig)
+        validateCodeOwnerConfig(branchNameKey, user, codeOwnerBackend, baseCodeOwnerConfig)
             .collect(toImmutableSet());
-    return validateCodeOwnerConfig(branchNameKey, revWalk, user, codeOwnerBackend, codeOwnerConfig)
+    return validateCodeOwnerConfig(branchNameKey, user, codeOwnerBackend, codeOwnerConfig)
         .map(
             commitValidationMessage ->
                 issuesInBaseVersion.contains(commitValidationMessage)
@@ -790,7 +758,6 @@
    * Validates the given code owner config and returns validation issues as stream.
    *
    * @param branchNameKey the branch and the project
-   * @param revWalk rev walk that should be used to load the code owner configs from {@code project}
    * @param user user for which the code owner visibility checks should be performed
    * @param codeOwnerBackend the code owner backend from which the code owner config was loaded
    * @param codeOwnerConfig the code owner config that should be validated
@@ -799,7 +766,6 @@
    */
   public Stream<CommitValidationMessage> validateCodeOwnerConfig(
       BranchNameKey branchNameKey,
-      RevWalk revWalk,
       IdentifiedUser user,
       CodeOwnerBackend codeOwnerBackend,
       CodeOwnerConfig codeOwnerConfig) {
@@ -811,10 +777,7 @@
             codeOwnerBackend.getFilePath(codeOwnerConfig.key()),
             codeOwnerConfig),
         validateImports(
-            branchNameKey,
-            revWalk,
-            codeOwnerBackend.getFilePath(codeOwnerConfig.key()),
-            codeOwnerConfig));
+            branchNameKey, codeOwnerBackend.getFilePath(codeOwnerConfig.key()), codeOwnerConfig));
   }
 
   /**
@@ -891,7 +854,6 @@
    * Validates the imports of the given code owner config.
    *
    * @param branchNameKey the branch and the project
-   * @param revWalk rev walk that should be used to load the code owner configs from {@code project}
    * @param codeOwnerConfigFilePath the path of the code owner config file which contains the code
    *     owner config
    * @param codeOwnerConfig the code owner config for which the imports should be validated
@@ -899,19 +861,19 @@
    *     if there are no issues
    */
   private Stream<CommitValidationMessage> validateImports(
-      BranchNameKey branchNameKey,
-      RevWalk revWalk,
-      Path codeOwnerConfigFilePath,
-      CodeOwnerConfig codeOwnerConfig) {
+      BranchNameKey branchNameKey, Path codeOwnerConfigFilePath, CodeOwnerConfig codeOwnerConfig) {
     try {
-      RevCommit codeOwnerConfigRevision = revWalk.parseCommit(codeOwnerConfig.revision());
+      RevCommit codeOwnerConfigRevision;
+      try (Repository repo = repoManager.openRepository(branchNameKey.project());
+          RevWalk revWalk = new RevWalk(repo)) {
+        codeOwnerConfigRevision = revWalk.parseCommit(codeOwnerConfig.revision());
+      }
       return Streams.concat(
               codeOwnerConfig.imports().stream()
                   .map(
                       codeOwnerConfigReference ->
                           validateCodeOwnerConfigReference(
                               branchNameKey,
-                              revWalk,
                               codeOwnerConfigFilePath,
                               codeOwnerConfig.key(),
                               codeOwnerConfigRevision,
@@ -923,7 +885,6 @@
                       codeOwnerConfigReference ->
                           validateCodeOwnerConfigReference(
                               branchNameKey,
-                              revWalk,
                               codeOwnerConfigFilePath,
                               codeOwnerConfig.key(),
                               codeOwnerConfigRevision,
@@ -941,7 +902,6 @@
    * Validates a code owner config reference.
    *
    * @param branchNameKey the branch and the project
-   * @param revWalk rev walk that should be used to load the code owner configs from {@code project}
    * @param codeOwnerConfigFilePath the path of the code owner config file which contains the code
    *     owner config reference
    * @param keyOfImportingCodeOwnerConfig key of the importing code owner config
@@ -954,7 +914,6 @@
    */
   private Optional<CommitValidationMessage> validateCodeOwnerConfigReference(
       BranchNameKey branchNameKey,
-      RevWalk revWalk,
       Path codeOwnerConfigFilePath,
       CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig,
       RevCommit codeOwnerConfigRevision,
@@ -1040,8 +999,7 @@
       // from it could fail with MissingObjectException.
       Optional<CodeOwnerConfig> importedCodeOwnerConfig =
           keyOfImportedCodeOwnerConfig.project().equals(branchNameKey.project())
-              ? codeOwnerBackend.getCodeOwnerConfig(
-                  keyOfImportedCodeOwnerConfig, revWalk, revision.get())
+              ? codeOwnerBackend.getCodeOwnerConfig(keyOfImportedCodeOwnerConfig, revision.get())
               : codeOwnerBackend.getCodeOwnerConfig(keyOfImportedCodeOwnerConfig, revision.get());
       if (!importedCodeOwnerConfig.isPresent()) {
         return nonResolvableImport(
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/AbstractGetCodeOwnersForPathIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/AbstractGetCodeOwnersForPathIT.java
index 2688b07..74c9eb2 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/AbstractGetCodeOwnersForPathIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/AbstractGetCodeOwnersForPathIT.java
@@ -49,7 +49,6 @@
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigImportMode;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigReference;
-import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerResolver;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerSet;
 import com.google.gerrit.plugins.codeowners.restapi.CheckCodeOwnerCapability;
@@ -78,7 +77,7 @@
   @Inject private GroupOperations groupOperations;
   @Inject private ProjectOperations projectOperations;
 
-  private TestPathExpressions testPathExpressions;
+  protected TestPathExpressions testPathExpressions;
 
   @Before
   public void setup() throws Exception {
@@ -1473,7 +1472,7 @@
   }
 
   @Test
-  public void debugRequireCallerToBeAdminOrHaveTheCheckCodeOwnerCapability() throws Exception {
+  public void debugRequiresCallerToBeAdminOrHaveTheCheckCodeOwnerCapability() throws Exception {
     requestScopeOperations.setApiUser(user.id());
     AuthException authException =
         assertThrows(
@@ -1581,23 +1580,67 @@
                 project.get(),
                 getCodeOwnerConfigFileName(),
                 nonExistingProject.get()),
-            String.format(
-                "resolving code owner reference %s", CodeOwnerReference.create(user.email())),
-            String.format("resolved to account %d", user.id().get()),
+            String.format("resolved email %s to account %d", user.email(), user.id().get()),
             String.format("resolve code owners for %s from code owner config %s", path, fooKey),
             String.format(
-                "resolving code owner reference %s", CodeOwnerReference.create(nonExistingEmail)),
-            String.format(
                 "cannot resolve code owner email %s: no account with this email exists",
                 nonExistingEmail),
             String.format("resolve code owners for %s from code owner config %s", path, rootKey),
-            String.format(
-                "resolving code owner reference %s", CodeOwnerReference.create(admin.email())),
-            String.format("resolved to account %d", admin.id().get()),
+            String.format("resolved email %s to account %d", admin.email(), admin.id().get()),
             "resolve global code owners",
-            String.format(
-                "resolving code owner reference %s",
-                CodeOwnerReference.create(globalOwner.email())),
-            String.format("resolved to account %d", globalOwner.id().get()));
+            String.format("resolved email %s to account %d", admin.email(), admin.id().get()));
+  }
+
+  @Test
+  public void getCodeOwnersOrderDiffersIfDifferentSeedsAreUsed() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    // create some code owner configs
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(admin.email())
+        .addCodeOwnerEmail(user.email())
+        .addCodeOwnerEmail(user2.email())
+        .create();
+
+    Random random = new Random();
+
+    CodeOwnersInfo codeOwnersInfo =
+        queryCodeOwners(getCodeOwnersApi().query().withSeed(random.nextLong()), "/foo/bar/baz.md");
+    // all code owners have the same score, hence their order is random
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id(), user.id(), user2.id());
+
+    // Check that the order for further requests that use a different seed is different (at least
+    // once).
+    List<Account.Id> accountIdsOrder1 =
+        codeOwnersInfo.codeOwners.stream()
+            .map(info -> Account.id(info.account._accountId))
+            .collect(toList());
+    boolean foundDifferentOrder = false;
+    for (int i = 0; i < 50; i++) {
+      codeOwnersInfo =
+          queryCodeOwners(
+              getCodeOwnersApi().query().withSeed(random.nextLong()), "/foo/bar/baz.md");
+      assertThat(codeOwnersInfo)
+          .hasCodeOwnersThat()
+          .comparingElementsUsing(hasAccountId())
+          .containsExactlyElementsIn(accountIdsOrder1);
+
+      List<Account.Id> accountIdsOrder2 =
+          codeOwnersInfo.codeOwners.stream()
+              .map(info -> Account.id(info.account._accountId))
+              .collect(toList());
+      if (!accountIdsOrder2.equals(accountIdsOrder1)) {
+        foundDifferentOrder = true;
+        break;
+      }
+    }
+    assertThat(foundDifferentOrder).isTrue();
   }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerConfigFilesInRevisionIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerConfigFilesInRevisionIT.java
index b003707..980ee02 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerConfigFilesInRevisionIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerConfigFilesInRevisionIT.java
@@ -47,7 +47,6 @@
   private static final ObjectId TEST_REVISION =
       ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
 
-  private BackendConfig backendConfig;
   private FindOwnersCodeOwnerConfigParser findOwnersCodeOwnerConfigParser;
   private ProtoCodeOwnerConfigParser protoCodeOwnerConfigParser;
 
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerIT.java
index 0db2388..cedced9 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerIT.java
@@ -23,6 +23,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
@@ -44,6 +45,8 @@
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.TestCodeOwnerConfigCreation;
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.TestPathExpressions;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerCheckInfo;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerAnnotation;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerAnnotations;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigImportMode;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigReference;
@@ -53,7 +56,7 @@
 import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
-import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.inject.Inject;
@@ -75,6 +78,7 @@
   @Inject private ProjectOperations projectOperations;
   @Inject @ServerInitiated private Provider<AccountsUpdate> accountsUpdate;
   @Inject private ExternalIdNotes.Factory externalIdNotesFactory;
+  @Inject private ExternalIdFactory externalIdFactory;
 
   private TestPathExpressions testPathExpressions;
 
@@ -144,12 +148,13 @@
     assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
     assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
     assertThat(checkCodeOwnerInfo).isNotOwnedByAllUsers();
+    assertThat(checkCodeOwnerInfo).hasAnnotationsThat().isEmpty();
     assertThat(checkCodeOwnerInfo)
         .hasDebugLogsThatContainAllOf(
             String.format(
-                "found email %s as code owner in %s",
+                "found email %s as a code owner in %s",
                 codeOwner.email(), getCodeOwnerConfigFilePath("/foo/")),
-            String.format("resolved to account %s", codeOwner.id()));
+            String.format("resolved email %s to account %s", codeOwner.email(), codeOwner.id()));
   }
 
   @Test
@@ -178,15 +183,15 @@
     assertThat(checkCodeOwnerInfo)
         .hasDebugLogsThatContainAllOf(
             String.format(
-                "found email %s as code owner in %s",
+                "found email %s as a code owner in %s",
                 codeOwner.email(), getCodeOwnerConfigFilePath("/foo/bar/")),
             String.format(
-                "found email %s as code owner in %s",
+                "found email %s as a code owner in %s",
                 codeOwner.email(), getCodeOwnerConfigFilePath("/foo/")),
             String.format(
-                "found email %s as code owner in %s",
+                "found email %s as a code owner in %s",
                 codeOwner.email(), getCodeOwnerConfigFilePath(ROOT_PATH)),
-            String.format("resolved to account %s", codeOwner.id()));
+            String.format("resolved email %s to account %s", codeOwner.email(), codeOwner.id()));
   }
 
   @Test
@@ -222,13 +227,13 @@
     assertThat(checkCodeOwnerInfo)
         .hasDebugLogsThatContainAllOf(
             String.format(
-                "found email %s as code owner in %s",
+                "found email %s as a code owner in %s",
                 codeOwner.email(), getCodeOwnerConfigFilePath("/foo/bar/")),
             String.format(
-                "found email %s as code owner in %s",
+                "found email %s as a code owner in %s",
                 codeOwner.email(), getCodeOwnerConfigFilePath("/foo/")),
             "parent code owners are ignored",
-            String.format("resolved to account %s", codeOwner.id()));
+            String.format("resolved email %s to account %s", codeOwner.email(), codeOwner.id()));
   }
 
   @Test
@@ -257,9 +262,9 @@
     assertThat(checkCodeOwnerInfo)
         .hasDebugLogsThatContainAllOf(
             String.format(
-                "found email %s as code owner in %s",
+                "found email %s as a code owner in %s",
                 secondaryEmail, getCodeOwnerConfigFilePath(ROOT_PATH)),
-            String.format("resolved to account %s", codeOwner.id()));
+            String.format("resolved email %s to account %s", secondaryEmail, codeOwner.id()));
   }
 
   @Test
@@ -282,8 +287,11 @@
     assertThat(checkCodeOwnerInfo)
         .hasDebugLogsThatContainAllOf(
             String.format(
-                "found email %s as code owner in %s",
-                CodeOwnerResolver.ALL_USERS_WILDCARD, getCodeOwnerConfigFilePath(ROOT_PATH)));
+                "found the all users wildcard ('%s') as a code owner in %s which makes %s a code"
+                    + " owner",
+                CodeOwnerResolver.ALL_USERS_WILDCARD,
+                getCodeOwnerConfigFilePath(ROOT_PATH),
+                codeOwner.email()));
   }
 
   @Test
@@ -306,11 +314,14 @@
     assertThat(checkCodeOwnerInfo)
         .hasDebugLogsThatContainAllOf(
             String.format(
-                "found email %s as code owner in %s",
+                "found email %s as a code owner in %s",
                 codeOwner.email(), getCodeOwnerConfigFilePath(ROOT_PATH)),
             String.format(
-                "found email %s as code owner in %s",
-                CodeOwnerResolver.ALL_USERS_WILDCARD, getCodeOwnerConfigFilePath(ROOT_PATH)));
+                "found the all users wildcard ('%s') as a code owner in %s which makes %s a code"
+                    + " owner",
+                CodeOwnerResolver.ALL_USERS_WILDCARD,
+                getCodeOwnerConfigFilePath(ROOT_PATH),
+                codeOwner.email()));
   }
 
   @Test
@@ -323,7 +334,8 @@
     assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
     assertThat(checkCodeOwnerInfo).isNotOwnedByAllUsers();
     assertThat(checkCodeOwnerInfo)
-        .hasDebugLogsThatContainAllOf(String.format("resolved to account %s", user.id()));
+        .hasDebugLogsThatContainAllOf(
+            String.format("resolved email %s to account %s", user.email(), user.id()));
   }
 
   @Test
@@ -344,7 +356,7 @@
     assertThat(checkCodeOwnerInfo)
         .hasDebugLogsThatContainAllOf(
             String.format(
-                "found email %s as code owner in %s",
+                "found email %s as a code owner in %s",
                 nonExistingEmail, getCodeOwnerConfigFilePath(ROOT_PATH)),
             String.format(
                 "cannot resolve code owner email %s: no account with this email exists",
@@ -373,7 +385,7 @@
     assertThat(checkCodeOwnerInfo)
         .hasDebugLogsThatContainAllOf(
             String.format(
-                "found email %s as code owner in %s",
+                "found email %s as a code owner in %s",
                 ambiguousEmail, getCodeOwnerConfigFilePath(ROOT_PATH)),
             String.format(
                 "cannot resolve code owner email %s: email is ambiguous", ambiguousEmail));
@@ -387,7 +399,7 @@
     try (Repository allUsersRepo = repoManager.openRepository(allUsers);
         MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
       ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
-      extIdNotes.upsert(ExternalId.createEmail(accountId, orphanedEmail));
+      extIdNotes.upsert(externalIdFactory.createEmail(accountId, orphanedEmail));
       extIdNotes.commit(md);
     }
 
@@ -405,7 +417,7 @@
     assertThat(checkCodeOwnerInfo)
         .hasDebugLogsThatContainAllOf(
             String.format(
-                "found email %s as code owner in %s",
+                "found email %s as a code owner in %s",
                 orphanedEmail, getCodeOwnerConfigFilePath(ROOT_PATH)),
             String.format(
                 "cannot resolve account %s for email %s: account does not exists",
@@ -436,10 +448,11 @@
     assertThat(checkCodeOwnerInfo)
         .hasDebugLogsThatContainAllOf(
             String.format(
-                "found email %s as code owner in %s",
+                "found email %s as a code owner in %s",
                 inactiveUser.email(), getCodeOwnerConfigFilePath(ROOT_PATH)),
             String.format(
-                "account %s for email %s is inactive", inactiveUser.id(), inactiveUser.email()));
+                "ignoring inactive account %s for email %s",
+                inactiveUser.id(), inactiveUser.email()));
   }
 
   @Test
@@ -467,7 +480,7 @@
     assertThat(checkCodeOwnerInfo)
         .hasDebugLogsThatContainAllOf(
             String.format(
-                "found email %s as code owner in %s",
+                "found email %s as a code owner in %s",
                 emailWithAllowedEmailDomain, getCodeOwnerConfigFilePath(ROOT_PATH)),
             String.format(
                 "domain %s of email %s is allowed",
@@ -501,7 +514,7 @@
     assertThat(checkCodeOwnerInfo)
         .hasDebugLogsThatContainAllOf(
             String.format(
-                "found email %s as code owner in %s",
+                "found email %s as a code owner in %s",
                 emailWithNonAllowedEmailDomain, getCodeOwnerConfigFilePath(ROOT_PATH)),
             String.format(
                 "domain %s of email %s is not allowed",
@@ -542,8 +555,10 @@
     assertThat(checkCodeOwnerInfo)
         .hasDebugLogsThatContainAllOf(
             String.format(
-                "found email %s as code owner in %s",
-                CodeOwnerResolver.ALL_USERS_WILDCARD, getCodeOwnerConfigFilePath(ROOT_PATH)));
+                "found the all users wildcard ('%s') as a code owner in %s which makes %s a code owner",
+                CodeOwnerResolver.ALL_USERS_WILDCARD,
+                getCodeOwnerConfigFilePath(ROOT_PATH),
+                CodeOwnerResolver.ALL_USERS_WILDCARD));
   }
 
   @Test
@@ -567,9 +582,11 @@
     assertThat(checkCodeOwnerInfo)
         .hasDebugLogsThatContainAllOf(
             String.format(
-                "found email %s as code owner in default code owner config",
+                "found email %s as a code owner in the default code owner config",
                 defaultCodeOwner.email()),
-            String.format("resolved to account %s", defaultCodeOwner.id()));
+            String.format(
+                "resolved email %s to account %s",
+                defaultCodeOwner.email(), defaultCodeOwner.id()));
   }
 
   @Test
@@ -593,9 +610,12 @@
     assertThat(checkCodeOwnerInfo)
         .hasDebugLogsThatContainAllOf(
             String.format(
-                "found email %s as code owner in default code owner config",
-                CodeOwnerResolver.ALL_USERS_WILDCARD),
-            String.format("resolved to account %s", defaultCodeOwner.id()));
+                "found the all users wildcard ('%s') as a code owner in the default code owner"
+                    + " config which makes %s a code owner",
+                CodeOwnerResolver.ALL_USERS_WILDCARD, defaultCodeOwner.email()),
+            String.format(
+                "resolved email %s to account %s",
+                defaultCodeOwner.email(), defaultCodeOwner.id()));
   }
 
   @Test
@@ -619,7 +639,8 @@
     assertThat(checkCodeOwnerInfo)
         .hasDebugLogsThatContainAllOf(
             String.format("found email %s as global code owner", globalCodeOwner.email()),
-            String.format("resolved to account %s", globalCodeOwner.id()));
+            String.format(
+                "resolved email %s to account %s", globalCodeOwner.email(), globalCodeOwner.id()));
   }
 
   @Test
@@ -646,7 +667,8 @@
         .hasDebugLogsThatContainAllOf(
             String.format(
                 "found email %s as global code owner", CodeOwnerResolver.ALL_USERS_WILDCARD),
-            String.format("resolved to account %s", globalCodeOwner.id()));
+            String.format(
+                "resolved email %s to account %s", globalCodeOwner.email(), globalCodeOwner.id()));
   }
 
   @Test
@@ -669,10 +691,10 @@
     assertThat(checkCodeOwnerInfo)
         .hasDebugLogsThatContainAllOf(
             String.format(
-                "found email %s as code owner in %s",
+                "found email %s as a code owner in %s",
                 codeOwner.email(), getCodeOwnerConfigFilePath("/foo/")),
             String.format("account %s is visible to user %s", codeOwner.id(), user.username()),
-            String.format("resolved to account %s", codeOwner.id()));
+            String.format("resolved email %s to account %s", codeOwner.email(), codeOwner.id()));
   }
 
   @Test
@@ -708,7 +730,7 @@
     assertThat(checkCodeOwnerInfo)
         .hasDebugLogsThatContainAllOf(
             String.format(
-                "found email %s as code owner in %s",
+                "found email %s as a code owner in %s",
                 codeOwner.email(), getCodeOwnerConfigFilePath(ROOT_PATH)),
             String.format(
                 "cannot resolve code owner email %s: account %s is not visible to user %s",
@@ -741,7 +763,7 @@
     assertThat(checkCodeOwnerInfo)
         .hasDebugLogsThatContainAllOf(
             String.format(
-                "found email %s as code owner in %s",
+                "found email %s as a code owner in %s",
                 secondaryEmail, getCodeOwnerConfigFilePath(ROOT_PATH)),
             String.format(
                 "cannot resolve code owner email %s: account %s is referenced by secondary email"
@@ -854,7 +876,7 @@
 
     CodeOwnerConfigReference unresolvableCodeOwnerConfigReference =
         CodeOwnerConfigReference.create(
-            CodeOwnerConfigImportMode.ALL, "non-existing/" + getCodeOwnerConfigFileName());
+            CodeOwnerConfigImportMode.ALL, "/non-existing/" + getCodeOwnerConfigFileName());
     codeOwnerConfigOperations
         .newCodeOwnerConfig()
         .project(project)
@@ -927,9 +949,9 @@
                 "per-file code owner set with path expressions [%s] matches",
                 testPathExpressions.matchFileType("md")),
             String.format(
-                "found email %s as code owner in %s",
+                "found email %s as a code owner in %s",
                 mdOwner.email(), getCodeOwnerConfigFilePath("/foo/")),
-            String.format("resolved to account %s", mdOwner.id()));
+            String.format("resolved email %s to account %s", mdOwner.email(), mdOwner.id()));
     assertThat(checkCodeOwnerInfo)
         .hasDebugLogsThatDoNotContainAnyOf(
             String.format(
@@ -998,9 +1020,10 @@
                     + " parent code owners, hence ignoring the folder code owners",
                 testPathExpressions.matchFileType("md")),
             String.format(
-                "found email %s as code owner in %s",
+                "found email %s as a code owner in %s",
                 fileCodeOwner.email(), getCodeOwnerConfigFilePath("/foo/")),
-            String.format("resolved to account %s", fileCodeOwner.id()));
+            String.format(
+                "resolved email %s to account %s", fileCodeOwner.email(), fileCodeOwner.id()));
   }
 
   @Test
@@ -1052,9 +1075,9 @@
                 getCodeOwnerConfigFileName(),
                 getCodeOwnerConfigFileName()),
             String.format(
-                "found email %s as code owner in %s",
+                "found email %s as a code owner in %s",
                 codeOwner.email(), getCodeOwnerConfigFilePath("/foo/")),
-            String.format("resolved to account %s", codeOwner.id()));
+            String.format("resolved email %s to account %s", codeOwner.email(), codeOwner.id()));
   }
 
   @Test
@@ -1116,9 +1139,10 @@
                 getCodeOwnerConfigFileName(),
                 testPathExpressions.matchFileType("md")),
             String.format(
-                "found email %s as code owner in %s",
+                "found email %s as a code owner in %s",
                 mdCodeOwner.email(), getCodeOwnerConfigFilePath("/foo/")),
-            String.format("resolved to account %s", mdCodeOwner.id()));
+            String.format(
+                "resolved email %s to account %s", mdCodeOwner.email(), mdCodeOwner.id()));
 
     // 2. check for user and path of an md file
     checkCodeOwnerInfo = checkCodeOwner(path, user.email());
@@ -1140,7 +1164,7 @@
                 testPathExpressions.matchFileType("md"),
                 getCodeOwnerConfigFileName(),
                 testPathExpressions.matchFileType("md")),
-            String.format("resolved to account %s", user.id()));
+            String.format("resolved email %s to account %s", user.email(), user.id()));
     assertThat(checkCodeOwnerInfo)
         .hasDebugLogsThatDoNotContainAnyOf(String.format("email %s", user.email()));
 
@@ -1156,7 +1180,8 @@
                 "Code owner config %s:%s:/foo/%s imports:\n"
                     + "* /bar/%s (global import, import mode = ALL)",
                 project, "master", getCodeOwnerConfigFileName(), getCodeOwnerConfigFileName()),
-            String.format("resolved to account %s", mdCodeOwner.id()));
+            String.format(
+                "resolved email %s to account %s", mdCodeOwner.email(), mdCodeOwner.id()));
     assertThat(checkCodeOwnerInfo)
         .hasDebugLogsThatDoNotContainAnyOf(String.format("email %s", mdCodeOwner.email()));
   }
@@ -1443,6 +1468,95 @@
     assertThat(checkCodeOwnerInfo).cannotApproveChange();
   }
 
+  @Test
+  public void checkCodeOwnerWithAnnotations() throws Exception {
+    skipTestIfAnnotationsNotSupportedByCodeOwnersBackend();
+
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addCodeOwnerEmail(codeOwner.email())
+                .addAnnotation(
+                    codeOwner.email(), CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION)
+                .build())
+        .create();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addCodeOwnerEmail(CodeOwnerResolver.ALL_USERS_WILDCARD)
+                .addAnnotation(
+                    CodeOwnerResolver.ALL_USERS_WILDCARD, CodeOwnerAnnotation.create("ANNOTATION"))
+                .build())
+        .create();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/bar/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addCodeOwnerEmail(codeOwner.email())
+                .addAnnotation(
+                    codeOwner.email(), CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION)
+                .addAnnotation(codeOwner.email(), CodeOwnerAnnotation.create("OTHER_ANNOTATION"))
+                .build())
+        .create();
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, codeOwner.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo)
+        .hasAnnotationsThat()
+        .containsExactly(CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION.key())
+        .inOrder();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as a code owner in %s",
+                codeOwner.email(), getCodeOwnerConfigFilePath("/foo/bar/")),
+            String.format(
+                "email %s is annotated with %s",
+                codeOwner.email(),
+                ImmutableSet.of(
+                    CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION.key(),
+                    "OTHER_ANNOTATION")),
+            String.format(
+                "found the all users wildcard ('%s') as a code owner in %s which makes %s a code"
+                    + " owner",
+                CodeOwnerResolver.ALL_USERS_WILDCARD,
+                getCodeOwnerConfigFilePath("/foo/"),
+                codeOwner.email()),
+            String.format(
+                "found annotations for the all users wildcard ('%s') which apply to %s: %s",
+                CodeOwnerResolver.ALL_USERS_WILDCARD,
+                codeOwner.email(),
+                ImmutableSet.of("ANNOTATION")),
+            String.format(
+                "found email %s as a code owner in %s",
+                codeOwner.email(), getCodeOwnerConfigFilePath("/")),
+            String.format(
+                "email %s is annotated with %s",
+                codeOwner.email(),
+                ImmutableSet.of(CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION.key())),
+            String.format(
+                "dropping unsupported annotations for %s: %s",
+                codeOwner.email(), ImmutableSet.of("ANNOTATION", "OTHER_ANNOTATION")));
+  }
+
   private CodeOwnerCheckInfo checkCodeOwner(String path, String email) throws RestApiException {
     return checkCodeOwner(path, email, /* user= */ null);
   }
@@ -1506,7 +1620,7 @@
             accountId,
             (a, u) ->
                 u.addExternalId(
-                    ExternalId.create(
+                    externalIdFactory.create(
                         "foo",
                         "bar" + accountId.get(),
                         accountId,
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorErrorHandlingIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorErrorHandlingIT.java
new file mode 100644
index 0000000..a1c34ba
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorErrorHandlingIT.java
@@ -0,0 +1,149 @@
+// Copyright (C) 2021 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.plugins.codeowners.acceptance.api;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.PrivateInternals_DynamicMapImpl;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigUpdate;
+import com.google.gerrit.plugins.codeowners.backend.PathExpressionMatcher;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.inject.Key;
+import com.google.inject.util.Providers;
+import java.nio.file.Path;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Tests for {@code com.google.gerrit.plugins.codeowners.validation.CodeOwnerConfigValidator} that
+ * verify the error handling during the validation.
+ */
+public class CodeOwnerConfigValidatorErrorHandlingIT extends AbstractCodeOwnersIT {
+  private DynamicMap<CodeOwnerBackend> codeOwnerBackends;
+
+  @Before
+  public void setUpCodeOwnersPlugin() throws Exception {
+    codeOwnerBackends =
+        plugin.getSysInjector().getInstance(new Key<DynamicMap<CodeOwnerBackend>>() {});
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = FailingCodeOwnerBackend.ID)
+  public void pushFailsOnInternalError() throws Exception {
+    try (AutoCloseable registration = registerTestBackend(new FailingCodeOwnerBackend())) {
+      PushOneCommit.Result r = createChange("Add code owners", "OWNERS", "content");
+      r.assertErrorStatus("internal error");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = FailingCodeOwnerBackend.ID)
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnCommitReceived", value = "DRY_RUN")
+  public void pushSucceedsOnInternalErrorIfValidationIsDoneAsDryRun() throws Exception {
+    try (AutoCloseable registration = registerTestBackend(new FailingCodeOwnerBackend())) {
+      PushOneCommit.Result r = createChange("Add code owners", "OWNERS", "content");
+      r.assertOkStatus();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = FailingCodeOwnerBackend.ID)
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "PROJECT_OWNERS")
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "true")
+  public void submitFailsOnInternalError() throws Exception {
+    try (AutoCloseable registration = registerTestBackend(new FailingCodeOwnerBackend())) {
+      disableCodeOwnersForProject(project);
+      PushOneCommit.Result r = createChange("Add code owners", "OWNERS", "content");
+      r.assertOkStatus();
+      enableCodeOwnersForProject(project);
+      approve(r.getChangeId());
+      IllegalStateException exception =
+          assertThrows(
+              IllegalStateException.class,
+              () -> gApi.changes().id(r.getChangeId()).current().submit());
+      assertThat(exception).hasMessageThat().isEqualTo(FailingCodeOwnerBackend.EXCEPTION_MESSAGE);
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = FailingCodeOwnerBackend.ID)
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "DRY_RUN")
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "PROJECT_OWNERS")
+  public void submitSucceedsOnInternalErrorIfValidationIsDoneAsDryRun() throws Exception {
+    try (AutoCloseable registration = registerTestBackend(new FailingCodeOwnerBackend())) {
+      disableCodeOwnersForProject(project);
+      PushOneCommit.Result r = createChange("Add code owners", "OWNERS", "content");
+      r.assertOkStatus();
+      enableCodeOwnersForProject(project);
+      approve(r.getChangeId());
+      gApi.changes().id(r.getChangeId()).current().submit();
+      assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
+    }
+  }
+
+  private AutoCloseable registerTestBackend(CodeOwnerBackend codeOwnerBackend) {
+    RegistrationHandle registrationHandle =
+        ((PrivateInternals_DynamicMapImpl<CodeOwnerBackend>) codeOwnerBackends)
+            .put("gerrit", FailingCodeOwnerBackend.ID, Providers.of(codeOwnerBackend));
+    return registrationHandle::remove;
+  }
+
+  private static class FailingCodeOwnerBackend implements CodeOwnerBackend {
+    static final String ID = "test-backend";
+    static final String EXCEPTION_MESSAGE = "failure from test";
+
+    @Override
+    public boolean isCodeOwnerConfigFile(NameKey project, String fileName) {
+      throw new IllegalStateException(EXCEPTION_MESSAGE);
+    }
+
+    @Override
+    public Optional<CodeOwnerConfig> getCodeOwnerConfig(
+        CodeOwnerConfig.Key codeOwnerConfigKey, ObjectId revision) {
+      return Optional.empty();
+    }
+
+    @Override
+    public Path getFilePath(CodeOwnerConfig.Key codeOwnerConfigKey) {
+      return codeOwnerConfigKey.filePath("OWNERS");
+    }
+
+    @Override
+    public Optional<CodeOwnerConfig> upsertCodeOwnerConfig(
+        CodeOwnerConfig.Key codeOwnerConfigKey,
+        CodeOwnerConfigUpdate codeOwnerConfigUpdate,
+        IdentifiedUser currentUser) {
+      return Optional.empty();
+    }
+
+    @Override
+    public Optional<PathExpressionMatcher> getPathExpressionMatcher(BranchNameKey branchNameKey) {
+      return Optional.empty();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorIT.java
index fda0093..efa94d0 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorIT.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.plugins.codeowners.acceptance.api;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -33,29 +33,23 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.MergeInput;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.registration.PrivateInternals_DynamicMapImpl;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.TestCodeOwnerConfigCreation;
-import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigImportMode;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigImportType;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigReference;
-import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigUpdate;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerResolver;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerSet;
-import com.google.gerrit.plugins.codeowners.backend.PathExpressionMatcher;
 import com.google.gerrit.plugins.codeowners.backend.config.BackendConfig;
 import com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig;
 import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersBackend;
@@ -65,22 +59,21 @@
 import com.google.gerrit.plugins.codeowners.common.CodeOwnerConfigValidationPolicy;
 import com.google.gerrit.plugins.codeowners.validation.SkipCodeOwnerConfigValidationCapability;
 import com.google.gerrit.plugins.codeowners.validation.SkipCodeOwnerConfigValidationPushOption;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.inject.Inject;
-import com.google.inject.Key;
-import com.google.inject.util.Providers;
-import java.nio.file.Path;
-import java.util.Optional;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Before;
 import org.junit.Test;
 
-/** Tests for {@code com.google.gerrit.plugins.codeowners.validation.CodeOwnerConfigValidator}. */
+/**
+ * Tests for {@code com.google.gerrit.plugins.codeowners.validation.CodeOwnerConfigValidator}.
+ * {@link CodeOwnerConfigValidatorOnSubmitIT} and {@link CodeOwnerConfigValidatorErrorHandlingIT}
+ * contain further tests for {@code
+ * com.google.gerrit.plugins.codeowners.validation.CodeOwnerConfigValidator}.
+ */
 public class CodeOwnerConfigValidatorIT extends AbstractCodeOwnersIT {
   private static final ObjectId TEST_REVISION =
       ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
@@ -88,10 +81,8 @@
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ProjectOperations projectOperations;
 
-  private BackendConfig backendConfig;
   private FindOwnersCodeOwnerConfigParser findOwnersCodeOwnerConfigParser;
   private ProtoCodeOwnerConfigParser protoCodeOwnerConfigParser;
-  private DynamicMap<CodeOwnerBackend> codeOwnerBackends;
 
   @Before
   public void setUpCodeOwnersPlugin() throws Exception {
@@ -100,8 +91,6 @@
         plugin.getSysInjector().getInstance(FindOwnersCodeOwnerConfigParser.class);
     protoCodeOwnerConfigParser =
         plugin.getSysInjector().getInstance(ProtoCodeOwnerConfigParser.class);
-    codeOwnerBackends =
-        plugin.getSysInjector().getInstance(new Key<DynamicMap<CodeOwnerBackend>>() {});
   }
 
   @Test
@@ -111,6 +100,47 @@
   }
 
   @Test
+  public void codeOwnerConfigFileWithNonMatchingFileExtensionIsNotValidated() throws Exception {
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owner config with file extension",
+            getCodeOwnerConfigFileName() + ".foo",
+            "INVALID");
+    assertOkWithoutMessages(r);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
+  public void codeOwnerConfigFileWithMatchingFileExtensionIsValidated() throws Exception {
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owner config with file extension",
+            getCodeOwnerConfigFileName() + ".foo",
+            "INVALID");
+    String abbreviatedCommit = abbreviateName(r.getCommit());
+    r.assertErrorStatus(
+        String.format(
+            "commit %s: [code-owners] %s", abbreviatedCommit, "invalid code owner config files"));
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.enableCodeOwnerConfigFilesWithFileExtensions",
+      value = "true")
+  public void codeOwnerConfigFileWithFileExtensionIsValidatedIfFileExtensionsAreEnabled()
+      throws Exception {
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owner config with file extension",
+            getCodeOwnerConfigFileName() + ".foo",
+            "INVALID");
+    String abbreviatedCommit = abbreviateName(r.getCommit());
+    r.assertErrorStatus(
+        String.format(
+            "commit %s: [code-owners] %s", abbreviatedCommit, "invalid code owner config files"));
+  }
+
+  @Test
   public void canUploadConfigWithoutIssues() throws Exception {
     CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
 
@@ -173,30 +203,6 @@
   }
 
   @Test
-  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "true")
-  public void canSubmitConfigWithoutIssues() throws Exception {
-    setAsDefaultCodeOwners(admin);
-
-    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
-
-    // Create a code owner config without issues.
-    PushOneCommit.Result r =
-        createChange(
-            "Add code owners",
-            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getJGitFilePath(),
-            format(
-                CodeOwnerConfig.builder(codeOwnerConfigKey, TEST_REVISION)
-                    .addCodeOwnerSet(CodeOwnerSet.createWithoutPathExpressions(admin.email()))
-                    .build()));
-    r.assertOkStatus();
-
-    // Approve and submit the change.
-    approve(r.getChangeId());
-    gApi.changes().id(r.getChangeId()).current().submit();
-    assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
-  }
-
-  @Test
   public void canUploadConfigWithoutIssues_withImport() throws Exception {
     skipTestIfImportsNotSupportedByCodeOwnersBackend();
 
@@ -1111,53 +1117,6 @@
   }
 
   @Test
-  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "true")
-  public void cannotSubmitConfigWithNewIssues() throws Exception {
-    setAsDefaultCodeOwners(admin);
-
-    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
-
-    // disable the code owners functionality so that we can upload a a change with a code owner
-    // config that has issues
-    disableCodeOwnersForProject(project);
-
-    // upload a change with a code owner config that has issues (non-resolvable code owners)
-    String unknownEmail = "non-existing-email@example.com";
-    PushOneCommit.Result r =
-        createChange(
-            "Add code owners",
-            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getJGitFilePath(),
-            format(
-                CodeOwnerConfig.builder(codeOwnerConfigKey, TEST_REVISION)
-                    .addCodeOwnerSet(CodeOwnerSet.createWithoutPathExpressions(unknownEmail))
-                    .build()));
-    r.assertOkStatus();
-
-    // re-enable the code owners functionality for the project
-    enableCodeOwnersForProject(project);
-
-    // approve the change
-    approve(r.getChangeId());
-
-    // try to submit the change
-    ResourceConflictException exception =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(r.getChangeId()).current().submit());
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            String.format(
-                "Failed to submit 1 change due to the following problems:\n"
-                    + "Change %d: [code-owners] invalid code owner config files:\n"
-                    + "  ERROR: code owner email '%s' in '%s' cannot be resolved for %s",
-                r.getChange().getId().get(),
-                unknownEmail,
-                codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath(),
-                identifiedUserFactory.create(admin.id()).getLoggableName()));
-  }
-
-  @Test
   @GerritConfig(name = "plugin.code-owners.enableValidationOnCommitReceived", value = "dry_run")
   public void canUploadConfigWithNewIssuesIfValidationIsDoneAsDryRun() throws Exception {
     CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
@@ -1220,103 +1179,6 @@
   }
 
   @Test
-  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
-  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "true")
-  public void cannotSubmitConfigWithCodeOwnersThatAreNotVisibleToThePatchSetUploader()
-      throws Exception {
-    setAsDefaultCodeOwners(admin);
-
-    // Create a new user that is not a member of any group. This means 'user' and 'admin' are not
-    // visible to this user since they do not share any group.
-    TestAccount user2 = accountCreator.user2();
-
-    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
-
-    // disable the code owners functionality so that we can upload a change with a code owner
-    // config that has issues
-    disableCodeOwnersForProject(project);
-
-    // upload a change as user2 with a code owner config that contains a code owner that is not
-    // visible to user2
-    PushOneCommit.Result r =
-        createChange(
-            user2,
-            "Add code owners",
-            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getJGitFilePath(),
-            format(
-                CodeOwnerConfig.builder(codeOwnerConfigKey, TEST_REVISION)
-                    .addCodeOwnerSet(CodeOwnerSet.createWithoutPathExpressions(admin.email()))
-                    .build()));
-    r.assertOkStatus();
-
-    // re-enable the code owners functionality for the project
-    enableCodeOwnersForProject(project);
-
-    // approve the change
-    approve(r.getChangeId());
-
-    // try to submit the change as admin who can see the code owners in the config, the submit still
-    // fails because it is checked that the uploader (user2) can see the code owners
-    ResourceConflictException exception =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(r.getChangeId()).current().submit());
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            String.format(
-                "Failed to submit 1 change due to the following problems:\n"
-                    + "Change %d: [code-owners] invalid code owner config files:\n"
-                    + "  ERROR: code owner email '%s' in '%s' cannot be resolved for %s",
-                r.getChange().getId().get(),
-                admin.email(),
-                codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath(),
-                identifiedUserFactory.create(user2.id()).getLoggableName()));
-  }
-
-  @Test
-  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
-  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "true")
-  public void canSubmitConfigWithCodeOwnersThatAreNotVisibleToTheSubmitterButVisibleToTheUploader()
-      throws Exception {
-    setAsDefaultCodeOwners(admin);
-
-    // Create a new user that is not a member of any group. This means 'user' and 'admin' are not
-    // visible to this user since they do not share any group.
-    TestAccount user2 = accountCreator.user2();
-
-    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
-
-    // upload a change as admin with a code owner config that contains a code owner that is not
-    // visible to user2
-    PushOneCommit.Result r =
-        createChange(
-            "Add code owners",
-            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getJGitFilePath(),
-            format(
-                CodeOwnerConfig.builder(codeOwnerConfigKey, TEST_REVISION)
-                    .addCodeOwnerSet(CodeOwnerSet.createWithoutPathExpressions(user.email()))
-                    .build()));
-    r.assertOkStatus();
-
-    // approve the change
-    approve(r.getChangeId());
-
-    // grant user2 submit permissions
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
-        .update();
-
-    // submit the change as user2 who cannot see the code owners in the config, the submit succeeds
-    // because it is checked that the uploader (admin) can see the code owners
-    requestScopeOperations.setApiUser(user2.id());
-    gApi.changes().id(r.getChangeId()).current().submit();
-    assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
-  }
-
-  @Test
   public void uploadConfigWithGlobalSelfImportReportsAWarning() throws Exception {
     testUploadConfigWithSelfImport(CodeOwnerConfigImportType.GLOBAL);
   }
@@ -1413,6 +1275,121 @@
   }
 
   @Test
+  public void cannotUploadConfigWithGlobalImportOfFileWithFileExtension() throws Exception {
+    testCannotUploadConfigWithImportOfFileWithFileExtension(CodeOwnerConfigImportType.GLOBAL);
+  }
+
+  @Test
+  public void cannotUploadConfigWithPerFileImportOfFileWithFileExtension() throws Exception {
+    testCannotUploadConfigWithImportOfFileWithFileExtension(CodeOwnerConfigImportType.PER_FILE);
+  }
+
+  private void testCannotUploadConfigWithImportOfFileWithFileExtension(
+      CodeOwnerConfigImportType importType) throws Exception {
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
+
+    // create a code owner config that imports a code owner config from the same folder but with a
+    // file extension in the file name
+    CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig = createCodeOwnerConfigKey("/");
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .fileName(getCodeOwnerConfigFileName() + ".extension")
+            .addCodeOwnerEmail(user.email())
+            .create();
+    GitUtil.fetch(testRepo, "refs/*:refs/*");
+    testRepo.reset(projectOperations.project(project).getHead("master"));
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        CodeOwnerConfigReference.builder(
+                CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY,
+                codeOwnerConfigOperations
+                    .codeOwnerConfig(keyOfImportedCodeOwnerConfig)
+                    .getFilePath())
+            .setProject(project)
+            .build();
+    CodeOwnerConfig codeOwnerConfig =
+        createCodeOwnerConfigWithImport(
+            keyOfImportingCodeOwnerConfig, importType, codeOwnerConfigReference);
+
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations
+                .codeOwnerConfig(keyOfImportingCodeOwnerConfig)
+                .getJGitFilePath(),
+            format(codeOwnerConfig));
+    assertErrorWithMessages(
+        r,
+        "invalid code owner config files",
+        String.format(
+            "invalid %s import in '%s':" + " '%s' is not a code owner config file",
+            importType.getType(),
+            codeOwnerConfigOperations.codeOwnerConfig(keyOfImportingCodeOwnerConfig).getFilePath(),
+            codeOwnerConfigOperations.codeOwnerConfig(keyOfImportedCodeOwnerConfig).getFilePath()));
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.enableCodeOwnerConfigFilesWithFileExtensions",
+      value = "true")
+  public void canUploadConfigWithGlobalImportOfFileWithFileExtensionIfFileExtensionsAreEnabled()
+      throws Exception {
+    testUploadConfigWithImportOfFileWithFileExtension(CodeOwnerConfigImportType.GLOBAL);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.enableCodeOwnerConfigFilesWithFileExtensions",
+      value = "true")
+  public void canUploadConfigWithPerFileImportOfFileWithFileExtensionIfFileExtensionsAreEnabled()
+      throws Exception {
+    testUploadConfigWithImportOfFileWithFileExtension(CodeOwnerConfigImportType.PER_FILE);
+  }
+
+  private void testUploadConfigWithImportOfFileWithFileExtension(
+      CodeOwnerConfigImportType importType) throws Exception {
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
+
+    // create a code owner config that imports a code owner config from the same folder but with a
+    // file extension in the file name
+    CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig = createCodeOwnerConfigKey("/");
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .fileName(getCodeOwnerConfigFileName() + ".extension")
+            .addCodeOwnerEmail(user.email())
+            .create();
+    GitUtil.fetch(testRepo, "refs/*:refs/*");
+    testRepo.reset(projectOperations.project(project).getHead("master"));
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        CodeOwnerConfigReference.builder(
+                CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY,
+                codeOwnerConfigOperations
+                    .codeOwnerConfig(keyOfImportedCodeOwnerConfig)
+                    .getFilePath())
+            .setProject(project)
+            .build();
+    CodeOwnerConfig codeOwnerConfig =
+        createCodeOwnerConfigWithImport(
+            keyOfImportingCodeOwnerConfig, importType, codeOwnerConfigReference);
+
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations
+                .codeOwnerConfig(keyOfImportingCodeOwnerConfig)
+                .getJGitFilePath(),
+            format(codeOwnerConfig));
+    r.assertOkStatus();
+  }
+
+  @Test
   public void cannotUploadConfigWithGlobalImportFromNonExistingProject() throws Exception {
     testUploadConfigWithImportFromNonExistingProject(CodeOwnerConfigImportType.GLOBAL);
   }
@@ -1795,6 +1772,43 @@
   }
 
   @Test
+  public void cannotUploadConfigWithPerFileImportWithImportModeAll() throws Exception {
+    assume().that(backendConfig.getDefaultBackend()).isInstanceOf(FindOwnersBackend.class);
+
+    // create a code owner config that can be imported
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .folderPath("/")
+            .addCodeOwnerEmail(user.email())
+            .create();
+    GitUtil.fetch(testRepo, "refs/*:refs/*");
+
+    // codeOwnerConfigOperations cannot create a code owner config that has a per file import with
+    // import mode ALL, hence we just hard-code the contents of the OWNERS file here.
+    String codeOwnerConfig =
+        "per-file foo=include "
+            + codeOwnerConfigOperations.codeOwnerConfig(keyOfImportedCodeOwnerConfig).getFilePath();
+
+    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
+
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getJGitFilePath(),
+            codeOwnerConfig);
+    assertFatalWithMessages(
+        r,
+        "invalid code owner config files",
+        String.format(
+            "invalid code owner config file '%s' (project = %s, branch = master):\n  %s",
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath(),
+            project,
+            "keyword 'include' is not supported for per file imports: " + codeOwnerConfig));
+  }
+
+  @Test
   public void
       forMergeCommitsNonResolvablePerFileImportsFromOtherProjectsAreReportedAsWarningsIfImportsDontSpecifyBranch()
           throws Exception {
@@ -2103,9 +2117,7 @@
         .create();
 
     // Create a change that merges the other branch into master. The code owner config files in the
-    // created merge commit will be validated. This only works if CodeOwnerConfigValidator uses the
-    // same RevWalk instance that inserted the new merge commit. If it doesn't, the create change
-    // call below would fail with a MissingObjectException.
+    // created merge commit will be validated.
     ChangeInput changeInput = new ChangeInput();
     changeInput.project = project.get();
     changeInput.branch = "master";
@@ -2118,131 +2130,88 @@
   }
 
   @Test
-  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "false")
-  public void canSubmitNonParseableConfigIfValidationIsDisabled() throws Exception {
-    testCanSubmitNonParseableConfig();
-  }
+  public void skipValidationForMergeCommitCreatedViaTheCreateChangeRestApi() throws Exception {
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
 
-  @Test
-  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "dry_run")
-  public void canSubmitNonParseableConfigIfValidationIsDoneAsDryRun() throws Exception {
-    testCanSubmitNonParseableConfig();
-  }
+    // Create another branch.
+    String branchName = "stable";
+    createBranch(BranchNameKey.create(project, branchName));
 
-  @Test
-  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "forced_dry_run")
-  public void canSubmitNonParseableConfigIfValidationIsDoneAsForcedDryRun() throws Exception {
-    disableCodeOwnersForProject(project);
-    testCanSubmitNonParseableConfig();
-  }
+    // Create a code owner config file in the other branch.
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch(branchName)
+        .folderPath("/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
 
-  private void testCanSubmitNonParseableConfig() throws Exception {
-    setAsDefaultCodeOwners(admin);
+    // Create a conflicting code owner config file in the target branch.
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(user.email())
+        .create();
 
-    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
-
-    // disable the code owners functionality so that we can upload a non-parseable code owner config
-    // that we then try to submit
-    disableCodeOwnersForProject(project);
-
-    PushOneCommit.Result r =
-        createChange(
-            "Add code owners",
-            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getJGitFilePath(),
-            "INVALID");
-    r.assertOkStatus();
-
-    // re-enable the code owners functionality for the project
-    enableCodeOwnersForProject(project);
-
-    // submit the change
-    approve(r.getChangeId());
-    gApi.changes().id(r.getChangeId()).current().submit();
-    assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "forced")
-  public void
-      cannotSubmitConfigWithIssuesIfCodeOwnersFunctionalityIsDisabledButValidationIsEnforced()
-          throws Exception {
-    disableCodeOwnersForProject(project);
-
-    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
-
-    // upload a change with a code owner config that has issues (non-resolvable code owners)
-    String unknownEmail = "non-existing-email@example.com";
-    PushOneCommit.Result r =
-        createChange(
-            "Add code owners",
-            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getJGitFilePath(),
-            format(
-                CodeOwnerConfig.builder(codeOwnerConfigKey, TEST_REVISION)
-                    .addCodeOwnerSet(CodeOwnerSet.createWithoutPathExpressions(unknownEmail))
-                    .build()));
-    r.assertOkStatus();
-
-    // approve the change
-    approve(r.getChangeId());
-
-    // try to submit the change
-    ResourceConflictException exception =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(r.getChangeId()).current().submit());
-    assertThat(exception)
+    // Try creating a change that merges the other branch into master. The change creation fails
+    // because the code owner config file in the other branch conflicts with the code owner config
+    // file in the master branch.
+    ChangeInput changeInput = new ChangeInput();
+    changeInput.project = project.get();
+    changeInput.branch = "master";
+    changeInput.subject = "A change";
+    changeInput.status = ChangeStatus.NEW;
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = gApi.projects().name(project.get()).branch(branchName).get().revision;
+    changeInput.merge = mergeInput;
+    MergeConflictException mergeConflictException =
+        assertThrows(MergeConflictException.class, () -> gApi.changes().create(changeInput));
+    assertThat(mergeConflictException)
         .hasMessageThat()
-        .isEqualTo(
+        .isEqualTo(String.format("merge conflict(s):\n%s", getCodeOwnerConfigFileName()));
+
+    // Try creating the merge change with conflicts. Fails because the code owner config file
+    // contains conflict markers which fails the code owner config file validation.
+    mergeInput.allowConflicts = true;
+    ResourceConflictException resourceConflictException =
+        assertThrows(ResourceConflictException.class, () -> gApi.changes().create(changeInput));
+    assertThat(resourceConflictException)
+        .hasMessageThat()
+        .contains(
             String.format(
-                "Failed to submit 1 change due to the following problems:\n"
-                    + "Change %d: [code-owners] invalid code owner config files:\n"
-                    + "  ERROR: code owner email '%s' in '%s' cannot be resolved for %s",
-                r.getChange().getId().get(),
-                unknownEmail,
-                codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath(),
-                identifiedUserFactory.create(admin.id()).getLoggableName()));
+                "[code-owners] invalid code owner config file '/%s'",
+                getCodeOwnerConfigFileName()));
+
+    // Create the merge change with skipping code owners validation.
+    changeInput.validationOptions =
+        ImmutableMap.of(
+            String.format("code-owners~%s", SkipCodeOwnerConfigValidationPushOption.NAME), "true");
+    gApi.changes().create(changeInput);
   }
 
   @Test
-  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "false")
-  public void canSubmitConfigWithIssuesIfValidationIsDisabled() throws Exception {
-    testCanSubmitConfigWithIssues();
-  }
+  public void userWithoutCapabilitySkipValidationCannotSkipValidationWithCreateChangeRestApi()
+      throws Exception {
+    requestScopeOperations.setApiUser(user.id());
 
-  @Test
-  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "dry_run")
-  public void canSubmitConfigWithIssuesIfValidationIsDoneAsDryRun() throws Exception {
-    testCanSubmitConfigWithIssues();
-  }
-
-  private void testCanSubmitConfigWithIssues() throws Exception {
-    setAsDefaultCodeOwners(admin);
-
-    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
-
-    // disable the code owners functionality so that we can upload a code owner config with issues
-    // that we then try to submit
-    disableCodeOwnersForProject(project);
-
-    // upload a code owner config that has issues (non-resolvable code owners)
-    String unknownEmail1 = "non-existing-email@example.com";
-    PushOneCommit.Result r =
-        createChange(
-            "Add code owners",
-            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getJGitFilePath(),
-            format(
-                CodeOwnerConfig.builder(codeOwnerConfigKey, TEST_REVISION)
-                    .addCodeOwnerSet(CodeOwnerSet.createWithoutPathExpressions(unknownEmail1))
-                    .build()));
-    r.assertOkStatus();
-
-    // re-enable the code owners functionality for the project
-    enableCodeOwnersForProject(project);
-
-    // submit the change
-    approve(r.getChangeId());
-    gApi.changes().id(r.getChangeId()).current().submit();
-    assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
+    ChangeInput changeInput = new ChangeInput();
+    changeInput.project = project.get();
+    changeInput.branch = "master";
+    changeInput.subject = "A change";
+    changeInput.status = ChangeStatus.NEW;
+    changeInput.validationOptions =
+        ImmutableMap.of(
+            String.format("code-owners~%s", SkipCodeOwnerConfigValidationPushOption.NAME), "true");
+    ResourceConflictException resourceConflictException =
+        assertThrows(ResourceConflictException.class, () -> gApi.changes().create(changeInput));
+    assertThat(resourceConflictException)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "[code-owners] %s for plugin code-owners not permitted",
+                SkipCodeOwnerConfigValidationCapability.ID));
   }
 
   @Test
@@ -2385,60 +2354,6 @@
   }
 
   @Test
-  @GerritConfig(name = "plugin.code-owners.backend", value = FailingCodeOwnerBackend.ID)
-  public void pushFailsOnInternalError() throws Exception {
-    try (AutoCloseable registration = registerTestBackend(new FailingCodeOwnerBackend())) {
-      PushOneCommit.Result r = createChange("Add code owners", "OWNERS", "content");
-      r.assertErrorStatus("internal error");
-    }
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.backend", value = FailingCodeOwnerBackend.ID)
-  @GerritConfig(name = "plugin.code-owners.enableValidationOnCommitReceived", value = "DRY_RUN")
-  public void pushSucceedsOnInternalErrorIfValidationIsDoneAsDryRun() throws Exception {
-    try (AutoCloseable registration = registerTestBackend(new FailingCodeOwnerBackend())) {
-      PushOneCommit.Result r = createChange("Add code owners", "OWNERS", "content");
-      r.assertOkStatus();
-    }
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.backend", value = FailingCodeOwnerBackend.ID)
-  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "PROJECT_OWNERS")
-  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "true")
-  public void submitFailsOnInternalError() throws Exception {
-    try (AutoCloseable registration = registerTestBackend(new FailingCodeOwnerBackend())) {
-      disableCodeOwnersForProject(project);
-      PushOneCommit.Result r = createChange("Add code owners", "OWNERS", "content");
-      r.assertOkStatus();
-      enableCodeOwnersForProject(project);
-      approve(r.getChangeId());
-      IllegalStateException exception =
-          assertThrows(
-              IllegalStateException.class,
-              () -> gApi.changes().id(r.getChangeId()).current().submit());
-      assertThat(exception).hasMessageThat().isEqualTo(FailingCodeOwnerBackend.EXCEPTION_MESSAGE);
-    }
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.backend", value = FailingCodeOwnerBackend.ID)
-  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "DRY_RUN")
-  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "PROJECT_OWNERS")
-  public void submitSucceedsOnInternalErrorIfValidationIsDoneAsDryRun() throws Exception {
-    try (AutoCloseable registration = registerTestBackend(new FailingCodeOwnerBackend())) {
-      disableCodeOwnersForProject(project);
-      PushOneCommit.Result r = createChange("Add code owners", "OWNERS", "content");
-      r.assertOkStatus();
-      enableCodeOwnersForProject(project);
-      approve(r.getChangeId());
-      gApi.changes().id(r.getChangeId()).current().submit();
-      assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
-    }
-  }
-
-  @Test
   @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "true")
   public void disableValidationForBranch() throws Exception {
     setAsDefaultCodeOwners(admin);
@@ -2702,45 +2617,4 @@
     pushResult.assertNotMessage("warning");
     pushResult.assertNotMessage("hint");
   }
-
-  private AutoCloseable registerTestBackend(CodeOwnerBackend codeOwnerBackend) {
-    RegistrationHandle registrationHandle =
-        ((PrivateInternals_DynamicMapImpl<CodeOwnerBackend>) codeOwnerBackends)
-            .put("gerrit", FailingCodeOwnerBackend.ID, Providers.of(codeOwnerBackend));
-    return registrationHandle::remove;
-  }
-
-  private static class FailingCodeOwnerBackend implements CodeOwnerBackend {
-    static final String ID = "test-backend";
-    static final String EXCEPTION_MESSAGE = "failure from test";
-
-    @Override
-    public boolean isCodeOwnerConfigFile(NameKey project, String fileName) {
-      throw new IllegalStateException(EXCEPTION_MESSAGE);
-    }
-
-    @Override
-    public Optional<CodeOwnerConfig> getCodeOwnerConfig(
-        CodeOwnerConfig.Key codeOwnerConfigKey, RevWalk revWalk, ObjectId revision) {
-      return Optional.empty();
-    }
-
-    @Override
-    public Path getFilePath(CodeOwnerConfig.Key codeOwnerConfigKey) {
-      return codeOwnerConfigKey.filePath("OWNERS");
-    }
-
-    @Override
-    public Optional<CodeOwnerConfig> upsertCodeOwnerConfig(
-        CodeOwnerConfig.Key codeOwnerConfigKey,
-        CodeOwnerConfigUpdate codeOwnerConfigUpdate,
-        IdentifiedUser currentUser) {
-      return Optional.empty();
-    }
-
-    @Override
-    public Optional<PathExpressionMatcher> getPathExpressionMatcher() {
-      return Optional.empty();
-    }
-  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorOnSubmitIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorOnSubmitIT.java
new file mode 100644
index 0000000..b92d0e0
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorOnSubmitIT.java
@@ -0,0 +1,378 @@
+// Copyright (C) 2021 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.plugins.codeowners.acceptance.api;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerSet;
+import com.google.gerrit.plugins.codeowners.backend.config.BackendConfig;
+import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersBackend;
+import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersCodeOwnerConfigParser;
+import com.google.gerrit.plugins.codeowners.backend.proto.ProtoBackend;
+import com.google.gerrit.plugins.codeowners.backend.proto.ProtoCodeOwnerConfigParser;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Tests for {@code com.google.gerrit.plugins.codeowners.validation.CodeOwnerConfigValidator} that
+ * verify the validation on submit.
+ */
+public class CodeOwnerConfigValidatorOnSubmitIT extends AbstractCodeOwnersIT {
+  private static final ObjectId TEST_REVISION =
+      ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ProjectOperations projectOperations;
+
+  private FindOwnersCodeOwnerConfigParser findOwnersCodeOwnerConfigParser;
+  private ProtoCodeOwnerConfigParser protoCodeOwnerConfigParser;
+
+  @Before
+  public void setUpCodeOwnersPlugin() throws Exception {
+    backendConfig = plugin.getSysInjector().getInstance(BackendConfig.class);
+    findOwnersCodeOwnerConfigParser =
+        plugin.getSysInjector().getInstance(FindOwnersCodeOwnerConfigParser.class);
+    protoCodeOwnerConfigParser =
+        plugin.getSysInjector().getInstance(ProtoCodeOwnerConfigParser.class);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "true")
+  public void canSubmitConfigWithoutIssues() throws Exception {
+    setAsDefaultCodeOwners(admin);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
+
+    // Create a code owner config without issues.
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getJGitFilePath(),
+            format(
+                CodeOwnerConfig.builder(codeOwnerConfigKey, TEST_REVISION)
+                    .addCodeOwnerSet(CodeOwnerSet.createWithoutPathExpressions(admin.email()))
+                    .build()));
+    r.assertOkStatus();
+
+    // Approve and submit the change.
+    approve(r.getChangeId());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "true")
+  public void cannotSubmitConfigWithNewIssues() throws Exception {
+    setAsDefaultCodeOwners(admin);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
+
+    // disable the code owners functionality so that we can upload a a change with a code owner
+    // config that has issues
+    disableCodeOwnersForProject(project);
+
+    // upload a change with a code owner config that has issues (non-resolvable code owners)
+    String unknownEmail = "non-existing-email@example.com";
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getJGitFilePath(),
+            format(
+                CodeOwnerConfig.builder(codeOwnerConfigKey, TEST_REVISION)
+                    .addCodeOwnerSet(CodeOwnerSet.createWithoutPathExpressions(unknownEmail))
+                    .build()));
+    r.assertOkStatus();
+
+    // re-enable the code owners functionality for the project
+    enableCodeOwnersForProject(project);
+
+    // approve the change
+    approve(r.getChangeId());
+
+    // try to submit the change
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r.getChangeId()).current().submit());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Failed to submit 1 change due to the following problems:\n"
+                    + "Change %d: [code-owners] invalid code owner config files:\n"
+                    + "  ERROR: code owner email '%s' in '%s' cannot be resolved for %s",
+                r.getChange().getId().get(),
+                unknownEmail,
+                codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath(),
+                identifiedUserFactory.create(admin.id()).getLoggableName()));
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "true")
+  public void cannotSubmitConfigWithCodeOwnersThatAreNotVisibleToThePatchSetUploader()
+      throws Exception {
+    setAsDefaultCodeOwners(admin);
+
+    // Create a new user that is not a member of any group. This means 'user' and 'admin' are not
+    // visible to this user since they do not share any group.
+    TestAccount user2 = accountCreator.user2();
+
+    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
+
+    // disable the code owners functionality so that we can upload a change with a code owner
+    // config that has issues
+    disableCodeOwnersForProject(project);
+
+    // upload a change as user2 with a code owner config that contains a code owner that is not
+    // visible to user2
+    PushOneCommit.Result r =
+        createChange(
+            user2,
+            "Add code owners",
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getJGitFilePath(),
+            format(
+                CodeOwnerConfig.builder(codeOwnerConfigKey, TEST_REVISION)
+                    .addCodeOwnerSet(CodeOwnerSet.createWithoutPathExpressions(admin.email()))
+                    .build()));
+    r.assertOkStatus();
+
+    // re-enable the code owners functionality for the project
+    enableCodeOwnersForProject(project);
+
+    // approve the change
+    approve(r.getChangeId());
+
+    // try to submit the change as admin who can see the code owners in the config, the submit still
+    // fails because it is checked that the uploader (user2) can see the code owners
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r.getChangeId()).current().submit());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Failed to submit 1 change due to the following problems:\n"
+                    + "Change %d: [code-owners] invalid code owner config files:\n"
+                    + "  ERROR: code owner email '%s' in '%s' cannot be resolved for %s",
+                r.getChange().getId().get(),
+                admin.email(),
+                codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath(),
+                identifiedUserFactory.create(user2.id()).getLoggableName()));
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "true")
+  public void canSubmitConfigWithCodeOwnersThatAreNotVisibleToTheSubmitterButVisibleToTheUploader()
+      throws Exception {
+    setAsDefaultCodeOwners(admin);
+
+    // Create a new user that is not a member of any group. This means 'user' and 'admin' are not
+    // visible to this user since they do not share any group.
+    TestAccount user2 = accountCreator.user2();
+
+    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
+
+    // upload a change as admin with a code owner config that contains a code owner that is not
+    // visible to user2
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getJGitFilePath(),
+            format(
+                CodeOwnerConfig.builder(codeOwnerConfigKey, TEST_REVISION)
+                    .addCodeOwnerSet(CodeOwnerSet.createWithoutPathExpressions(user.email()))
+                    .build()));
+    r.assertOkStatus();
+
+    // approve the change
+    approve(r.getChangeId());
+
+    // grant user2 submit permissions
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    // submit the change as user2 who cannot see the code owners in the config, the submit succeeds
+    // because it is checked that the uploader (admin) can see the code owners
+    requestScopeOperations.setApiUser(user2.id());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "false")
+  public void canSubmitNonParseableConfigIfValidationIsDisabled() throws Exception {
+    testCanSubmitNonParseableConfig();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "dry_run")
+  public void canSubmitNonParseableConfigIfValidationIsDoneAsDryRun() throws Exception {
+    testCanSubmitNonParseableConfig();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "forced_dry_run")
+  public void canSubmitNonParseableConfigIfValidationIsDoneAsForcedDryRun() throws Exception {
+    disableCodeOwnersForProject(project);
+    testCanSubmitNonParseableConfig();
+  }
+
+  private void testCanSubmitNonParseableConfig() throws Exception {
+    setAsDefaultCodeOwners(admin);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
+
+    // disable the code owners functionality so that we can upload a non-parseable code owner config
+    // that we then try to submit
+    disableCodeOwnersForProject(project);
+
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getJGitFilePath(),
+            "INVALID");
+    r.assertOkStatus();
+
+    // re-enable the code owners functionality for the project
+    enableCodeOwnersForProject(project);
+
+    // submit the change
+    approve(r.getChangeId());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "forced")
+  public void
+      cannotSubmitConfigWithIssuesIfCodeOwnersFunctionalityIsDisabledButValidationIsEnforced()
+          throws Exception {
+    disableCodeOwnersForProject(project);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
+
+    // upload a change with a code owner config that has issues (non-resolvable code owners)
+    String unknownEmail = "non-existing-email@example.com";
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getJGitFilePath(),
+            format(
+                CodeOwnerConfig.builder(codeOwnerConfigKey, TEST_REVISION)
+                    .addCodeOwnerSet(CodeOwnerSet.createWithoutPathExpressions(unknownEmail))
+                    .build()));
+    r.assertOkStatus();
+
+    // approve the change
+    approve(r.getChangeId());
+
+    // try to submit the change
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r.getChangeId()).current().submit());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Failed to submit 1 change due to the following problems:\n"
+                    + "Change %d: [code-owners] invalid code owner config files:\n"
+                    + "  ERROR: code owner email '%s' in '%s' cannot be resolved for %s",
+                r.getChange().getId().get(),
+                unknownEmail,
+                codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath(),
+                identifiedUserFactory.create(admin.id()).getLoggableName()));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "false")
+  public void canSubmitConfigWithIssuesIfValidationIsDisabled() throws Exception {
+    testCanSubmitConfigWithIssues();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "dry_run")
+  public void canSubmitConfigWithIssuesIfValidationIsDoneAsDryRun() throws Exception {
+    testCanSubmitConfigWithIssues();
+  }
+
+  private void testCanSubmitConfigWithIssues() throws Exception {
+    setAsDefaultCodeOwners(admin);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
+
+    // disable the code owners functionality so that we can upload a code owner config with issues
+    // that we then try to submit
+    disableCodeOwnersForProject(project);
+
+    // upload a code owner config that has issues (non-resolvable code owners)
+    String unknownEmail1 = "non-existing-email@example.com";
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getJGitFilePath(),
+            format(
+                CodeOwnerConfig.builder(codeOwnerConfigKey, TEST_REVISION)
+                    .addCodeOwnerSet(CodeOwnerSet.createWithoutPathExpressions(unknownEmail1))
+                    .build()));
+    r.assertOkStatus();
+
+    // re-enable the code owners functionality for the project
+    enableCodeOwnersForProject(project);
+
+    // submit the change
+    approve(r.getChangeId());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  private CodeOwnerConfig.Key createCodeOwnerConfigKey(String folderPath) {
+    return CodeOwnerConfig.Key.create(project, "master", folderPath);
+  }
+
+  private String format(CodeOwnerConfig codeOwnerConfig) throws Exception {
+    if (backendConfig.getDefaultBackend() instanceof FindOwnersBackend) {
+      return findOwnersCodeOwnerConfigParser.formatAsString(codeOwnerConfig);
+    } else if (backendConfig.getDefaultBackend() instanceof ProtoBackend) {
+      return protoCodeOwnerConfigParser.formatAsString(codeOwnerConfig);
+    }
+
+    throw new IllegalStateException(
+        String.format(
+            "unknown code owner backend: %s",
+            backendConfig.getDefaultBackend().getClass().getName()));
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerHasOperandsIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerHasOperandsIT.java
new file mode 100644
index 0000000..070cbb5
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerHasOperandsIT.java
@@ -0,0 +1,308 @@
+// Copyright (C) 2021 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.plugins.codeowners.acceptance.api;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status.NOT_APPLICABLE;
+import static com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status.SATISFIED;
+import static com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status.UNSATISFIED;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.stream.Collectors.toList;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.Streams;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.extensions.api.changes.Changes.QueryRequest;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerApprovalHasOperand;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnersInternalServerErrorException;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.inject.Inject;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+@Sandboxed
+public class CodeOwnerHasOperandsIT extends AbstractCodeOwnersIT {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ChangeQueryBuilder changeQueryBuilder;
+
+  private CodeOwnerApprovalHasOperand codeOwnerApprovalHasOperand;
+
+  @Before
+  public void setup() throws Exception {
+    codeOwnerApprovalHasOperand =
+        plugin.getSysInjector().getInstance(CodeOwnerApprovalHasOperand.class);
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Owner-Approval")
+            .setApplicabilityExpression(SubmitRequirementExpression.of("has:enabled_code-owners"))
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("has:approval_code-owners"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+  }
+
+  @Test
+  public void hasApproval_notSupportedInSearchQueries() throws Exception {
+    Exception thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery("has:approval_code-owners"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Operator 'has:approval_code-owners' cannot be used in queries");
+  }
+
+  @Test
+  public void hasEnabled_notSupportedInSearchQueries() throws Exception {
+    Exception thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery("has:enabled_code-owners"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Operator 'has:enabled_code-owners' cannot be used in queries");
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
+  public void hasApproval_satisfied() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    ChangeData changeData = createChange("Change Adding A File", path, "file content").getChange();
+    int changeId = changeData.change().getChangeId();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+    assertSubmitRequirement(changeInfo.submitRequirements, "Code-Owner-Approval", UNSATISFIED);
+
+    // Add a Code-Review+1 from a code owner (by default this counts as code owner approval).
+    requestScopeOperations.setApiUser(user.id());
+    recommend(changeData.change().getKey().get());
+    changeInfo = gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+    assertSubmitRequirement(changeInfo.submitRequirements, "Code-Owner-Approval", SATISFIED);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
+  public void hasApproval_unsatisfiedIfChangeIsClosed() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    ChangeData changeData = createChange("Change Adding A File", path, "file content").getChange();
+    int changeId = changeData.change().getChangeId();
+
+    // Add a Code-Review+1 from a code owner (by default this counts as code owner approval).
+    requestScopeOperations.setApiUser(user.id());
+    recommend(changeData.change().getKey().get());
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+    assertSubmitRequirement(changeInfo.submitRequirements, "Code-Owner-Approval", SATISFIED);
+
+    // Approve and submit.
+    requestScopeOperations.setApiUser(admin.id());
+    approve(changeData.change().getKey().get());
+    gApi.changes().id(changeId).current().submit();
+    changeInfo = gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+    // When the change is merged, submit requirement results are persisted in NoteDb. Later lookups
+    // return the persisted snapshot. Currently writing to NoteDb is disabled.
+    // TODO(ghareeb): update this check when we enable writing to NoteDb again.
+    assertNonExistentSubmitRequirement(changeInfo.submitRequirements, "Code-Owner-Approval");
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
+  public void hasApproval_internalServerError() throws Exception {
+    ChangeData changeData = createChange().getChange();
+
+    // Create a ChangeData without change notes to trigger an error.
+    // Set change and current patch set, so that this info can be included into the error message.
+    ChangeData changeDataWithoutChangeNotes = mock(ChangeData.class);
+    when(changeDataWithoutChangeNotes.change()).thenReturn(changeData.change());
+    when(changeDataWithoutChangeNotes.currentPatchSet()).thenReturn(changeData.currentPatchSet());
+
+    CodeOwnersInternalServerErrorException exception =
+        assertThrows(
+            CodeOwnersInternalServerErrorException.class,
+            () ->
+                codeOwnerApprovalHasOperand
+                    .create(changeQueryBuilder)
+                    .asMatchable()
+                    .match(changeDataWithoutChangeNotes));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Failed to evaluate code owner statuses for patch set %d of change %d.",
+                changeData.change().currentPatchSetId().get(), changeData.change().getId().get()));
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
+  public void hasApproval_ruleErrorForNonParsableCodeOwnerConfig() throws Exception {
+    String nameOfInvalidCodeOwnerConfigFile = getCodeOwnerConfigFileName();
+    createNonParseableCodeOwnerConfig(nameOfInvalidCodeOwnerConfigFile);
+
+    ChangeData changeData = createChange().getChange();
+    ChangeInfo changeInfo =
+        gApi.changes()
+            .id(changeData.change().getChangeId())
+            .get(ListChangesOption.SUBMIT_REQUIREMENTS);
+    // Requirement is unsatisfied if a relevant code owner config file is not parseable and hence
+    // the submit rule cannot be evaluated.
+    assertSubmitRequirement(changeInfo.submitRequirements, "Code-Owner-Approval", UNSATISFIED);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.disabled", value = "true")
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
+  public void hasEnabled_notMatchingWhenCodeOwnersIsDisabledForTheChange() throws Exception {
+    Change change =
+        createChange("Change Adding A File", "foo/bar.baz", "file content").getChange().change();
+    String changeId = change.getKey().get();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+    assertSubmitRequirement(changeInfo.submitRequirements, "Code-Owner-Approval", NOT_APPLICABLE);
+
+    requestScopeOperations.setApiUser(user.id());
+    recommend(changeId);
+    changeInfo = gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+    assertSubmitRequirement(changeInfo.submitRequirements, "Code-Owner-Approval", NOT_APPLICABLE);
+  }
+
+  private List<ChangeInfo> assertQuery(Object query, Change... changes) throws Exception {
+    QueryRequest queryRequest = newQuery(query);
+    Change.Id[] changeArray = Arrays.stream(changes).map(Change::getId).toArray(Change.Id[]::new);
+    List<ChangeInfo> result = queryRequest.get();
+    Iterable<Change.Id> ids = ids(result);
+    assertWithMessage(format(queryRequest.getQuery(), ids, changeArray))
+        .that(ids)
+        .containsExactlyElementsIn(Arrays.asList(changeArray))
+        .inOrder();
+    return result;
+  }
+
+  private static Iterable<Change.Id> ids(Iterable<ChangeInfo> changes) {
+    return Streams.stream(changes).map(c -> Change.id(c._number)).collect(toList());
+  }
+
+  private QueryRequest newQuery(Object query) {
+    return gApi.changes().query(query.toString());
+  }
+
+  private void assertSubmitRequirement(
+      Collection<SubmitRequirementResultInfo> requirements, String name, Status status) {
+    for (SubmitRequirementResultInfo requirement : requirements) {
+      if (requirement.name.equals(name) && requirement.status == status) {
+        return;
+      }
+    }
+    throw new AssertionError(
+        String.format(
+            "Could not find submit requirement %s with status %s (results = %s)",
+            name,
+            status,
+            requirements.stream()
+                .map(r -> String.format("%s=%s", r.name, r.status))
+                .collect(toImmutableList())));
+  }
+
+  private void assertNonExistentSubmitRequirement(
+      Collection<SubmitRequirementResultInfo> requirements, String name) {
+    for (SubmitRequirementResultInfo requirement : requirements) {
+      if (requirement.name.equals(name)) {
+        throw new AssertionError("Found a submit requirement with name " + name);
+      }
+    }
+  }
+
+  private String format(String query, Iterable<Change.Id> actualIds, Change.Id... expectedChanges)
+      throws RestApiException {
+    return "query '"
+        + query
+        + "' with expected changes "
+        + format(Arrays.asList(expectedChanges))
+        + " and result "
+        + format(actualIds);
+  }
+
+  private String format(Iterable<Change.Id> changeIds) throws RestApiException {
+    Iterator<Change.Id> changeIdsItr = changeIds.iterator();
+    StringBuilder b = new StringBuilder();
+    b.append("[");
+    while (changeIdsItr.hasNext()) {
+      Change.Id id = changeIdsItr.next();
+      ChangeInfo c = gApi.changes().id(id.get()).get();
+      b.append("{")
+          .append(id)
+          .append(" (")
+          .append(c.changeId)
+          .append("), ")
+          .append("dest=")
+          .append(BranchNameKey.create(Project.nameKey(c.project), c.branch))
+          .append(", ")
+          .append("status=")
+          .append(c.status)
+          .append(", ")
+          .append("lastUpdated=")
+          .append(c.updated.getTime())
+          .append("}");
+      if (changeIdsItr.hasNext()) {
+        b.append(", ");
+      }
+    }
+    b.append("]");
+    return b.toString();
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerSubmitRuleIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerSubmitRuleIT.java
index 5d63b73..fef1205 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerSubmitRuleIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerSubmitRuleIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.plugins.codeowners.acceptance.api;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerStatusInfoSubject.assertThat;
 import static com.google.gerrit.plugins.codeowners.testing.LegacySubmitRequirementInfoSubject.assertThatCollection;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -38,6 +39,8 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatusInfo;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerSet;
+import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersBackend;
 import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
 import com.google.gerrit.plugins.codeowners.testing.LegacySubmitRequirementInfoSubject;
 import com.google.gerrit.plugins.codeowners.util.JgitPath;
@@ -488,6 +491,50 @@
   }
 
   @Test
+  public void changeIsSubmittableIfOwnersFileContainsInvalidPathExpression() throws Exception {
+    assume().that(backendConfig.getDefaultBackend()).isInstanceOf(FindOwnersBackend.class);
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(admin.email())
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addPathExpression("{foo") // invalid because '{' is not closed
+                .addCodeOwnerEmail(user.email())
+                .build())
+        .create();
+
+    PushOneCommit.Result r = createChange("Some Change", "foo.txt", "some content");
+    String changeId = r.getChangeId();
+
+    // Apply Code-Review+2 to satisfy the MaxWithBlock function of the Code-Review label.
+    approve(changeId);
+
+    ChangeInfo changeInfo =
+        gApi.changes()
+            .id(changeId)
+            .get(
+                ListChangesOption.SUBMITTABLE,
+                ListChangesOption.ALL_REVISIONS,
+                ListChangesOption.CURRENT_ACTIONS);
+    assertThat(changeInfo.submittable).isTrue();
+
+    // Check the submit requirement.
+    LegacySubmitRequirementInfoSubject submitRequirementInfoSubject =
+        assertThatCollection(changeInfo.requirements).onlyElement();
+    submitRequirementInfoSubject.hasStatusThat().isEqualTo("OK");
+    submitRequirementInfoSubject.hasFallbackTextThat().isEqualTo("Code Owners");
+    submitRequirementInfoSubject.hasTypeThat().isEqualTo("code-owners");
+
+    // Submit the change.
+    gApi.changes().id(changeId).current().submit();
+    assertThat(gApi.changes().id(changeId).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
   @GerritConfig(
       name = "plugin.code-owners.mergeCommitStrategy",
       value = "FILES_WITH_CONFLICT_RESOLUTION")
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnAddReviewerIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnAddReviewerIT.java
index 91c40db..bfc0283 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnAddReviewerIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnAddReviewerIT.java
@@ -15,21 +15,34 @@
 package com.google.gerrit.plugins.codeowners.acceptance.api;
 
 import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
+import com.github.rholder.retry.RetryerBuilder;
+import com.github.rholder.retry.StopStrategies;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import com.google.gerrit.server.util.AccountTemplateUtil;
+import java.time.Duration;
 import java.util.Collection;
+import java.util.concurrent.Callable;
 import org.junit.Test;
 
 /**
  * Acceptance test for {@code com.google.gerrit.plugins.codeowners.backend.CodeOwnersOnAddReviewer}.
+ *
+ * <p>For tests the change message that is posted when a code owner is added as a reviewer, is added
+ * synchronously by default (see {@link AbstractCodeOwnersIT #defaultConfig()}). Tests that want to
+ * verify the asynchronous posting of this change message need to set {@code
+ * plugin.code-owners.enableAsyncMessageOnAddReviewer=true} in {@code gerrit.config} explicitly (by
+ * using the {@link GerritConfig} annotation).
  */
 public class CodeOwnersOnAddReviewerIT extends AbstractCodeOwnersIT {
   @Test
@@ -88,8 +101,8 @@
     assertThat(Iterables.getLast(messages).message)
         .isEqualTo(
             String.format(
-                "%s who was added as reviewer owns the following files:\n* %s\n",
-                user.fullName(), path));
+                "%s, who was added as reviewer owns the following files:\n* %s\n",
+                AccountTemplateUtil.getAccountTemplate(user.id()), path));
   }
 
   @Test
@@ -124,8 +137,8 @@
     assertThat(Iterables.getLast(messages).message)
         .isEqualTo(
             String.format(
-                "%s who was added as reviewer owns the following files:\n* %s\n* %s\n",
-                user.fullName(), path1, path2));
+                "%s, who was added as reviewer owns the following files:\n* %s\n* %s\n",
+                AccountTemplateUtil.getAccountTemplate(user.id()), path1, path2));
   }
 
   @Test
@@ -187,12 +200,12 @@
     assertThat(Iterables.getLast(messages).message)
         .isEqualTo(
             String.format(
-                "%s who was added as reviewer owns the following files:\n"
+                "%s, who was added as reviewer owns the following files:\n"
                     + "* %s\n"
                     + "* %s\n"
                     + "* %s\n"
                     + "* %s\n",
-                user.fullName(), path4, path3, path1, path2));
+                AccountTemplateUtil.getAccountTemplate(user.id()), path4, path3, path1, path2));
   }
 
   @Test
@@ -233,12 +246,12 @@
     assertThat(Iterables.getLast(messages).message)
         .isEqualTo(
             String.format(
-                "%s who was added as reviewer owns the following files:\n"
+                "%s, who was added as reviewer owns the following files:\n"
                     + "* %s\n"
                     + "* %s\n"
                     + "* %s\n"
                     + "(more files)\n",
-                user.fullName(), path4, path3, path5));
+                AccountTemplateUtil.getAccountTemplate(user.id()), path4, path3, path5));
   }
 
   @Test
@@ -314,14 +327,49 @@
 
     // Add reviewer via PostReview.
     gApi.changes().id(changeId).current().review(ReviewInput.create().reviewer(user.email()));
-    gApi.changes().id(changeId).addReviewer(user.email());
 
     Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
     assertThat(Iterables.getLast(messages).message)
         .isEqualTo(
             String.format(
-                "%s who was added as reviewer owns the following files:\n* %s\n",
-                user.fullName(), path));
+                "%s, who was added as reviewer owns the following files:\n* %s\n",
+                AccountTemplateUtil.getAccountTemplate(user.id()), path));
+  }
+
+  @Test
+  public void multipleCodeOwnerAddedAsReviewersAtTheSameTime() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(user.email())
+        .addCodeOwnerEmail(user2.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    // Add code owners 'user' and 'user2' as reviewers.
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .review(ReviewInput.create().reviewer(user.email()).reviewer(user2.email()));
+
+    // We expect that 1 change message is added that lists the path owned for each of the new
+    // reviewers ('user' and 'user2').
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "%s, who was added as reviewer owns the following files:\n* %s\n\n"
+                    + "%s, who was added as reviewer owns the following files:\n* %s\n",
+                AccountTemplateUtil.getAccountTemplate(user.id()),
+                path,
+                AccountTemplateUtil.getAccountTemplate(user2.id()),
+                path));
   }
 
   @Test
@@ -352,11 +400,53 @@
                     + "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                admin.fullName(), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
     assertThat(Iterables.getLast(messages).message)
         .isEqualTo(
             String.format(
-                "%s who was added as reviewer owns the following files:\n* %s\n",
-                user.fullName(), path));
+                "%s, who was added as reviewer owns the following files:\n* %s\n",
+                AccountTemplateUtil.getAccountTemplate(user.id()), path));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableAsyncMessageOnAddReviewer", value = "true")
+  public void asyncChangeMessageThatListsOwnedPaths() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    assertAsyncChangeMessage(
+        changeId,
+        String.format(
+            "%s, who was added as reviewer owns the following files:\n* %s\n",
+            AccountTemplateUtil.getAccountTemplate(user.id()), path));
+  }
+
+  private void assertAsyncChangeMessage(String changeId, String expectedChangeMessage)
+      throws Exception {
+    assertAsync(
+        () -> {
+          Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+          assertThat(Iterables.getLast(messages).message).isEqualTo(expectedChangeMessage);
+          return null;
+        });
+  }
+
+  private <T> T assertAsync(Callable<T> assertion) throws Exception {
+    return RetryerBuilder.<T>newBuilder()
+        .retryIfException(t -> true)
+        .withStopStrategy(
+            StopStrategies.stopAfterDelay(Duration.ofSeconds(1).toMillis(), MILLISECONDS))
+        .build()
+        .call(() -> assertion.call());
   }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersPluginConfigValidatorIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersPluginConfigValidatorIT.java
index 3b1258e..f7f94be 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersPluginConfigValidatorIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersPluginConfigValidatorIT.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.plugins.codeowners.testing.RequiredApprovalSubject.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -35,6 +36,7 @@
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackendId;
 import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
+import com.google.gerrit.plugins.codeowners.backend.PathExpressions;
 import com.google.gerrit.plugins.codeowners.backend.config.BackendConfig;
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig;
@@ -242,6 +244,86 @@
   }
 
   @Test
+  public void configurePathExpressionsForProject() throws Exception {
+    fetchRefsMetaConfig();
+
+    Config cfg = new Config();
+    cfg.setString(
+        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        BackendConfig.KEY_PATH_EXPRESSIONS,
+        PathExpressions.GLOB.name());
+    setCodeOwnersConfig(cfg);
+
+    PushResult r = pushRefsMetaConfig();
+    assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getPathExpressions("master"))
+        .value()
+        .isEqualTo(PathExpressions.GLOB);
+  }
+
+  @Test
+  public void configurePathExpressionsForBranch() throws Exception {
+    fetchRefsMetaConfig();
+
+    Config cfg = new Config();
+    cfg.setString(
+        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+        "master",
+        BackendConfig.KEY_PATH_EXPRESSIONS,
+        PathExpressions.GLOB.name());
+    setCodeOwnersConfig(cfg);
+
+    PushResult r = pushRefsMetaConfig();
+    assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getPathExpressions("master"))
+        .value()
+        .isEqualTo(PathExpressions.GLOB);
+  }
+
+  @Test
+  public void cannotConfigureInvalidPathExpressionsForProject() throws Exception {
+    fetchRefsMetaConfig();
+
+    Config cfg = new Config();
+    cfg.setString(
+        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        BackendConfig.KEY_PATH_EXPRESSIONS,
+        "INVALID");
+    setCodeOwnersConfig(cfg);
+
+    PushResult r = pushRefsMetaConfig();
+    assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus())
+        .isEqualTo(Status.REJECTED_OTHER_REASON);
+    assertThat(r.getMessages())
+        .contains(
+            "Path expressions 'INVALID' that are configured in code-owners.config"
+                + " (parameter codeOwners.pathExpressions) not found.");
+  }
+
+  @Test
+  public void cannotConfigureInvalidPathExpressionsForBranch() throws Exception {
+    fetchRefsMetaConfig();
+
+    Config cfg = new Config();
+    cfg.setString(
+        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+        "master",
+        BackendConfig.KEY_PATH_EXPRESSIONS,
+        "INVALID");
+    setCodeOwnersConfig(cfg);
+
+    PushResult r = pushRefsMetaConfig();
+    assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus())
+        .isEqualTo(Status.REJECTED_OTHER_REASON);
+    assertThat(r.getMessages())
+        .contains(
+            "Path expressions 'INVALID' that are configured in code-owners.config"
+                + " (parameter codeOwners.master.pathExpressions) not found.");
+  }
+
+  @Test
   public void configureRequiredApproval() throws Exception {
     fetchRefsMetaConfig();
 
@@ -528,6 +610,27 @@
   }
 
   @Test
+  public void configureEnableAsyncMessageOnAddReviewer() throws Exception {
+    fetchRefsMetaConfig();
+
+    Config cfg = new Config();
+    cfg.setBoolean(
+        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        GeneralConfig.KEY_ENABLE_ASYNC_MESSAGE_ON_ADD_REVIEWER,
+        false);
+    setCodeOwnersConfig(cfg);
+
+    PushResult r = pushRefsMetaConfig();
+    assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
+    assertThat(
+            codeOwnersPluginConfiguration
+                .getProjectConfig(project)
+                .enableAsyncMessageOnAddReviewer())
+        .isFalse();
+  }
+
+  @Test
   public void cannotSetInvalidMaxPathsInChangeMessages() throws Exception {
     fetchRefsMetaConfig();
 
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerBranchConfigIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerBranchConfigIT.java
index 3777242..c2d2900 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerBranchConfigIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerBranchConfigIT.java
@@ -48,8 +48,6 @@
  * com.google.gerrit.plugins.codeowners.acceptance.restapi.GetCodeOwnerBranchConfigRestIT}.
  */
 public class GetCodeOwnerBranchConfigIT extends AbstractCodeOwnersIT {
-  private BackendConfig backendConfig;
-
   @Before
   public void setup() throws Exception {
     backendConfig = plugin.getSysInjector().getInstance(BackendConfig.class);
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerProjectConfigIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerProjectConfigIT.java
index 64f81af..6e8333e 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerProjectConfigIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerProjectConfigIT.java
@@ -55,8 +55,6 @@
 public class GetCodeOwnerProjectConfigIT extends AbstractCodeOwnersIT {
   @Inject private ProjectOperations projectOperations;
 
-  private BackendConfig backendConfig;
-
   @Before
   public void setup() throws Exception {
     backendConfig = plugin.getSysInjector().getInstance(BackendConfig.class);
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerStatusIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerStatusIT.java
index d8c51e6..fb87053 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerStatusIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerStatusIT.java
@@ -22,15 +22,14 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatusInfo;
-import com.google.gerrit.plugins.codeowners.backend.CodeOwnersExperimentFeaturesConstants;
 import com.google.gerrit.plugins.codeowners.backend.FileCodeOwnerStatus;
 import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
 import com.google.gerrit.plugins.codeowners.util.JgitPath;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.inject.Inject;
 import java.nio.file.Path;
 import java.nio.file.Paths;
@@ -73,8 +72,15 @@
     assertThat(codeOwnerStatus)
         .hasFileCodeOwnerStatusesThat()
         .comparingElementsUsing(isFileCodeOwnerStatus())
-        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.PENDING));
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
     assertThat(codeOwnerStatus).hasMoreThat().isNull();
+    assertThat(codeOwnerStatus).hasAccounts(user);
   }
 
   @Test
@@ -126,9 +132,20 @@
         .containsExactly(
             FileCodeOwnerStatus.addition(path4, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
             FileCodeOwnerStatus.addition(path3, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
-            FileCodeOwnerStatus.addition(path1, CodeOwnerStatus.PENDING),
-            FileCodeOwnerStatus.addition(path2, CodeOwnerStatus.PENDING))
+            FileCodeOwnerStatus.addition(
+                path1,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))),
+            FileCodeOwnerStatus.addition(
+                path2,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))))
         .inOrder();
+    assertThat(codeOwnerStatus).hasAccounts(user);
 
     codeOwnerStatus =
         changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().withStart(1).get();
@@ -140,9 +157,20 @@
         .comparingElementsUsing(isFileCodeOwnerStatus())
         .containsExactly(
             FileCodeOwnerStatus.addition(path3, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
-            FileCodeOwnerStatus.addition(path1, CodeOwnerStatus.PENDING),
-            FileCodeOwnerStatus.addition(path2, CodeOwnerStatus.PENDING))
+            FileCodeOwnerStatus.addition(
+                path1,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))),
+            FileCodeOwnerStatus.addition(
+                path2,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))))
         .inOrder();
+    assertThat(codeOwnerStatus).hasAccounts(user);
 
     codeOwnerStatus =
         changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().withStart(2).get();
@@ -153,9 +181,20 @@
         .hasFileCodeOwnerStatusesThat()
         .comparingElementsUsing(isFileCodeOwnerStatus())
         .containsExactly(
-            FileCodeOwnerStatus.addition(path1, CodeOwnerStatus.PENDING),
-            FileCodeOwnerStatus.addition(path2, CodeOwnerStatus.PENDING))
+            FileCodeOwnerStatus.addition(
+                path1,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))),
+            FileCodeOwnerStatus.addition(
+                path2,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))))
         .inOrder();
+    assertThat(codeOwnerStatus).hasAccounts(user);
 
     codeOwnerStatus =
         changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().withStart(3).get();
@@ -165,7 +204,14 @@
     assertThat(codeOwnerStatus)
         .hasFileCodeOwnerStatusesThat()
         .comparingElementsUsing(isFileCodeOwnerStatus())
-        .containsExactly(FileCodeOwnerStatus.addition(path2, CodeOwnerStatus.PENDING));
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path2,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
+    assertThat(codeOwnerStatus).hasAccounts(user);
 
     codeOwnerStatus =
         changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().withStart(4).get();
@@ -173,6 +219,7 @@
         .hasPatchSetNumberThat()
         .isEqualTo(r.getChange().currentPatchSet().id().get());
     assertThat(codeOwnerStatus).hasFileCodeOwnerStatusesThat().isEmpty();
+    assertThat(codeOwnerStatus).hasAccountsThat().isNull();
   }
 
   @Test
@@ -223,6 +270,7 @@
         .comparingElementsUsing(isFileCodeOwnerStatus())
         .containsExactly(
             FileCodeOwnerStatus.addition(path4, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    assertThat(codeOwnerStatus).hasAccountsThat().isNull();
     assertThat(codeOwnerStatus).hasMoreThat().isTrue();
 
     codeOwnerStatus =
@@ -237,6 +285,7 @@
             FileCodeOwnerStatus.addition(path4, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
             FileCodeOwnerStatus.addition(path3, CodeOwnerStatus.INSUFFICIENT_REVIEWERS))
         .inOrder();
+    assertThat(codeOwnerStatus).hasAccountsThat().isNull();
     assertThat(codeOwnerStatus).hasMoreThat().isTrue();
 
     codeOwnerStatus =
@@ -250,8 +299,14 @@
         .containsExactly(
             FileCodeOwnerStatus.addition(path4, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
             FileCodeOwnerStatus.addition(path3, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
-            FileCodeOwnerStatus.addition(path1, CodeOwnerStatus.PENDING))
+            FileCodeOwnerStatus.addition(
+                path1,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))))
         .inOrder();
+    assertThat(codeOwnerStatus).hasAccounts(user);
     assertThat(codeOwnerStatus).hasMoreThat().isTrue();
 
     codeOwnerStatus =
@@ -265,26 +320,25 @@
         .containsExactly(
             FileCodeOwnerStatus.addition(path4, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
             FileCodeOwnerStatus.addition(path3, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
-            FileCodeOwnerStatus.addition(path1, CodeOwnerStatus.PENDING),
-            FileCodeOwnerStatus.addition(path2, CodeOwnerStatus.PENDING))
+            FileCodeOwnerStatus.addition(
+                path1,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))),
+            FileCodeOwnerStatus.addition(
+                path2,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))))
         .inOrder();
+    assertThat(codeOwnerStatus).hasAccounts(user);
     assertThat(codeOwnerStatus).hasMoreThat().isNull();
   }
 
   @Test
   public void getStatusWithLimitForRename() throws Exception {
-    testGetStatusWithLimitForRenamedFile(/* useDiffCache= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
-  public void getStatusWithLimitForRename_useDiffCache() throws Exception {
-    testGetStatusWithLimitForRenamedFile(/* useDiffCache= */ true);
-  }
-
-  private void testGetStatusWithLimitForRenamedFile(boolean useDiffCache) throws Exception {
     codeOwnerConfigOperations
         .newCodeOwnerConfig()
         .project(project)
@@ -302,35 +356,21 @@
 
     CodeOwnerStatusInfo codeOwnerStatus =
         changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().withLimit(1).get();
-    if (useDiffCache) {
-      assertThat(codeOwnerStatus)
-          .hasFileCodeOwnerStatusesThat()
-          .comparingElementsUsing(isFileCodeOwnerStatus())
-          .containsExactly(
-              FileCodeOwnerStatus.rename(
-                  oldPath,
-                  CodeOwnerStatus.PENDING,
-                  newPath,
-                  CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
-      assertThat(codeOwnerStatus).hasMoreThat().isNull();
-    } else {
-      assertThat(codeOwnerStatus)
-          .hasFileCodeOwnerStatusesThat()
-          .comparingElementsUsing(isFileCodeOwnerStatus())
-          .containsExactly(
-              FileCodeOwnerStatus.addition(newPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
-      assertThat(codeOwnerStatus).hasMoreThat().isTrue();
-
-      codeOwnerStatus =
-          changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().withLimit(2).get();
-      assertThat(codeOwnerStatus)
-          .hasFileCodeOwnerStatusesThat()
-          .comparingElementsUsing(isFileCodeOwnerStatus())
-          .containsExactly(
-              FileCodeOwnerStatus.addition(newPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
-              FileCodeOwnerStatus.deletion(oldPath, CodeOwnerStatus.PENDING));
-      assertThat(codeOwnerStatus).hasMoreThat().isNull();
-    }
+    assertThat(codeOwnerStatus)
+        .hasFileCodeOwnerStatusesThat()
+        .comparingElementsUsing(isFileCodeOwnerStatus())
+        .containsExactly(
+            FileCodeOwnerStatus.rename(
+                oldPath,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id())),
+                newPath,
+                CodeOwnerStatus.INSUFFICIENT_REVIEWERS,
+                /* reasonNewPath= */ null));
+    assertThat(codeOwnerStatus).hasMoreThat().isNull();
+    assertThat(codeOwnerStatus).hasAccounts(user);
   }
 
   @Test
@@ -386,8 +426,14 @@
         .comparingElementsUsing(isFileCodeOwnerStatus())
         .containsExactly(
             FileCodeOwnerStatus.addition(path3, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
-            FileCodeOwnerStatus.addition(path1, CodeOwnerStatus.PENDING))
+            FileCodeOwnerStatus.addition(
+                path1,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))))
         .inOrder();
+    assertThat(codeOwnerStatus).hasAccounts(user);
     assertThat(codeOwnerStatus).hasMoreThat().isTrue();
   }
 
@@ -432,18 +478,6 @@
 
   @Test
   public void getStatusForRenamedFile() throws Exception {
-    testGetStatusForRenamedFile(/* useDiffCache= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
-  public void getStatusForRenamedFile_useDiffCache() throws Exception {
-    testGetStatusForRenamedFile(/* useDiffCache= */ true);
-  }
-
-  private void testGetStatusForRenamedFile(boolean useDiffCache) throws Exception {
     TestAccount user2 = accountCreator.user2();
 
     setAsCodeOwners("/foo/bar/", user);
@@ -455,67 +489,55 @@
 
     CodeOwnerStatusInfo codeOwnerStatus =
         changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().get();
-    if (useDiffCache) {
-      assertThat(codeOwnerStatus)
-          .hasFileCodeOwnerStatusesThat()
-          .comparingElementsUsing(isFileCodeOwnerStatus())
-          .containsExactly(
-              FileCodeOwnerStatus.rename(
-                  oldPath,
-                  CodeOwnerStatus.INSUFFICIENT_REVIEWERS,
-                  newPath,
-                  CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
-    } else {
-      assertThat(codeOwnerStatus)
-          .hasFileCodeOwnerStatusesThat()
-          .comparingElementsUsing(isFileCodeOwnerStatus())
-          .containsExactly(
-              FileCodeOwnerStatus.deletion(oldPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
-              FileCodeOwnerStatus.addition(newPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
-    }
+    assertThat(codeOwnerStatus)
+        .hasFileCodeOwnerStatusesThat()
+        .comparingElementsUsing(isFileCodeOwnerStatus())
+        .containsExactly(
+            FileCodeOwnerStatus.rename(
+                oldPath,
+                CodeOwnerStatus.INSUFFICIENT_REVIEWERS,
+                newPath,
+                CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add a reviewer that is a code owner of the old path.
     gApi.changes().id(changeId).addReviewer(user.email());
 
     codeOwnerStatus = changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().get();
-    if (useDiffCache) {
-      assertThat(codeOwnerStatus)
-          .hasFileCodeOwnerStatusesThat()
-          .comparingElementsUsing(isFileCodeOwnerStatus())
-          .containsExactly(
-              FileCodeOwnerStatus.rename(
-                  oldPath,
-                  CodeOwnerStatus.PENDING,
-                  newPath,
-                  CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
-    } else {
-      assertThat(codeOwnerStatus)
-          .hasFileCodeOwnerStatusesThat()
-          .comparingElementsUsing(isFileCodeOwnerStatus())
-          .containsExactly(
-              FileCodeOwnerStatus.deletion(oldPath, CodeOwnerStatus.PENDING),
-              FileCodeOwnerStatus.addition(newPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
-    }
+    assertThat(codeOwnerStatus)
+        .hasFileCodeOwnerStatusesThat()
+        .comparingElementsUsing(isFileCodeOwnerStatus())
+        .containsExactly(
+            FileCodeOwnerStatus.rename(
+                oldPath,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id())),
+                newPath,
+                CodeOwnerStatus.INSUFFICIENT_REVIEWERS,
+                /* reasonNewPath= */ null));
+    assertThat(codeOwnerStatus).hasAccounts(user);
 
     // Add a reviewer that is a code owner of the new path.
     gApi.changes().id(changeId).addReviewer(user2.email());
 
     codeOwnerStatus = changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().get();
-    if (useDiffCache) {
-      assertThat(codeOwnerStatus)
-          .hasFileCodeOwnerStatusesThat()
-          .comparingElementsUsing(isFileCodeOwnerStatus())
-          .containsExactly(
-              FileCodeOwnerStatus.rename(
-                  oldPath, CodeOwnerStatus.PENDING, newPath, CodeOwnerStatus.PENDING));
-    } else {
-      assertThat(codeOwnerStatus)
-          .hasFileCodeOwnerStatusesThat()
-          .comparingElementsUsing(isFileCodeOwnerStatus())
-          .containsExactly(
-              FileCodeOwnerStatus.deletion(oldPath, CodeOwnerStatus.PENDING),
-              FileCodeOwnerStatus.addition(newPath, CodeOwnerStatus.PENDING));
-    }
+    assertThat(codeOwnerStatus)
+        .hasFileCodeOwnerStatusesThat()
+        .comparingElementsUsing(isFileCodeOwnerStatus())
+        .containsExactly(
+            FileCodeOwnerStatus.rename(
+                oldPath,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id())),
+                newPath,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user2.id()))));
+    assertThat(codeOwnerStatus).hasAccounts(user, user2);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInBranchIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInBranchIT.java
index c260776..dfd97d4 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInBranchIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInBranchIT.java
@@ -32,7 +32,9 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.plugins.codeowners.api.CodeOwners;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnersInfo;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerAnnotations;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerSet;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerSetModification;
 import com.google.inject.Inject;
 import java.util.List;
@@ -243,4 +245,69 @@
         .comparingElementsUsing(hasAccountId())
         .containsExactly(serviceUser.id());
   }
+
+  @Test
+  public void codeOwnersWithLastResortSuggestionAnnotationAreIncluded() throws Exception {
+    skipTestIfAnnotationsNotSupportedByCodeOwnersBackend();
+
+    TestAccount user2 = accountCreator.user2();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addCodeOwnerEmail(admin.email())
+                .addAnnotation(
+                    admin.email(), CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION)
+                .addCodeOwnerEmail(user.email())
+                .addCodeOwnerEmail(user2.email())
+                .build())
+        .create();
+
+    // Expectation: admin is included because GetCodeOwnersForPathInBranch ignores the
+    // LAST_RESORT_SUGGESTION annotation (GetCodeOwnersForPathInBranch is for listing code owners,
+    // but this annotation is only relevant for GetCodeOwnersForPathInChange which is for suggesting
+    // code owners)
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id(), user.id(), user2.id());
+  }
+
+  @Test
+  public void perFileCodeOwnersWithLastResortSuggestionAnnotationAreIncluded() throws Exception {
+    skipTestIfAnnotationsNotSupportedByCodeOwnersBackend();
+
+    TestAccount user2 = accountCreator.user2();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addPathExpression(testPathExpressions.matchFileType("md"))
+                .addCodeOwnerEmail(admin.email())
+                .addAnnotation(
+                    admin.email(), CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION)
+                .addCodeOwnerEmail(user.email())
+                .addCodeOwnerEmail(user2.email())
+                .build())
+        .create();
+
+    // Expectation: admin is included because GetCodeOwnersForPathInBranch ignores the
+    // LAST_RESORT_SUGGESTION annotation (GetCodeOwnersForPathInBranch is for listing code owners,
+    // but this annotation is only relevant for GetCodeOwnersForPathInChange which is for suggesting
+    // code owners)
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id(), user.id(), user2.id());
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInChangeIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInChangeIT.java
index 1feefc6..a4d69f4 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInChangeIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInChangeIT.java
@@ -36,7 +36,10 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.plugins.codeowners.api.CodeOwners;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnersInfo;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwner;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerAnnotations;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerResolver;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerSet;
 import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.inject.Inject;
 import java.util.List;
@@ -327,6 +330,402 @@
   }
 
   @Test
+  public void codeOwnersWithLastResortSuggestionAnnotationAreFilteredOut() throws Exception {
+    skipTestIfAnnotationsNotSupportedByCodeOwnersBackend();
+
+    TestAccount user2 = accountCreator.user2();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addCodeOwnerEmail(admin.email())
+                .addAnnotation(
+                    admin.email(), CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION)
+                .addCodeOwnerEmail(user.email())
+                .addCodeOwnerEmail(user2.email())
+                .build())
+        .create();
+
+    // Expectation: admin is filtered out because it is annotated with LAST_RESORT_SUGGESTION.
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(user.id(), user2.id());
+  }
+
+  @Test
+  public void codeOwnersWithLastResortSuggestionAnnotation_annotationIgnoredIfResultWouldBeEmpty()
+      throws Exception {
+    skipTestIfAnnotationsNotSupportedByCodeOwnersBackend();
+
+    TestAccount serviceUser =
+        accountCreator.create("serviceUser", "service.user@example.com", "Service User", null);
+
+    // Make 'serviceUser' a service user.
+    groupOperations
+        .group(groupCache.get(AccountGroup.nameKey("Service Users")).get().getGroupUUID())
+        .forUpdate()
+        .addMember(serviceUser.id())
+        .update();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addCodeOwnerEmail(serviceUser.email())
+                .addCodeOwnerEmail(changeOwner.email())
+                .addCodeOwnerEmail(admin.email())
+                .addAnnotation(
+                    admin.email(), CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION)
+                .addCodeOwnerEmail(user.email())
+                .addAnnotation(user.email(), CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION)
+                .build())
+        .create();
+
+    // Expectation: The service user and the change owner are filtered out. admin and user get
+    // suggested despite of the LAST_RESORT_SUGGESTION annotation since ignoring them would make
+    // the result empty.
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id(), user.id());
+  }
+
+  @Test
+  public void
+      codeOwnersWithLastResortSuggestionAnnotation_annotationIgnoredIfCodeOwnerIsAlreadyAReviewer()
+          throws Exception {
+    skipTestIfAnnotationsNotSupportedByCodeOwnersBackend();
+
+    TestAccount user2 = accountCreator.user2();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addCodeOwnerEmail(admin.email())
+                .addCodeOwnerEmail(user.email())
+                .addAnnotation(user.email(), CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION)
+                .addCodeOwnerEmail(user2.email())
+                .addAnnotation(
+                    user2.email(), CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION)
+                .build())
+        .create();
+
+    // Expectation: admin is suggested, user and user2 get filtered out due to the
+    // LAST_RESORT_SUGGESTION annotation
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id());
+
+    // Add user as a reviewer.
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    // Expectation: user is being suggested now despite of the LAST_RESORT_SUGGESTION annotation
+    // because user is a reviewer, admin is still suggested and user2 is still filtered out due to
+    // the LAST_RESORT_SUGGESTION annotation
+    codeOwnersInfo = queryCodeOwners("foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id(), user.id());
+  }
+
+  @Test
+  public void codeOwnersWithLastResortSuggestionAnnotation_annotationSetForAllUsersWildcard()
+      throws Exception {
+    skipTestIfAnnotationsNotSupportedByCodeOwnersBackend();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addCodeOwnerEmail(CodeOwnerResolver.ALL_USERS_WILDCARD)
+                .addAnnotation(
+                    CodeOwnerResolver.ALL_USERS_WILDCARD,
+                    CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION)
+                .addCodeOwnerEmail(admin.email())
+                .addCodeOwnerEmail(user.email())
+                .build())
+        .create();
+
+    // Expectation: Since all code owners are annotated with LAST_RESORT_SUGGESTION (via the
+    // annotation on the all users wildcard) the result would be empty if code owners with the
+    // LAST_RESORT_SUGGESTION annotation are omitted. Hence in this case the LAST_RESORT_SUGGESTION
+    // annotation is ignored and we expect admin and user to be suggested.
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id(), user.id());
+  }
+
+  @Test
+  public void perFileCodeOwnersWithLastResortSuggestionAnnotationAreFilteredOut() throws Exception {
+    skipTestIfAnnotationsNotSupportedByCodeOwnersBackend();
+
+    TestAccount user2 = accountCreator.user2();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addPathExpression(testPathExpressions.matchFileType("md"))
+                .addCodeOwnerEmail(admin.email())
+                .addAnnotation(
+                    admin.email(), CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION)
+                .addCodeOwnerEmail(user.email())
+                .addCodeOwnerEmail(user2.email())
+                .build())
+        .create();
+
+    // Expectation: admin is filtered out because it is annotated with LAST_RESORT_SUGGESTION.
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(user.id(), user2.id());
+  }
+
+  @Test
+  public void
+      perFileCodeOwnersWithLastResortSuggestionAnnotation_annotationIgnoredIfResultWouldBeEmpty()
+          throws Exception {
+    skipTestIfAnnotationsNotSupportedByCodeOwnersBackend();
+
+    TestAccount serviceUser =
+        accountCreator.create("serviceUser", "service.user@example.com", "Service User", null);
+
+    // Make 'serviceUser' a service user.
+    groupOperations
+        .group(groupCache.get(AccountGroup.nameKey("Service Users")).get().getGroupUUID())
+        .forUpdate()
+        .addMember(serviceUser.id())
+        .update();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addPathExpression(testPathExpressions.matchFileType("md"))
+                .addCodeOwnerEmail(serviceUser.email())
+                .addCodeOwnerEmail(changeOwner.email())
+                .addCodeOwnerEmail(admin.email())
+                .addAnnotation(
+                    admin.email(), CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION)
+                .addCodeOwnerEmail(user.email())
+                .addAnnotation(user.email(), CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION)
+                .build())
+        .create();
+
+    // Expectation: The service user and the change owner are filtered out. admin and user get
+    // suggested despite of the LAST_RESORT_SUGGESTION annotation since ignoring them would make
+    // the result empty.
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id(), user.id());
+  }
+
+  @Test
+  public void
+      perFileCodeOwnersWithLastResortSuggestionAnnotation_annotationIgnoredIfCodeOwnerIsAlreadyAReviewer()
+          throws Exception {
+    skipTestIfAnnotationsNotSupportedByCodeOwnersBackend();
+
+    TestAccount user2 = accountCreator.user2();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addPathExpression(testPathExpressions.matchFileType("md"))
+                .addCodeOwnerEmail(admin.email())
+                .addCodeOwnerEmail(user.email())
+                .addAnnotation(user.email(), CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION)
+                .addCodeOwnerEmail(user2.email())
+                .addAnnotation(
+                    user2.email(), CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION)
+                .build())
+        .create();
+
+    // Expectation: admin is suggested, user and user2 get filtered out due to the
+    // LAST_RESORT_SUGGESTION annotation
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id());
+
+    // Add user as a reviewer.
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    // Expectation: user is being suggested now despite of the LAST_RESORT_SUGGESTION annotation
+    // because user is a reviewer, admin is still suggested and user2 is still filtered out due to
+    // the LAST_RESORT_SUGGESTION annotation
+    codeOwnersInfo = queryCodeOwners("foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id(), user.id());
+  }
+
+  @Test
+  public void perFileCodeOwnersWithLastResortSuggestionAnnotation_annotationSetForAllUsersWildcard()
+      throws Exception {
+    skipTestIfAnnotationsNotSupportedByCodeOwnersBackend();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addPathExpression(testPathExpressions.matchFileType("md"))
+                .addCodeOwnerEmail(CodeOwnerResolver.ALL_USERS_WILDCARD)
+                .addAnnotation(
+                    CodeOwnerResolver.ALL_USERS_WILDCARD,
+                    CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION)
+                .addCodeOwnerEmail(admin.email())
+                .addCodeOwnerEmail(user.email())
+                .build())
+        .create();
+
+    // Expectation: Since all code owners are annotated with LAST_RESORT_SUGGESTION (via the
+    // annotation on the all users wildcard) the result would be empty if code owners with the
+    // LAST_RESORT_SUGGESTION annotation are omitted. Hence in this case the LAST_RESORT_SUGGESTION
+    // annotation is ignored and we expect admin and user to be suggested.
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id(), user.id());
+  }
+
+  @Test
+  public void lastResortSuggestionTakesEffectEvenIfCodeOwnerIsAlsoSpecifiedWithoutThisAnnotation()
+      throws Exception {
+    skipTestIfAnnotationsNotSupportedByCodeOwnersBackend();
+
+    TestAccount user2 = accountCreator.user2();
+
+    // Code owner config with admin as code owner, but without annotation.
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    // Code owner config that specifies admin multiple times as code owner, but only once with the
+    // LAST_RESORT_SUGGESTION annotation.
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addCodeOwnerEmail(admin.email())
+                .addAnnotation(
+                    admin.email(), CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION)
+                .addCodeOwnerEmail(user.email())
+                .addCodeOwnerEmail(user2.email())
+                .build())
+        // Another code owner set with admin as folder code owner, but without annotation.
+        .addCodeOwnerSet(CodeOwnerSet.builder().addCodeOwnerEmail(admin.email()).build())
+        // A per-file code owner with admin as file code owner, but without annotation.
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addPathExpression(testPathExpressions.matchFileType("md"))
+                .addCodeOwnerEmail(admin.email())
+                .build())
+        .create();
+
+    // Another code owner config with admin as code owner, but without annotation.
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/bar/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    // Expectation: admin is filtered out because at one place it is annotated with
+    // LAST_RESORT_SUGGESTION.
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(user.id(), user2.id());
+  }
+
+  @Test
+  public void lastResortSuggestionOnNonMatchingPerFileRuleDoesntHaveAnyEffect() throws Exception {
+    skipTestIfAnnotationsNotSupportedByCodeOwnersBackend();
+
+    TestAccount user2 = accountCreator.user2();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addCodeOwnerEmail(admin.email())
+                .addCodeOwnerEmail(user.email())
+                .addCodeOwnerEmail(user2.email())
+                .build())
+        // Non-matching per-file code owner with LAST_RESORT_SUGGESTION annotation.
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addPathExpression(testPathExpressions.matchFileType("txt"))
+                .addCodeOwnerEmail(admin.email())
+                .addAnnotation(
+                    admin.email(), CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION)
+                .build())
+        .create();
+
+    // Expectation: admin is suggested since the LAST_RESORT_SUGGESTION annotation is set on the
+    // per-file rule which doesn't match.
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id(), user.id(), user2.id());
+  }
+
+  @Test
   public void codeOwnersThatAreReviewersAreReturnedFirst() throws Exception {
     TestAccount user2 = accountCreator.user2();
 
@@ -429,4 +828,54 @@
         .inOrder();
     assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isTrue();
   }
+
+  @Test
+  public void filteredOutCodeOwnersAreMentionedInDebugLogs() throws Exception {
+    skipTestIfAnnotationsNotSupportedByCodeOwnersBackend();
+
+    // Create a service user.
+    TestAccount serviceUser =
+        accountCreator.create("serviceUser", "service.user@example.com", "Service User", null);
+    groupOperations
+        .group(groupCache.get(AccountGroup.nameKey("Service Users")).get().getGroupUUID())
+        .forUpdate()
+        .addMember(serviceUser.id())
+        .update();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addCodeOwnerEmail(changeOwner.email())
+                .addCodeOwnerEmail(serviceUser.email())
+                .addCodeOwnerEmail(admin.email())
+                .addAnnotation(
+                    admin.email(), CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION)
+                .addCodeOwnerEmail(user.email())
+                .build())
+        .create();
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnersInfo codeOwnersInfo =
+        queryCodeOwners(getCodeOwnersApi().query().withDebug(/* debug= */ true), path);
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(user.id());
+    assertThat(codeOwnersInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "filtering out %s because this code owner is the change owner",
+                CodeOwner.create(changeOwner.id())),
+            String.format(
+                "filtering out %s because this code owner is a service user",
+                CodeOwner.create(serviceUser.id())),
+            String.format(
+                "filtering out %s because this code owner is annotated with %s",
+                CodeOwner.create(admin.id()),
+                CodeOwnerAnnotations.LAST_RESORT_SUGGESTION_ANNOTATION.key()));
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetOwnedPathsIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetOwnedPathsIT.java
index 681513b..5b19b9a 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetOwnedPathsIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetOwnedPathsIT.java
@@ -15,13 +15,18 @@
 package com.google.gerrit.plugins.codeowners.acceptance.api;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.plugins.codeowners.testing.OwnedChangedFileInfoSubject.assertThat;
 import static com.google.gerrit.plugins.codeowners.testing.OwnedPathsInfoSubject.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
+import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
@@ -29,6 +34,9 @@
 import com.google.gerrit.plugins.codeowners.restapi.GetOwnedPaths;
 import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.inject.Inject;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
 import org.junit.Test;
 
 /**
@@ -130,11 +138,155 @@
             .getOwnedPaths()
             .forUser(user.email())
             .get();
+
+    assertThat(ownedPathsInfo).hasOwnedChangedFilesThat().hasSize(2);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(0)).hasOwnedNewPath(path1);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(0)).hasEmptyOldPath();
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(1)).hasOwnedNewPath(path2);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(1)).hasEmptyOldPath();
+
     assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactly(path1, path2).inOrder();
     assertThat(ownedPathsInfo).hasMoreThat().isNull();
   }
 
   @Test
+  public void getOwnedPathsForDeletedFiles() throws Exception {
+    setAsCodeOwners("/foo/", user);
+
+    String path1 = "/foo/bar/baz.md";
+    String path2 = "/foo/baz/bar.md";
+    String path3 = "/bar/foo.md";
+
+    createChange(
+            "Change Adding Files",
+            ImmutableMap.of(
+                JgitPath.of(path1).get(),
+                "file content 1",
+                JgitPath.of(path2).get(),
+                "file content 2",
+                JgitPath.of(path3).get(),
+                "file content 3"))
+        .getChangeId();
+
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Change Deleting Files",
+            ImmutableMap.of(
+                JgitPath.of(path1).get(),
+                "file content 1",
+                JgitPath.of(path2).get(),
+                "file content 2",
+                JgitPath.of(path3).get(),
+                "file content 3"));
+    Result r = push.rm("refs/for/master");
+    r.assertOkStatus();
+    String changeId = r.getChangeId();
+
+    OwnedPathsInfo ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .forUser(user.email())
+            .get();
+
+    assertThat(ownedPathsInfo).hasOwnedChangedFilesThat().hasSize(2);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(0)).hasEmptyNewPath();
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(0)).hasOwnedOldPath(path1);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(1)).hasEmptyNewPath();
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(1)).hasOwnedOldPath(path2);
+
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactly(path1, path2).inOrder();
+    assertThat(ownedPathsInfo).hasMoreThat().isNull();
+  }
+
+  @Test
+  public void getOwnedPathsForRenamedFiles() throws Exception {
+    setAsCodeOwners("/foo/", user);
+
+    // Rename 1: user owns old and new path
+    String newPath1 = "/foo/test1.md";
+    String oldPath1 = "/foo/bar/test1.md";
+
+    // Rename 2: user owns only new path
+    String newPath2 = "/foo/test2.md";
+    String oldPath2 = "/other/test2.md";
+
+    // Rename 3: user owns only old path
+    String newPath3 = "/other/test3.md";
+    String oldPath3 = "/foo/test3.md";
+
+    // Rename 4: user owns neither old nor new path
+    String newPath4 = "/other/test4.md";
+    String oldPath4 = "/other/foo/test4.md";
+
+    String changeId1 =
+        createChange(
+                "Change Adding Files",
+                ImmutableMap.of(
+                    JgitPath.of(oldPath1).get(),
+                    "file content 1",
+                    JgitPath.of(oldPath2).get(),
+                    "file content 2",
+                    JgitPath.of(oldPath3).get(),
+                    "file content 3",
+                    JgitPath.of(oldPath4).get(),
+                    "file content 4"))
+            .getChangeId();
+
+    // The PushOneCommit test API doesn't support renaming files in a change. Use the change edit
+    // Java API instead.
+    ChangeInput changeInput = new ChangeInput();
+    changeInput.project = project.get();
+    changeInput.branch = "master";
+    changeInput.subject = "Change Renaming Files";
+    changeInput.baseChange = changeId1;
+    String changeId2 = gApi.changes().create(changeInput).get().changeId;
+    gApi.changes().id(changeId2).edit().create();
+    gApi.changes()
+        .id(changeId2)
+        .edit()
+        .renameFile(JgitPath.of(oldPath1).get(), JgitPath.of(newPath1).get());
+    gApi.changes()
+        .id(changeId2)
+        .edit()
+        .renameFile(JgitPath.of(oldPath2).get(), JgitPath.of(newPath2).get());
+    gApi.changes()
+        .id(changeId2)
+        .edit()
+        .renameFile(JgitPath.of(oldPath3).get(), JgitPath.of(newPath3).get());
+    gApi.changes()
+        .id(changeId2)
+        .edit()
+        .renameFile(JgitPath.of(oldPath4).get(), JgitPath.of(newPath4).get());
+    gApi.changes().id(changeId2).edit().publish(new PublishChangeEditInput());
+
+    OwnedPathsInfo ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId2)
+            .current()
+            .getOwnedPaths()
+            .forUser(user.email())
+            .get();
+
+    assertThat(ownedPathsInfo).hasOwnedChangedFilesThat().hasSize(3);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(0)).hasOwnedNewPath(newPath1);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(0)).hasOwnedOldPath(oldPath1);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(1)).hasOwnedNewPath(newPath2);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(1)).hasNonOwnedOldPath(oldPath2);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(2)).hasNonOwnedNewPath(newPath3);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(2)).hasOwnedOldPath(oldPath3);
+
+    List<String> ownedPaths = Arrays.asList(newPath1, oldPath1, newPath2, oldPath3);
+    Collections.sort(ownedPaths);
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactlyElementsIn(ownedPaths).inOrder();
+
+    assertThat(ownedPathsInfo).hasMoreThat().isNull();
+  }
+
+  @Test
   public void getOwnedPathForOwnUser() throws Exception {
     setAsRootCodeOwners(admin);
 
@@ -371,6 +523,203 @@
   }
 
   @Test
+  public void getOwnedPathsForRenamedFilesWithLimit() throws Exception {
+    setAsCodeOwners("/foo/", user);
+
+    // Rename 1: user owns old and new path
+    String newPath1 = "/foo/test1.md";
+    String oldPath1 = "/foo/bar/test1.md";
+
+    // Rename 2: user owns only new path
+    String newPath2 = "/foo/test2.md";
+    String oldPath2 = "/other/test2.md";
+
+    // Rename 3: user owns only old path
+    String newPath3 = "/other/test3.md";
+    String oldPath3 = "/foo/test3.md";
+
+    // Rename 4: user owns neither old nor new path
+    String newPath4 = "/other/test4.md";
+    String oldPath4 = "/other/foo/test4.md";
+
+    String changeId1 =
+        createChange(
+                "Change Adding Files",
+                ImmutableMap.of(
+                    JgitPath.of(oldPath1).get(),
+                    "file content 1",
+                    JgitPath.of(oldPath2).get(),
+                    "file content 2",
+                    JgitPath.of(oldPath3).get(),
+                    "file content 3",
+                    JgitPath.of(oldPath4).get(),
+                    "file content 4"))
+            .getChangeId();
+
+    // The PushOneCommit test API doesn't support renaming files in a change. Use the change edit
+    // Java API instead.
+    ChangeInput changeInput = new ChangeInput();
+    changeInput.project = project.get();
+    changeInput.branch = "master";
+    changeInput.subject = "Change Renaming Files";
+    changeInput.baseChange = changeId1;
+    String changeId2 = gApi.changes().create(changeInput).get().changeId;
+    gApi.changes().id(changeId2).edit().create();
+    gApi.changes()
+        .id(changeId2)
+        .edit()
+        .renameFile(JgitPath.of(oldPath1).get(), JgitPath.of(newPath1).get());
+    gApi.changes()
+        .id(changeId2)
+        .edit()
+        .renameFile(JgitPath.of(oldPath2).get(), JgitPath.of(newPath2).get());
+    gApi.changes()
+        .id(changeId2)
+        .edit()
+        .renameFile(JgitPath.of(oldPath3).get(), JgitPath.of(newPath3).get());
+    gApi.changes()
+        .id(changeId2)
+        .edit()
+        .renameFile(JgitPath.of(oldPath4).get(), JgitPath.of(newPath4).get());
+    gApi.changes().id(changeId2).edit().publish(new PublishChangeEditInput());
+
+    OwnedPathsInfo ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId2)
+            .current()
+            .getOwnedPaths()
+            .withLimit(1)
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo).hasOwnedChangedFilesThat().hasSize(1);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(0)).hasOwnedNewPath(newPath1);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(0)).hasOwnedOldPath(oldPath1);
+    List<String> ownedPaths = Arrays.asList(newPath1, oldPath1);
+    Collections.sort(ownedPaths);
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactlyElementsIn(ownedPaths);
+    assertThat(ownedPathsInfo).hasMoreThat().isTrue();
+
+    ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId2)
+            .current()
+            .getOwnedPaths()
+            .withLimit(2)
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo).hasOwnedChangedFilesThat().hasSize(2);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(0)).hasOwnedNewPath(newPath1);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(0)).hasOwnedOldPath(oldPath1);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(1)).hasOwnedNewPath(newPath2);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(1)).hasNonOwnedOldPath(oldPath2);
+    ownedPaths = Arrays.asList(newPath1, oldPath1, newPath2);
+    Collections.sort(ownedPaths);
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactlyElementsIn(ownedPaths).inOrder();
+    assertThat(ownedPathsInfo).hasMoreThat().isTrue();
+
+    ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId2)
+            .current()
+            .getOwnedPaths()
+            .withLimit(3)
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo).hasOwnedChangedFilesThat().hasSize(3);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(0)).hasOwnedNewPath(newPath1);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(0)).hasOwnedOldPath(oldPath1);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(1)).hasOwnedNewPath(newPath2);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(1)).hasNonOwnedOldPath(oldPath2);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(2)).hasNonOwnedNewPath(newPath3);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(2)).hasOwnedOldPath(oldPath3);
+    ownedPaths = Arrays.asList(newPath1, oldPath1, newPath2, oldPath3);
+    Collections.sort(ownedPaths);
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactlyElementsIn(ownedPaths).inOrder();
+    assertThat(ownedPathsInfo).hasMoreThat().isNull();
+  }
+
+  @Test
+  public void getOwnedPathsForRenamedFilesWithStartAndLimit() throws Exception {
+    setAsCodeOwners("/foo/", user);
+
+    // Rename 1: user owns old and new path
+    String newPath1 = "/foo/test1.md";
+    String oldPath1 = "/foo/bar/test1.md";
+
+    // Rename 2: user owns only new path
+    String newPath2 = "/foo/test2.md";
+    String oldPath2 = "/other/test2.md";
+
+    // Rename 3: user owns only old path
+    String newPath3 = "/other/test3.md";
+    String oldPath3 = "/foo/test3.md";
+
+    // Rename 4: user owns neither old nor new path
+    String newPath4 = "/other/test4.md";
+    String oldPath4 = "/other/foo/test4.md";
+
+    String changeId1 =
+        createChange(
+                "Change Adding Files",
+                ImmutableMap.of(
+                    JgitPath.of(oldPath1).get(),
+                    "file content 1",
+                    JgitPath.of(oldPath2).get(),
+                    "file content 2",
+                    JgitPath.of(oldPath3).get(),
+                    "file content 3",
+                    JgitPath.of(oldPath4).get(),
+                    "file content 4"))
+            .getChangeId();
+
+    // The PushOneCommit test API doesn't support renaming files in a change. Use the change edit
+    // Java API instead.
+    ChangeInput changeInput = new ChangeInput();
+    changeInput.project = project.get();
+    changeInput.branch = "master";
+    changeInput.subject = "Change Renaming Files";
+    changeInput.baseChange = changeId1;
+    String changeId2 = gApi.changes().create(changeInput).get().changeId;
+    gApi.changes().id(changeId2).edit().create();
+    gApi.changes()
+        .id(changeId2)
+        .edit()
+        .renameFile(JgitPath.of(oldPath1).get(), JgitPath.of(newPath1).get());
+    gApi.changes()
+        .id(changeId2)
+        .edit()
+        .renameFile(JgitPath.of(oldPath2).get(), JgitPath.of(newPath2).get());
+    gApi.changes()
+        .id(changeId2)
+        .edit()
+        .renameFile(JgitPath.of(oldPath3).get(), JgitPath.of(newPath3).get());
+    gApi.changes()
+        .id(changeId2)
+        .edit()
+        .renameFile(JgitPath.of(oldPath4).get(), JgitPath.of(newPath4).get());
+    gApi.changes().id(changeId2).edit().publish(new PublishChangeEditInput());
+
+    OwnedPathsInfo ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId2)
+            .current()
+            .getOwnedPaths()
+            .withStart(1)
+            .withLimit(2)
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo).hasOwnedChangedFilesThat().hasSize(2);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(0)).hasOwnedNewPath(newPath2);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(0)).hasNonOwnedOldPath(oldPath2);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(1)).hasNonOwnedNewPath(newPath3);
+    assertThat(ownedPathsInfo.ownedChangedFiles.get(1)).hasOwnedOldPath(oldPath3);
+    List<String> ownedPaths = Arrays.asList(newPath2, oldPath3);
+    Collections.sort(ownedPaths);
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactlyElementsIn(ownedPaths);
+    assertThat(ownedPathsInfo).hasMoreThat().isNull();
+  }
+
+  @Test
   public void getOwnedPathsLimitedByDefault() throws Exception {
     setAsRootCodeOwners(user);
 
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/OnCodeOwnerApprovalIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/OnCodeOwnerApprovalIT.java
index 8a8b9c4..efd2166 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/OnCodeOwnerApprovalIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/OnCodeOwnerApprovalIT.java
@@ -31,9 +31,12 @@
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import com.google.gerrit.server.util.AccountTemplateUtil;
+import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
 import java.util.Collection;
 import java.util.HashMap;
+import java.util.List;
 import org.junit.Test;
 
 /** Acceptance test for {@code com.google.gerrit.plugins.codeowners.backend.OnCodeOwnerApproval}. */
@@ -84,7 +87,7 @@
                     + "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                admin.fullName(), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
   }
 
   @Test
@@ -118,7 +121,7 @@
                     + "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                admin.fullName(), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
   }
 
   @Test
@@ -160,7 +163,7 @@
                     + "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                admin.fullName(), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
 
     // Apply the Code-Review+1 approval again and add an unrelated vote (Code-Review+1 is ignored).
     ReviewInput reviewInput = ReviewInput.recommend();
@@ -210,7 +213,7 @@
                     + "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                admin.fullName(), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
 
     // Apply the Code-Review+1 approval again and add a comment (Code-Review +1 is ignored)
     ReviewInput.CommentInput commentInput = new ReviewInput.CommentInput();
@@ -251,7 +254,7 @@
                     + "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                admin.fullName(), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
 
     // Apply the Code-Review+1 by another code owner
     requestScopeOperations.setApiUser(user.id());
@@ -265,7 +268,7 @@
                     + "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                user.fullName(), path));
+                AccountTemplateUtil.getAccountTemplate(user.id()), path));
   }
 
   @Test
@@ -294,7 +297,7 @@
                     + "By voting Code-Review+2 the following files are still code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                admin.fullName(), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
   }
 
   @Test
@@ -326,7 +329,7 @@
                     + "By voting Code-Review+1 the following files are still explicitly code-owner"
                     + " approved by %s:\n"
                     + "* %s\n",
-                admin.fullName(), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
   }
 
   @Test
@@ -358,7 +361,7 @@
                     + "By voting Code-Review+2 the following files are still explicitly code-owner"
                     + " approved by %s:\n"
                     + "* %s\n",
-                admin.fullName(), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
   }
 
   @Test
@@ -387,7 +390,7 @@
                     + "By voting Code-Review+1 the following files are still code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                admin.fullName(), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
   }
 
   @Test
@@ -416,7 +419,7 @@
                     + "By removing the Code-Review vote the following files are no longer"
                     + " code-owner approved by %s:\n"
                     + "* %s\n",
-                admin.fullName(), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
   }
 
   @Test
@@ -446,7 +449,7 @@
                     + "By voting Code-Review-1 the following files are no longer code-owner"
                     + " approved by %s:\n"
                     + "* %s\n",
-                admin.fullName(), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
   }
 
   @Test
@@ -486,7 +489,7 @@
                     + " %s:\n"
                     + "* %s\n"
                     + "* %s\n",
-                admin.fullName(), path1, path2));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), path1, path2));
   }
 
   @Test
@@ -514,7 +517,7 @@
                     + "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                admin.fullName(), oldPath));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), oldPath));
   }
 
   @Test
@@ -587,7 +590,7 @@
                     + "By voting Code-Review+1 the following files are now explicitly code-owner"
                     + " approved by %s:\n"
                     + "* %s\n",
-                admin.fullName(), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
   }
 
   @Test
@@ -620,7 +623,9 @@
                     + "* %s\n"
                     + "\n"
                     + "The listed files are still implicitly approved by %s.\n",
-                admin.fullName(), path, admin.fullName()));
+                AccountTemplateUtil.getAccountTemplate(admin.id()),
+                path,
+                AccountTemplateUtil.getAccountTemplate(admin.id())));
   }
 
   @Test
@@ -653,7 +658,9 @@
                     + "* %s\n"
                     + "\n"
                     + "The listed files are still implicitly approved by %s.\n",
-                admin.fullName(), path, admin.fullName()));
+                AccountTemplateUtil.getAccountTemplate(admin.id()),
+                path,
+                AccountTemplateUtil.getAccountTemplate(admin.id())));
   }
 
   @Test
@@ -681,7 +688,7 @@
                     + "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                admin.fullName(), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
   }
 
   @Test
@@ -713,7 +720,7 @@
                     + "By removing the Code-Review vote the following files are no longer"
                     + " code-owner approved by %s:\n"
                     + "* %s\n",
-                admin.fullName(), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
   }
 
   @Test
@@ -745,7 +752,7 @@
                     + "By voting Code-Review-1 the following files are no longer code-owner"
                     + " approved by %s:\n"
                     + "* %s\n",
-                admin.fullName(), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
   }
 
   @Test
@@ -790,7 +797,7 @@
                     + "* %s\n"
                     + "* %s\n"
                     + "* %s\n",
-                admin.fullName(), path4, path3, path1, path2));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), path4, path3, path1, path2));
   }
 
   @Test
@@ -838,7 +845,7 @@
                     + "* %s\n"
                     + "* %s\n"
                     + "(more files)\n",
-                admin.fullName(), path4, path3, path5));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), path4, path3, path5));
   }
 
   @Test
@@ -904,7 +911,7 @@
                     + "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                admin.fullName(), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
   }
 
   @Test
@@ -1063,7 +1070,7 @@
                     + "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                user.fullName(), path));
+                AccountTemplateUtil.getAccountTemplate(user.id()), path));
   }
 
   @Test
@@ -1089,4 +1096,38 @@
 
     assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1: Code-Review-2");
   }
+
+  @Test
+  public void extendedChangeMessageIsIncludedInEmailNotification() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    // Do the voting as a different user to trigger an email notification (if the only recipient is
+    // also the sender the email is omitted).
+    requestScopeOperations.setApiUser(user.id());
+
+    sender.clear();
+
+    recommend(changeId);
+
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.body())
+        .contains(
+            String.format(
+                "Patch Set 1: Code-Review+1\n\n"
+                    + "By voting Code-Review+1 the following files are now code-owner approved by"
+                    + " %s <%s>:\n"
+                    + "* %s\n",
+                user.fullName(), user.email(), path));
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/OnCodeOwnerOverrrideIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/OnCodeOwnerOverrrideIT.java
index 1265466..eaf507e 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/OnCodeOwnerOverrrideIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/OnCodeOwnerOverrrideIT.java
@@ -31,9 +31,12 @@
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import com.google.gerrit.server.util.AccountTemplateUtil;
+import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
 import java.util.Collection;
 import java.util.HashMap;
+import java.util.List;
 import java.util.regex.Pattern;
 import org.junit.Test;
 
@@ -71,7 +74,7 @@
             String.format(
                 "Patch Set 1: Owners-Override+1\n\n"
                     + "By voting Owners-Override+1 the code-owners submit requirement is overridden by %s\n",
-                admin.fullName()));
+                AccountTemplateUtil.getAccountTemplate(admin.id())));
   }
 
   @Test
@@ -108,7 +111,7 @@
             String.format(
                 "Patch Set 1: Owners-Override+1\n\n"
                     + "By voting Owners-Override+1 the code-owners submit requirement is overridden by %s\n",
-                admin.fullName()));
+                AccountTemplateUtil.getAccountTemplate(admin.id())));
 
     // Apply the Owners-Override+1 approval by another user
     requestScopeOperations.setApiUser(user.id());
@@ -120,7 +123,7 @@
             String.format(
                 "Patch Set 1: Owners-Override+1\n\n"
                     + "By voting Owners-Override+1 the code-owners submit requirement is overridden by %s\n",
-                user.fullName()));
+                AccountTemplateUtil.getAccountTemplate(user.id())));
   }
 
   @Test
@@ -156,7 +159,7 @@
                 "Patch Set 1: Owners-Override+2\n\n"
                     + "By voting Owners-Override+2 the code-owners submit requirement is still"
                     + " overridden by %s\n",
-                admin.fullName()));
+                AccountTemplateUtil.getAccountTemplate(admin.id())));
   }
 
   @Test
@@ -192,7 +195,7 @@
                 "Patch Set 1: Owners-Override+1\n\n"
                     + "By voting Owners-Override+1 the code-owners submit requirement is still"
                     + " overridden by %s\n",
-                admin.fullName()));
+                AccountTemplateUtil.getAccountTemplate(admin.id())));
   }
 
   @Test
@@ -214,7 +217,7 @@
                 "Patch Set 1: -Owners-Override\n\n"
                     + "By removing the Owners-Override vote the code-owners submit requirement is"
                     + " no longer overridden by %s\n",
-                admin.fullName()));
+                AccountTemplateUtil.getAccountTemplate(admin.id())));
   }
 
   @Test
@@ -250,7 +253,7 @@
                 "Patch Set 1: Owners-Override-1\n\n"
                     + "By voting Owners-Override-1 the code-owners submit requirement is no longer"
                     + " overridden by %s\n",
-                admin.fullName()));
+                AccountTemplateUtil.getAccountTemplate(admin.id())));
   }
 
   @Test
@@ -308,7 +311,7 @@
                     + "(1 comment)\n\n"
                     + "By voting Owners-Override+1 the code-owners submit requirement is"
                     + " overridden by %s\n",
-                admin.fullName()));
+                AccountTemplateUtil.getAccountTemplate(admin.id())));
   }
 
   @Test
@@ -351,7 +354,7 @@
             String.format(
                 "Patch Set 1: Owners-Override+1\n\n"
                     + "By voting Owners-Override+1 the code-owners submit requirement is overridden by %s\n",
-                admin.fullName()));
+                AccountTemplateUtil.getAccountTemplate(admin.id())));
   }
 
   @Test
@@ -386,7 +389,8 @@
                             + " overridden by %s\n\n"
                             + "By voting Owners-Override+1 the code-owners submit requirement is"
                             + " overridden by %s\n",
-                        admin.fullName(), admin.fullName())));
+                        AccountTemplateUtil.getAccountTemplate(admin.id()),
+                        AccountTemplateUtil.getAccountTemplate(admin.id()))));
   }
 
   @Test
@@ -420,14 +424,14 @@
             String.format(
                 "By voting Owners-Override+1 the code-owners submit requirement is"
                     + " overridden by %s\n",
-                admin.fullName()));
+                AccountTemplateUtil.getAccountTemplate(admin.id())));
     assertThat(Iterables.getLast(messages).message)
         .contains(
             String.format(
                 "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                admin.fullName(), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
   }
 
   @Test
@@ -597,7 +601,7 @@
             String.format(
                 "Patch Set 1: Owners-Override+1\n\n"
                     + "By voting Owners-Override+1 the code-owners submit requirement is overridden by %s\n",
-                user.fullName()));
+                AccountTemplateUtil.getAccountTemplate(user.id())));
   }
 
   @Test
@@ -640,4 +644,30 @@
 
     assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1: Owners-Override-2");
   }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void extendedChangeMessageIsIncludedInEmailNotification() throws Exception {
+    createOwnersOverrideLabel();
+
+    String changeId = createChange().getChangeId();
+
+    // Do the voting as a different user to trigger an email notification (if the only recipient is
+    // also the sender the email is omitted).
+    requestScopeOperations.setApiUser(user.id());
+
+    sender.clear();
+
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.body())
+        .contains(
+            String.format(
+                "Patch Set 1: Owners-Override+1\n\n"
+                    + "By voting Owners-Override+1 the code-owners submit requirement is overridden by %s <%s>\n",
+                user.fullName(), user.email()));
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/AbstractGetCodeOwnersForPathRestIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/AbstractGetCodeOwnersForPathRestIT.java
index 7aa024d..9b675e8 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/AbstractGetCodeOwnersForPathRestIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/AbstractGetCodeOwnersForPathRestIT.java
@@ -65,13 +65,6 @@
   protected abstract String getUrl(String path);
 
   @Test
-  public void getCodeOwnerConfigForInvalidPath() throws Exception {
-    RestResponse r = adminRestSession.get(getUrl("\0"));
-    r.assertBadRequest();
-    assertThat(r.getEntityContent()).contains("Nul character not allowed");
-  }
-
-  @Test
   public void getCodeOwnersWithAccountOptions() throws Exception {
     codeOwnerConfigOperations
         .newCodeOwnerConfig()
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetCodeOwnerConfigForPathInBranchRestIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetCodeOwnerConfigForPathInBranchRestIT.java
index 8ae7b98..2df8529 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetCodeOwnerConfigForPathInBranchRestIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetCodeOwnerConfigForPathInBranchRestIT.java
@@ -49,20 +49,6 @@
   }
 
   @Test
-  @GerritConfig(name = "plugin.code-owners.enableExperimentalRestEndpoints", value = "true")
-  public void getCodeOwnerConfigsForInvalidPath() throws Exception {
-    RestResponse r =
-        adminRestSession.get(
-            String.format(
-                "/projects/%s/branches/%s/code_owners.config/%s",
-                IdString.fromDecoded(project.get()),
-                IdString.fromDecoded("master"),
-                IdString.fromDecoded("\0")));
-    r.assertBadRequest();
-    assertThat(r.getEntityContent()).contains("Nul character not allowed");
-  }
-
-  @Test
   @GerritConfig(name = "plugin.code-owners.backend", value = "non-existing-backend")
   @GerritConfig(name = "plugin.code-owners.enableExperimentalRestEndpoints", value = "true")
   public void cannotGetCodeOwnerConfigIfPluginConfigurationIsInvalid() throws Exception {
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/RenameEmailRestIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/RenameEmailRestIT.java
index 70f7b22..6203aa1 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/RenameEmailRestIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/RenameEmailRestIT.java
@@ -40,8 +40,6 @@
 public class RenameEmailRestIT extends AbstractCodeOwnersIT {
   @Inject private AccountOperations accountOperations;
 
-  private BackendConfig backendConfig;
-
   @Before
   public void setup() throws Exception {
     backendConfig = plugin.getSysInjector().getInstance(BackendConfig.class);
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/testsuite/TestPathExpressionsTest.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/testsuite/TestPathExpressionsTest.java
index cd155ba..b3d15d6 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/testsuite/TestPathExpressionsTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/testsuite/TestPathExpressionsTest.java
@@ -19,6 +19,7 @@
 
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.PrivateInternals_DynamicMapImpl;
@@ -35,7 +36,6 @@
 import java.nio.file.Path;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -200,7 +200,7 @@
 
     @Override
     public Optional<CodeOwnerConfig> getCodeOwnerConfig(
-        CodeOwnerConfig.Key codeOwnerConfigKey, RevWalk revWalk, ObjectId revision) {
+        CodeOwnerConfig.Key codeOwnerConfigKey, ObjectId revision) {
       throw new UnsupportedOperationException();
     }
 
@@ -218,7 +218,7 @@
     }
 
     @Override
-    public Optional<PathExpressionMatcher> getPathExpressionMatcher() {
+    public Optional<PathExpressionMatcher> getPathExpressionMatcher(BranchNameKey branchNameKey) {
       return Optional.ofNullable(pathExpressionMatcher);
     }
   }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/AbstractCodeOwnerConfigParserTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/AbstractCodeOwnerConfigParserTest.java
index 59a2050..3a44ed7 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/AbstractCodeOwnerConfigParserTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/AbstractCodeOwnerConfigParserTest.java
@@ -366,6 +366,25 @@
   }
 
   @Test
+  public void setCodeOwnerSetsWithGlobPathExpression() throws Exception {
+    CodeOwnerSet codeOwnerSet1 = CodeOwnerSet.createWithoutPathExpressions(EMAIL_1, EMAIL_3);
+    CodeOwnerSet codeOwnerSet2 =
+        CodeOwnerSet.builder()
+            .addPathExpression("{foo,bar}/**/baz[1-5]/a[,]b*.md")
+            .addCodeOwnerEmail(EMAIL_2)
+            .build();
+    assertParseAndFormat(
+        getCodeOwnerConfig(false, codeOwnerSet1, codeOwnerSet2),
+        codeOwnerConfig -> {
+          assertThat(codeOwnerConfig)
+              .hasCodeOwnerSetsThat()
+              .containsExactly(codeOwnerSet1, codeOwnerSet2)
+              .inOrder();
+        },
+        getCodeOwnerConfig(false, codeOwnerSet1, codeOwnerSet2));
+  }
+
+  @Test
   public void setMultipleCodeOwnerSetsWithPathExpressions() throws Exception {
     CodeOwnerSet codeOwnerSet1 =
         CodeOwnerSet.builder()
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackendTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackendTest.java
index 1bbce45..7ec9295 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackendTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackendTest.java
@@ -21,6 +21,7 @@
 
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
 import com.google.gerrit.plugins.codeowners.testing.backend.TestCodeOwnerConfigStorage;
 import com.google.gerrit.plugins.codeowners.util.JgitPath;
@@ -487,4 +488,13 @@
         .isEqualTo(
             Paths.get(codeOwnerConfigKey.folderPath() + codeOwnerConfigKey.fileName().get()));
   }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.pathExpressions", value = "GLOB")
+  public void getConfiguredPathExpressionMatcher() throws Exception {
+    com.google.gerrit.truth.OptionalSubject.assertThat(
+            codeOwnerBackend.getPathExpressionMatcher(BranchNameKey.create(project, "master")))
+        .value()
+        .isInstanceOf(GlobMatcher.class);
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/BUILD b/javatests/com/google/gerrit/plugins/codeowners/backend/BUILD
index f21c5c1..5db2a85 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/BUILD
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/BUILD
@@ -36,6 +36,7 @@
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//java/com/google/gerrit/truth",
         "//lib:guava",
         "//lib:jgit",
         "//lib:jgit-junit",
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/ChangedFilesTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/ChangedFilesTest.java
index 4534df2..d72e981 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/ChangedFilesTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/ChangedFilesTest.java
@@ -65,429 +65,16 @@
   }
 
   @Test
-  public void cannotComputeForNullRevisionResource() throws Exception {
+  public void cannotGetFromDiffCacheForNullRevisionResource() throws Exception {
     NullPointerException npe =
         assertThrows(
-            NullPointerException.class, () -> changedFiles.compute(/* revisionResource= */ null));
+            NullPointerException.class,
+            () -> changedFiles.getFromDiffCache(/* revisionResource= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("revisionResource");
   }
 
   @Test
-  public void cannotComputeForNullProject() throws Exception {
-    NullPointerException npe =
-        assertThrows(
-            NullPointerException.class,
-            () -> changedFiles.compute(/* project= */ null, ObjectId.zeroId()));
-    assertThat(npe).hasMessageThat().isEqualTo("project");
-  }
-
-  @Test
-  public void cannotComputeForNullRevision() throws Exception {
-    NullPointerException npe =
-        assertThrows(
-            NullPointerException.class, () -> changedFiles.compute(project, /* revision= */ null));
-    assertThat(npe).hasMessageThat().isEqualTo("revision");
-  }
-
-  @Test
-  public void computeForChangeThatAddedAFile() throws Exception {
-    String path = "/foo/bar/baz.txt";
-    String changeId =
-        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
-
-    ImmutableList<ChangedFile> changedFilesSet =
-        changedFiles.compute(getRevisionResource(changeId));
-    assertThat(changedFilesSet).hasSize(1);
-    ChangedFile changedFile = Iterables.getOnlyElement(changedFilesSet);
-    assertThat(changedFile).hasNewPath().value().isEqualTo(Paths.get(path));
-    assertThat(changedFile).hasOldPath().isEmpty();
-    assertThat(changedFile).isNoRename();
-    assertThat(changedFile).isNoDeletion();
-  }
-
-  @Test
-  public void computeForChangeThatModifiedAFile() throws Exception {
-    String path = "/foo/bar/baz.txt";
-    createChange("Test Change", JgitPath.of(path).get(), "file content").getChangeId();
-
-    String changeId =
-        createChange("Change Modifying A File", JgitPath.of(path).get(), "new file content")
-            .getChangeId();
-
-    ImmutableList<ChangedFile> changedFilesSet =
-        changedFiles.compute(getRevisionResource(changeId));
-    assertThat(changedFilesSet).hasSize(1);
-    ChangedFile changedFile = Iterables.getOnlyElement(changedFilesSet);
-    assertThat(changedFile).hasNewPath().value().isEqualTo(Paths.get(path));
-    assertThat(changedFile).hasOldPath().value().isEqualTo(Paths.get(path));
-    assertThat(changedFile).isNoRename();
-    assertThat(changedFile).isNoDeletion();
-  }
-
-  @Test
-  public void computeForChangeThatDeletedAFile() throws Exception {
-    String path = "/foo/bar/baz.txt";
-    String changeId = createChangeWithFileDeletion(path);
-
-    ImmutableList<ChangedFile> changedFilesSet =
-        changedFiles.compute(getRevisionResource(changeId));
-    assertThat(changedFilesSet).hasSize(1);
-    ChangedFile changedFile = Iterables.getOnlyElement(changedFilesSet);
-    assertThat(changedFile).hasNewPath().isEmpty();
-    assertThat(changedFile).hasOldPath().value().isEqualTo(Paths.get(path));
-    assertThat(changedFile).isNoRename();
-    assertThat(changedFile).isDeletion();
-  }
-
-  @Test
-  public void computeForChangeThatRenamedAFile() throws Exception {
-    String oldPath = "/foo/bar/old.txt";
-    String newPath = "/foo/bar/new.txt";
-    String changeId = createChangeWithFileRename(oldPath, newPath);
-
-    gApi.changes().id(changeId).current().files();
-
-    // A renamed file is reported as addition of new path + deletion of old path. This is because
-    // ChangedFiles uses a DiffFormatter without rename detection (because rename detection requires
-    // loading the file contents which is too expensive).
-    ImmutableList<ChangedFile> changedFilesSet =
-        changedFiles.compute(getRevisionResource(changeId));
-    assertThat(changedFilesSet).hasSize(2);
-    ChangedFileSubject changedFile1 = assertThatCollection(changedFilesSet).element(0);
-    changedFile1.hasNewPath().value().isEqualTo(Paths.get(newPath));
-    changedFile1.hasOldPath().isEmpty();
-    changedFile1.isNoRename();
-    changedFile1.isNoDeletion();
-    ChangedFileSubject changedFile2 = assertThatCollection(changedFilesSet).element(1);
-    changedFile2.hasNewPath().isEmpty();
-    changedFile2.hasOldPath().value().isEqualTo(Paths.get(oldPath));
-    changedFile2.isNoRename();
-    changedFile2.isDeletion();
-  }
-
-  @Test
-  @TestProjectInput(createEmptyCommit = false)
-  public void computeForInitialChangeThatAddedAFile() throws Exception {
-    String path = "/foo/bar/baz.txt";
-    Result r = createChange("Change Adding A File", JgitPath.of(path).get(), "file content");
-    assertThat(r.getCommit().getParents()).isEmpty();
-    String changeId = r.getChangeId();
-
-    ImmutableList<ChangedFile> changedFilesSet =
-        changedFiles.compute(getRevisionResource(changeId));
-    assertThat(changedFilesSet).hasSize(1);
-    ChangedFile changedFile = Iterables.getOnlyElement(changedFilesSet);
-    assertThat(changedFile).hasNewPath().value().isEqualTo(Paths.get(path));
-    assertThat(changedFile).hasOldPath().isEmpty();
-    assertThat(changedFile).isNoRename();
-    assertThat(changedFile).isNoDeletion();
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.mergeCommitStrategy", value = "ALL_CHANGED_FILES")
-  public void computeForMergeChange_allChangedFiles() throws Exception {
-    testComputeForMergeChange(MergeCommitStrategy.ALL_CHANGED_FILES);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "plugin.code-owners.mergeCommitStrategy",
-      value = "FILES_WITH_CONFLICT_RESOLUTION")
-  public void computeForMergeChange_filesWithConflictResolution() throws Exception {
-    testComputeForMergeChange(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
-  }
-
-  private void testComputeForMergeChange(MergeCommitStrategy mergeCommitStrategy) throws Exception {
-    setAsRootCodeOwners(admin);
-
-    String file1 = "foo/a.txt";
-    String file2 = "bar/b.txt";
-
-    // Create a base change.
-    Change.Id baseChange =
-        changeOperations.newChange().branch("master").file(file1).content("base content").create();
-    approveAndSubmit(baseChange);
-
-    // Create another branch
-    String branchName = "foo";
-    BranchInput branchInput = new BranchInput();
-    branchInput.ref = branchName;
-    branchInput.revision = projectOperations.project(project).getHead("master").name();
-    gApi.projects().name(project.get()).branch(branchInput.ref).create(branchInput);
-
-    // Create a change in master that touches file1.
-    Change.Id changeInMaster =
-        changeOperations
-            .newChange()
-            .branch("master")
-            .file(file1)
-            .content("master content")
-            .create();
-    approveAndSubmit(changeInMaster);
-
-    // Create a change in the other branch and that touches file1 and creates file2.
-    Change.Id changeInOtherBranch =
-        changeOperations
-            .newChange()
-            .branch(branchName)
-            .file(file1)
-            .content("other content")
-            .file(file2)
-            .content("content")
-            .create();
-    approveAndSubmit(changeInOtherBranch);
-
-    // Create a merge change with a conflict resolution for file1 and file2 with the same content as
-    // in the other branch (no conflict on file2).
-    Change.Id mergeChange =
-        changeOperations
-            .newChange()
-            .branch("master")
-            .mergeOfButBaseOnFirst()
-            .tipOfBranch("master")
-            .and()
-            .tipOfBranch(branchName)
-            .file(file1)
-            .content("merged content")
-            .file(file2)
-            .content("content")
-            .create();
-
-    ImmutableList<ChangedFile> changedFilesSet =
-        changedFiles.compute(getRevisionResource(Integer.toString(mergeChange.get())));
-
-    if (MergeCommitStrategy.ALL_CHANGED_FILES.equals(mergeCommitStrategy)) {
-      assertThat(changedFilesSet).comparingElementsUsing(hasPath()).containsExactly(file1, file2);
-    } else if (MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION.equals(mergeCommitStrategy)) {
-      assertThat(changedFilesSet).comparingElementsUsing(hasPath()).containsExactly(file1);
-    } else {
-      fail("expected merge commit strategy: " + mergeCommitStrategy);
-    }
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.mergeCommitStrategy", value = "ALL_CHANGED_FILES")
-  public void computeForMergeChangeThatContainsADeletedFileAsConflictResolution_allChangedFiles()
-      throws Exception {
-    testComputeForMergeChangeThatContainsADeletedFileAsConflictResolution();
-  }
-
-  @Test
-  @GerritConfig(
-      name = "plugin.code-owners.mergeCommitStrategy",
-      value = "FILES_WITH_CONFLICT_RESOLUTION")
-  public void
-      computeForMergeChangeThatContainsADeletedFileAsConflictResolution_filesWithConflictResolution()
-          throws Exception {
-    testComputeForMergeChangeThatContainsADeletedFileAsConflictResolution();
-  }
-
-  private void testComputeForMergeChangeThatContainsADeletedFileAsConflictResolution()
-      throws Exception {
-    setAsRootCodeOwners(admin);
-
-    String file = "foo/a.txt";
-
-    // Create a base change.
-    Change.Id baseChange =
-        changeOperations.newChange().branch("master").file(file).content("base content").create();
-    approveAndSubmit(baseChange);
-
-    // Create another branch
-    String branchName = "foo";
-    BranchInput branchInput = new BranchInput();
-    branchInput.ref = branchName;
-    branchInput.revision = projectOperations.project(project).getHead("master").name();
-    gApi.projects().name(project.get()).branch(branchInput.ref).create(branchInput);
-
-    // Create a change in master that touches file1.
-    Change.Id changeInMaster =
-        changeOperations.newChange().branch("master").file(file).content("master content").create();
-    approveAndSubmit(changeInMaster);
-
-    // Create a change in the other branch and that deleted file1.
-    PushOneCommit push =
-        pushFactory.create(admin.newIdent(), testRepo, "Change Deleting A File", file, "");
-    Result r = push.rm("refs/for/master");
-    r.assertOkStatus();
-    approveAndSubmit(r.getChange().getId());
-
-    // Create a merge change with resolving the conflict on file between the edit in master and the
-    // deletion in the other branch by deleting the file.
-    Change.Id mergeChange =
-        changeOperations
-            .newChange()
-            .branch("master")
-            .mergeOf()
-            .tipOfBranch("master")
-            .and()
-            .tipOfBranch(branchName)
-            .file(file)
-            .delete()
-            .create();
-
-    ImmutableList<ChangedFile> changedFilesSet =
-        changedFiles.compute(getRevisionResource(Integer.toString(mergeChange.get())));
-    ImmutableSet<String> oldPaths =
-        changedFilesSet.stream()
-            .map(changedFile -> JgitPath.of(changedFile.oldPath().get()).get())
-            .collect(toImmutableSet());
-    assertThat(oldPaths).containsExactly(file);
-  }
-
-  @Test
-  public void computeReturnsChangedFilesSortedByPath() throws Exception {
-    String file1 = "foo/bar.baz";
-    String file2 = "foo/baz.bar";
-    String file3 = "bar/foo.baz";
-    String file4 = "bar/baz.foo";
-    String file5 = "baz/foo.bar";
-    String changeId =
-        createChange(
-                "Test Change",
-                ImmutableMap.of(
-                    file1,
-                    "file content",
-                    file2,
-                    "file content",
-                    file3,
-                    "file content",
-                    file4,
-                    "file content",
-                    file5,
-                    "file content"))
-            .getChangeId();
-
-    ImmutableList<ChangedFile> changedFilesSet =
-        changedFiles.compute(getRevisionResource(changeId));
-    assertThat(changedFilesSet)
-        .comparingElementsUsing(hasPath())
-        .containsExactly(file4, file3, file5, file1, file2)
-        .inOrder();
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.mergeCommitStrategy", value = "ALL_CHANGED_FILES")
-  public void computeReturnsChangedFilesSortedByPath_mergeCommitAgainstFirstParent()
-      throws Exception {
-    testComputeReturnsChangedFilesSortedByPathForMerge(MergeCommitStrategy.ALL_CHANGED_FILES);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "plugin.code-owners.mergeCommitStrategy",
-      value = "FILES_WITH_CONFLICT_RESOLUTION")
-  public void computeReturnsChangedFilesSortedByPath_mergeCommitAgainstAutoMerge()
-      throws Exception {
-    testComputeReturnsChangedFilesSortedByPathForMerge(
-        MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
-  }
-
-  private void testComputeReturnsChangedFilesSortedByPathForMerge(
-      MergeCommitStrategy mergeCommitStrategy) throws Exception {
-    setAsRootCodeOwners(admin);
-
-    String file1 = "foo/bar.baz";
-    String file2 = "foo/baz.bar";
-    String file3 = "bar/foo.baz";
-    String file4 = "bar/baz.foo";
-    String file5 = "baz/foo.bar";
-
-    // Create a base change.
-    Change.Id baseChange =
-        changeOperations
-            .newChange()
-            .branch("master")
-            .file(file1)
-            .content("base content")
-            .file(file3)
-            .content("base content")
-            .file(file5)
-            .content("base content")
-            .create();
-    approveAndSubmit(baseChange);
-
-    // Create another branch
-    String branchName = "foo";
-    BranchInput branchInput = new BranchInput();
-    branchInput.ref = branchName;
-    branchInput.revision = projectOperations.project(project).getHead("master").name();
-    gApi.projects().name(project.get()).branch(branchInput.ref).create(branchInput);
-
-    // Create a change in master that touches file1, file3 and file5
-    Change.Id changeInMaster =
-        changeOperations
-            .newChange()
-            .branch("master")
-            .file(file1)
-            .content("master content")
-            .file(file3)
-            .content("master content")
-            .file(file5)
-            .content("master content")
-            .create();
-    approveAndSubmit(changeInMaster);
-
-    // Create a change in the other branch and that touches file1, file3, file5 and creates file2,
-    // file4.
-    Change.Id changeInOtherBranch =
-        changeOperations
-            .newChange()
-            .branch(branchName)
-            .file(file1)
-            .content("other content")
-            .file(file2)
-            .content("content")
-            .file(file3)
-            .content("other content")
-            .file(file4)
-            .content("content")
-            .file(file5)
-            .content("other content")
-            .create();
-    approveAndSubmit(changeInOtherBranch);
-
-    // Create a merge change with a conflict resolution for file1 and file2 with the same content as
-    // in the other branch (no conflict on file2).
-    Change.Id mergeChange =
-        changeOperations
-            .newChange()
-            .branch("master")
-            .mergeOfButBaseOnFirst()
-            .tipOfBranch("master")
-            .and()
-            .tipOfBranch(branchName)
-            .file(file1)
-            .content("merged content")
-            .file(file2)
-            .content("content")
-            .file(file3)
-            .content("merged content")
-            .file(file4)
-            .content("content")
-            .file(file5)
-            .content("merged content")
-            .create();
-
-    ImmutableList<ChangedFile> changedFilesSet =
-        changedFiles.compute(getRevisionResource(Integer.toString(mergeChange.get())));
-
-    if (MergeCommitStrategy.ALL_CHANGED_FILES.equals(mergeCommitStrategy)) {
-      assertThat(changedFilesSet)
-          .comparingElementsUsing(hasPath())
-          .containsExactly(file4, file3, file5, file1, file2)
-          .inOrder();
-    } else if (MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION.equals(mergeCommitStrategy)) {
-      assertThat(changedFilesSet)
-          .comparingElementsUsing(hasPath())
-          .containsExactly(file3, file5, file1);
-    } else {
-      fail("expected merge commit strategy: " + mergeCommitStrategy);
-    }
-  }
-
-  @Test
-  public void cannotGetFromDiffCacheForNullProject() throws Exception {
+  public void cannotGetFromDiffCacheForNullProject_v1() throws Exception {
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
@@ -496,7 +83,7 @@
   }
 
   @Test
-  public void cannotGetFromDiffCacheForNullRevision() throws Exception {
+  public void cannotGetFromDiffCacheForNullRevision_v1() throws Exception {
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
@@ -505,12 +92,46 @@
   }
 
   @Test
+  public void cannotGetFromDiffCacheForNullProject_v2() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                changedFiles.getFromDiffCache(
+                    /* project= */ null, ObjectId.zeroId(), MergeCommitStrategy.ALL_CHANGED_FILES));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void cannotGetFromDiffCacheForNullRevision_v2() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                changedFiles.getFromDiffCache(
+                    project, /* revision= */ null, MergeCommitStrategy.ALL_CHANGED_FILES));
+    assertThat(npe).hasMessageThat().isEqualTo("revision");
+  }
+
+  @Test
+  public void cannotGetFromDiffCacheForNullMergeCommitStrategy() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                changedFiles.getFromDiffCache(
+                    project, ObjectId.zeroId(), /* mergeCommitStrategy= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("mergeCommitStrategy");
+  }
+
+  @Test
   public void getFromDiffCacheForChangeThatAddedAFile() throws Exception {
     String path = "/foo/bar/baz.txt";
     RevCommit commit =
         createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getCommit();
 
-    ImmutableList<ChangedFile> changedFilesSet = changedFiles.getFromDiffCache(project, commit);
+    ImmutableList<ChangedFile> changedFilesSet =
+        changedFiles.getFromDiffCache(project, commit, MergeCommitStrategy.ALL_CHANGED_FILES);
     assertThat(changedFilesSet).hasSize(1);
     ChangedFile changedFile = Iterables.getOnlyElement(changedFilesSet);
     assertThat(changedFile).hasNewPath().value().isEqualTo(Paths.get(path));
@@ -528,7 +149,8 @@
         createChange("Change Modifying A File", JgitPath.of(path).get(), "new file content")
             .getCommit();
 
-    ImmutableList<ChangedFile> changedFilesSet = changedFiles.getFromDiffCache(project, commit);
+    ImmutableList<ChangedFile> changedFilesSet =
+        changedFiles.getFromDiffCache(project, commit, MergeCommitStrategy.ALL_CHANGED_FILES);
     assertThat(changedFilesSet).hasSize(1);
     ChangedFile changedFile = Iterables.getOnlyElement(changedFilesSet);
     assertThat(changedFile).hasNewPath().value().isEqualTo(Paths.get(path));
@@ -544,7 +166,9 @@
 
     ImmutableList<ChangedFile> changedFilesSet =
         changedFiles.getFromDiffCache(
-            project, getRevisionResource(changeId).getPatchSet().commitId());
+            project,
+            getRevisionResource(changeId).getPatchSet().commitId(),
+            MergeCommitStrategy.ALL_CHANGED_FILES);
     assertThat(changedFilesSet).hasSize(1);
     ChangedFile changedFile = Iterables.getOnlyElement(changedFilesSet);
     assertThat(changedFile).hasNewPath().isEmpty();
@@ -563,7 +187,9 @@
 
     ImmutableList<ChangedFile> changedFilesSet =
         changedFiles.getFromDiffCache(
-            project, getRevisionResource(changeId).getPatchSet().commitId());
+            project,
+            getRevisionResource(changeId).getPatchSet().commitId(),
+            MergeCommitStrategy.ALL_CHANGED_FILES);
     ChangedFileSubject changedFile = assertThatCollection(changedFilesSet).onlyElement();
     changedFile.hasNewPath().value().isEqualTo(Paths.get(newPath));
     changedFile.hasOldPath().value().isEqualTo(Paths.get(oldPath));
@@ -579,10 +205,14 @@
         createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getCommit();
     assertThat(commit.getParents()).isEmpty();
 
-    IllegalStateException exception =
-        assertThrows(
-            IllegalStateException.class, () -> changedFiles.getFromDiffCache(project, commit));
-    assertThat(exception).hasMessageThat().isEqualTo("diff cache doesn't support initial commits");
+    ImmutableList<ChangedFile> changedFilesSet =
+        changedFiles.getFromDiffCache(project, commit, MergeCommitStrategy.ALL_CHANGED_FILES);
+    assertThat(changedFilesSet).hasSize(1);
+    ChangedFile changedFile = Iterables.getOnlyElement(changedFilesSet);
+    assertThat(changedFile).hasNewPath().value().isEqualTo(Paths.get(path));
+    assertThat(changedFile).hasOldPath().isEmpty();
+    assertThat(changedFile).isNoRename();
+    assertThat(changedFile).isNoDeletion();
   }
 
   @Test
@@ -659,7 +289,8 @@
     ImmutableList<ChangedFile> changedFilesSet =
         changedFiles.getFromDiffCache(
             project,
-            getRevisionResource(Integer.toString(mergeChange.get())).getPatchSet().commitId());
+            getRevisionResource(Integer.toString(mergeChange.get())).getPatchSet().commitId(),
+            mergeCommitStrategy);
 
     if (MergeCommitStrategy.ALL_CHANGED_FILES.equals(mergeCommitStrategy)) {
       assertThat(changedFilesSet).comparingElementsUsing(hasPath()).containsExactly(file1, file2);
@@ -675,7 +306,8 @@
   public void
       getFromFileDiffCacheForMergeChangeThatContainsADeletedFileAsConflictResolution_allChangedFiles()
           throws Exception {
-    testGetFromFileDiffCacheForMergeChangeThatContainsADeletedFileAsConflictResolution();
+    testGetFromFileDiffCacheForMergeChangeThatContainsADeletedFileAsConflictResolution(
+        MergeCommitStrategy.ALL_CHANGED_FILES);
   }
 
   @Test
@@ -685,11 +317,12 @@
   public void
       getFromFileDiffCacheForMergeChangeThatContainsADeletedFileAsConflictResolution_filesWithConflictResolution()
           throws Exception {
-    testGetFromFileDiffCacheForMergeChangeThatContainsADeletedFileAsConflictResolution();
+    testGetFromFileDiffCacheForMergeChangeThatContainsADeletedFileAsConflictResolution(
+        MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
   }
 
-  private void testGetFromFileDiffCacheForMergeChangeThatContainsADeletedFileAsConflictResolution()
-      throws Exception {
+  private void testGetFromFileDiffCacheForMergeChangeThatContainsADeletedFileAsConflictResolution(
+      MergeCommitStrategy mergeCommitStrategy) throws Exception {
     setAsRootCodeOwners(admin);
 
     String file = "foo/a.txt";
@@ -735,7 +368,8 @@
     ImmutableList<ChangedFile> changedFilesSet =
         changedFiles.getFromDiffCache(
             project,
-            getRevisionResource(Integer.toString(mergeChange.get())).getPatchSet().commitId());
+            getRevisionResource(Integer.toString(mergeChange.get())).getPatchSet().commitId(),
+            mergeCommitStrategy);
     ImmutableSet<String> oldPaths =
         changedFilesSet.stream()
             .map(changedFile -> JgitPath.of(changedFile.oldPath().get()).get())
@@ -766,7 +400,8 @@
                     "file content"))
             .getCommit();
 
-    ImmutableList<ChangedFile> changedFilesSet = changedFiles.getFromDiffCache(project, commit);
+    ImmutableList<ChangedFile> changedFilesSet =
+        changedFiles.getFromDiffCache(project, commit, MergeCommitStrategy.ALL_CHANGED_FILES);
     assertThat(changedFilesSet)
         .comparingElementsUsing(hasPath())
         .containsExactly(file4, file3, file5, file1, file2)
@@ -880,7 +515,8 @@
     ImmutableList<ChangedFile> changedFilesSet =
         changedFiles.getFromDiffCache(
             project,
-            getRevisionResource(Integer.toString(mergeChange.get())).getPatchSet().commitId());
+            getRevisionResource(Integer.toString(mergeChange.get())).getPatchSet().commitId(),
+            mergeCommitStrategy);
 
     if (MergeCommitStrategy.ALL_CHANGED_FILES.equals(mergeCommitStrategy)) {
       assertThat(changedFilesSet)
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckForAccountTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckForAccountTest.java
index 5693c57..1220c6c 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckForAccountTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckForAccountTest.java
@@ -257,6 +257,36 @@
         .isEqualTo(CodeOwnerStatus.APPROVED);
   }
 
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "PROJECT_OWNERS")
+  public void notApprovedByProjectOwner_projectOwnersAreFallbackCodeOwner_otherOwnerDefined()
+      throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "CodeOwner", /* displayName= */ null);
+    setAsRootCodeOwners(codeOwner);
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+    ChangeNotes changeNotes = getChangeNotes(changeId);
+
+    // Verify that the file would be not approved by the 'admin' user. The 'admin' user is a
+    // project owner, but fallback code owners are not applied if code ownership was explicitly
+    // defined.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatusesForAccount(
+            changeNotes, changeNotes.getCurrentPatchSet(), admin.id());
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
   @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
   @Test
   public void approvedByFallbackCodeOwner() throws Exception {
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java
index 98268cb..34642d5 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.plugins.codeowners.backend;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject.assertThatCollection;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -25,6 +26,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
@@ -32,18 +34,21 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.CodeOwnerConfigOperations;
 import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
-import com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject;
 import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.inject.Inject;
 import java.nio.file.Path;
 import java.nio.file.Paths;
@@ -151,19 +156,6 @@
 
   @Test
   public void getStatusForFileRename_insufficientReviewers() throws Exception {
-    testGetStatusForFileRename_insufficientReviewers(/* useDiffCache= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
-  public void getStatusForFileRename_insufficientReviewers_useDiffCache() throws Exception {
-    testGetStatusForFileRename_insufficientReviewers(/* useDiffCache= */ true);
-  }
-
-  private void testGetStatusForFileRename_insufficientReviewers(boolean useDiffCache)
-      throws Exception {
     TestAccount user2 = accountCreator.user2();
 
     Path oldPath = Paths.get("/foo/old.bar");
@@ -178,20 +170,13 @@
     recommend(changeId);
 
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    if (useDiffCache) {
-      assertThatCollection(fileCodeOwnerStatuses)
-          .containsExactly(
-              FileCodeOwnerStatus.rename(
-                  oldPath,
-                  CodeOwnerStatus.INSUFFICIENT_REVIEWERS,
-                  newPath,
-                  CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
-    } else {
-      assertThatCollection(fileCodeOwnerStatuses)
-          .containsExactly(
-              FileCodeOwnerStatus.deletion(oldPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
-              FileCodeOwnerStatus.addition(newPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
-    }
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.rename(
+                oldPath,
+                CodeOwnerStatus.INSUFFICIENT_REVIEWERS,
+                newPath,
+                CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
@@ -213,7 +198,13 @@
 
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.PENDING));
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
   }
 
   @Test
@@ -237,7 +228,13 @@
 
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.modification(path, CodeOwnerStatus.PENDING));
+        .containsExactly(
+            FileCodeOwnerStatus.modification(
+                path,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
   }
 
   @Test
@@ -258,23 +255,17 @@
 
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.deletion(path, CodeOwnerStatus.PENDING));
+        .containsExactly(
+            FileCodeOwnerStatus.deletion(
+                path,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
   }
 
   @Test
   public void getStatusForFileRename_pendingOldPath() throws Exception {
-    testGetStatusForFileRename_pendingOldPath(/* useDiffCache= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
-  public void getStatusForFileRename_pendingOldPath_useDiffCache() throws Exception {
-    testGetStatusForFileRename_pendingOldPath(/* useDiffCache= */ true);
-  }
-
-  private void testGetStatusForFileRename_pendingOldPath(boolean useDiffCache) throws Exception {
     TestAccount user2 = accountCreator.user2();
 
     setAsCodeOwners("/foo/bar/", user);
@@ -291,36 +282,21 @@
     recommend(changeId);
 
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    if (useDiffCache) {
-      assertThatCollection(fileCodeOwnerStatuses)
-          .containsExactly(
-              FileCodeOwnerStatus.rename(
-                  oldPath,
-                  CodeOwnerStatus.PENDING,
-                  newPath,
-                  CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
-    } else {
-      assertThatCollection(fileCodeOwnerStatuses)
-          .containsExactly(
-              FileCodeOwnerStatus.deletion(oldPath, CodeOwnerStatus.PENDING),
-              FileCodeOwnerStatus.addition(newPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
-    }
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.rename(
+                oldPath,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id())),
+                newPath,
+                CodeOwnerStatus.INSUFFICIENT_REVIEWERS,
+                /* reasonNewPath= */ null));
   }
 
   @Test
   public void getStatusForFileRename_pendingNewPath() throws Exception {
-    testGetStatusForFileRename_pendingNewPath(/* useDiffCache= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
-  public void getStatusForFileRename_pendingNewPath_useDiffCache() throws Exception {
-    testGetStatusForFileRename_pendingNewPath(/* useDiffCache= */ true);
-  }
-
-  private void testGetStatusForFileRename_pendingNewPath(boolean useDiffCache) throws Exception {
     TestAccount user2 = accountCreator.user2();
 
     setAsCodeOwners("/foo/baz/", user);
@@ -337,20 +313,17 @@
     recommend(changeId);
 
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    if (useDiffCache) {
-      assertThatCollection(fileCodeOwnerStatuses)
-          .containsExactly(
-              FileCodeOwnerStatus.rename(
-                  oldPath,
-                  CodeOwnerStatus.INSUFFICIENT_REVIEWERS,
-                  newPath,
-                  CodeOwnerStatus.PENDING));
-    } else {
-      assertThatCollection(fileCodeOwnerStatuses)
-          .containsExactly(
-              FileCodeOwnerStatus.deletion(oldPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
-              FileCodeOwnerStatus.addition(newPath, CodeOwnerStatus.PENDING));
-    }
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.rename(
+                oldPath,
+                CodeOwnerStatus.INSUFFICIENT_REVIEWERS,
+                /* reasonOldPath= */ null,
+                newPath,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
   }
 
   @Test
@@ -367,7 +340,13 @@
 
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "approved by %s who is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
   }
 
   @Test
@@ -386,7 +365,13 @@
 
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.modification(path, CodeOwnerStatus.APPROVED));
+        .containsExactly(
+            FileCodeOwnerStatus.modification(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "approved by %s who is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
   }
 
   @Test
@@ -402,23 +387,17 @@
 
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.deletion(path, CodeOwnerStatus.APPROVED));
+        .containsExactly(
+            FileCodeOwnerStatus.deletion(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "approved by %s who is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
   }
 
   @Test
   public void getStatusForFileRename_approvedOldPath() throws Exception {
-    testGetStatusForFileRename_approvedOldPath(/* useDiffCache= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
-  public void getStatusForFileRename_approvedOldPath_useDiffCache() throws Exception {
-    testGetStatusForFileRename_approvedOldPath(/* useDiffCache= */ true);
-  }
-
-  private void testGetStatusForFileRename_approvedOldPath(boolean useDiffCache) throws Exception {
     setAsCodeOwners("/foo/bar/", user);
 
     Path oldPath = Paths.get("/foo/bar/abc.txt");
@@ -431,36 +410,21 @@
     recommend(changeId);
 
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    if (useDiffCache) {
-      assertThatCollection(fileCodeOwnerStatuses)
-          .containsExactly(
-              FileCodeOwnerStatus.rename(
-                  oldPath,
-                  CodeOwnerStatus.APPROVED,
-                  newPath,
-                  CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
-    } else {
-      assertThatCollection(fileCodeOwnerStatuses)
-          .containsExactly(
-              FileCodeOwnerStatus.deletion(oldPath, CodeOwnerStatus.APPROVED),
-              FileCodeOwnerStatus.addition(newPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
-    }
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.rename(
+                oldPath,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "approved by %s who is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id())),
+                newPath,
+                CodeOwnerStatus.INSUFFICIENT_REVIEWERS,
+                /* reasonNewPath= */ null));
   }
 
   @Test
   public void getStatusForFileRename_approvedNewPath() throws Exception {
-    testGetStatusForFileRename_approvedNewPath(/* useDiffCache= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
-  public void getStatusForFileRename_approvedNewPath_useDiffCache() throws Exception {
-    testGetStatusForFileRename_approvedNewPath(/* useDiffCache= */ true);
-  }
-
-  private void testGetStatusForFileRename_approvedNewPath(boolean useDiffCache) throws Exception {
     setAsCodeOwners("/foo/baz/", user);
 
     Path oldPath = Paths.get("/foo/bar/abc.txt");
@@ -473,20 +437,17 @@
     recommend(changeId);
 
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    if (useDiffCache) {
-      assertThatCollection(fileCodeOwnerStatuses)
-          .containsExactly(
-              FileCodeOwnerStatus.rename(
-                  oldPath,
-                  CodeOwnerStatus.INSUFFICIENT_REVIEWERS,
-                  newPath,
-                  CodeOwnerStatus.APPROVED));
-    } else {
-      assertThatCollection(fileCodeOwnerStatuses)
-          .containsExactly(
-              FileCodeOwnerStatus.deletion(oldPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
-              FileCodeOwnerStatus.addition(newPath, CodeOwnerStatus.APPROVED));
-    }
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.rename(
+                oldPath,
+                CodeOwnerStatus.INSUFFICIENT_REVIEWERS,
+                /* reasonOldPath= */ null,
+                newPath,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "approved by %s who is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
   }
 
   @Test
@@ -529,14 +490,22 @@
       amendChange(otherCodeOwner, changeId);
     }
 
+    FileCodeOwnerStatus expectedFileCodeOwnerStatus;
+    if (implicitApprovalsEnabled && uploaderMatchesChangeOwner) {
+      expectedFileCodeOwnerStatus =
+          FileCodeOwnerStatus.addition(
+              path,
+              CodeOwnerStatus.APPROVED,
+              String.format(
+                  "implicitly approved by the patch set uploader %s who is a code owner",
+                  AccountTemplateUtil.getAccountTemplate(changeOwner.id())));
+    } else {
+      expectedFileCodeOwnerStatus =
+          FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    }
+
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(
-            FileCodeOwnerStatus.addition(
-                path,
-                implicitApprovalsEnabled && uploaderMatchesChangeOwner
-                    ? CodeOwnerStatus.APPROVED
-                    : CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    assertThatCollection(fileCodeOwnerStatuses).containsExactly(expectedFileCodeOwnerStatus);
   }
 
   @Test
@@ -581,14 +550,22 @@
       amendChange(otherCodeOwner, changeId);
     }
 
+    FileCodeOwnerStatus expectedFileCodeOwnerStatus;
+    if (implicitApprovalsEnabled && uploaderMatchesChangeOwner) {
+      expectedFileCodeOwnerStatus =
+          FileCodeOwnerStatus.modification(
+              path,
+              CodeOwnerStatus.APPROVED,
+              String.format(
+                  "implicitly approved by the patch set uploader %s who is a code owner",
+                  AccountTemplateUtil.getAccountTemplate(changeOwner.id())));
+    } else {
+      expectedFileCodeOwnerStatus =
+          FileCodeOwnerStatus.modification(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    }
+
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(
-            FileCodeOwnerStatus.modification(
-                path,
-                implicitApprovalsEnabled && uploaderMatchesChangeOwner
-                    ? CodeOwnerStatus.APPROVED
-                    : CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    assertThatCollection(fileCodeOwnerStatuses).containsExactly(expectedFileCodeOwnerStatus);
   }
 
   @Test
@@ -630,33 +607,28 @@
       amendChange(otherCodeOwner, changeId);
     }
 
+    FileCodeOwnerStatus expectedFileCodeOwnerStatus;
+    if (implicitApprovalsEnabled && uploaderMatchesChangeOwner) {
+      expectedFileCodeOwnerStatus =
+          FileCodeOwnerStatus.deletion(
+              path,
+              CodeOwnerStatus.APPROVED,
+              String.format(
+                  "implicitly approved by the patch set uploader %s who is a code owner",
+                  AccountTemplateUtil.getAccountTemplate(changeOwner.id())));
+    } else {
+      expectedFileCodeOwnerStatus =
+          FileCodeOwnerStatus.deletion(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    }
+
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(
-            FileCodeOwnerStatus.deletion(
-                path,
-                implicitApprovalsEnabled && uploaderMatchesChangeOwner
-                    ? CodeOwnerStatus.APPROVED
-                    : CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    assertThatCollection(fileCodeOwnerStatuses).containsExactly(expectedFileCodeOwnerStatus);
   }
 
   @Test
   public void getStatusForFileRename_noImplicitApprovalOnOldPath() throws Exception {
     testImplicitApprovalOnGetStatusForFileRenameOnOldPath(
-        /* implicitApprovalsEnabled= */ false,
-        /* uploaderMatchesChangeOwner= */ true,
-        /* useDiffCache= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
-  public void getStatusForFileRename_noImplicitApprovalOnOldPath_useDiffCache() throws Exception {
-    testImplicitApprovalOnGetStatusForFileRenameOnOldPath(
-        /* implicitApprovalsEnabled= */ false,
-        /* uploaderMatchesChangeOwner= */ true,
-        /* useDiffCache= */ true);
+        /* implicitApprovalsEnabled= */ false, /* uploaderMatchesChangeOwner= */ true);
   }
 
   @Test
@@ -664,49 +636,18 @@
   public void getStatusForFileRename_noImplicitApprovalOnOldPath_uploaderDoesntMatchChangeOwner()
       throws Exception {
     testImplicitApprovalOnGetStatusForFileRenameOnOldPath(
-        /* implicitApprovalsEnabled= */ true,
-        /* uploaderMatchesChangeOwner= */ false,
-        /* useDiffCache= */ false);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
-  public void
-      getStatusForFileRename_noImplicitApprovalOnOldPath_uploaderDoesntMatchChangeOwner_useDiffCache()
-          throws Exception {
-    testImplicitApprovalOnGetStatusForFileRenameOnOldPath(
-        /* implicitApprovalsEnabled= */ true,
-        /* uploaderMatchesChangeOwner= */ false,
-        /* useDiffCache= */ true);
+        /* implicitApprovalsEnabled= */ true, /* uploaderMatchesChangeOwner= */ false);
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
   public void getStatusForFileRename_withImplicitApprovalOnOldPath() throws Exception {
     testImplicitApprovalOnGetStatusForFileRenameOnOldPath(
-        /* implicitApprovalsEnabled= */ true,
-        /* uploaderMatchesChangeOwner= */ true,
-        /* useDiffCache= */ false);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
-  public void getStatusForFileRename_withImplicitApprovalOnOldPath_useDiffCache() throws Exception {
-    testImplicitApprovalOnGetStatusForFileRenameOnOldPath(
-        /* implicitApprovalsEnabled= */ true,
-        /* uploaderMatchesChangeOwner= */ true,
-        /* useDiffCache= */ true);
+        /* implicitApprovalsEnabled= */ true, /* uploaderMatchesChangeOwner= */ true);
   }
 
   private void testImplicitApprovalOnGetStatusForFileRenameOnOldPath(
-      boolean implicitApprovalsEnabled, boolean uploaderMatchesChangeOwner, boolean useDiffCache)
-      throws Exception {
+      boolean implicitApprovalsEnabled, boolean uploaderMatchesChangeOwner) throws Exception {
     TestAccount changeOwner = admin;
     TestAccount otherCodeOwner =
         accountCreator.create(
@@ -725,46 +666,34 @@
     }
 
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    if (useDiffCache) {
-      assertThatCollection(fileCodeOwnerStatuses)
-          .containsExactly(
-              FileCodeOwnerStatus.rename(
-                  oldPath,
-                  implicitApprovalsEnabled && uploaderMatchesChangeOwner
-                      ? CodeOwnerStatus.APPROVED
-                      : CodeOwnerStatus.INSUFFICIENT_REVIEWERS,
-                  newPath,
-                  CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    FileCodeOwnerStatus expectedFileCodeOwnerStatus;
+    if (implicitApprovalsEnabled && uploaderMatchesChangeOwner) {
+      expectedFileCodeOwnerStatus =
+          FileCodeOwnerStatus.rename(
+              oldPath,
+              CodeOwnerStatus.APPROVED,
+              String.format(
+                  "implicitly approved by the patch set uploader %s who is a code owner",
+                  AccountTemplateUtil.getAccountTemplate(changeOwner.id())),
+              newPath,
+              CodeOwnerStatus.INSUFFICIENT_REVIEWERS,
+              /* reasonNewPath= */ null);
     } else {
-      assertThatCollection(fileCodeOwnerStatuses)
-          .containsExactly(
-              FileCodeOwnerStatus.deletion(
-                  oldPath,
-                  implicitApprovalsEnabled && uploaderMatchesChangeOwner
-                      ? CodeOwnerStatus.APPROVED
-                      : CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
-              FileCodeOwnerStatus.addition(newPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+      expectedFileCodeOwnerStatus =
+          FileCodeOwnerStatus.rename(
+              oldPath,
+              CodeOwnerStatus.INSUFFICIENT_REVIEWERS,
+              newPath,
+              CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
     }
+
+    assertThatCollection(fileCodeOwnerStatuses).containsExactly(expectedFileCodeOwnerStatus);
   }
 
   @Test
   public void testImplicitApprovalOnGetStatusForFileRenameOnNewPath() throws Exception {
     testImplicitApprovalOnGetStatusForFileRenameOnNewPath(
-        /* implicitApprovalsEnabled= */ false,
-        /* uploaderMatchesChangeOwner= */ true,
-        /* useDiffCache= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
-  public void testImplicitApprovalOnGetStatusForFileRenameOnNewPath_useDiffCache()
-      throws Exception {
-    testImplicitApprovalOnGetStatusForFileRenameOnNewPath(
-        /* implicitApprovalsEnabled= */ false,
-        /* uploaderMatchesChangeOwner= */ true,
-        /* useDiffCache= */ true);
+        /* implicitApprovalsEnabled= */ false, /* uploaderMatchesChangeOwner= */ true);
   }
 
   @Test
@@ -772,49 +701,18 @@
   public void getStatusForFileRename_noImplicitApprovalOnNewPath_uploaderDoesntMatchChangeOwner()
       throws Exception {
     testImplicitApprovalOnGetStatusForFileRenameOnNewPath(
-        /* implicitApprovalsEnabled= */ true,
-        /* uploaderMatchesChangeOwner= */ false,
-        /* useDiffCache= */ false);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
-  public void
-      getStatusForFileRename_noImplicitApprovalOnNewPath_uploaderDoesntMatchChangeOwner_useDiffCache()
-          throws Exception {
-    testImplicitApprovalOnGetStatusForFileRenameOnNewPath(
-        /* implicitApprovalsEnabled= */ true,
-        /* uploaderMatchesChangeOwner= */ false,
-        /* useDiffCache= */ true);
+        /* implicitApprovalsEnabled= */ true, /* uploaderMatchesChangeOwner= */ false);
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
   public void getStatusForFileRename_withImplicitApprovalOnNewPath() throws Exception {
     testImplicitApprovalOnGetStatusForFileRenameOnNewPath(
-        /* implicitApprovalsEnabled= */ true,
-        /* uploaderMatchesChangeOwner= */ true,
-        /* useDiffCache= */ false);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
-  public void getStatusForFileRename_withImplicitApprovalOnNewPath_useDiffCache() throws Exception {
-    testImplicitApprovalOnGetStatusForFileRenameOnNewPath(
-        /* implicitApprovalsEnabled= */ true,
-        /* uploaderMatchesChangeOwner= */ true,
-        /* useDiffCache= */ true);
+        /* implicitApprovalsEnabled= */ true, /* uploaderMatchesChangeOwner= */ true);
   }
 
   private void testImplicitApprovalOnGetStatusForFileRenameOnNewPath(
-      boolean implicitApprovalsEnabled, boolean uploaderMatchesChangeOwner, boolean useDiffCache)
-      throws Exception {
+      boolean implicitApprovalsEnabled, boolean uploaderMatchesChangeOwner) throws Exception {
     TestAccount changeOwner = admin;
     TestAccount otherCodeOwner =
         accountCreator.create(
@@ -833,26 +731,28 @@
     }
 
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    if (useDiffCache) {
-      assertThatCollection(fileCodeOwnerStatuses)
-          .containsExactly(
-              FileCodeOwnerStatus.rename(
-                  oldPath,
-                  CodeOwnerStatus.INSUFFICIENT_REVIEWERS,
-                  newPath,
-                  implicitApprovalsEnabled && uploaderMatchesChangeOwner
-                      ? CodeOwnerStatus.APPROVED
-                      : CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    FileCodeOwnerStatus expectedFileCodeOwnerStatus;
+    if (implicitApprovalsEnabled && uploaderMatchesChangeOwner) {
+      expectedFileCodeOwnerStatus =
+          FileCodeOwnerStatus.rename(
+              oldPath,
+              CodeOwnerStatus.INSUFFICIENT_REVIEWERS,
+              /* reasonOldPath= */ null,
+              newPath,
+              CodeOwnerStatus.APPROVED,
+              String.format(
+                  "implicitly approved by the patch set uploader %s who is a code owner",
+                  AccountTemplateUtil.getAccountTemplate(changeOwner.id())));
     } else {
-      assertThatCollection(fileCodeOwnerStatuses)
-          .containsExactly(
-              FileCodeOwnerStatus.deletion(oldPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
-              FileCodeOwnerStatus.addition(
-                  newPath,
-                  implicitApprovalsEnabled && uploaderMatchesChangeOwner
-                      ? CodeOwnerStatus.APPROVED
-                      : CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+      expectedFileCodeOwnerStatus =
+          FileCodeOwnerStatus.rename(
+              oldPath,
+              CodeOwnerStatus.INSUFFICIENT_REVIEWERS,
+              newPath,
+              CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
     }
+
+    assertThatCollection(fileCodeOwnerStatuses).containsExactly(expectedFileCodeOwnerStatus);
   }
 
   @Test
@@ -904,7 +804,13 @@
     requestScopeOperations.setApiUser(admin.id());
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "approved by %s who is a code owner (all users are code owners)",
+                    AccountTemplateUtil.getAccountTemplate(admin.id()))));
   }
 
   @Test
@@ -953,14 +859,23 @@
       amendChange(otherCodeOwner, changeId);
     }
 
+    FileCodeOwnerStatus expectedFileCodeOwnerStatus;
+    if (implicitApprovalsEnabled && uploaderMatchesChangeOwner) {
+      expectedFileCodeOwnerStatus =
+          FileCodeOwnerStatus.addition(
+              path,
+              CodeOwnerStatus.APPROVED,
+              String.format(
+                  "implicitly approved by the patch set uploader %s who is a code owner"
+                      + " (all users are code owners)",
+                  AccountTemplateUtil.getAccountTemplate(changeOwner.id())));
+    } else {
+      expectedFileCodeOwnerStatus =
+          FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    }
+
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(
-            FileCodeOwnerStatus.addition(
-                path,
-                implicitApprovalsEnabled && uploaderMatchesChangeOwner
-                    ? CodeOwnerStatus.APPROVED
-                    : CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    assertThatCollection(fileCodeOwnerStatuses).containsExactly(expectedFileCodeOwnerStatus);
   }
 
   @Test
@@ -992,7 +907,13 @@
     // Check that the status of the file is PENDING now.
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.PENDING));
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a code owner (all users are code owners)",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
   }
 
   @Test
@@ -1027,7 +948,13 @@
     requestScopeOperations.setApiUser(admin.id());
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "approved by %s who is a global code owner",
+                    AccountTemplateUtil.getAccountTemplate(bot.id()))));
   }
 
   @Test
@@ -1078,14 +1005,23 @@
       amendChange(otherBot, changeId);
     }
 
+    FileCodeOwnerStatus expectedFileCodeOwnerStatus;
+    if (implicitApprovalsEnabled && uploaderMatchesChangeOwner) {
+      expectedFileCodeOwnerStatus =
+          FileCodeOwnerStatus.addition(
+              path,
+              CodeOwnerStatus.APPROVED,
+              String.format(
+                  "implicitly approved by the patch set uploader %s who is a global code"
+                      + " owner",
+                  AccountTemplateUtil.getAccountTemplate(bot.id())));
+    } else {
+      expectedFileCodeOwnerStatus =
+          FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    }
+
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(
-            FileCodeOwnerStatus.addition(
-                path,
-                implicitApprovalsEnabled && uploaderMatchesChangeOwner
-                    ? CodeOwnerStatus.APPROVED
-                    : CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    assertThatCollection(fileCodeOwnerStatuses).containsExactly(expectedFileCodeOwnerStatus);
   }
 
   @Test
@@ -1114,7 +1050,13 @@
     requestScopeOperations.setApiUser(admin.id());
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.PENDING));
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a global code owner",
+                    AccountTemplateUtil.getAccountTemplate(bot.id()))));
 
     // Let the bot approve the change.
     projectOperations
@@ -1129,7 +1071,13 @@
     requestScopeOperations.setApiUser(admin.id());
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "approved by %s who is a global code owner",
+                    AccountTemplateUtil.getAccountTemplate(bot.id()))));
   }
 
   @Test
@@ -1155,7 +1103,14 @@
     requestScopeOperations.setApiUser(admin.id());
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "approved by %s who is a global code owner"
+                        + " (all users are global code owners)",
+                    AccountTemplateUtil.getAccountTemplate(admin.id()))));
   }
 
   @Test
@@ -1197,14 +1152,23 @@
       amendChange(otherCodeOwner, changeId);
     }
 
+    FileCodeOwnerStatus expectedFileCodeOwnerStatus;
+    if (implicitApprovalsEnabled && uploaderMatchesChangeOwner) {
+      expectedFileCodeOwnerStatus =
+          FileCodeOwnerStatus.addition(
+              path,
+              CodeOwnerStatus.APPROVED,
+              String.format(
+                  "implicitly approved by the patch set uploader %s who is a global code owner"
+                      + " (all users are global code owners)",
+                  AccountTemplateUtil.getAccountTemplate(changeOwner.id())));
+    } else {
+      expectedFileCodeOwnerStatus =
+          FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    }
+
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(
-            FileCodeOwnerStatus.addition(
-                path,
-                implicitApprovalsEnabled && uploaderMatchesChangeOwner
-                    ? CodeOwnerStatus.APPROVED
-                    : CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    assertThatCollection(fileCodeOwnerStatuses).containsExactly(expectedFileCodeOwnerStatus);
   }
 
   @Test
@@ -1228,7 +1192,13 @@
     // Check that the status of the file is PENDING now.
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.PENDING));
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a global code owner (all users are global code owners)",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
   }
 
   @Test
@@ -1257,7 +1227,13 @@
     // The expected status is APPROVED since 'user' which is configured as code owner on the root
     // level approved the change.
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "approved by %s who is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
   }
 
   @Test
@@ -1295,8 +1271,18 @@
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
         .containsExactly(
-            FileCodeOwnerStatus.addition(path1, CodeOwnerStatus.APPROVED),
-            FileCodeOwnerStatus.addition(path2, CodeOwnerStatus.APPROVED));
+            FileCodeOwnerStatus.addition(
+                path1,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "override approval Owners-Override+1 by %s is present",
+                    AccountTemplateUtil.getAccountTemplate(admin.id()))),
+            FileCodeOwnerStatus.addition(
+                path2,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "override approval Owners-Override+1 by %s is present",
+                    AccountTemplateUtil.getAccountTemplate(admin.id()))));
   }
 
   @Test
@@ -1337,8 +1323,18 @@
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
         .containsExactly(
-            FileCodeOwnerStatus.addition(path1, CodeOwnerStatus.APPROVED),
-            FileCodeOwnerStatus.addition(path2, CodeOwnerStatus.APPROVED));
+            FileCodeOwnerStatus.addition(
+                path1,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "override approval Owners-Override+1 by %s is present",
+                    AccountTemplateUtil.getAccountTemplate(admin.id()))),
+            FileCodeOwnerStatus.addition(
+                path2,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "override approval Owners-Override+1 by %s is present",
+                    AccountTemplateUtil.getAccountTemplate(admin.id()))));
 
     // Delete the override approval.
     gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 0));
@@ -1357,8 +1353,18 @@
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
         .containsExactly(
-            FileCodeOwnerStatus.addition(path1, CodeOwnerStatus.APPROVED),
-            FileCodeOwnerStatus.addition(path2, CodeOwnerStatus.APPROVED));
+            FileCodeOwnerStatus.addition(
+                path1,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "override approval Another-Override+1 by %s is present",
+                    AccountTemplateUtil.getAccountTemplate(admin.id()))),
+            FileCodeOwnerStatus.addition(
+                path2,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "override approval Another-Override+1 by %s is present",
+                    AccountTemplateUtil.getAccountTemplate(admin.id()))));
   }
 
   @Test
@@ -1523,7 +1529,13 @@
     requestScopeOperations.setApiUser(admin.id());
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "approved by %s who is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
 
     // Change some other file ('user' who uploads the change is a code owner and hence owner
     // approvals are implicit for this change)
@@ -1543,7 +1555,13 @@
     // Check that the file is still approved.
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "approved by %s who is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
   }
 
   @Test
@@ -1575,7 +1593,13 @@
     requestScopeOperations.setApiUser(admin.id());
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "override approval Owners-Override+1 by %s is present",
+                    AccountTemplateUtil.getAccountTemplate(admin.id()))));
 
     // Change some other file and submit the change with an override.
     String changeId2 =
@@ -1594,7 +1618,13 @@
     // Check that the file is still approved.
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "override approval Owners-Override+1 by %s is present",
+                    AccountTemplateUtil.getAccountTemplate(admin.id()))));
   }
 
   @Test
@@ -1629,7 +1659,13 @@
     requestScopeOperations.setApiUser(admin.id());
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "approved by %s who is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
   }
 
   @Test
@@ -1675,7 +1711,13 @@
     requestScopeOperations.setApiUser(admin.id());
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "override approval Owners-Override+2 by %s is present",
+                    AccountTemplateUtil.getAccountTemplate(user2.id()))));
   }
 
   @Test
@@ -1709,7 +1751,13 @@
     requestScopeOperations.setApiUser(admin.id());
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "approved by %s who is a default code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
   }
 
   @Test
@@ -1752,17 +1800,22 @@
       amendChange(otherCodeOwner, changeId);
     }
 
+    FileCodeOwnerStatus expectedFileCodeOwnerStatus;
+    if (implicitApprovalsEnabled && uploaderMatchesChangeOwner) {
+      expectedFileCodeOwnerStatus =
+          FileCodeOwnerStatus.addition(
+              path,
+              CodeOwnerStatus.APPROVED,
+              String.format(
+                  "implicitly approved by the patch set uploader %s who is a default code owner",
+                  AccountTemplateUtil.getAccountTemplate(changeOwner.id())));
+    } else {
+      expectedFileCodeOwnerStatus =
+          FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    }
+
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(
-            FileCodeOwnerStatus.addition(
-                path,
-                implicitApprovalsEnabled && uploaderMatchesChangeOwner
-                    ? CodeOwnerStatus.APPROVED
-                    : CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    assertThatCollection(fileCodeOwnerStatuses).containsExactly(expectedFileCodeOwnerStatus);
   }
 
   @Test
@@ -1790,7 +1843,13 @@
     requestScopeOperations.setApiUser(admin.id());
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.PENDING));
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a default code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
 
     // Let the default code owner approve the change.
     projectOperations
@@ -1805,7 +1864,13 @@
     requestScopeOperations.setApiUser(admin.id());
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "approved by %s who is a default code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
   }
 
   @Test
@@ -1847,7 +1912,11 @@
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses =
         getFileCodeOwnerStatuses(changeIdOfRevert);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.deletion(path, CodeOwnerStatus.APPROVED));
+        .containsExactly(
+            FileCodeOwnerStatus.deletion(
+                path,
+                CodeOwnerStatus.APPROVED,
+                "change is a pure revert and is exempted from requiring code owner approvals"));
   }
 
   @Test
@@ -1903,7 +1972,13 @@
     // approvals.
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "patch set uploader %s is exempted from requiring code owner approvals",
+                    AccountTemplateUtil.getAccountTemplate(exemptedUser.id()))));
 
     // Amend the change by another user, so that the other non-exempted user becomes the last
     // uploader.
@@ -1917,6 +1992,67 @@
             FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  @TestProjectInput(submitType = SubmitType.REBASE_ALWAYS)
+  public void implicitApproval_rebaseAlways() throws Exception {
+    TestAccount changeOwner = admin;
+    setAsRootCodeOwners(changeOwner);
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // The change is implicitly approved because the change owner and uploader is a code owner.
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "implicitly approved by the patch set uploader %s who is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(changeOwner.id()))));
+
+    // Allow all users to approve and submit changes.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.allowLabel("Code-Review")
+                .range(0, 2)
+                .ref("refs/*")
+                .group(REGISTERED_USERS)
+                .build())
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    // Add a Code-Review+2 vote from a non code owner to satisfy the submit requirement for the
+    // Code-Review label.
+    requestScopeOperations.setApiUser(user.id());
+    approve(changeId);
+
+    // Submit the change as a non code owner.
+    gApi.changes().id(changeId).current().submit();
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    assertThat(changeInfo.status).isEqualTo(ChangeStatus.MERGED);
+
+    // Since the submit type is REBASE_ALWAYS we expect that a new patch set was added that has the
+    // submitter as uploader.
+    assertThat(changeInfo.revisions).hasSize(2);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).uploader._accountId)
+        .isEqualTo(user.id().get());
+
+    // Asking for the code owner status now reports the file with status INSUFFICIENT_REVIEWERS
+    // because the implicit approval does not apply to the newly created patch set (since the
+    // uploader of the latest patch set is not a code owner and doesn't match the change owner).
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+  }
+
   private ImmutableSet<FileCodeOwnerStatus> getFileCodeOwnerStatuses(String changeId)
       throws Exception {
     return codeOwnerApprovalCheck.getFileStatusesAsSet(
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithAllUsersAsFallbackCodeOwnersTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithAllUsersAsFallbackCodeOwnersTest.java
index 08052d8..c91d897 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithAllUsersAsFallbackCodeOwnersTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithAllUsersAsFallbackCodeOwnersTest.java
@@ -25,9 +25,9 @@
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.CodeOwnerConfigOperations;
 import com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig;
 import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
-import com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject;
 import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import java.nio.file.Path;
@@ -77,14 +77,9 @@
 
     // Verify that the file is not approved yet.
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add a fallback code owner as reviewer.
     gApi.changes().id(changeId).addReviewer(user.email());
@@ -92,13 +87,9 @@
     // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
     // defined).
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add a Code-Review+1 (= code owner approval) from a fallback code owner.
     requestScopeOperations.setApiUser(user.id());
@@ -107,13 +98,9 @@
     // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
     // defined).
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
@@ -130,14 +117,9 @@
 
     // Verify that the file is not approved yet.
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
@@ -156,14 +138,9 @@
 
     // Verify that the file is not approved yet.
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add a fallback code owner as reviewer.
     gApi.changes().id(changeId).addReviewer(user.email());
@@ -171,13 +148,9 @@
     // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
     // defined).
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add a Code-Review+1 (= code owner approval) from a fallback code owner.
     requestScopeOperations.setApiUser(user.id());
@@ -186,13 +159,9 @@
     // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
     // defined).
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
@@ -213,14 +182,9 @@
 
     // Verify that the file is not approved yet.
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
@@ -232,27 +196,23 @@
     // Verify that the file is not approved yet (the change owner is a code owner, but
     // implicit approvals are disabled).
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
-    // Add a user a fallback code owner as reviewer.
+    // Add a user who is fallback code owner as reviewer.
     gApi.changes().id(changeId).addReviewer(user.email());
 
     // Verify that the status is pending now .
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.PENDING);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a fallback code owner (all users are fallback code owners)",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
 
     // Add a Code-Review+1 (= code owner approval) from a fallback code owner.
     requestScopeOperations.setApiUser(user.id());
@@ -260,13 +220,15 @@
 
     // Verify that the status is approved now
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.APPROVED);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "approved by %s who is a fallback code owner"
+                        + " (all users are fallback code owners)",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
   }
 
   @Test
@@ -279,14 +241,15 @@
     // Verify that the file is approved (the change owner is a code owner and implicit approvals are
     // enabled).
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.APPROVED);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "implicitly approved by the patch set uploader %s who is a fallback code"
+                        + " owner (all users are fallback code owners)",
+                    AccountTemplateUtil.getAccountTemplate(admin.id()))));
   }
 
   @Test
@@ -305,14 +268,9 @@
 
     // Verify that the file is not approved yet.
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add a fallback code owner as reviewer.
     gApi.changes().id(changeId).addReviewer(user.email());
@@ -320,13 +278,9 @@
     // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
     // defined).
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add a Code-Review+1 (= code owner approval) from a fallback code owner.
     requestScopeOperations.setApiUser(user.id());
@@ -335,13 +289,9 @@
     // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
     // defined).
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
@@ -361,14 +311,9 @@
 
     // Verify that the file is not approved yet.
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
@@ -388,14 +333,9 @@
 
     // Verify that the file is not approved yet.
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add a fallback code owner as reviewer.
     gApi.changes().id(changeId).addReviewer(user.email());
@@ -403,13 +343,9 @@
     // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
     // defined).
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add a Code-Review+1 (= code owner approval) from a fallback code owner.
     requestScopeOperations.setApiUser(user.id());
@@ -418,13 +354,9 @@
     // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
     // defined).
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
@@ -445,14 +377,9 @@
 
     // Verify that the file is not approved yet.
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
@@ -477,14 +404,9 @@
 
     // Verify that the file is not approved yet.
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add a fallback code owner as reviewer.
     gApi.changes().id(changeId).addReviewer(user.email());
@@ -492,13 +414,9 @@
     // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
     // defined).
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add a Code-Review+1 (= code owner approval) from a fallback code owner.
     requestScopeOperations.setApiUser(user.id());
@@ -507,13 +425,9 @@
     // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
     // defined).
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
@@ -540,14 +454,9 @@
 
     // Verify that the file is not approved yet.
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   private ImmutableSet<FileCodeOwnerStatus> getFileCodeOwnerStatuses(String changeId)
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithProjectOwnersAsFallbackCodeOwnersTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithProjectOwnersAsFallbackCodeOwnersTest.java
index 4ab563c..dfb0144 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithProjectOwnersAsFallbackCodeOwnersTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithProjectOwnersAsFallbackCodeOwnersTest.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.plugins.codeowners.backend;
 
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
-import static com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject.assertThat;
 import static com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject.assertThatCollection;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
@@ -30,9 +29,9 @@
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
 import com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig;
 import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
-import com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject;
 import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import java.nio.file.Path;
@@ -84,14 +83,9 @@
 
     // Verify that the file is not approved yet.
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Let the bot approve the change.
     projectOperations
@@ -105,13 +99,14 @@
     // Check that the file is approved now.
     requestScopeOperations.setApiUser(admin.id());
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.APPROVED);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "approved by %s who is a global code owner",
+                    AccountTemplateUtil.getAccountTemplate(bot.id()))));
   }
 
   @Test
@@ -154,18 +149,23 @@
       amendChange(projectOwner, changeId);
     }
 
+    FileCodeOwnerStatus expectedFileCodeOwnerStatus;
+    if (implicitApprovalsEnabled && uploaderMatchesChangeOwner) {
+      expectedFileCodeOwnerStatus =
+          FileCodeOwnerStatus.addition(
+              path,
+              CodeOwnerStatus.APPROVED,
+              String.format(
+                  "implicitly approved by the patch set uploader %s who is a global code"
+                      + " owner",
+                  AccountTemplateUtil.getAccountTemplate(bot.id())));
+    } else {
+      expectedFileCodeOwnerStatus =
+          FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    }
+
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(
-            implicitApprovalsEnabled && uploaderMatchesChangeOwner
-                ? CodeOwnerStatus.APPROVED
-                : CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses).containsExactly(expectedFileCodeOwnerStatus);
   }
 
   @Test
@@ -183,14 +183,9 @@
 
     // Verify that the status of the file is INSUFFICIENT_REVIEWERS.
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add the bot approve as reviewer.
     gApi.changes().id(changeId).addReviewer(bot.email());
@@ -198,13 +193,14 @@
     // Check that the status of the file is PENDING now.
     requestScopeOperations.setApiUser(admin.id());
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.PENDING);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a global code owner",
+                    AccountTemplateUtil.getAccountTemplate(bot.id()))));
 
     // Let the bot approve the change.
     projectOperations
@@ -218,13 +214,14 @@
     // Check that the file is approved now.
     requestScopeOperations.setApiUser(admin.id());
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.APPROVED);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "approved by %s who is a global code owner",
+                    AccountTemplateUtil.getAccountTemplate(bot.id()))));
   }
 
   @Test
@@ -239,14 +236,9 @@
     // Verify that the file is not approved yet (the change owner is a global code owner, but
     // implicit approvals are disabled).
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add an approval by a user that is a code owner only through the global code ownership.
     approve(changeId);
@@ -254,13 +246,15 @@
     // Check that the file is approved now.
     requestScopeOperations.setApiUser(admin.id());
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.APPROVED);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "approved by %s who is a global code owner"
+                        + " (all users are global code owners)",
+                    AccountTemplateUtil.getAccountTemplate(admin.id()))));
   }
 
   @Test
@@ -303,18 +297,23 @@
       amendChange(otherProjectOwner, changeId);
     }
 
+    FileCodeOwnerStatus expectedFileCodeOwnerStatus;
+    if (implicitApprovalsEnabled && uploaderMatchesChangeOwner) {
+      expectedFileCodeOwnerStatus =
+          FileCodeOwnerStatus.addition(
+              path,
+              CodeOwnerStatus.APPROVED,
+              String.format(
+                  "implicitly approved by the patch set uploader %s who is a global code owner"
+                      + " (all users are global code owners)",
+                  AccountTemplateUtil.getAccountTemplate(projectOwner.id())));
+    } else {
+      expectedFileCodeOwnerStatus =
+          FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    }
+
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(
-            implicitApprovalsEnabled && uploaderMatchesChangeOwner
-                ? CodeOwnerStatus.APPROVED
-                : CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses).containsExactly(expectedFileCodeOwnerStatus);
   }
 
   @Test
@@ -328,27 +327,23 @@
     // Verify that the status of the file is INSUFFICIENT_REVIEWERS (since there is no implicit
     // approval by default).
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add a user as reviewer that is a code owner only through the global code ownership.
     gApi.changes().id(changeId).addReviewer(user.email());
 
     // Check that the status of the file is PENDING now.
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.PENDING);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a global code owner (all users are global code owners)",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
   }
 
   @Test
@@ -371,17 +366,9 @@
     recommend(changeId);
 
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
@@ -402,17 +389,15 @@
     recommend(changeId);
 
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.PENDING);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.PENDING,
+                String.format(
+                    "reviewer %s is a fallback code owner"
+                        + " (all project owners are fallback code owners)",
+                    AccountTemplateUtil.getAccountTemplate(admin.id()))));
   }
 
   @Test
@@ -428,17 +413,15 @@
     recommend(changeId);
 
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.APPROVED);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "approved by %s who is a fallback code owner"
+                        + " (all project owners are fallback code owners)",
+                    AccountTemplateUtil.getAccountTemplate(admin.id()))));
   }
 
   @Test
@@ -476,21 +459,23 @@
       amendChange(user, changeId);
     }
 
+    FileCodeOwnerStatus expectedFileCodeOwnerStatus;
+    if (implicitApprovalsEnabled && uploaderMatchesChangeOwner) {
+      expectedFileCodeOwnerStatus =
+          FileCodeOwnerStatus.addition(
+              path,
+              CodeOwnerStatus.APPROVED,
+              String.format(
+                  "implicitly approved by the patch set uploader %s who is a fallback code"
+                      + " owner (all project owners are fallback code owners)",
+                  AccountTemplateUtil.getAccountTemplate(projectOwner.id())));
+    } else {
+      expectedFileCodeOwnerStatus =
+          FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    }
+
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(
-            implicitApprovalsEnabled && uploaderMatchesChangeOwner
-                ? CodeOwnerStatus.APPROVED
-                : CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+    assertThatCollection(fileCodeOwnerStatuses).containsExactly(expectedFileCodeOwnerStatus);
   }
 
   @Test
@@ -508,17 +493,9 @@
     amendChange(admin2, changeId);
 
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
@@ -528,6 +505,8 @@
 
     // Create a change with a user that is not a project owner.
     TestRepository<InMemoryRepository> testRepo = cloneProject(project, user);
+    String path1 = "bar/baz.config";
+    String path2 = "foo/baz.config";
     String changeId =
         pushFactory
             .create(
@@ -535,31 +514,37 @@
                 testRepo,
                 "Test Change",
                 ImmutableMap.of(
-                    "foo/baz.config", "content",
-                    "bar/baz.config", "other content"))
+                    path2, "content",
+                    path1, "other content"))
             .to("refs/for/master")
             .getChangeId();
 
     // Without Owners-Override approval the expected status is INSUFFICIENT_REVIEWERS.
-    for (FileCodeOwnerStatus fileCodeOwnerStatus : getFileCodeOwnerStatuses(changeId)) {
-      assertThat(fileCodeOwnerStatus)
-          .hasNewPathStatus()
-          .value()
-          .hasStatusThat()
-          .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
-    }
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path1, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
+            FileCodeOwnerStatus.addition(path2, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add an override approval.
     gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
 
     // With Owners-Override approval the expected status is APPROVED.
-    for (FileCodeOwnerStatus fileCodeOwnerStatus : getFileCodeOwnerStatuses(changeId)) {
-      assertThat(fileCodeOwnerStatus)
-          .hasNewPathStatus()
-          .value()
-          .hasStatusThat()
-          .isEqualTo(CodeOwnerStatus.APPROVED);
-    }
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path1,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "override approval Owners-Override+1 by %s is present",
+                    AccountTemplateUtil.getAccountTemplate(admin.id()))),
+            FileCodeOwnerStatus.addition(
+                path2,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "override approval Owners-Override+1 by %s is present",
+                    AccountTemplateUtil.getAccountTemplate(admin.id()))));
   }
 
   @Test
@@ -572,6 +557,8 @@
 
     // Create a change with a user that is not a project owner.
     TestRepository<InMemoryRepository> testRepo = cloneProject(project, user);
+    String path1 = "bar/baz.config";
+    String path2 = "foo/baz.config";
     String changeId =
         pushFactory
             .create(
@@ -579,56 +566,68 @@
                 testRepo,
                 "Test Change",
                 ImmutableMap.of(
-                    "foo/baz.config", "content",
-                    "bar/baz.config", "other content"))
+                    path2, "content",
+                    path1, "other content"))
             .to("refs/for/master")
             .getChangeId();
 
     // Without override approval the expected status is INSUFFICIENT_REVIEWERS.
-    for (FileCodeOwnerStatus fileCodeOwnerStatus : getFileCodeOwnerStatuses(changeId)) {
-      assertThat(fileCodeOwnerStatus)
-          .hasNewPathStatus()
-          .value()
-          .hasStatusThat()
-          .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
-    }
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path1, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
+            FileCodeOwnerStatus.addition(path2, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add an override approval (by a user that is not a project owners, and hence no code owner).
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
 
     // With override approval the expected status is APPROVED.
-    for (FileCodeOwnerStatus fileCodeOwnerStatus : getFileCodeOwnerStatuses(changeId)) {
-      assertThat(fileCodeOwnerStatus)
-          .hasNewPathStatus()
-          .value()
-          .hasStatusThat()
-          .isEqualTo(CodeOwnerStatus.APPROVED);
-    }
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path1,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "override approval Owners-Override+1 by %s is present",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))),
+            FileCodeOwnerStatus.addition(
+                path2,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "override approval Owners-Override+1 by %s is present",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
 
     // Delete the override approval.
     gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 0));
 
     // Without override approval the expected status is INSUFFICIENT_REVIEWERS.
-    for (FileCodeOwnerStatus fileCodeOwnerStatus : getFileCodeOwnerStatuses(changeId)) {
-      assertThat(fileCodeOwnerStatus)
-          .hasNewPathStatus()
-          .value()
-          .hasStatusThat()
-          .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
-    }
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path1, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
+            FileCodeOwnerStatus.addition(path2, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add another override approval.
     gApi.changes().id(changeId).current().review(new ReviewInput().label("Another-Override", 1));
 
     // With override approval the expected status is APPROVED.
-    for (FileCodeOwnerStatus fileCodeOwnerStatus : getFileCodeOwnerStatuses(changeId)) {
-      assertThat(fileCodeOwnerStatus)
-          .hasNewPathStatus()
-          .value()
-          .hasStatusThat()
-          .isEqualTo(CodeOwnerStatus.APPROVED);
-    }
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path1,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "override approval Another-Override+1 by %s is present",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))),
+            FileCodeOwnerStatus.addition(
+                path2,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "override approval Another-Override+1 by %s is present",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
   }
 
   @Test
@@ -672,7 +671,13 @@
     requestScopeOperations.setApiUser(admin.id());
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     assertThatCollection(fileCodeOwnerStatuses)
-        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "approved by %s who is a default code owner",
+                    AccountTemplateUtil.getAccountTemplate(user.id()))));
   }
 
   private ImmutableSet<FileCodeOwnerStatus> getFileCodeOwnerStatuses(String changeId)
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithSelfApprovalsIgnoredTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithSelfApprovalsIgnoredTest.java
index f09a9e8..a665ad4 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithSelfApprovalsIgnoredTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithSelfApprovalsIgnoredTest.java
@@ -26,9 +26,9 @@
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
 import com.google.gerrit.plugins.codeowners.backend.config.OverrideApprovalConfig;
 import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
-import com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject;
 import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import java.nio.file.Path;
@@ -84,14 +84,9 @@
 
     // Verify that the file is not approved.
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add a self Code-Review+1 (= code owner approval).
     requestScopeOperations.setApiUser(codeOwner.id());
@@ -99,13 +94,9 @@
 
     // Verify that the file is not approved (since self approvals are ignored).
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
@@ -125,14 +116,9 @@
 
     // Verify that the file is not approved.
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add a Code-Review+1 (= code owner approval) from the change owner.
     requestScopeOperations.setApiUser(changeOwner.id());
@@ -141,13 +127,14 @@
     // Verify that the file is approved now (since the change owner is not the uploader of the
     // current patch set).
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.APPROVED);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "approved by %s who is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(changeOwner.id()))));
   }
 
   @Test
@@ -171,14 +158,9 @@
 
     // Verify that the file is not approved.
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add the code owner as reviewer.
     gApi.changes().id(changeId).addReviewer(user.email());
@@ -186,13 +168,9 @@
     // Verify that the file is not pending (the code owner is the uploader of the current patch set
     // and self approvals are ignored).
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add a Code-Review+1 (= code owner approval) by the code owner.
     requestScopeOperations.setApiUser(codeOwner.id());
@@ -201,13 +179,9 @@
     // Verify that the file is not approved (since the code owner is the uploader of the current
     // patch set and self approvals are ignored).
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
@@ -225,14 +199,9 @@
 
     // Verify that the file is not approved.
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
@@ -257,14 +226,9 @@
 
     // Verify that the file is not approved.
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
@@ -282,14 +246,14 @@
 
     // Verify that the file is approved.
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.APPROVED);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "implicitly approved by the patch set uploader %s who is a code owner",
+                    AccountTemplateUtil.getAccountTemplate(codeOwner.id()))));
   }
 
   @Test
@@ -312,16 +276,11 @@
     // Upload another patch set by a code owner.
     amendChange(codeOwner, changeId);
 
-    // Verify that the file is approved.
+    // Verify that the file is not approved.
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
@@ -337,14 +296,9 @@
 
     // Verify that the file is not approved.
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add an override approval.
     requestScopeOperations.setApiUser(changeOwner.id());
@@ -353,13 +307,9 @@
     // Verify that the file is not approved (since self approvals on the override label are
     // ignored).
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
@@ -378,14 +328,9 @@
 
     // Verify that the file is not approved.
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add an override approval from the change owner.
     requestScopeOperations.setApiUser(changeOwner.id());
@@ -394,13 +339,14 @@
     // Verify that the file is approved now (since the change owner is not the uploader of the
     // current patch set and hence the override counts).
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.APPROVED);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                CodeOwnerStatus.APPROVED,
+                String.format(
+                    "override approval Owners-Override+1 by %s is present",
+                    AccountTemplateUtil.getAccountTemplate(changeOwner.id()))));
   }
 
   @Test
@@ -419,27 +365,18 @@
 
     // Verify that the file is not approved.
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add an override approval.
     gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
 
     // Verify that the file is not approved (since the override from the uploader is ignored).
     fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
-    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   private ImmutableSet<FileCodeOwnerStatus> getFileCodeOwnerStatuses(String changeId)
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerBackendIdTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerBackendIdTest.java
index 3f5ee09..a5e56f2 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerBackendIdTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerBackendIdTest.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
 import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersBackend;
@@ -26,7 +27,6 @@
 import java.nio.file.Path;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Test;
 
 /** Tests for {@link com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackendId}. */
@@ -50,9 +50,7 @@
   private static class TestCodeOwnerBackend implements CodeOwnerBackend {
     @Override
     public Optional<CodeOwnerConfig> getCodeOwnerConfig(
-        CodeOwnerConfig.Key codeOwnerConfigKey,
-        @Nullable RevWalk revWalk,
-        @Nullable ObjectId revision) {
+        CodeOwnerConfig.Key codeOwnerConfigKey, @Nullable ObjectId revision) {
       throw new UnsupportedOperationException("not implemented");
     }
 
@@ -75,7 +73,7 @@
     }
 
     @Override
-    public Optional<PathExpressionMatcher> getPathExpressionMatcher() {
+    public Optional<PathExpressionMatcher> getPathExpressionMatcher(BranchNameKey branchNameKey) {
       return Optional.empty();
     }
   }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResultTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResultTest.java
index d64f5bf..18ea017 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResultTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResultTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.plugins.codeowners.backend;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ImmutableSet;
 import org.junit.Test;
 
@@ -25,6 +26,7 @@
     CodeOwnerResolverResult codeOwnerResolverResult =
         CodeOwnerResolverResult.create(
             ImmutableSet.of(CodeOwner.create(admin.id())),
+            /* annotations= */ ImmutableMultimap.of(),
             /* ownedByAllUsers= */ false,
             /* hasUnresolvedCodeOwners= */ false,
             /* hasUnresolvedImports= */ false,
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverTest.java
index 0787e9a..b32c3b5 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverTest.java
@@ -21,6 +21,7 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.TestMetricMaker;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -28,7 +29,7 @@
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
-import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.inject.Inject;
@@ -51,12 +52,14 @@
   @Inject @ServerInitiated private Provider<AccountsUpdate> accountsUpdate;
   @Inject private AccountOperations accountOperations;
   @Inject private ExternalIdNotes.Factory externalIdNotesFactory;
+  @Inject private TestMetricMaker testMetricMaker;
+  @Inject private ExternalIdFactory externalIdFactory;
 
-  private Provider<CodeOwnerResolver> codeOwnerResolver;
+  private Provider<CodeOwnerResolver> codeOwnerResolverProvider;
 
   @Before
   public void setUpCodeOwnersPlugin() throws Exception {
-    codeOwnerResolver =
+    codeOwnerResolverProvider =
         plugin.getSysInjector().getInstance(new Key<Provider<CodeOwnerResolver>>() {});
   }
 
@@ -66,7 +69,7 @@
         assertThrows(
             NullPointerException.class,
             () ->
-                codeOwnerResolver
+                codeOwnerResolverProvider
                     .get()
                     .resolve(/* codeOwnerReference= */ (CodeOwnerReference) null));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwnerReference");
@@ -75,7 +78,7 @@
         assertThrows(
             NullPointerException.class,
             () ->
-                codeOwnerResolver
+                codeOwnerResolverProvider
                     .get()
                     .resolve(/* codeOwnerReferences= */ (Set<CodeOwnerReference>) null));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwnerReferences");
@@ -85,7 +88,9 @@
   public void resolveCodeOwnerReferenceForNonExistingEmail() throws Exception {
     String nonExistingEmail = "non-existing@example.com";
     OptionalResultWithMessages<CodeOwner> result =
-        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(nonExistingEmail));
+        codeOwnerResolverProvider
+            .get()
+            .resolveWithMessages(CodeOwnerReference.create(nonExistingEmail));
     assertThat(result).isEmpty();
     assertThat(result)
         .hasMessagesThat()
@@ -98,7 +103,9 @@
   @Test
   public void resolveCodeOwnerReferenceForEmail() throws Exception {
     OptionalResultWithMessages<CodeOwner> result =
-        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(admin.email()));
+        codeOwnerResolverProvider
+            .get()
+            .resolveWithMessages(CodeOwnerReference.create(admin.email()));
     assertThat(result.get()).hasAccountIdThat().isEqualTo(admin.id());
     assertThat(result)
         .hasMessagesThat()
@@ -108,7 +115,7 @@
   @Test
   public void cannotResolveCodeOwnerReferenceForStarAsEmail() throws Exception {
     OptionalResultWithMessages<CodeOwner> result =
-        codeOwnerResolver
+        codeOwnerResolverProvider
             .get()
             .resolveWithMessages(CodeOwnerReference.create(CodeOwnerResolver.ALL_USERS_WILDCARD));
     assertThat(result).isEmpty();
@@ -131,14 +138,16 @@
             user.id(),
             (a, u) ->
                 u.addExternalId(
-                    ExternalId.create(
+                    externalIdFactory.create(
                         "foo", "bar", user.id(), admin.email(), /* hashedPassword= */ null)));
 
     // Deactivate the 'user' account.
     accountOperations.account(user.id()).forUpdate().inactive().update();
 
     OptionalResultWithMessages<CodeOwner> result =
-        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(admin.email()));
+        codeOwnerResolverProvider
+            .get()
+            .resolveWithMessages(CodeOwnerReference.create(admin.email()));
     assertThat(result.get()).hasAccountIdThat().isEqualTo(admin.id());
   }
 
@@ -152,11 +161,13 @@
             user.id(),
             (a, u) ->
                 u.addExternalId(
-                    ExternalId.create(
+                    externalIdFactory.create(
                         "foo", "bar", user.id(), admin.email(), /* hashedPassword= */ null)));
 
     OptionalResultWithMessages<CodeOwner> result =
-        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(admin.email()));
+        codeOwnerResolverProvider
+            .get()
+            .resolveWithMessages(CodeOwnerReference.create(admin.email()));
     assertThat(result).isEmpty();
     assertThat(result)
         .hasMessagesThat()
@@ -172,12 +183,12 @@
     try (Repository allUsersRepo = repoManager.openRepository(allUsers);
         MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
       ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
-      extIdNotes.upsert(ExternalId.createEmail(accountId, email));
+      extIdNotes.upsert(externalIdFactory.createEmail(accountId, email));
       extIdNotes.commit(md);
     }
 
     OptionalResultWithMessages<CodeOwner> result =
-        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(email));
+        codeOwnerResolverProvider.get().resolveWithMessages(CodeOwnerReference.create(email));
     assertThat(result).isEmpty();
     assertThat(result)
         .hasMessagesThat()
@@ -194,11 +205,14 @@
   public void resolveCodeOwnerReferenceForInactiveUser() throws Exception {
     accountOperations.account(user.id()).forUpdate().inactive().update();
     OptionalResultWithMessages<CodeOwner> result =
-        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(user.email()));
+        codeOwnerResolverProvider
+            .get()
+            .resolveWithMessages(CodeOwnerReference.create(user.email()));
     assertThat(result).isEmpty();
     assertThat(result)
         .hasMessagesThat()
-        .contains(String.format("account %s for email %s is inactive", user.id(), user.email()));
+        .contains(
+            String.format("ignoring inactive account %s for email %s", user.id(), user.email()));
   }
 
   @Test
@@ -212,7 +226,9 @@
     // user2 cannot see the admin account since they do not share any group and
     // "accounts.visibility" is set to "SAME_GROUP".
     OptionalResultWithMessages<CodeOwner> result =
-        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(admin.email()));
+        codeOwnerResolverProvider
+            .get()
+            .resolveWithMessages(CodeOwnerReference.create(admin.email()));
     assertThat(result).isEmpty();
     assertThat(result)
         .hasMessagesThat()
@@ -233,7 +249,9 @@
     // admin has the "Modify Account" global capability and hence can see the secondary email of the
     // user account.
     OptionalResultWithMessages<CodeOwner> result =
-        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(secondaryEmail));
+        codeOwnerResolverProvider
+            .get()
+            .resolveWithMessages(CodeOwnerReference.create(secondaryEmail));
     assertThat(result.get()).hasAccountIdThat().isEqualTo(user.id());
     assertThat(result)
         .hasMessagesThat()
@@ -246,7 +264,7 @@
     // user account if another user is the calling user
     requestScopeOperations.setApiUser(user2.id());
     result =
-        codeOwnerResolver
+        codeOwnerResolverProvider
             .get()
             .forUser(identifiedUserFactory.create(admin.id()))
             .resolveWithMessages(CodeOwnerReference.create(secondaryEmail));
@@ -260,7 +278,10 @@
 
     // user can see its own secondary email.
     requestScopeOperations.setApiUser(user.id());
-    result = codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(secondaryEmail));
+    result =
+        codeOwnerResolverProvider
+            .get()
+            .resolveWithMessages(CodeOwnerReference.create(secondaryEmail));
     assertThat(result.get()).hasAccountIdThat().isEqualTo(user.id());
     assertThat(result)
         .hasMessagesThat()
@@ -272,7 +293,7 @@
     // user can see its own secondary email if another user is the calling user.
     requestScopeOperations.setApiUser(user2.id());
     result =
-        codeOwnerResolver
+        codeOwnerResolverProvider
             .get()
             .forUser(identifiedUserFactory.create(user.id()))
             .resolveWithMessages(CodeOwnerReference.create(secondaryEmail));
@@ -295,7 +316,9 @@
     // email of the admin account.
     requestScopeOperations.setApiUser(user.id());
     OptionalResultWithMessages<CodeOwner> result =
-        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(secondaryEmail));
+        codeOwnerResolverProvider
+            .get()
+            .resolveWithMessages(CodeOwnerReference.create(secondaryEmail));
     assertThat(result).isEmpty();
     assertThat(result)
         .hasMessagesThat()
@@ -308,7 +331,7 @@
     // email of the admin account if another user is the calling user
     requestScopeOperations.setApiUser(admin.id());
     result =
-        codeOwnerResolver
+        codeOwnerResolverProvider
             .get()
             .forUser(identifiedUserFactory.create(user.id()))
             .resolveWithMessages(CodeOwnerReference.create(secondaryEmail));
@@ -327,7 +350,9 @@
         CodeOwnerConfig.builder(CodeOwnerConfig.Key.create(project, "master", "/"), TEST_REVISION)
             .build();
     CodeOwnerResolverResult result =
-        codeOwnerResolver.get().resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
+        codeOwnerResolverProvider
+            .get()
+            .resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
     assertThat(result.codeOwners()).isEmpty();
     assertThat(result.ownedByAllUsers()).isFalse();
     assertThat(result.hasUnresolvedCodeOwners()).isFalse();
@@ -341,7 +366,9 @@
             .build();
 
     CodeOwnerResolverResult result =
-        codeOwnerResolver.get().resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
+        codeOwnerResolverProvider
+            .get()
+            .resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
     assertThat(result.codeOwnersAccountIds()).containsExactly(admin.id(), user.id());
     assertThat(result.ownedByAllUsers()).isFalse();
     assertThat(result.hasUnresolvedCodeOwners()).isFalse();
@@ -356,7 +383,9 @@
             .build();
 
     CodeOwnerResolverResult result =
-        codeOwnerResolver.get().resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
+        codeOwnerResolverProvider
+            .get()
+            .resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
     assertThat(result.codeOwnersAccountIds()).isEmpty();
     assertThat(result.ownedByAllUsers()).isTrue();
     assertThat(result.hasUnresolvedCodeOwners()).isFalse();
@@ -371,7 +400,9 @@
                     admin.email(), "non-existing@example.com"))
             .build();
     CodeOwnerResolverResult result =
-        codeOwnerResolver.get().resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
+        codeOwnerResolverProvider
+            .get()
+            .resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
     assertThat(result.codeOwnersAccountIds()).containsExactly(admin.id());
     assertThat(result.ownedByAllUsers()).isFalse();
     assertThat(result.hasUnresolvedCodeOwners()).isTrue();
@@ -387,19 +418,109 @@
                     "*", admin.email(), "non-existing@example.com"))
             .build();
     CodeOwnerResolverResult result =
-        codeOwnerResolver.get().resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
+        codeOwnerResolverProvider
+            .get()
+            .resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
     assertThat(result.codeOwnersAccountIds()).containsExactly(admin.id());
     assertThat(result.ownedByAllUsers()).isTrue();
     assertThat(result.hasUnresolvedCodeOwners()).isTrue();
   }
 
   @Test
+  public void resolvePathCodeOwnersWithAnnotations() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    CodeOwnerConfig codeOwnerConfig =
+        CodeOwnerConfig.builder(CodeOwnerConfig.Key.create(project, "master", "/"), TEST_REVISION)
+            .addCodeOwnerSet(
+                CodeOwnerSet.builder()
+                    .addCodeOwnerEmail(admin.email())
+                    .addAnnotation(admin.email(), CodeOwnerAnnotation.create("FOO"))
+                    .addAnnotation(admin.email(), CodeOwnerAnnotation.create("BAR"))
+                    .addCodeOwnerEmail(user.email())
+                    .addAnnotation(user.email(), CodeOwnerAnnotation.create("BAZ"))
+                    .addCodeOwnerEmail(user2.email())
+                    .build())
+            .build();
+
+    CodeOwnerResolverResult result =
+        codeOwnerResolverProvider
+            .get()
+            .resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
+    assertThat(result.codeOwnersAccountIds()).containsExactly(admin.id(), user.id(), user2.id());
+    assertThat(result.annotations().keySet())
+        .containsExactly(CodeOwner.create(admin.id()), CodeOwner.create(user.id()));
+    assertThat(result.annotations().get(CodeOwner.create(admin.id())))
+        .containsExactly(CodeOwnerAnnotation.create("FOO"), CodeOwnerAnnotation.create("BAR"));
+    assertThat(result.annotations().get(CodeOwner.create(user.id())))
+        .containsExactly(CodeOwnerAnnotation.create("BAZ"));
+  }
+
+  @Test
+  public void resolvePathCodeOwnersWithAnnotations_annotationOnAllUsersWildcard() throws Exception {
+    CodeOwnerConfig codeOwnerConfig =
+        CodeOwnerConfig.builder(CodeOwnerConfig.Key.create(project, "master", "/"), TEST_REVISION)
+            .addCodeOwnerSet(
+                CodeOwnerSet.builder()
+                    .addCodeOwnerEmail(admin.email())
+                    .addAnnotation(admin.email(), CodeOwnerAnnotation.create("FOO"))
+                    .addCodeOwnerEmail(CodeOwnerResolver.ALL_USERS_WILDCARD)
+                    .addAnnotation(
+                        CodeOwnerResolver.ALL_USERS_WILDCARD, CodeOwnerAnnotation.create("BAR"))
+                    .addCodeOwnerEmail(user.email())
+                    .build())
+            .build();
+
+    CodeOwnerResolverResult result =
+        codeOwnerResolverProvider
+            .get()
+            .resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
+    assertThat(result.codeOwnersAccountIds()).containsExactly(admin.id(), user.id());
+    assertThat(result.annotations().keySet())
+        .containsExactly(CodeOwner.create(admin.id()), CodeOwner.create(user.id()));
+    assertThat(result.annotations().get(CodeOwner.create(admin.id())))
+        .containsExactly(CodeOwnerAnnotation.create("FOO"), CodeOwnerAnnotation.create("BAR"));
+    assertThat(result.annotations().get(CodeOwner.create(user.id())))
+        .containsExactly(CodeOwnerAnnotation.create("BAR"));
+  }
+
+  @Test
+  public void resolvePathCodeOwnersWithAnnotations_annotationOnMultipleEmailsOfTheSameUser()
+      throws Exception {
+    // add secondary email to user account
+    String secondaryEmail = "user@foo.bar";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    CodeOwnerConfig codeOwnerConfig =
+        CodeOwnerConfig.builder(CodeOwnerConfig.Key.create(project, "master", "/"), TEST_REVISION)
+            .addCodeOwnerSet(
+                CodeOwnerSet.builder()
+                    .addCodeOwnerEmail(user.email())
+                    .addAnnotation(user.email(), CodeOwnerAnnotation.create("FOO"))
+                    .addCodeOwnerEmail(secondaryEmail)
+                    .addAnnotation(secondaryEmail, CodeOwnerAnnotation.create("BAR"))
+                    .build())
+            .build();
+
+    // admin has the "Modify Account" global capability and hence can see the secondary email of the
+    // user account.
+    CodeOwnerResolverResult result =
+        codeOwnerResolverProvider
+            .get()
+            .resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
+    assertThat(result.codeOwnersAccountIds()).containsExactly(user.id());
+    assertThat(result.annotations().keySet()).containsExactly(CodeOwner.create(user.id()));
+    assertThat(result.annotations().get(CodeOwner.create(user.id())))
+        .containsExactly(CodeOwnerAnnotation.create("FOO"), CodeOwnerAnnotation.create("BAR"));
+  }
+
+  @Test
   public void cannotResolvePathCodeOwnersOfNullCodeOwnerConfig() throws Exception {
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
             () ->
-                codeOwnerResolver
+                codeOwnerResolverProvider
                     .get()
                     .resolvePathCodeOwners(/* codeOwnerConfig= */ null, Paths.get("/README.md")));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwnerConfig");
@@ -415,7 +536,7 @@
         assertThrows(
             NullPointerException.class,
             () ->
-                codeOwnerResolver
+                codeOwnerResolverProvider
                     .get()
                     .resolvePathCodeOwners(codeOwnerConfig, /* absolutePath= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("absolutePath");
@@ -426,7 +547,8 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () -> codeOwnerResolver.get().resolvePathCodeOwners(/* pathCodeOwners= */ null));
+            () ->
+                codeOwnerResolverProvider.get().resolvePathCodeOwners(/* pathCodeOwners= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("pathCodeOwners");
   }
 
@@ -441,7 +563,7 @@
         assertThrows(
             IllegalStateException.class,
             () ->
-                codeOwnerResolver
+                codeOwnerResolverProvider
                     .get()
                     .resolvePathCodeOwners(codeOwnerConfig, Paths.get(relativePath)));
     assertThat(npe)
@@ -461,11 +583,11 @@
 
     // user2 cannot see the admin account since they do not share any group and
     // "accounts.visibility" is set to "SAME_GROUP".
-    assertThat(codeOwnerResolver.get().resolve(adminCodeOwnerReference)).isEmpty();
+    assertThat(codeOwnerResolverProvider.get().resolve(adminCodeOwnerReference)).isEmpty();
 
     // if visibility is not enforced the code owner reference can be resolved regardless
     Optional<CodeOwner> codeOwner =
-        codeOwnerResolver.get().enforceVisibility(false).resolve(adminCodeOwnerReference);
+        codeOwnerResolverProvider.get().enforceVisibility(false).resolve(adminCodeOwnerReference);
     assertThat(codeOwner).value().hasAccountIdThat().isEqualTo(admin.id());
   }
 
@@ -477,10 +599,10 @@
     TestAccount user2 = accountCreator.user2();
 
     // admin is the current user and can see the account
-    assertThat(codeOwnerResolver.get().resolve(CodeOwnerReference.create(user.email())))
+    assertThat(codeOwnerResolverProvider.get().resolve(CodeOwnerReference.create(user.email())))
         .isPresent();
     assertThat(
-            codeOwnerResolver
+            codeOwnerResolverProvider
                 .get()
                 .forUser(identifiedUserFactory.create(admin.id()))
                 .resolve(CodeOwnerReference.create(user.email())))
@@ -488,7 +610,7 @@
 
     // user2 cannot see the account
     assertThat(
-            codeOwnerResolver
+            codeOwnerResolverProvider
                 .get()
                 .forUser(identifiedUserFactory.create(user2.id()))
                 .resolve(CodeOwnerReference.create(user.email())))
@@ -498,7 +620,8 @@
   @Test
   @GerritConfig(name = "plugin.code-owners.allowedEmailDomain", value = "example.net")
   public void resolveCodeOwnerReferenceForEmailWithNonAllowedEmailDomain() throws Exception {
-    assertThat(codeOwnerResolver.get().resolve(CodeOwnerReference.create("foo@example.com")))
+    assertThat(
+            codeOwnerResolverProvider.get().resolve(CodeOwnerReference.create("foo@example.com")))
         .isEmpty();
   }
 
@@ -507,7 +630,7 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () -> codeOwnerResolver.get().isEmailDomainAllowed(/* email= */ null));
+            () -> codeOwnerResolverProvider.get().isEmailDomainAllowed(/* email= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("email");
   }
 
@@ -550,7 +673,7 @@
   private void assertIsEmailDomainAllowed(
       String email, boolean expectedResult, String expectedMessage) {
     OptionalResultWithMessages<Boolean> isEmailDomainAllowedResult =
-        codeOwnerResolver.get().isEmailDomainAllowed(email);
+        codeOwnerResolverProvider.get().isEmailDomainAllowed(email);
     assertThat(isEmailDomainAllowedResult.get()).isEqualTo(expectedResult);
     assertThat(isEmailDomainAllowedResult.messages()).containsExactly(expectedMessage);
   }
@@ -558,7 +681,7 @@
   @Test
   public void resolveCodeOwnerReferences() throws Exception {
     CodeOwnerResolverResult result =
-        codeOwnerResolver
+        codeOwnerResolverProvider
             .get()
             .resolve(
                 ImmutableSet.of(
@@ -572,7 +695,7 @@
   @Test
   public void resolveCodeOwnerReferencesNonResolveableCodeOwnersAreFilteredOut() throws Exception {
     CodeOwnerResolverResult result =
-        codeOwnerResolver
+        codeOwnerResolverProvider
             .get()
             .resolve(
                 ImmutableSet.of(
@@ -585,14 +708,84 @@
 
   @Test
   public void isResolvable() throws Exception {
-    assertThat(codeOwnerResolver.get().isResolvable(CodeOwnerReference.create(admin.email())))
+    assertThat(
+            codeOwnerResolverProvider.get().isResolvable(CodeOwnerReference.create(admin.email())))
         .isTrue();
   }
 
   @Test
   public void isNotResolvable() throws Exception {
     assertThat(
-            codeOwnerResolver.get().isResolvable(CodeOwnerReference.create("unknown@example.com")))
+            codeOwnerResolverProvider
+                .get()
+                .isResolvable(CodeOwnerReference.create("unknown@example.com")))
         .isFalse();
   }
+
+  @Test
+  public void emailIsResolvedOnlyOnce() throws Exception {
+    testMetricMaker.reset();
+    CodeOwnerResolver codeOwnerResolver = codeOwnerResolverProvider.get();
+    OptionalResultWithMessages<CodeOwner> result =
+        codeOwnerResolver.resolveWithMessages(CodeOwnerReference.create(admin.email()));
+    assertThat(result.get()).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(testMetricMaker.getCount("plugins/code-owners/count_code_owner_resolutions"))
+        .isEqualTo(1);
+    assertThat(testMetricMaker.getCount("plugins/code-owners/count_code_owner_cache_reads"))
+        .isEqualTo(0);
+
+    // Doing the same lookup again doesn't resolve the code owner again.
+    testMetricMaker.reset();
+    result = codeOwnerResolver.resolveWithMessages(CodeOwnerReference.create(admin.email()));
+    assertThat(result.get()).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(testMetricMaker.getCount("plugins/code-owners/count_code_owner_resolutions"))
+        .isEqualTo(0);
+    assertThat(testMetricMaker.getCount("plugins/code-owners/count_code_owner_cache_reads"))
+        .isEqualTo(1);
+  }
+
+  @Test
+  public void nonExistingEmailIsResolvedOnlyOnce() throws Exception {
+    testMetricMaker.reset();
+    CodeOwnerResolver codeOwnerResolver = codeOwnerResolverProvider.get();
+    OptionalResultWithMessages<CodeOwner> result =
+        codeOwnerResolver.resolveWithMessages(
+            CodeOwnerReference.create("non-existing@example.com"));
+    assertThat(result).isEmpty();
+    assertThat(testMetricMaker.getCount("plugins/code-owners/count_code_owner_resolutions"))
+        .isEqualTo(1);
+    assertThat(testMetricMaker.getCount("plugins/code-owners/count_code_owner_cache_reads"))
+        .isEqualTo(0);
+
+    // Doing the same lookup again doesn't resolve the code owner again.
+    testMetricMaker.reset();
+    result =
+        codeOwnerResolver.resolveWithMessages(
+            CodeOwnerReference.create("non-existing@example.com"));
+    assertThat(result).isEmpty();
+    assertThat(testMetricMaker.getCount("plugins/code-owners/count_code_owner_resolutions"))
+        .isEqualTo(0);
+    assertThat(testMetricMaker.getCount("plugins/code-owners/count_code_owner_cache_reads"))
+        .isEqualTo(1);
+  }
+
+  @Test
+  public void resolveCodeOwnerReferencesThatPointToTheSameAccount() throws Exception {
+    // add secondary email to user account
+    String secondaryEmail = "user@foo.bar";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    // admin has the "Modify Account" global capability and hence can see the secondary email of the
+    // user account.
+    CodeOwnerResolverResult result =
+        codeOwnerResolverProvider
+            .get()
+            .resolve(
+                ImmutableSet.of(
+                    CodeOwnerReference.create(user.email()),
+                    CodeOwnerReference.create(secondaryEmail)));
+    assertThat(result.codeOwnersAccountIds()).containsExactly(user.id());
+    assertThat(result.ownedByAllUsers()).isFalse();
+    assertThat(result.hasUnresolvedCodeOwners()).isFalse();
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/GlobMatcherTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/GlobMatcherTest.java
index 5291bb7..73f525d 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/GlobMatcherTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/GlobMatcherTest.java
@@ -82,4 +82,10 @@
     assertMatch(pathExpression2, "foo-1.txt", "foo-2.txt");
     assertNoMatch(pathExpression2, "foo-5.txt", "foo-11.txt", "sub/foo-3.txt", "sub/sub/foo-4.txt");
   }
+
+  @Test
+  public void invalidPattern() throws Exception {
+    // the path expressions is invalid because '{' is not closed
+    assertNoMatch("{foo-[1-4].txt", "foo-1.txt", "foo-2.txt", "foo-5.txt", "foo-11.txt");
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersTest.java
index dce1d52..9563ade 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersTest.java
@@ -49,7 +49,6 @@
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -502,6 +501,139 @@
   }
 
   @Test
+  public void importOfNonCodeOwnerConfigFileIsIgnored() throws Exception {
+    // create a file that looks like a code owner config file, but which has a name that is not
+    // allowed as code owner config file
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .fileName("FOO")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    // create config with import of non code owner config file
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addImport(CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/FOO"))
+            .addCodeOwnerSet(CodeOwnerSet.builder().addCodeOwnerEmail(admin.email()).build())
+            .create();
+
+    Optional<PathCodeOwners> pathCodeOwners =
+        pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
+            importingCodeOwnerConfigKey,
+            projectOperations.project(project).getHead("master"),
+            Paths.get("/foo.md"));
+    assertThat(pathCodeOwners).isPresent();
+
+    // Expectation: we get the global code owner from the importing code owner config, the
+    // import of the non code owner config file is silently ignored
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
+        .comparingElementsUsing(hasEmail())
+        .containsExactly(admin.email());
+    assertThat(pathCodeOwnersResult.hasUnresolvedImports()).isTrue();
+  }
+
+  @Test
+  public void importOfCodeOwnerConfigFileWithFileExtensionIsIgnored() throws Exception {
+    // Create a code owner config file with a file extension. This file is only considered as a code
+    // owner config file if either the file extension matches the configured file extension (config
+    // parameter fileExtension) or file extensions are enabled for code owner config files (config
+    // paramater enableCodeOwnerConfigFilesWithFileExtensions). Both is not the case here, hence any
+    // import of this file in another code owner config file should get ignored.
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .fileName("OWNERS.foo")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    // create the importing config
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addImport(
+                CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/OWNERS.FOO"))
+            .addCodeOwnerSet(CodeOwnerSet.builder().addCodeOwnerEmail(admin.email()).build())
+            .create();
+
+    Optional<PathCodeOwners> pathCodeOwners =
+        pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
+            importingCodeOwnerConfigKey,
+            projectOperations.project(project).getHead("master"),
+            Paths.get("/foo.md"));
+    assertThat(pathCodeOwners).isPresent();
+
+    // Expectation: we get the global code owner from the importing code owner config, the
+    // import of the code owner config file with the file extension is silently ignored since it is
+    // not considered as a code owner config file
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
+        .comparingElementsUsing(hasEmail())
+        .containsExactly(admin.email());
+    assertThat(pathCodeOwnersResult.hasUnresolvedImports()).isTrue();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.enableCodeOwnerConfigFilesWithFileExtensions",
+      value = "true")
+  public void importOfCodeOwnerConfigFileWithFileExtension() throws Exception {
+    // Create a code owner config file with a file extension. This file is considered as a code
+    // owner config file since file extensions for code owner config files are enabled (paramater
+    // enableCodeOwnerConfigFilesWithFileExtensions).
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .fileName("OWNERS.FOO")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    // create the importing config
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addImport(
+                CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/OWNERS.FOO"))
+            .addCodeOwnerSet(CodeOwnerSet.builder().addCodeOwnerEmail(admin.email()).build())
+            .create();
+
+    Optional<PathCodeOwners> pathCodeOwners =
+        pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
+            importingCodeOwnerConfigKey,
+            projectOperations.project(project).getHead("master"),
+            Paths.get("/foo.md"));
+    assertThat(pathCodeOwners).isPresent();
+
+    // Expectation: we get the global code owner from the importing code owner config and the global
+    // code owner from the imported code owner config
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
+        .comparingElementsUsing(hasEmail())
+        .containsExactly(admin.email(), user.email());
+    assertThat(pathCodeOwnersResult.hasUnresolvedImports()).isFalse();
+  }
+
+  @Test
   public void importGlobalCodeOwners_importModeAll() throws Exception {
     testImportGlobalCodeOwners(CodeOwnerConfigImportMode.ALL);
   }
@@ -2066,6 +2198,120 @@
         .isFalse();
   }
 
+  @Test
+  public void transitiveImportsAcrossProjects() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    Project.NameKey otherProject = projectOperations.newProject().create();
+
+    // create importing config
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(admin.email())
+            .addImport(
+                CodeOwnerConfigReference.builder(
+                        CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, "/bar/OWNERS")
+                    .setProject(otherProject)
+                    .build())
+            .create();
+
+    // create imported config in other project
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(otherProject)
+        .branch("master")
+        .folderPath("/bar/")
+        .addCodeOwnerEmail(user.email())
+        .addImport(CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/baz/OWNERS"))
+        .create();
+
+    // create transitively imported config in other project
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(otherProject)
+        .branch("master")
+        .folderPath("/baz/")
+        .addCodeOwnerSet(CodeOwnerSet.builder().addCodeOwnerEmail(user2.email()).build())
+        .create();
+
+    Optional<PathCodeOwners> pathCodeOwners =
+        pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
+            importingCodeOwnerConfigKey,
+            projectOperations.project(project).getHead("master"),
+            Paths.get("/foo.md"));
+    assertThat(pathCodeOwners).isPresent();
+
+    // Expectation: we get the global owners from the importing code owner config and from the
+    // directly and transitively imported code owner configs in the other project
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
+        .comparingElementsUsing(hasEmail())
+        .containsExactly(admin.email(), user.email(), user2.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
+        .isFalse();
+  }
+
+  @Test
+  public void transitiveImportsWithRelativePaths() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    Project.NameKey otherProject = projectOperations.newProject().create();
+
+    // create importing config
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(admin.email())
+            .addImport(
+                CodeOwnerConfigReference.builder(
+                        CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, "bar/OWNERS")
+                    .setProject(otherProject)
+                    .build())
+            .create();
+
+    // create imported config
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(otherProject)
+        .branch("master")
+        .folderPath("/bar/")
+        .addCodeOwnerEmail(user.email())
+        .addImport(CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "baz/OWNERS"))
+        .create();
+
+    // create transitively imported config
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(otherProject)
+        .branch("master")
+        .folderPath("/bar/baz/")
+        .addCodeOwnerSet(CodeOwnerSet.builder().addCodeOwnerEmail(user2.email()).build())
+        .create();
+
+    Optional<PathCodeOwners> pathCodeOwners =
+        pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
+            importingCodeOwnerConfigKey,
+            projectOperations.project(project).getHead("master"),
+            Paths.get("/foo.md"));
+    assertThat(pathCodeOwners).isPresent();
+
+    // Expectation: we get the global owners from the importing code owner config and from the
+    // directly and transitively imported code owner configs
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
+        .comparingElementsUsing(hasEmail())
+        .containsExactly(admin.email(), user.email(), user2.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
+        .isFalse();
+  }
+
   private CodeOwnerConfig.Builder createCodeOwnerBuilder() {
     return CodeOwnerConfig.builder(
         CodeOwnerConfig.Key.create(BranchNameKey.create(project, "master"), Paths.get("/")),
@@ -2093,9 +2339,7 @@
 
     @Override
     public Optional<CodeOwnerConfig> getCodeOwnerConfig(
-        CodeOwnerConfig.Key codeOwnerConfigKey,
-        @Nullable RevWalk revWalk,
-        @Nullable ObjectId revision) {
+        CodeOwnerConfig.Key codeOwnerConfigKey, @Nullable ObjectId revision) {
       throw new UnsupportedOperationException("not implemented");
     }
 
@@ -2108,7 +2352,7 @@
     }
 
     @Override
-    public Optional<PathExpressionMatcher> getPathExpressionMatcher() {
+    public Optional<PathExpressionMatcher> getPathExpressionMatcher(BranchNameKey branchNameKey) {
       return Optional.ofNullable(pathExpressionMatcher);
     }
 
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/BackendConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/BackendConfigTest.java
index 10ef51c..dcc9cb3 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/config/BackendConfigTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/BackendConfigTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.plugins.codeowners.backend.config.BackendConfig.KEY_BACKEND;
+import static com.google.gerrit.plugins.codeowners.backend.config.BackendConfig.KEY_PATH_EXPRESSIONS;
 import static com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
@@ -26,6 +27,7 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackendId;
+import com.google.gerrit.plugins.codeowners.backend.PathExpressions;
 import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersBackend;
 import com.google.gerrit.plugins.codeowners.backend.proto.ProtoBackend;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
@@ -186,6 +188,106 @@
   }
 
   @Test
+  public void cannotGetPathExpressionsForBranchWithNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                backendConfig.getPathExpressionsForBranch(
+                    null, BranchNameKey.create(project, "master")));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void cannotGetPathExpressionsForBranchForNullBranch() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> backendConfig.getPathExpressionsForBranch(new Config(), null));
+    assertThat(npe).hasMessageThat().isEqualTo("branch");
+  }
+
+  @Test
+  public void getPathExpressionsForBranchWhenPathExpressionsAreNotSet() throws Exception {
+    assertThat(
+            backendConfig.getPathExpressionsForBranch(
+                new Config(), BranchNameKey.create(project, "master")))
+        .isEmpty();
+  }
+
+  @Test
+  public void getPathExpressionsForBranch() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS,
+        "refs/heads/master",
+        KEY_PATH_EXPRESSIONS,
+        PathExpressions.GLOB.name());
+    assertThat(
+            backendConfig.getPathExpressionsForBranch(cfg, BranchNameKey.create(project, "master")))
+        .value()
+        .isEqualTo(PathExpressions.GLOB);
+  }
+
+  @Test
+  public void getPathExpressionsForBranchShortName() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, "master", KEY_PATH_EXPRESSIONS, PathExpressions.GLOB.name());
+    assertThat(
+            backendConfig.getPathExpressionsForBranch(cfg, BranchNameKey.create(project, "master")))
+        .value()
+        .isEqualTo(PathExpressions.GLOB);
+  }
+
+  @Test
+  public void getPathExpressionsForBranchIfConfigIsInvalid() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, "master", KEY_PATH_EXPRESSIONS, "INVALID");
+    assertThat(
+            backendConfig.getPathExpressionsForBranch(cfg, BranchNameKey.create(project, "master")))
+        .isEmpty();
+  }
+
+  @Test
+  public void cannotGetPathExpressionsForProjectWithNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> backendConfig.getPathExpressionsForProject(null, project));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void cannotGetPathExpressionsForProjectForNullProject() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> backendConfig.getPathExpressionsForProject(new Config(), null));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void getPathExpressionsForProjectWhenBackendIsNotSet() throws Exception {
+    assertThat(backendConfig.getPathExpressionsForProject(new Config(), project)).isEmpty();
+  }
+
+  @Test
+  public void getPathExpressionsForProject() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, null, KEY_PATH_EXPRESSIONS, PathExpressions.GLOB.name());
+    assertThat(backendConfig.getPathExpressionsForProject(cfg, project))
+        .value()
+        .isEqualTo(PathExpressions.GLOB);
+  }
+
+  @Test
+  public void getPathExpressionsForProjectIfConfigIsInvalid() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, null, KEY_PATH_EXPRESSIONS, "INVALID");
+    assertThat(backendConfig.getPathExpressionsForProject(cfg, project)).isEmpty();
+  }
+
+  @Test
   public void cannotValidateProjectLevelConfigWithNullFileName() throws Exception {
     NullPointerException npe =
         assertThrows(
@@ -204,6 +306,23 @@
   }
 
   @Test
+  public void getDefaultPathExpressions() throws Exception {
+    assertThat(backendConfig.getDefaultPathExpressions()).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.pathExpressions", value = "GLOB")
+  public void getConfiguredDefaultPathExpressions() throws Exception {
+    assertThat(backendConfig.getDefaultPathExpressions()).value().isEqualTo(PathExpressions.GLOB);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.pathExpressions", value = "INVALID")
+  public void getDefaultPathExpressionsIfConfigIsInvalid() throws Exception {
+    assertThat(backendConfig.getDefaultPathExpressions()).isEmpty();
+  }
+
+  @Test
   public void validateEmptyProjectLevelConfig() throws Exception {
     ImmutableList<CommitValidationMessage> commitValidationMessage =
         backendConfig.validateProjectLevelConfig("code-owners.config", new Config());
@@ -215,13 +334,15 @@
     Config cfg = new Config();
     cfg.setString(
         SECTION_CODE_OWNERS, null, KEY_BACKEND, CodeOwnerBackendId.FIND_OWNERS.getBackendId());
+    cfg.setString(SECTION_CODE_OWNERS, null, KEY_PATH_EXPRESSIONS, PathExpressions.GLOB.name());
     ImmutableList<CommitValidationMessage> commitValidationMessage =
         backendConfig.validateProjectLevelConfig("code-owners.config", cfg);
     assertThat(commitValidationMessage).isEmpty();
   }
 
   @Test
-  public void validateInvalidProjectLevelConfig_invalidProjectConfiguration() throws Exception {
+  public void validateInvalidProjectLevelConfig_invalidProjectLevelBackendConfiguration()
+      throws Exception {
     Config cfg = new Config();
     cfg.setString(SECTION_CODE_OWNERS, null, KEY_BACKEND, "INVALID");
     ImmutableList<CommitValidationMessage> commitValidationMessages =
@@ -237,7 +358,25 @@
   }
 
   @Test
-  public void validateInvalidProjectLevelConfig_invalidBranchConfiguration() throws Exception {
+  public void validateInvalidProjectLevelConfig_invalidProjectLevelPathExpressionsConfiguration()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, null, KEY_PATH_EXPRESSIONS, "INVALID");
+    ImmutableList<CommitValidationMessage> commitValidationMessages =
+        backendConfig.validateProjectLevelConfig("code-owners.config", cfg);
+    assertThat(commitValidationMessages).hasSize(1);
+    CommitValidationMessage commitValidationMessage =
+        Iterables.getOnlyElement(commitValidationMessages);
+    assertThat(commitValidationMessage.getType()).isEqualTo(ValidationMessage.Type.ERROR);
+    assertThat(commitValidationMessage.getMessage())
+        .isEqualTo(
+            "Path expressions 'INVALID' that are configured in code-owners.config (parameter"
+                + " codeOwners.pathExpressions) not found.");
+  }
+
+  @Test
+  public void validateInvalidProjectLevelConfig_invalidBranchLevelBackendConfiguration()
+      throws Exception {
     Config cfg = new Config();
     cfg.setString(SECTION_CODE_OWNERS, "someBranch", KEY_BACKEND, "INVALID");
     ImmutableList<CommitValidationMessage> commitValidationMessages =
@@ -251,4 +390,21 @@
             "Code owner backend 'INVALID' that is configured in code-owners.config (parameter"
                 + " codeOwners.someBranch.backend) not found.");
   }
+
+  @Test
+  public void validateInvalidProjectLevelConfig_invalidBranchLevelPathExpressionsConfiguration()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, "someBranch", KEY_PATH_EXPRESSIONS, "INVALID");
+    ImmutableList<CommitValidationMessage> commitValidationMessages =
+        backendConfig.validateProjectLevelConfig("code-owners.config", cfg);
+    assertThat(commitValidationMessages).hasSize(1);
+    CommitValidationMessage commitValidationMessage =
+        Iterables.getOnlyElement(commitValidationMessages);
+    assertThat(commitValidationMessage.getType()).isEqualTo(ValidationMessage.Type.ERROR);
+    assertThat(commitValidationMessage.getMessage())
+        .isEqualTo(
+            "Path expressions 'INVALID' that are configured in code-owners.config (parameter"
+                + " codeOwners.someBranch.pathExpressions) not found.");
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigValidatorTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigValidatorTest.java
index e05323c..d712ba4 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigValidatorTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigValidatorTest.java
@@ -24,7 +24,9 @@
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
 import org.junit.Test;
@@ -52,29 +54,33 @@
         /* subsection= */ null,
         GeneralConfig.KEY_FALLBACK_CODE_OWNERS,
         FallbackCodeOwners.ALL_USERS);
-    RevCommit commit =
-        testRepo
-            .commit()
-            .add("code-owners.config", cfg.toText())
-            .add("project.config", "INVALID")
-            .create();
-    CommitReceivedEvent receiveEvent = new CommitReceivedEvent();
-    receiveEvent.project =
-        projectCache.get(project).orElseThrow(illegalState(project)).getProject();
-    receiveEvent.refName = RefNames.REFS_CONFIG;
-    receiveEvent.commit = commit;
-    receiveEvent.revWalk = testRepo.getRevWalk();
-    receiveEvent.repoConfig = new Config();
-    CommitValidationException exception =
-        assertThrows(
-            CommitValidationException.class,
-            () -> codeOwnersPluginConfigValidator.onCommitReceived(receiveEvent));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            String.format(
-                "failed to validate file code-owners.config for revision %s in ref %s of project %s",
-                commit.getName(), RefNames.REFS_CONFIG, project));
-    assertThat(exception).hasCauseThat().isInstanceOf(ConfigInvalidException.class);
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      RevCommit commit =
+          testRepo
+              .commit()
+              .add("code-owners.config", cfg.toText())
+              .add("project.config", "INVALID")
+              .create();
+
+      CommitReceivedEvent receiveEvent = new CommitReceivedEvent();
+      receiveEvent.project =
+          projectCache.get(project).orElseThrow(illegalState(project)).getProject();
+      receiveEvent.refName = RefNames.REFS_CONFIG;
+      receiveEvent.commit = commit;
+      receiveEvent.revWalk = testRepo.getRevWalk();
+      receiveEvent.repoConfig = new Config();
+      CommitValidationException exception =
+          assertThrows(
+              CommitValidationException.class,
+              () -> codeOwnersPluginConfigValidator.onCommitReceived(receiveEvent));
+      assertThat(exception)
+          .hasMessageThat()
+          .isEqualTo(
+              String.format(
+                  "failed to validate file code-owners.config for revision %s in ref %s of project %s",
+                  commit.getName(), RefNames.REFS_CONFIG, project));
+      assertThat(exception).hasCauseThat().isInstanceOf(ConfigInvalidException.class);
+    }
   }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginGlobalConfigSnapshotTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginGlobalConfigSnapshotTest.java
index e382566..6a5c03d 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginGlobalConfigSnapshotTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginGlobalConfigSnapshotTest.java
@@ -85,7 +85,36 @@
   @Test
   @GerritConfig(name = "plugin.code-owners.maxCodeOwnerConfigCacheSize", value = "invalid")
   public void maxCodeOwnerConfigCacheSize_invalidConfig() throws Exception {
-    assertThat(cfgSnapshot().getMaxCodeOwnerConfigCacheSize()).isEmpty();
+    assertThat(cfgSnapshot().getMaxCodeOwnerConfigCacheSize())
+        .value()
+        .isEqualTo(CodeOwnersPluginGlobalConfigSnapshot.DEFAULT_MAX_CODE_OWNER_CONFIG_CACHE_SIZE);
+  }
+
+  @Test
+  public void codeOwnerCacheSizeIsLimitedByDefault() throws Exception {
+    assertThat(cfgSnapshot().getMaxCodeOwnerCacheSize())
+        .value()
+        .isEqualTo(CodeOwnersPluginGlobalConfigSnapshot.DEFAULT_MAX_CODE_OWNER_CACHE_SIZE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxCodeOwnerCacheSize", value = "0")
+  public void codeOwnerCacheSizeIsUnlimited() throws Exception {
+    assertThat(cfgSnapshot().getMaxCodeOwnerCacheSize()).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxCodeOwnerCacheSize", value = "10")
+  public void codeOwnerCacheSizeIsLimited() throws Exception {
+    assertThat(cfgSnapshot().getMaxCodeOwnerCacheSize()).value().isEqualTo(10);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxCodeOwnerCacheSize", value = "invalid")
+  public void maxCodeOwnerCacheSize_invalidConfig() throws Exception {
+    assertThat(cfgSnapshot().getMaxCodeOwnerCacheSize())
+        .value()
+        .isEqualTo(CodeOwnersPluginGlobalConfigSnapshot.DEFAULT_MAX_CODE_OWNER_CACHE_SIZE);
   }
 
   private CodeOwnersPluginGlobalConfigSnapshot cfgSnapshot() {
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshotTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshotTest.java
index f155572..1dbe420 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshotTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshotTest.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.entities.RefNames;
@@ -42,6 +43,7 @@
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigUpdate;
 import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
 import com.google.gerrit.plugins.codeowners.backend.PathExpressionMatcher;
+import com.google.gerrit.plugins.codeowners.backend.PathExpressions;
 import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersBackend;
 import com.google.gerrit.plugins.codeowners.common.CodeOwnerConfigValidationPolicy;
 import com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy;
@@ -52,7 +54,6 @@
 import java.nio.file.Path;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -1032,6 +1033,154 @@
   }
 
   @Test
+  public void cannotGetPathExpressionsForNullBranch() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> cfgSnapshot().getPathExpressions(/* branchName= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("branchName");
+  }
+
+  @Test
+  public void getPathExpressionsForNonExistingBranch() throws Exception {
+    assertThat(cfgSnapshot().getPathExpressions("non-existing")).isEmpty();
+  }
+
+  @Test
+  public void getPathExpressionsWhenNoPathExpressionsAreConfigured() throws Exception {
+    assertThat(cfgSnapshot().getPathExpressions("master")).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.pathExpressions", value = "GLOB")
+  public void getConfiguredPathExpressions() throws Exception {
+    assertThat(cfgSnapshot().getPathExpressions("master")).value().isEqualTo(PathExpressions.GLOB);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.pathExpressions",
+      value = "non-existing-path-expressions")
+  public void getPathExpressionsIfNonExistingPathExpressionsAreConfigured() throws Exception {
+    assertThat(cfgSnapshot().getPathExpressions("master")).isEmpty();
+  }
+
+  @Test
+  public void getPathExpressionsConfiguredOnProjectLevel() throws Exception {
+    configurePathExpressions(project, PathExpressions.GLOB.name());
+    assertThat(cfgSnapshot().getPathExpressions("master")).value().isEqualTo(PathExpressions.GLOB);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = "GLOB")
+  public void pathExpressionsConfiguredOnProjectLevelOverrideDefaultPathExpressions()
+      throws Exception {
+    configurePathExpressions(project, PathExpressions.SIMPLE.name());
+    assertThat(cfgSnapshot().getPathExpressions("master"))
+        .value()
+        .isEqualTo(PathExpressions.SIMPLE);
+  }
+
+  @Test
+  public void pathExpressionsAreInheritedFromParentProject() throws Exception {
+    configurePathExpressions(allProjects, PathExpressions.GLOB.name());
+    assertThat(cfgSnapshot().getPathExpressions("master")).value().isEqualTo(PathExpressions.GLOB);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.pathExpressions", value = "GLOB")
+  public void inheritedPathExpressionsOverrideDefaultPathExpressions() throws Exception {
+    configurePathExpressions(allProjects, PathExpressions.SIMPLE.name());
+    assertThat(cfgSnapshot().getPathExpressions("master"))
+        .value()
+        .isEqualTo(PathExpressions.SIMPLE);
+  }
+
+  @Test
+  public void projectLevelPathExpressionsOverrideInheritedPathExpressions() throws Exception {
+    configurePathExpressions(allProjects, PathExpressions.GLOB.name());
+    configurePathExpressions(project, PathExpressions.SIMPLE.name());
+    assertThat(cfgSnapshot().getPathExpressions("master"))
+        .value()
+        .isEqualTo(PathExpressions.SIMPLE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.pathExpressions", value = "GLOB")
+  public void
+      pathExpressionsAreReadFromGlobalConfigIfNonExistingPathExpressionsAreConfiguredOnProjectLevel()
+          throws Exception {
+    configurePathExpressions(project, "non-existing-path-expressions");
+    assertThat(cfgSnapshot().getPathExpressions("master")).value().isEqualTo(PathExpressions.GLOB);
+  }
+
+  @Test
+  public void projectLevelPathExpressionsForOtherProjectHasNoEffect() throws Exception {
+    Project.NameKey otherProject = projectOperations.newProject().create();
+    configurePathExpressions(otherProject, PathExpressions.GLOB.name());
+    assertThat(cfgSnapshot().getPathExpressions("master")).isEmpty();
+  }
+
+  @Test
+  public void getPathExpressionsConfiguredOnBranchLevel() throws Exception {
+    configurePathExpressions(project, "refs/heads/master", PathExpressions.GLOB.name());
+    assertThat(cfgSnapshot().getPathExpressions("master")).value().isEqualTo(PathExpressions.GLOB);
+  }
+
+  @Test
+  public void getPathExpressionsConfiguredOnBranchLevelShortName() throws Exception {
+    configurePathExpressions(project, "master", PathExpressions.GLOB.name());
+    assertThat(cfgSnapshot().getPathExpressions("master")).value().isEqualTo(PathExpressions.GLOB);
+  }
+
+  @Test
+  public void
+      branchLevelPathExpressionsOnFullNameTakePrecedenceOverBranchLevelPathExpressionsOnShortName()
+          throws Exception {
+    configurePathExpressions(project, "master", PathExpressions.GLOB.name());
+    configurePathExpressions(project, "refs/heads/master", PathExpressions.SIMPLE.name());
+    assertThat(cfgSnapshot().getPathExpressions("master"))
+        .value()
+        .isEqualTo(PathExpressions.SIMPLE);
+  }
+
+  @Test
+  public void branchLevelPathExpressionsOverridesProjectLevelPathExpressions() throws Exception {
+    configurePathExpressions(project, PathExpressions.GLOB.name());
+    configurePathExpressions(project, "master", PathExpressions.SIMPLE.name());
+    assertThat(cfgSnapshot().getPathExpressions("master"))
+        .value()
+        .isEqualTo(PathExpressions.SIMPLE);
+  }
+
+  @Test
+  public void
+      pathExpressionsAreReadFromProjectIfNonExistingPathExpressionsAreConfiguredOnBranchLevel()
+          throws Exception {
+    updateCodeOwnersConfig(
+        project,
+        codeOwnersConfig -> {
+          codeOwnersConfig.setString(
+              CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+              /* subsection= */ null,
+              BackendConfig.KEY_PATH_EXPRESSIONS,
+              PathExpressions.GLOB.name());
+          codeOwnersConfig.setString(
+              CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+              "master",
+              BackendConfig.KEY_PATH_EXPRESSIONS,
+              "non-existing-path-expressions");
+        });
+    assertThat(cfgSnapshot().getPathExpressions("master")).value().isEqualTo(PathExpressions.GLOB);
+  }
+
+  @Test
+  public void branchLevelPathExpressionsForOtherBranchHaveNoEffect() throws Exception {
+    configurePathExpressions(project, "foo", PathExpressions.GLOB.name());
+    assertThat(cfgSnapshot().getPathExpressions("master")).isEmpty();
+  }
+
+  @Test
   public void getDefaultRequiredApprovalWhenNoRequiredApprovalIsConfigured() throws Exception {
     RequiredApproval requiredApproval = cfgSnapshot().getRequiredApproval();
     assertThat(requiredApproval).hasLabelNameThat().isEqualTo(RequiredApprovalConfig.DEFAULT_LABEL);
@@ -1858,6 +2007,17 @@
     setCodeOwnersConfig(project, branch, BackendConfig.KEY_BACKEND, backendName);
   }
 
+  private void configurePathExpressions(Project.NameKey project, String pathExpressionsName)
+      throws Exception {
+    configurePathExpressions(project, /* branch= */ null, pathExpressionsName);
+  }
+
+  private void configurePathExpressions(
+      Project.NameKey project, @Nullable String branch, String pathExpressionsName)
+      throws Exception {
+    setCodeOwnersConfig(project, branch, BackendConfig.KEY_PATH_EXPRESSIONS, pathExpressionsName);
+  }
+
   private void configureRequiredApproval(Project.NameKey project, String requiredApproval)
       throws Exception {
     setCodeOwnersConfig(
@@ -1980,9 +2140,7 @@
 
     @Override
     public Optional<CodeOwnerConfig> getCodeOwnerConfig(
-        CodeOwnerConfig.Key codeOwnerConfigKey,
-        @Nullable RevWalk revWalk,
-        @Nullable ObjectId revision) {
+        CodeOwnerConfig.Key codeOwnerConfigKey, @Nullable ObjectId revision) {
       throw new UnsupportedOperationException("not implemented");
     }
 
@@ -2005,7 +2163,7 @@
     }
 
     @Override
-    public Optional<PathExpressionMatcher> getPathExpressionMatcher() {
+    public Optional<PathExpressionMatcher> getPathExpressionMatcher(BranchNameKey branchNameKey) {
       return Optional.empty();
     }
   }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java
index 9220d02..51011f0 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java
@@ -17,6 +17,8 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_ASYNC_MESSAGE_ON_ADD_REVIEWER;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_CODE_OWNER_CONFIG_FILES_WITH_FILE_EXTENSIONS;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_IMPLICIT_APPROVALS;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_VALIDATION_ON_SUBMIT;
@@ -91,6 +93,90 @@
   }
 
   @Test
+  public void cannotGetEnableCodeOwnerConfigFilesWithFileExtensionsForNullProject()
+      throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.enableCodeOwnerConfigFilesWithFileExtensions(
+                    /* project= */ null, new Config()));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void cannotGetEnableCodeOwnerConfigFilesWithFileExtensionsForNullPluginConfig()
+      throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.enableCodeOwnerConfigFilesWithFileExtensions(
+                    project, /* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noEnableCodeOwnerConfigFilesWithFileExtensionsConfiguration() throws Exception {
+    assertThat(generalConfig.enableCodeOwnerConfigFilesWithFileExtensions(project, new Config()))
+        .isFalse();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.enableCodeOwnerConfigFilesWithFileExtensions",
+      value = "true")
+  public void
+      enableCodeOwnerConfigFilesWithFileExtensionsConfigurationIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+          throws Exception {
+    assertThat(generalConfig.enableCodeOwnerConfigFilesWithFileExtensions(project, new Config()))
+        .isTrue();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.enableCodeOwnerConfigFilesWithFileExtensions",
+      value = "true")
+  public void
+      enableCodeOwnerConfigFilesWithFileExtensionsConfigurationInPluginConfigOverridesReadOnlyConfigurationInGerritConfig()
+          throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        KEY_ENABLE_CODE_OWNER_CONFIG_FILES_WITH_FILE_EXTENSIONS,
+        "false");
+    assertThat(generalConfig.enableCodeOwnerConfigFilesWithFileExtensions(project, cfg)).isFalse();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.enableCodeOwnerConfigFilesWithFileExtensions",
+      value = "true")
+  public void
+      invalidEnableCodeOwnerConfigFilesWithFileExtensionsConfigurationInPluginConfigIsIgnored()
+          throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        KEY_ENABLE_CODE_OWNER_CONFIG_FILES_WITH_FILE_EXTENSIONS,
+        "INVALID");
+    assertThat(generalConfig.enableCodeOwnerConfigFilesWithFileExtensions(project, cfg)).isTrue();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.enableCodeOwnerConfigFilesWithFileExtensions",
+      value = "INVALID")
+  public void
+      invalidEnableCodeOwnerConfigFilesWithFileExtensionsConfigurationInGerritConfigIsIgnored()
+          throws Exception {
+    assertThat(generalConfig.enableCodeOwnerConfigFilesWithFileExtensions(project, new Config()))
+        .isFalse();
+  }
+
+  @Test
   @GerritConfig(
       name = "plugin.code-owners.allowedEmailDomain",
       values = {"example.com", "example.net"})
@@ -1658,4 +1744,69 @@
     assertThat(generalConfig.getMaxPathsInChangeMessages(project, new Config()))
         .isEqualTo(DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES);
   }
+
+  @Test
+  public void cannotGetEnableAsyncMessageOnAddReviewerForNullProject() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.enableAsyncMessageOnAddReviewer(/* project= */ null, new Config()));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void cannotGetEnableAsyncMessageOnAddReviewerForNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.enableAsyncMessageOnAddReviewer(project, /* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noEnableAsyncMessageOnAddReviewer() throws Exception {
+    assertThat(generalConfig.enableAsyncMessageOnAddReviewer(project, new Config())).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableAsyncMessageOnAddReviewer", value = "false")
+  public void
+      enableAsyncMessageOnAddReviewerConfigurationIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+          throws Exception {
+    assertThat(generalConfig.enableAsyncMessageOnAddReviewer(project, new Config())).isFalse();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableAsyncMessageOnAddReviewer", value = "false")
+  public void
+      enableAsyncMessageOnAddReviewerConfigurationInPluginConfigOverridesEnableAsyncMessageOnAddReviewerConfigurationInGerritConfig()
+          throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        KEY_ENABLE_ASYNC_MESSAGE_ON_ADD_REVIEWER,
+        "true");
+    assertThat(generalConfig.enableAsyncMessageOnAddReviewer(project, cfg)).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableAsyncMessageOnAddReviewer", value = "false")
+  public void invalidEnableAsyncMessageOnAddReviewerConfigurationInPluginConfigIsIgnored()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        KEY_ENABLE_ASYNC_MESSAGE_ON_ADD_REVIEWER,
+        "INVALID");
+    assertThat(generalConfig.enableAsyncMessageOnAddReviewer(project, cfg)).isFalse();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableAsyncMessageOnAddReviewer", value = "INVALID")
+  public void invalidEnableAsyncMessageOnAddReviewerConfigurationInGerritConfigIsIgnored()
+      throws Exception {
+    assertThat(generalConfig.enableAsyncMessageOnAddReviewer(project, new Config())).isTrue();
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackendTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackendTest.java
index ff6f184..4b26b73 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackendTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackendTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.plugins.codeowners.backend.AbstractFileBasedCodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.AbstractFileBasedCodeOwnerBackendTest;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigParser;
@@ -41,7 +42,7 @@
 
   @Test
   public void getPathExpressionMatcher() throws Exception {
-    assertThat(codeOwnerBackend.getPathExpressionMatcher())
+    assertThat(codeOwnerBackend.getPathExpressionMatcher(BranchNameKey.create(project, "master")))
         .value()
         .isInstanceOf(FindOwnersGlobMatcher.class);
   }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParserTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParserTest.java
index 6499fa7..ae42bcd 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParserTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParserTest.java
@@ -22,6 +22,7 @@
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.plugins.codeowners.backend.AbstractCodeOwnerConfigParserTest;
@@ -168,7 +169,7 @@
   }
 
   @Test
-  public void codeOwnerConfigWithInlineComments() throws Exception {
+  public void codeOwnerConfigWithComment() throws Exception {
     assertParseAndFormat(
         getCodeOwnerConfig(EMAIL_1, EMAIL_2 + " # some comment", EMAIL_3),
         codeOwnerConfig ->
@@ -177,10 +178,107 @@
                 .onlyElement()
                 .hasCodeOwnersEmailsThat()
                 .containsExactly(EMAIL_1, EMAIL_2, EMAIL_3),
+        // inline comments are dropped
         getCodeOwnerConfig(EMAIL_1, EMAIL_2, EMAIL_3));
   }
 
   @Test
+  public void perFileCodeOwnerConfigWithComment() throws Exception {
+    assertParseAndFormat(
+        "per-file foo=" + EMAIL_1 + "," + EMAIL_2 + "," + EMAIL_3 + " # some comment",
+        codeOwnerConfig -> {
+          CodeOwnerSetSubject codeOwnerSetSubject =
+              assertThat(codeOwnerConfig).hasCodeOwnerSetsThat().onlyElement();
+          codeOwnerSetSubject.hasPathExpressionsThat().containsExactly("foo");
+          codeOwnerSetSubject.hasCodeOwnersEmailsThat().containsExactly(EMAIL_1, EMAIL_2, EMAIL_3);
+        },
+        // inline comments are dropped
+        getCodeOwnerConfig(
+            /* ignoreParentCodeOwners= */ false,
+            CodeOwnerSet.builder()
+                .addPathExpression("foo")
+                .addCodeOwnerEmail(EMAIL_1)
+                .addCodeOwnerEmail(EMAIL_2)
+                .addCodeOwnerEmail(EMAIL_3)
+                .build()));
+  }
+
+  @Test
+  public void setNoParentWithComment() throws Exception {
+    assertParseAndFormat(
+        "set noparent # some comment",
+        codeOwnerConfig -> {
+          assertThat(codeOwnerConfig).hasIgnoreParentCodeOwnersThat().isTrue();
+          assertThat(codeOwnerConfig).hasCodeOwnerSetsThat().isEmpty();
+        },
+        // inline comments are dropped
+        getCodeOwnerConfig(
+            CodeOwnerConfig.builder(
+                    CodeOwnerConfig.Key.create(project, "master", "/"), TEST_REVISION)
+                .setIgnoreParentCodeOwners()
+                .build()));
+  }
+
+  @Test
+  public void importCodeOwnerConfigWithComment() throws Exception {
+    Path path = Paths.get("/foo/bar/OWNERS");
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        CodeOwnerConfigReference.builder(CodeOwnerConfigImportMode.ALL, path).build();
+    assertParseAndFormat(
+        "include " + path + " # some comment",
+        codeOwnerConfig -> {
+          CodeOwnerConfigReferenceSubject codeOwnerConfigReferenceSubject =
+              assertThat(codeOwnerConfig).hasImportsThat().onlyElement();
+          codeOwnerConfigReferenceSubject.hasProjectThat().isEmpty();
+          codeOwnerConfigReferenceSubject.hasBranchThat().isEmpty();
+          codeOwnerConfigReferenceSubject.hasFilePathThat().isEqualTo(path);
+        },
+        // inline comments are dropped
+        getCodeOwnerConfig(codeOwnerConfigReference));
+  }
+
+  @Test
+  public void codeOwnerConfigWithAnnotations() throws Exception {
+    assertParseAndFormat(
+        getCodeOwnerConfig(
+            EMAIL_1,
+            EMAIL_2 + " #{FOO_BAR}#{BAR_BAZ} #NO_ANNOTATION, #{FOO} #{bar} #{bAz} other comment",
+            EMAIL_3),
+        codeOwnerConfig -> {
+          CodeOwnerSetSubject codeOwnerSetSubject =
+              assertThat(codeOwnerConfig).hasCodeOwnerSetsThat().onlyElement();
+          codeOwnerSetSubject.hasCodeOwnersEmailsThat().containsExactly(EMAIL_1, EMAIL_2, EMAIL_3);
+          codeOwnerSetSubject
+              .hasAnnotationsThat()
+              .containsExactly(EMAIL_2, ImmutableSet.of("FOO_BAR", "BAR_BAZ", "FOO", "bar", "bAz"));
+        },
+        // annotations are sorted alphabetically, the normal comment is dropped
+        EMAIL_1
+            + "\n"
+            + EMAIL_2
+            + " #{BAR_BAZ} #{FOO} #{FOO_BAR} #{bAz} #{bar}\n"
+            + EMAIL_3
+            + "\n");
+  }
+
+  @Test
+  public void codeOwnerConfigWithAnnotationsOnAllUsersWildcard() throws Exception {
+    assertParseAndFormat(
+        getCodeOwnerConfig(
+            "* #{FOO_BAR}#{BAR_BAZ} #NO_ANNOTATION, #{FOO} #{bar} #{bAz} other comment"),
+        codeOwnerConfig -> {
+          CodeOwnerSetSubject codeOwnerSetSubject =
+              assertThat(codeOwnerConfig).hasCodeOwnerSetsThat().onlyElement();
+          codeOwnerSetSubject.hasCodeOwnersEmailsThat().containsExactly("*");
+          codeOwnerSetSubject
+              .hasAnnotationsThat()
+              .containsExactly("*", ImmutableSet.of("FOO_BAR", "BAR_BAZ", "FOO", "bar", "bAz"));
+        },
+        // annotations are sorted alphabetically, the normal comment is dropped
+        "* #{BAR_BAZ} #{FOO} #{FOO_BAR} #{bAz} #{bar}\n");
+  }
+
+  @Test
   public void codeOwnerConfigWithNonSortedEmails() throws Exception {
     assertParseAndFormat(
         String.join("\n", EMAIL_3, EMAIL_2, EMAIL_1) + "\n",
@@ -196,7 +294,9 @@
   @Test
   public void setNoParentCanBeSetMultipleTimes() throws Exception {
     assertParseAndFormat(
-        getCodeOwnerConfig(true, CodeOwnerSet.createWithoutPathExpressions(EMAIL_1))
+        getCodeOwnerConfig(
+                /* ignoreParentCodeOwners= */ true,
+                CodeOwnerSet.createWithoutPathExpressions(EMAIL_1))
             + "\nset noparent\nset noparent",
         codeOwnerConfig -> {
           assertThat(codeOwnerConfig).hasIgnoreParentCodeOwnersThat().isTrue();
@@ -206,7 +306,9 @@
               .hasCodeOwnersEmailsThat()
               .containsExactly(EMAIL_1);
         },
-        getCodeOwnerConfig(true, CodeOwnerSet.createWithoutPathExpressions(EMAIL_1)));
+        getCodeOwnerConfig(
+            /* ignoreParentCodeOwners= */ true,
+            CodeOwnerSet.createWithoutPathExpressions(EMAIL_1)));
   }
 
   @Test
@@ -215,14 +317,16 @@
         CodeOwnerSet.builder().addPathExpression("foo").addCodeOwnerEmail(EMAIL_2).build();
     CodeOwnerSet globalCodeOwnerSet = CodeOwnerSet.createWithoutPathExpressions(EMAIL_1, EMAIL_3);
     assertParseAndFormat(
-        getCodeOwnerConfig(false, perFileCodeOwnerSet, globalCodeOwnerSet),
+        getCodeOwnerConfig(
+            /* ignoreParentCodeOwners= */ false, perFileCodeOwnerSet, globalCodeOwnerSet),
         codeOwnerConfig -> {
           assertThat(codeOwnerConfig)
               .hasCodeOwnerSetsThat()
               .containsExactly(globalCodeOwnerSet, perFileCodeOwnerSet)
               .inOrder();
         },
-        getCodeOwnerConfig(false, globalCodeOwnerSet, perFileCodeOwnerSet));
+        getCodeOwnerConfig(
+            /* ignoreParentCodeOwners= */ false, globalCodeOwnerSet, perFileCodeOwnerSet));
   }
 
   @Test
@@ -230,7 +334,7 @@
     CodeOwnerSet codeOwnerSet1 = CodeOwnerSet.createWithoutPathExpressions(EMAIL_1, EMAIL_3);
     CodeOwnerSet codeOwnerSet2 = CodeOwnerSet.createWithoutPathExpressions(EMAIL_2);
     assertParseAndFormat(
-        getCodeOwnerConfig(false, codeOwnerSet1, codeOwnerSet2),
+        getCodeOwnerConfig(/* ignoreParentCodeOwners= */ false, codeOwnerSet1, codeOwnerSet2),
         codeOwnerConfig -> {
           assertThat(codeOwnerConfig)
               .hasCodeOwnerSetsThat()
@@ -240,7 +344,8 @@
         },
         // The code owner sets without path expressions are merged into one code owner set.
         getCodeOwnerConfig(
-            false, CodeOwnerSet.createWithoutPathExpressions(EMAIL_1, EMAIL_2, EMAIL_3)));
+            /* ignoreParentCodeOwners= */ false,
+            CodeOwnerSet.createWithoutPathExpressions(EMAIL_1, EMAIL_2, EMAIL_3)));
   }
 
   @Test
@@ -254,7 +359,7 @@
             .addCodeOwnerEmail(EMAIL_2)
             .build();
     assertParseAndFormat(
-        getCodeOwnerConfig(false, codeOwnerSet),
+        getCodeOwnerConfig(/* ignoreParentCodeOwners= */ false, codeOwnerSet),
         codeOwnerConfig -> {
           // we expect 2 code owner sets:
           // 1. code owner set for line "per-file *.md,foo=set noparent"
@@ -428,6 +533,41 @@
   }
 
   @Test
+  public void perFileCodeOwnerConfigWithAnnotations() throws Exception {
+    assertParseAndFormat(
+        "per-file foo="
+            + EMAIL_1
+            + ","
+            + EMAIL_2
+            + ","
+            + EMAIL_3
+            + " #{FOO_BAR}#{BAR_BAZ} #NO_ANNOTATION, #{FOO} #{bar} #{bAz} other comment",
+        codeOwnerConfig -> {
+          CodeOwnerSetSubject codeOwnerSetSubject =
+              assertThat(codeOwnerConfig).hasCodeOwnerSetsThat().onlyElement();
+          codeOwnerSetSubject.hasPathExpressionsThat().containsExactly("foo");
+          codeOwnerSetSubject.hasCodeOwnersEmailsThat().containsExactly(EMAIL_1, EMAIL_2, EMAIL_3);
+          codeOwnerSetSubject
+              .hasAnnotationsThat()
+              .containsExactly(
+                  EMAIL_1,
+                  ImmutableSet.of("FOO_BAR", "BAR_BAZ", "FOO", "bar", "bAz"),
+                  EMAIL_2,
+                  ImmutableSet.of("FOO_BAR", "BAR_BAZ", "FOO", "bar", "bAz"),
+                  EMAIL_3,
+                  ImmutableSet.of("FOO_BAR", "BAR_BAZ", "FOO", "bar", "bAz"));
+        },
+        // annotations are sorted alphabetically, the normal comment is dropped, a newline is added
+        "per-file foo="
+            + EMAIL_1
+            + ","
+            + EMAIL_2
+            + ","
+            + EMAIL_3
+            + " #{BAR_BAZ} #{FOO} #{FOO_BAR} #{bAz} #{bar}\n");
+  }
+
+  @Test
   public void perFileCodeOwnerConfigImportFromSameProjectAndBranch() throws Exception {
     Path path = Paths.get("/foo/bar/OWNERS");
     CodeOwnerConfigReference codeOwnerConfigReference =
@@ -569,18 +709,16 @@
     // The 'include' keyword is used to for imports with import mode ALL, but it is not supported
     // for per-file imports. Trying to use it anyway should result in a proper error message.
     String line = "per-file foo=include /foo/bar/OWNERS";
-    IllegalStateException exception =
+    CodeOwnerConfigParseException exception =
         assertThrows(
-            IllegalStateException.class,
+            CodeOwnerConfigParseException.class,
             () ->
                 codeOwnerConfigParser.parse(
                     TEST_REVISION, CodeOwnerConfig.Key.create(project, "master", "/"), line));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            String.format(
-                "import mode %s is unsupported for per file import: %s",
-                CodeOwnerConfigImportMode.ALL.name(), line));
+    assertThat(exception).hasMessageThat().isEqualTo("invalid code owner config file");
+    assertThat(exception.getFullMessage("OWNERS"))
+        .contains(
+            String.format("keyword 'include' is not supported for per file imports: %s", line));
   }
 
   @Test
@@ -753,4 +891,40 @@
     assertThat(FindOwnersCodeOwnerConfigParser.replaceEmail(content + "\n", oldEmail, newEmail))
         .isEqualTo(expectedContent + "\n");
   }
+
+  @Test
+  public void splitGlobs() throws Exception {
+    // empty globs
+    assertSplitGlobs("");
+    assertSplitGlobs(",", "");
+
+    // single globs
+    assertSplitGlobs("BUILD", "BUILD");
+    assertSplitGlobs("*.md", "*.md");
+    assertSplitGlobs("foo/*", "foo/*");
+    assertSplitGlobs("{foo,bar}", "{foo,bar}");
+    assertSplitGlobs("{foo,bar}/**", "{foo,bar}/**");
+    assertSplitGlobs("{{foo,bar}}", "{{foo,bar}}");
+    assertSplitGlobs("foo[1-5]", "foo[1-5]");
+    assertSplitGlobs("a[,]b", "a[,]b");
+    assertSplitGlobs("a[[,]]b", "a[[,]]b");
+
+    // multiple globs
+    assertSplitGlobs("BUILD,*.md,foo/*", "BUILD", "*.md", "foo/*");
+    assertSplitGlobs(
+        "{foo,bar},{foo,bar}/**,{{foo,bar}}", "{foo,bar}", "{foo,bar}/**", "{{foo,bar}}");
+    assertSplitGlobs("foo[1-5],a[,]b,a[[,]]b", "foo[1-5]", "a[,]b", "a[[,]]b");
+    assertSplitGlobs("a[,]b,{foo,bar}", "a[,]b", "{foo,bar}");
+
+    // invalid globs
+    assertSplitGlobs("{foo,bar", "{foo,bar");
+    assertSplitGlobs("[abc,", "[abc,");
+    assertSplitGlobs("{foo,bar,a[,]b", "{foo,bar,a[,]b");
+  }
+
+  private static void assertSplitGlobs(String commaSeparatedGlobs, String... expectedGlobs) {
+    assertThat(FindOwnersCodeOwnerConfigParser.Parser.splitGlobs(commaSeparatedGlobs))
+        .asList()
+        .containsExactlyElementsIn(expectedGlobs);
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/proto/ProtoBackendTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/proto/ProtoBackendTest.java
index d231cdf..01d7091 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/proto/ProtoBackendTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/proto/ProtoBackendTest.java
@@ -2,6 +2,7 @@
 
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.plugins.codeowners.backend.AbstractFileBasedCodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.AbstractFileBasedCodeOwnerBackendTest;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigParser;
@@ -27,7 +28,7 @@
 
   @Test
   public void getPathExpressionMatcher() throws Exception {
-    assertThat(codeOwnerBackend.getPathExpressionMatcher())
+    assertThat(codeOwnerBackend.getPathExpressionMatcher(BranchNameKey.create(project, "master")))
         .value()
         .isInstanceOf(SimplePathExpressionMatcher.class);
   }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/proto/ProtoCodeOwnerConfigParserTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/proto/ProtoCodeOwnerConfigParserTest.java
index 2e768db..5d0d20c 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/proto/ProtoCodeOwnerConfigParserTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/proto/ProtoCodeOwnerConfigParserTest.java
@@ -127,12 +127,12 @@
     CodeOwnerSet codeOwnerSet1 = CodeOwnerSet.createWithoutPathExpressions(EMAIL_1, EMAIL_3);
     CodeOwnerSet codeOwnerSet2 = CodeOwnerSet.createWithoutPathExpressions(EMAIL_2);
     assertParseAndFormat(
-        getCodeOwnerConfig(false, codeOwnerSet1, codeOwnerSet2),
+        getCodeOwnerConfig(/* ignoreParentCodeOwners= */ false, codeOwnerSet1, codeOwnerSet2),
         codeOwnerConfig ->
             assertThat(codeOwnerConfig.codeOwnerSets())
                 .containsExactly(codeOwnerSet1, codeOwnerSet2)
                 .inOrder(),
-        getCodeOwnerConfig(false, codeOwnerSet1, codeOwnerSet2));
+        getCodeOwnerConfig(/* ignoreParentCodeOwners= */ false, codeOwnerSet1, codeOwnerSet2));
   }
 
   /**
diff --git a/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerStatusInfoJsonTest.java b/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerStatusInfoJsonTest.java
index 456e609..17fbdb1 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerStatusInfoJsonTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerStatusInfoJsonTest.java
@@ -23,6 +23,7 @@
 import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.common.ChangeType;
@@ -35,14 +36,23 @@
 import com.google.gerrit.plugins.codeowners.common.ChangedFile;
 import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
 import com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusInfoSubject;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.truth.ListSubject;
 import java.nio.file.Paths;
 import java.util.Optional;
 import org.eclipse.jgit.diff.DiffEntry;
+import org.junit.Before;
 import org.junit.Test;
 
 /** Tests for {@link CodeOwnerStatusInfoJson}. */
 public class CodeOwnerStatusInfoJsonTest extends AbstractCodeOwnersTest {
+  private CodeOwnerStatusInfoJson codeOwnerStatusInfoJson;
+
+  @Before
+  public void setUpCodeOwnersPlugin() throws Exception {
+    codeOwnerStatusInfoJson = plugin.getSysInjector().getInstance(CodeOwnerStatusInfoJson.class);
+  }
+
   @Test
   public void cannotFormatNullPathCodeOwnerStatus() throws Exception {
     NullPointerException npe =
@@ -62,6 +72,23 @@
         CodeOwnerStatusInfoJson.format(pathCodeOwnerStatus);
     assertThat(pathCodeOwnerStatusInfo).hasPathThat().isEqualTo("foo/bar.baz");
     assertThat(pathCodeOwnerStatusInfo).hasStatusThat().isEqualTo(CodeOwnerStatus.APPROVED);
+    assertThat(pathCodeOwnerStatusInfo).hasReasonsThat().isNull();
+  }
+
+  @Test
+  public void formatPathCodeOwnerStatusWithReasons() throws Exception {
+    PathCodeOwnerStatus pathCodeOwnerStatus =
+        PathCodeOwnerStatus.builder(Paths.get("/foo/bar.baz"), CodeOwnerStatus.APPROVED)
+            .addReason("one reason")
+            .addReason("another reason")
+            .build();
+    PathCodeOwnerStatusInfo pathCodeOwnerStatusInfo =
+        CodeOwnerStatusInfoJson.format(pathCodeOwnerStatus);
+    assertThat(pathCodeOwnerStatusInfo).hasPathThat().isEqualTo("foo/bar.baz");
+    assertThat(pathCodeOwnerStatusInfo).hasStatusThat().isEqualTo(CodeOwnerStatus.APPROVED);
+    assertThat(pathCodeOwnerStatusInfo)
+        .hasReasonsThat()
+        .containsExactly("one reason", "another reason");
   }
 
   @Test
@@ -189,7 +216,7 @@
         assertThrows(
             NullPointerException.class,
             () ->
-                CodeOwnerStatusInfoJson.format(
+                codeOwnerStatusInfoJson.format(
                     PatchSet.id(Change.id(1), 1), /* fileCodeOwnerStatuses= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("fileCodeOwnerStatuses");
   }
@@ -199,7 +226,7 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () -> CodeOwnerStatusInfoJson.format(/* patchSetId= */ null, ImmutableSet.of()));
+            () -> codeOwnerStatusInfoJson.format(/* patchSetId= */ null, ImmutableSet.of()));
     assertThat(npe).hasMessageThat().isEqualTo("patchSetId");
   }
 
@@ -212,7 +239,7 @@
     FileCodeOwnerStatus fileCodeOwnerStatus =
         FileCodeOwnerStatus.create(changedFile, Optional.of(pathCodeOwnerStatus), Optional.empty());
     CodeOwnerStatusInfo codeOwnerStatusInfo =
-        CodeOwnerStatusInfoJson.format(
+        codeOwnerStatusInfoJson.format(
             PatchSet.id(Change.id(1), 1), ImmutableSet.of(fileCodeOwnerStatus));
     assertThat(codeOwnerStatusInfo).hasPatchSetNumberThat().isEqualTo(1);
     FileCodeOwnerStatusInfoSubject fileCodeOwnerStatusInfoSubject =
@@ -228,7 +255,93 @@
         .value()
         .hasStatusThat()
         .isEqualTo(CodeOwnerStatus.APPROVED);
+    fileCodeOwnerStatusInfoSubject.hasNewPathStatusThat().value().hasReasonsThat().isNull();
     fileCodeOwnerStatusInfoSubject.hasOldPathStatusThat().isEmpty();
+    assertThat(codeOwnerStatusInfo).hasAccountsThat().isNull();
+  }
+
+  @Test
+  public void formatCodeOwnerStatusInfoWithReasons() throws Exception {
+    ChangedFile changedFile = mock(ChangedFile.class);
+    when(changedFile.changeType()).thenReturn(DiffEntry.ChangeType.ADD);
+    PathCodeOwnerStatus pathCodeOwnerStatus =
+        PathCodeOwnerStatus.builder(Paths.get("/foo/bar.baz"), CodeOwnerStatus.APPROVED)
+            .addReason("one reason")
+            .addReason("another reason")
+            .build();
+    FileCodeOwnerStatus fileCodeOwnerStatus =
+        FileCodeOwnerStatus.create(changedFile, Optional.of(pathCodeOwnerStatus), Optional.empty());
+    CodeOwnerStatusInfo codeOwnerStatusInfo =
+        codeOwnerStatusInfoJson.format(
+            PatchSet.id(Change.id(1), 1), ImmutableSet.of(fileCodeOwnerStatus));
+    assertThat(codeOwnerStatusInfo).hasPatchSetNumberThat().isEqualTo(1);
+    FileCodeOwnerStatusInfoSubject fileCodeOwnerStatusInfoSubject =
+        assertThat(codeOwnerStatusInfo).hasFileCodeOwnerStatusesThat().onlyElement();
+    fileCodeOwnerStatusInfoSubject.hasChangeTypeThat().isEqualTo(ChangeType.ADDED);
+    fileCodeOwnerStatusInfoSubject
+        .hasNewPathStatusThat()
+        .value()
+        .hasPathThat()
+        .isEqualTo("foo/bar.baz");
+    fileCodeOwnerStatusInfoSubject
+        .hasNewPathStatusThat()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+    fileCodeOwnerStatusInfoSubject
+        .hasNewPathStatusThat()
+        .value()
+        .hasReasonsThat()
+        .containsExactly("one reason", "another reason");
+    fileCodeOwnerStatusInfoSubject.hasOldPathStatusThat().isEmpty();
+    assertThat(codeOwnerStatusInfo).hasAccountsThat().isNull();
+  }
+
+  @Test
+  public void formatCodeOwnerStatusInfoWithReasonsThatReferenceAccounts() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+    String reason1 =
+        String.format(
+            "because %s did something", AccountTemplateUtil.getAccountTemplate(user.id()));
+    String reason2 =
+        String.format(
+            "because %s, %s and %s did something else",
+            AccountTemplateUtil.getAccountTemplate(admin.id()),
+            AccountTemplateUtil.getAccountTemplate(user.id()),
+            AccountTemplateUtil.getAccountTemplate(user2.id()));
+    ChangedFile changedFile = mock(ChangedFile.class);
+    when(changedFile.changeType()).thenReturn(DiffEntry.ChangeType.ADD);
+    PathCodeOwnerStatus pathCodeOwnerStatus =
+        PathCodeOwnerStatus.builder(Paths.get("/foo/bar.baz"), CodeOwnerStatus.APPROVED)
+            .addReason(reason1)
+            .addReason(reason2)
+            .build();
+    FileCodeOwnerStatus fileCodeOwnerStatus =
+        FileCodeOwnerStatus.create(changedFile, Optional.of(pathCodeOwnerStatus), Optional.empty());
+    CodeOwnerStatusInfo codeOwnerStatusInfo =
+        codeOwnerStatusInfoJson.format(
+            PatchSet.id(Change.id(1), 1), ImmutableSet.of(fileCodeOwnerStatus));
+    assertThat(codeOwnerStatusInfo).hasPatchSetNumberThat().isEqualTo(1);
+    FileCodeOwnerStatusInfoSubject fileCodeOwnerStatusInfoSubject =
+        assertThat(codeOwnerStatusInfo).hasFileCodeOwnerStatusesThat().onlyElement();
+    fileCodeOwnerStatusInfoSubject.hasChangeTypeThat().isEqualTo(ChangeType.ADDED);
+    fileCodeOwnerStatusInfoSubject
+        .hasNewPathStatusThat()
+        .value()
+        .hasPathThat()
+        .isEqualTo("foo/bar.baz");
+    fileCodeOwnerStatusInfoSubject
+        .hasNewPathStatusThat()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+    fileCodeOwnerStatusInfoSubject
+        .hasNewPathStatusThat()
+        .value()
+        .hasReasonsThat()
+        .containsExactly(reason1, reason2);
+    fileCodeOwnerStatusInfoSubject.hasOldPathStatusThat().isEmpty();
+    assertThat(codeOwnerStatusInfo).hasAccounts(admin, user, user2);
   }
 
   @Test
@@ -262,7 +375,7 @@
             Optional.of(oldPathCodeOwnerStatus3));
 
     CodeOwnerStatusInfo codeOwnerStatusInfo =
-        CodeOwnerStatusInfoJson.format(
+        codeOwnerStatusInfoJson.format(
             PatchSet.id(Change.id(1), 1),
             ImmutableSet.of(fileCodeOwnerStatus3, fileCodeOwnerStatus2, fileCodeOwnerStatus1));
     ListSubject<FileCodeOwnerStatusInfoSubject, FileCodeOwnerStatusInfo> listSubject =
diff --git a/resources/Documentation/about.md b/resources/Documentation/about.md
index d558de6..fecbc41 100644
--- a/resources/Documentation/about.md
+++ b/resources/Documentation/about.md
@@ -1,4 +1,4 @@
-The @PLUGIN@ plugin provides support for defining
+The @PLUGIN@ plugin provides support to define
 [code owners](user-guide.html#codeOwners) for directories and files in a
 repository/branch.
 
@@ -6,65 +6,9 @@
 touched files are covered by [approvals](user-guide.html#codeOwnerApproval) from
 code owners.
 
+An overview of the supported features can be found [here](feature-set.html).
+
 **IMPORTANT:** Before installing/enabling the plugin, or enabling the code
 owners functionality for further projects, follow the instructions from the
 [setup guide](setup-guide.html).
 
-**NOTE:** This plugin is specifically developed to support code owners for the
-Chrome and Android teams at Google. This means some of the functionality and
-design decisons are driven by Google-specific use-cases. Nonetheless the support
-for code owners is pretty generic and [configurable](config.html) so that it
-should be suitable for other teams as well.
-
-## <a id="functionality">Functionality
-
-* Support for defining code owners:
-    * Code owners can be specified in `OWNERS` files that can appear in any
-      directory in the source branch.
-    * Default code owners can be specified on repository level by an `OWNERS`
-      file in the `refs/meta/config` branch.
-    * Global code owners across repositories can be configured.
-    * A fallback code owners policy controls who owns files that are not covered
-      by `OWNERS` files.
-    * Code owners can be specified by email (groups are not supported).
-    * Inheritance from parent directories is supported and can be disabled.
-    * Including an `OWNERS` file from other directories / branches / projects is
-      possible (only on the same host).
-    * File globs can be used.
-    * see [code owners documentation](config-guide.html#codeOwners) and
-      [OWNERS syntax](backend-find-owners.html#syntax)
-<br><br>
-* Prevents submitting changes without code owner approvals:
-    * Which votes count as code owner approvals is
-      [configurable](setup-guide.html#configureCodeOwnerApproval).
-    * Implemented as Java submit rule (no Prolog).
-<br><br>
-* Support for overrides:
-    * Privileged users can be allowed to override the code owner submit check.
-    * Overriding is done by voting on a [configured override
-      label](setup-guide.html#configureCodeOwnerOverrides).
-    * see [override setup](config-faqs.html#setupOverrides)
-<br><br>
-* UI extensions on change screen:
-    * Code owner suggestion
-    * Display of the code owners submit requirement
-    * Display of code owner statuses in the file list
-    * Change messages that list the owned paths.
-    * see [UI walkthrough](how-to-use.html) and [user guide](user-guide.html)
-<br><br>
-* Extensible:
-    * Supports multiple [backends](backends.html) which can implement different
-      syntaxes for `OWNERS` files.
-<br><br>
-* Validation:
-    * updates to `OWNERS` files are [validated](validation.html) on commit
-      received and submit
-    * `OWNERS` files can be validated on demand to detect consistency issues
-<br><br>
-* Rich REST API:
-    * see [REST API documentation](rest-api.html)
-<br><br>
-* Highly configurable:
-    * see [setup guide](setup-guide.html), [config-guide](config-guide.html),
-      [config FAQs](config-faqs.html) and [config documentation](config.html)
-
diff --git a/resources/Documentation/backend-find-owners-cookbook.md b/resources/Documentation/backend-find-owners-cookbook.md
index 98db7c8..af9afee 100644
--- a/resources/Documentation/backend-find-owners-cookbook.md
+++ b/resources/Documentation/backend-find-owners-cookbook.md
@@ -7,6 +7,12 @@
 **NOTE:** The syntax of `OWNERS` files is described
 [here](backend-find-owners.html#syntax).
 
+**NOTE:** By default the `find-owners` backend uses
+[FIND_OWNERS_GLOB's](path-expressions.html) as path expressions, but it's
+possible that a different path expression syntax is
+[configured](config.html#pluginCodeOwnersPathExpressions). All examples on this
+page assume that `FIND_OWNERS_GLOB`'s are used as path expressions.
+
 ### <a id="defineUsersAsCodeOwners">Define users as code owners
 
 To define a set of users as code owners, each user email must be placed in a
diff --git a/resources/Documentation/backend-find-owners.md b/resources/Documentation/backend-find-owners.md
index a1af71d..18fb11e 100644
--- a/resources/Documentation/backend-find-owners.md
+++ b/resources/Documentation/backend-find-owners.md
@@ -9,12 +9,18 @@
 ## <a id="codeOwnerConfiguration">Code owner configuration
 
 Code owners are defined in `OWNERS` files which are stored in the source tree.
+
 The code owners that are defined in an `OWNERS` file apply to the directory that
 contains the `OWNERS` file, and all its subdirectories (except if a subdirectory
 contains an `OWNERS` file that disables the inheritance of code owners from the
 parent directories via the [set noparent](#setNoparent) keyword).
 
-### <a id="defaultCodeOwnerConfiguration">
+**NOTE:** It is also possible to define code owners in `<prefix>_OWNERS` or
+`OWNERS_<extension>` files that can be imported into `OWNERS` files (further
+details about code owner config files are described
+[here](backends.html#codeOwnerConfigFiles)).
+
+### <a id="defaultCodeOwnerConfiguration">Default code owners
 Default code owners that apply to all branches can be defined in an `OWNERS`
 file in the root directory of the `refs/meta/config` branch. This `OWNERS` file
 is the parent of the root `OWNERS` files in all branches. This means if a root
@@ -25,11 +31,12 @@
 can be done by setting [global code
 owners](config.html#codeOwnersGlobalCodeOwner).
 
-### <a id="codeOwnerConfigFileExtension">
-**NOTE:** It's possible that projects have a [file extension for code owner
-config files](config.html#codeOwnersFileExtension) configured. In this case the
-code owners are defined in `OWNERS.<file-extension>` files and `OWNERS` files
-are ignored.
+### <a id="codeOwnerConfigFileExtension">Using a file extension for OWNERS files
+It's possible that projects have a [file
+extension](config.html#codeOwnersFileExtension) for code owner config files
+configured. In this case the code owners are defined in
+`OWNERS.<file-extension>` files and `OWNERS` files are ignored. Further details
+about this are described [here](backends.html#codeOwnerConfigFiles).
 
 ## <a id="cookbook">Cookbook
 
@@ -45,6 +52,12 @@
   [restriction prefix](#restrictionPrefixes)
 * a [comment](#comments)
 
+**NOTE:** By default the `find-owners` backend uses
+[FIND_OWNERS_GLOB's](path-expressions.html) as path expressions, but it's
+possible that a different path expression syntax is
+[configured](config.html#pluginCodeOwnersPathExpressions). All examples on this
+page assume that `FIND_OWNERS_GLOB`'s are used as path expressions.
+
 ### <a id="fileLevelRules">File-level rules
 
 File-level rules apply to the entire `OWNERS` file and should not be repeated.
@@ -150,13 +163,9 @@
 Absolute paths are recommended for distant paths, but also to make it easier to
 copy or integrate the line between multiple `OWNERS` files.
 
-The file that is referenced by the `file` keyword must be a code owner config
-file. This means it cannot have an arbitrary name, but the file name must be
-`OWNERS` or `OWNER.<file-extension>`, if a
-[file extension](#codeOwnerConfigFileExtension) is configured. In addition it is
-allowed that the file names have an arbitray prefix (`<prefix>_OWNERS`, e.g.
-`BUILD_OWNERS`) or an arbitrary extension (`OWNERS_<extension>`, e.g.
-`OWNERS_BUILD`).
+The file that is referenced by the `file` keyword must be a [code owner config
+file](backends.html#codeOwnerConfigFiles). It's not possible to import arbitrary
+files.
 
 It's also possible to reference code owner config files from other projects or
 branches (only within the same host):
@@ -176,16 +185,26 @@
 
 If referenced `OWNERS` files do not exists, they are silently ignored when code
 owners are resolved, but trying to add references to non-existing `OWNERS` file
-will be rejected on upload/submit.
+will be rejected on upload/submit. Being ignored means that the `@PLUGIN@`
+doesn't bail out with an error when code owners are resolved, but the import of
+non-resolvable `OWNERS` files [prevents fallback code owners from being
+applied](config.html#pluginCodeOwnersFallbackCodeOwners). The reason for this is
+that if there is an unresolved import, it is assumed that it was intended to
+define code owners, e.g. stricter code owners than the fallback code owners, and
+hence the fallback code owners should not be applied.
 
 When referencing an external `OWNERS` file via the `file`  keyword, only
-non-restricted [access grants](#accessGrants) are imported. This means
-`per-file` rules from the referenced `OWNERS` file are not pulled in and also
-any [set noparent](#setNoparent) line in the referenced `OWNERS` file is
-ignored, but recursive imports are being resolved.
+non-restricted [access grants](#accessGrants) are imported. If they contain
+imports (via the `file` or [include](#includeKeyword) keyword) they are
+recursively resolved. This means for the referenced `OWNERS` files the following
+things are ignored and not pulled in:
 
-To also import `per-file` rules and any [set noparent](#setNoparent) line use
-the [include](#includeKeyword) keyword instead.
+1. `per-file` rules
+2. any [set noparent](#setNoparent) line
+3. `OWNERS` files in parent directories
+
+To also import `per-file` rules and any [set noparent](#setNoparent) line (1. +
+2.) use the [include](#includeKeyword) keyword instead.
 
 #### <a id="includeKeyword">include keyword
 
@@ -291,16 +310,50 @@
   per-file docs.config,*.md=richard.roe@example.com
 ```
 
+### <a id="anotations">Annotations
+
+Lines representing [access grants](#accessGrants) can be annotated. Annotations
+have the format `#{ANNOTATION_NAME}` and can appear at the end of the line.
+E.g.:
+
+```
+  john.doe@example.com #{LAST_RESORT_SUGGESTION}
+  per-file docs.config,*.md=richard.roe@example.com #{LAST_RESORT_SUGGESTION}
+```
+\
+Annotations can be mixed with [comments](#comments) that can appear before and
+after annotations, E.g.:
+
+```
+  jane.roe@example.com # foo bar #{LAST_RESORT_SUGGESTION} baz
+```
+\
+The following annotations are supported:
+
+#### <a id="lastResortSuggestion">
+* `LAST_RESORT_SUGGESTION`:
+  Code owners with this annotation are omitted when [suggesting code
+  owners](rest-api.html#list-code-owners-for-path-in-change), except if dropping
+  these code owners would make the suggestion result empty or if these code
+  owners are already reviewers of the change. If code ownership is assigned to
+  the same code owner through multiple relevant access grants in the same code
+  owner config file or in other relevant code owner config files the code owner
+  gets omitted from the suggestion if it has the `LAST_RESORT_SUGGESTION` set on
+  any of the access grants.
+
+Unknown annotations are silently ignored.
+
+**NOTE:** If an access grant line that assigns code ownership to multiple users
+has an annotation, this annotation applies to all these users. E.g. if an
+annotation is set for the all users wildcard (aka `*`) it applies to all users.
+
 ### <a id="comments">Comments
 
 The '#' character indicates the beginning of a comment. Arbitrary text may be
 added in comments.
 
-Comments are only supported in 2 places:
-
-* comment lines:
-  A line starting with '#' (`# <comment-text>`).
-* comments after [user emails](#userEmails) (`<user-email> # <comment-text>`).
+Comments can appear at the end of any line or consume the whole line (a line
+starting with '#', `# <comment-test`).
 
 Comments are not interpreted by the `code-owners` plugin and are intended for
 human readers of the `OWNERS` files. However some projects/teams may have own
diff --git a/resources/Documentation/backends.md b/resources/Documentation/backends.md
index 0f392dd..7069717 100644
--- a/resources/Documentation/backends.md
+++ b/resources/Documentation/backends.md
@@ -15,6 +15,88 @@
 [configured](setup-guide.html#configureCodeOwnersBackend) globally, per
 repository or per branch.
 
+## <a id="codeOwnerConfigFiles">Code owner config files
+
+Code owner config files are stored in the source tree of the repository and
+define the [code owners](user-guide.html#codeOwners) for a path.
+
+The code owners that are defined in a code owner config file apply to the
+directory that contains the code owner config file, and all its subdirectories
+(except if a subdirectory contains a code owner config file that disables the
+inheritance of code owners from the parent directories).
+
+In which files code owners are defined depends on the configured code owner
+backend:
+
+| Backend       | Primary code owner config files |
+| ------------- | ------------------------------- |
+| `find-owners` | `OWNERS`                        |
+| `proto`       | `OWNERS_METADATA`               |
+
+In addition, there can be secondary code owner config files, which may be
+imported into other code owner config files. These files have no effect on their
+own, but only when they are directly or indirectly imported into a primary code
+owner config file.
+
+| Backend       | Secondary code owner config files                         |
+| ------------- | --------------------------------------------------------- |
+| `find-owners` | `<prefix>_OWNERS`, `OWNERS_<extension>`                   |
+| `proto`       | `<prefix>_OWNERS_METADATA`, `OWNERS_METADATA_<extension>` |
+
+Primary and secondary code owner config files are [validated](validation.html)
+by the `@PLUGIN@` plugin when they are changed to ensure that they are always
+parsable and valid.
+
+By configuring a [file extension](config.html#codeOwnersFileExtension) for code
+owner config files it is possible to use **a different set of code owner config
+files**:
+
+| Backend       | Primary code owner config files | Secondary code owner config files |
+| ------------- | ------------------------------- | --------------------------------- |
+| `find-owners` | `OWNERS.<configured-file-extension>` | `<prefix>_OWNERS.<configured-file-extension>`, `OWNERS_<extension>.<configured-file-extension>` |
+| `proto`       | `OWNERS_METADATA.<configured-file-extension>` | `<prefix>_OWNERS_METADATA.<configured-file-extension>`, `OWNERS_METADATA_<extension>.<configured-file-extension>` |
+
+In this case only the primary and secondary code owner config files with the
+configured file extension are considered as code owner config files. This means
+code owner config files without file extension or with other file extensions are
+not interpreted and also not validated.
+
+Configuring a file extension for code owner config files is useful if a
+repository is forked and the fork should use a different set of code owner
+config files than the upstream repository. For this use case it is important
+that the code owner config files from the upstream repository are not validated,
+as they may use a different syntax or reference non-resolvable accounts, and
+hence would always be detected as invalid.
+
+As some projects want to allow arbitrary file extensions for code owner config
+files, it is possible to enable arbitrary file extensions for code owner config
+files by [configuration](config.html#codeOwnersEnableCodeOwnerConfigFilesWithFileExtensions).
+If arbitrary file extensions are enabled, the following files are consideres as
+secondary code owner config files **in addition** to the once described above:
+
+| Backend       | Additional secondary code owner config files              |
+| ------------- | --------------------------------------------------------- |
+| `find-owners` | `OWNERS.<arbitrary-file-extension>`                       |
+| `proto`       | `OWNERS_METADATA.<arbitrary-file-extension>`              |
+
+With this setup code owner config files with any file extension are validated.
+
+**NOTE:** Enabling arbitrary file extensions for code owner config files
+conflicts with configuring a certain file extension for code owner config files
+in order to ignore upstream code owner config files, as in this case the
+upstream code owner config files should not be validated. This is why arbitrary
+file extensions should not be enabled if a certain file extension for code owner
+config files was already configured.
+
+**NOTE:** Code owner config files can only import other code owner config files,
+but not arbitrary files. This ensures that code owner config files only import
+files that have gone through validation and hence are known to be valid. If
+arbitrary files could be imported, the imported files may not be parsable since
+they were not validated. As a result of this, looking up code owners could
+break, which would block submission of all changes for which the invalid file is
+relevant. This would likely be considered as a serious outage, hence importing
+arbitrary files is disallow so that this cannot happen.
+
 ---
 
 Back to [@PLUGIN@ documentation index](index.html)
diff --git a/resources/Documentation/config-guide.md b/resources/Documentation/config-guide.md
index 86b447f..e9d5b88 100644
--- a/resources/Documentation/config-guide.md
+++ b/resources/Documentation/config-guide.md
@@ -200,6 +200,23 @@
 To avoid situations like this it is recommended to not enable implicit
 approvals.
 
+**NOTE:** Why are implicit approvals not always applied for the change owner?\
+If implicit approvals would be always applied for the change owner, and not
+only when the change owner is also the last uploader, anyone could upload a new
+patch set to a change that is owned by a code owner and get it implicitly
+approved by the change owner. This would be really bad, as it means that anyone
+could submit arbitrary code without a code owner having actually looked at it
+before the submission.
+
+**NOTE:** Why are implicit approvals not always applied for the last uploader?\
+If implicit approvals would be always applied for the last uploader, and not
+only when the last uploader is also the change owner, changes would get
+implicitly approved whenever a code owner touches a change of somebody else
+(e.g. when editing the commit message, since editing the commit message creates
+a new patch set which has the user editing the commit message as an uploader).
+This would be bad, because code owners are not aware that editing the commit
+message of a change would implictly code-owner approve it.
+
 ### <a id="securityMergeCommits">Required code owner approvals on merge commits
 
 If any branch doesn't require code owner approvals or if the code owners in any
diff --git a/resources/Documentation/config.md b/resources/Documentation/config.md
index 90e4e5c..308ca8f 100644
--- a/resources/Documentation/config.md
+++ b/resources/Documentation/config.md
@@ -148,6 +148,25 @@
         syntax so that the existing code owner config files can no longer be
         parsed.
 
+<a id="pluginCodeOwnersPathExpressions">plugin.@PLUGIN@.pathExpressions</a>
+:       The path expression syntax that is used in
+        [code owner config files](user-guide.html#codeOwnerConfigFiles).\
+        Can be overridden per project by setting
+        [codeOwners.pathExpressions](#codeOwnersPathExpressions) in
+        `@PLUGIN@.config`.\
+        The supported path expression syntaxes are listed and explained at the
+        [Path Expressions](path-expressions.html) page.\
+        By default unset which means that the path expression syntax is derived
+        from the configured [code owner backend](#pluginCodeOwnersBackend).\
+        \
+        **NOTE:** Be careful with changing this parameter as it affects how path
+        expressions in existing
+        [code owner config files](user-guide.html#codeOwnerConfigFiles) are
+        interpreted. E.g. by changing the path expression syntax existing path
+        expressions may now match different files, or existing path expressions
+        may no longer be valid and fail to parse, in which case they would not
+        match anything any more.
+
 <a id="pluginCodeOwnersFileExtension">plugin.@PLUGIN@.fileExtension</a>
 :       The file extension that should be used for code owner config files.\
         Allows to use a different code owner configuration in a fork. E.g. if
@@ -158,7 +177,33 @@
         Can be overridden per project by setting
         [codeOwners.fileExtension](#codeOwnersFileExtension) in
         `@PLUGIN@.config`.\
-        By default unset (no file extension is used).
+        By default unset (no file extension is used).\
+        If a file extension is configured,
+        [plugin.@PLUGIN@.enableCodeOwnerConfigFilesWithFileExtensions](#pluginCodeOwnersEnableCodeOwnerConfigFilesWithFileExtensions)
+        should be set to `false`, as otherwise code owner config files with any
+        file extension will be validated, which causes validation errors if code
+        owner config files with other file extensions use a different owners
+        syntax or reference users that do not exist on this Gerrit host.
+
+<a id="pluginCodeOwnersEnableCodeOwnerConfigFilesWithFileExtensions">plugin.@PLUGIN@.enableCodeOwnerConfigFilesWithFileExtensions</a>
+:       Whether file extensions for code owner config files are enabled.\
+        If enabled, code owner config files with file extensions are treated as
+        regular code owner config files. This means they are validated on
+        push/submit (if validation is enabled) and can be imported by other code
+        owner config files (regardless of whether they have the same file
+        extension or not).\
+        Enabling this option should not be used in combination with the
+        [plugin.@PLUGIN@.fileExtension](#pluginCodeOwnersFileExtension) option
+        as that option uses file extensions to differentiate different sets of
+        code owner config files in the same repository/branch which may use
+        different code owner syntaxes or reference users that do not exist on
+        this Gerrit host. In this case, code owner config files with (other)
+        file extensions should not be validated as they likely will fail the
+        validation.\
+        Can be overridden per project by setting
+        [codeOwners.enableCodeOwnerConfigFilesWithFileExtensions](#codeOwnersEnableCodeOwnerConfigFilesWithFileExtensions)
+        in `@PLUGIN@.config`.\
+        By default `false`.
 
 <a id="pluginCodeOwnersOverrideInfoUrl">plugin.@PLUGIN@.overrideInfoUrl</a>
 :       A URL for a page that provides host-specific information about how to
@@ -232,8 +277,8 @@
 <a id="pluginCodeOwnersExemptedUser">plugin.@PLUGIN@.exemptedUser</a>
 :       The email of a user that should be exempted from requiring code owner
         approvals.\
-        If a user is exempted from requiring code owner approvals changes that
-        are uploaded by this user are automatically code-owner approved.\
+        If a user is exempted from requiring code owner approvals patch sets
+        that are uploaded by this user are automatically code-owner approved.\
         Can be specified multiple times to exempt multiple users.\
         The configured value list can be extended on project-level by setting
         [codeOwners.exemptedUser](#codeOwnersExemptedUser) in
@@ -538,6 +583,19 @@
         in `@PLUGIN@.config`.\
         By default `50`.
 
+<a id="pluginCodeOwnersEnableAsyncMessageOnAddReviewer">plugin.@PLUGIN@.enableAsyncMessageOnAddReviewer</a>
+:       When a code owner is added as a reviewer the @PLUGIN@ plugin posts a
+        change message that lists owned paths of the code owner. This setting
+        controls whether this change message should be posted asynchronously.\
+        Posting these change messages asynchronously improves the latency for
+        post review and post reviewers calls, since they do not need to wait
+        until the owned paths are computed for all newly added reviewers
+        (computing the owned path for a user is rather expensive).\
+        Can be overridden per project by setting
+        [codeOwners.enableAsyncMessageOnAddReviewer](#codeOwnersEnableAsyncMessageOnAddReviewer)
+        in `@PLUGIN@.config`.\
+        By default `true`.
+
 <a id="pluginCodeOwnersMaxCodeOwnerConfigCacheSize">plugin.@PLUGIN@.maxCodeOwnerConfigCacheSize</a>
 :       When computing code owner file statuses for a change (e.g. to compute
         the results for the code owners submit rule) parsed code owner config
@@ -546,6 +604,16 @@
         code owner config files that are cached per request.\
         By default `10000`.
 
+<a id="pluginCodeOwnersMaxCodeOwnerCacheSize">plugin.@PLUGIN@.maxCodeOwnerCacheSize</a>
+:       When computing code owner file statuses for a change (e.g. to compute
+        the results for the code owners submit rule) emails that are mentioned
+        in relevant code owner config files need to be resolved to Gerrit
+        accounts. The resolved code owners are cached in memory for the time of
+        the request so that this resolution has to be done only once per email.\
+        This configuration parameter allows to set a limit for the number of
+        resolved code owners that are cached per request.\
+        By default `10000`.
+
 # <a id="projectConfiguration">Project configuration in @PLUGIN@.config</a>
 
 <a id="codeOwnersDisabled">codeOwners.disabled</a>
@@ -585,7 +653,7 @@
         The supported code owner backends are listed at the
         [Backends](backends.html) page.\
         If not set, the global setting
-        [plugin.@PLUGIN@.backend](#pluginCodeOwnersBackend) in `gerrit.config`\
+        [plugin.@PLUGIN@.backend](#pluginCodeOwnersBackend) in `gerrit.config`
         is used.\
         \
         **NOTE:** Be careful with changing this parameter as it invalidates all
@@ -601,7 +669,7 @@
         the one for the full name takes precedence.\
         Overrides the per repository setting
         [codeOwners.backend](#codeOwnersBackend) and the
-        `codeOwners.\<branch\>.backend` setting from parent projects.\
+        `codeOwners.<branch>.backend` setting from parent projects.\
         The supported code owner backends are listed at the
         [Backends](backends.html) page.\
         If not set, the project level configuration
@@ -614,6 +682,51 @@
         syntax so that the existing code owner config files can no longer be
         parsed.
 
+<a id="codeOwnersPathExpressions">codeOwners.pathExpressions</a>
+:       The path expression syntax that is used in`
+        [code owner config files](user-guide.html#codeOwnerConfigFiles).\
+        Overrides the global setting
+        [plugin.@PLUGIN@.pathExpressions](#pluginCodeOwnersPathExpressions) in
+        `gerrit.config` and the `codeOwners.pathExpressions` setting from parent
+        projects.\
+        Can be overridden per branch by setting
+        [codeOwners.\<branch\>.pathExpressions](#codeOwnersBranchPathExpressions).\
+        The supported path expression syntaxes are listed and explained at the
+        [Path Expressions](path-expressions.html) page.\
+        If not set, the global setting
+        [plugin.@PLUGIN@.pathExpressions](#pluginCodeOwnersPathExpressions) in
+        `gerrit.config` is used.\
+        \
+        **NOTE:** Be careful with changing this parameter as it affects how path
+        expressions in existing
+        [code owner config files](user-guide.html#codeOwnerConfigFiles) are
+        interpreted. E.g. by changing the path expression syntax existing path
+        expressions may now match different files, or existing path expressions
+        may no longer be valid and fail to parse, in which case they would not
+        match anything any more.
+
+<a id="codeOwnersBranchPathExpressions">codeOwners.\<branch\>.pathExpressions</a>
+:       The path expression syntax that is used in
+        [code owner config files](user-guide.html#codeOwnerConfigFiles) that are
+        contained in this branch.\
+        The branch can be the short or full name. If both configurations exist
+        the one for the full name takes precedence.\
+        Overrides the per repository setting
+        [codeOwners.pathExpressions](#codeOwnersPathExpressions) and the
+        `codeOwners.<branch>.pathExpressions` setting from parent projects.\
+        The path expression syntax that is used in
+        [code owner config files](user-guide.html#codeOwnerConfigFiles).\
+        If not set, the project level configuration
+        [codeOwners.pathExpressions](#codeOwnersPathExpressions) is used.\
+        \
+        **NOTE:** Be careful with changing this parameter as it affects how path
+        expressions in existing
+        [code owner config files](user-guide.html#codeOwnerConfigFiles) are
+        interpreted. E.g. by changing the path expression syntax existing path
+        expressions may now match different files, or existing path expressions
+        may no longer be valid and fail to parse, in which case they would not
+        match anything any more.
+
 <a id="codeOwnersFileExtension">codeOwners.fileExtension</a>
 :       The file extension that should be used for the code owner config files
         in this project.\
@@ -628,7 +741,36 @@
         projects.\
         If not set, the global setting
         [plugin.@PLUGIN@.fileExtension](#pluginCodeOwnersFileExtension) in
-        `gerrit.config` is used.
+        `gerrit.config` is used.\
+        If a file extension is configured,
+        [codeOwners.enableCodeOwnerConfigFilesWithFileExtensions](#codeOwnersEnableCodeOwnerConfigFilesWithFileExtensions)
+        should be set to `false`, as otherwise code owner config files with any
+        file extension will be validated, which causes validation errors if code
+        owner config files with other file extensions use a different owners
+        syntax or reference users that do not exist on this Gerrit host.
+
+<a id="codeOwnersEnableCodeOwnerConfigFilesWithFileExtensions">codeOwners.enableCodeOwnerConfigFilesWithFileExtensions</a>
+:       Whether file extensions for code owner config files are enabled.\
+        If enabled, code owner config files with file extensions are treated as
+        regular code owner config files. This means they are validated on
+        push/submit (if validation is enabled) and can be imported by other code
+        owner config files (regardless of whether they have the same file
+        extension or not).\
+        Enabling this option should not be used in combination with the
+        [plugin.@PLUGIN@.fileExtension](#pluginCodeOwnersFileExtension) option
+        as that option uses file extensions to differentiate different sets of
+        code owner config files in the same repository/branch which may use
+        different code owner syntaxes or reference users that do not exist on
+        this Gerrit host. In this case, code owner config files with (other)
+        file extensions should not be validated as they likely will fail the
+        validation.
+        Overrides the global setting
+        [plugin.@PLUGIN@.enableCodeOwnerConfigFilesWithFileExtensions](#pluginCodeOwnersEnableCodeOwnerConfigFilesWithFileExtensions)
+        in `gerrit.config` and the `codeOwners.fileExtension` setting from
+        parent projects.\
+        If not set, the global setting
+        [plugin.@PLUGIN@.enableCodeOwnerConfigFilesWithFileExtensions](#pluginCodeOwnersEnableCodeOwnerConfigFilesWithFileExtensions)
+        in `gerrit.config` is used.\
 
 <a id="codeOwnersOverrideInfoUrl">codeOwners.overrideInfoUrl</a>
 :       A URL for a page that provides project-specific information about how to
@@ -699,8 +841,8 @@
 <a id="codeOwnersExemptedUser">codeOwners.exemptedUser</a>
 :       The email of a user that should be exempted from requiring code owner
         approvals.\
-        If a user is exempted from requiring code owner approvals changes that
-        are uploaded by this user are automatically code-owner approved.\
+        If a user is exempted from requiring code owner approvals patch sets
+        that are uploaded by this user are automatically code-owner approved.\
         Can be specified multiple times to exempt multiple users.\
         Extends the global setting
         [plugin.@PLUGIN@.exemptedUser](#pluginCodeOwnersExemptedUser) in
@@ -978,6 +1120,22 @@
         [plugin.@PLUGIN@.maxPathsInChangeMessages](#pluginCodeOwnersMaxPathsInChangeMessages)
         in `gerrit.config` is used.
 
+<a id="codeOwnersEnableAsyncMessageOnAddReviewer">codeOwners.enableAsyncMessageOnAddReviewer</a>
+:       When a code owner is added as a reviewer the @PLUGIN@ plugin posts a
+        change message that lists owned paths of the code owner. This setting
+        controls whether this change message should be posted asynchronously.\
+        Posting these change messages asynchronously improves the latency for
+        post review and post reviewers calls, since they do not need to wait
+        until the owned paths are computed for all newly added reviewers
+        (computing the owned path for a user is rather expensive).\
+        Overrides the global setting
+        [plugin.@PLUGIN@.enableAsyncMessageOnAddReviewer](#pluginCodeOwnersEnableAsyncMessageOnAddReviewer)
+        in `gerrit.config` and the `codeOwners.enableAsyncMessageOnAddReviewer`
+        setting from parent projects.\
+        If not set, the global setting
+        [plugin.@PLUGIN@.enableAsyncMessageOnAddReviewer](#pluginCodeOwnersEnableAsyncMessageOnAddReviewer)
+        in `gerrit.config` is used.
+
 ---
 
 Back to [@PLUGIN@ documentation index](index.html)
diff --git a/resources/Documentation/disclaimer.md b/resources/Documentation/disclaimer.md
new file mode 100644
index 0000000..498022c
--- /dev/null
+++ b/resources/Documentation/disclaimer.md
@@ -0,0 +1,13 @@
+# Disclaimer
+
+The @PLUGIN@  plugin is specifically developed to support code owners for the
+Chrome and Android teams at Google. This means some of the functionality and
+design decisons are driven by Google-specific use-cases. Nonetheless the support
+for code owners is pretty generic and [configurable](config.html) so that it
+should be suitable for other teams as well.
+
+---
+
+Back to [@PLUGIN@ documentation index](index.html)
+
+Part of [Gerrit Code Review](../../../Documentation/index.html)
diff --git a/resources/Documentation/feature-set.md b/resources/Documentation/feature-set.md
new file mode 100644
index 0000000..f345660
--- /dev/null
+++ b/resources/Documentation/feature-set.md
@@ -0,0 +1,62 @@
+# Feature Set
+
+The `@PLUGIN@` plugin supports the following features:
+
+* Support for defining code owners:
+    * Code owners can be specified in `OWNERS` files that can appear in any
+      directory in the source branch.
+    * Default code owners can be specified on repository level by an `OWNERS`
+      file in the `refs/meta/config` branch.
+    * Global code owners across repositories can be configured.
+    * A fallback code owners policy controls who owns files that are not covered
+      by `OWNERS` files.
+    * Code owners can be specified by email (groups are not supported).
+    * Inheritance from parent directories is supported and can be disabled.
+    * Including an `OWNERS` file from other directories / branches / projects is
+      possible (only on the same host).
+    * File globs can be used.
+    * see [code owners documentation](config-guide.html#codeOwners) and
+      [OWNERS syntax](backend-find-owners.html#syntax)
+<br><br>
+* Prevents submitting changes without code owner approvals:
+    * Which votes count as code owner approvals is
+      [configurable](setup-guide.html#configureCodeOwnerApproval).
+    * Implemented as Java submit rule (no Prolog).
+    * Configuring [exemptions](user-guide.html#codeOwnerExemptions) is possible.
+<br><br>
+* Support for overrides:
+    * Privileged users can be allowed to override the code owner submit check.
+    * Overriding is done by voting on a [configured override
+      label](setup-guide.html#configureCodeOwnerOverrides).
+    * see [override setup](config-faqs.html#setupOverrides)
+<br><br>
+* UI extensions on change screen:
+    * [Code owner suggestion](how-to-use.html#howDoesItWork)
+    * [Display of the code owners submit requirement](how-to-use.html#codeOwnersSubmitRequirement)
+    * [Display of code owner statuses in the file list](how-to-use.html#perFilCodeOwnerStatuses)
+    * Change messages that list the owned paths.
+    * see [UI walkthrough](how-to-use.html) and [user guide](user-guide.html)
+<br><br>
+* Extensible:
+    * Supports multiple [backends](backends.html) which can implement different
+      syntaxes for `OWNERS` files.
+<br><br>
+* Validation:
+    * updates to `OWNERS` files are [validated](validation.html) on commit
+      received and submit
+    * `OWNERS` files in a [project](rest-api.html#check-code-owner-config-files)
+      or [revision](rest-api.html#check-code-owner-config-files-in-revision) can
+      be validated on demand to detect consistency issues
+<br><br>
+* Rich REST API:
+    * see [REST API documentation](rest-api.html)
+<br><br>
+* Highly configurable:
+    * see [setup guide](setup-guide.html), [config-guide](config-guide.html),
+      [config FAQs](config-faqs.html) and [config documentation](config.html)
+
+---
+
+Back to [@PLUGIN@ documentation index](index.html)
+
+Part of [Gerrit Code Review](../../../Documentation/index.html)
diff --git a/resources/Documentation/how-to-use.md b/resources/Documentation/how-to-use.md
index 23337be..4d7a2d9 100644
--- a/resources/Documentation/how-to-use.md
+++ b/resources/Documentation/how-to-use.md
@@ -1,177 +1,113 @@
 # Intro
 
-The `code-owners` plugin provides support for code owners in Gerrit and is
-replacing the `find-owners` plugin.
+The `@PLUGIN@` plugin provides support for
+[code owners](user-guide.html#codeOwners) in Gerrit.
 
-For projects that used code owners with the `find-owners` plugin before, the
-existing `OWNERS` files continue to work and the only major difference is that
-the `code-owners` plugin comes with a new UI for selecting code owners and
-showing the code owner status.
+If the `@PLUGIN@` plugin is enabled, changes can only be submitted if all
+touched files are covered by [approvals](user-guide.html#codeOwnerApproval) from
+code owners.
 
-The `code-owners` plugin is an open-source plugin and is maintained by the
-Gerrit team at Google.
+The features of the `@PLUGIN@` plugin are described [here](feature-set.html).
+
+**NOTE:** The `@PLUGIN@` is replacing the `find-owners` plugin. For projects that
+used code owners with the `find-owners` plugin before, the existing `OWNERS`
+files continue to work and the only major difference is that the `@PLUGIN@`
+plugin comes with a new UI for selecting code owners and showing the code owner
+status.
 
 This document focuses on the workflows in the UI. Further information can be
-found in the [backend user guide](user-guide.html).
+found in the [user guide](user-guide.html).
 
-### Enable the plugin
+## <a id="enableThePlugin">Enable the plugin
 
-#### As a user
+As a user you don’t need to do anything as the plugin is enabled by the host
+administrator.
 
-You don’t need to do anything as the plugin is enabled by the host admin.
-
-#### As an admin
-
-The `code-owners` plugin is only supported for the Gerrit version that is
-currently developed in master. The first Gerrit release that supports the
-`code-owners` plugin is Gerrit 3.3.0.
-
-Before installing/enabling the plugin, or enabling the code owners functionality
-for further projects, it is important that the plugin is correctly configured.
-The required configuration is described in the plugin [setup
+**NOTE:** As host administrator please follow the instructions in the [setup
 guide](setup-guide.html).
 
-### Bug report / Feedback
+## <a id="reportBug">Bug report / Feedback
 
-Report a bug or send feedback using this [Monorail
+Please report bugs or send feedback using this [Monorail
 template](https://bugs.chromium.org/p/gerrit/issues/entry?template=code-owners-plugin).
-You can also report a bug through the bug icon in the reply dialog next to the
+
+You can also report bugs through the bug icon in the reply dialog next to the
 `HIDE OWNERS` / `SUGGEST OWNERS` button.
 
 ![suggest owners from reply dialog](./suggest-owners-from-reply-dialog.png "Suggest owners")
 
-## Code Owners
+## <a id="howDoesItWork">How does the @PLUGIN@ plugin work?
 
-### Who are code owners?
+The `@PLUGIN@` plugin provides suggestions of code owners for files that you are
+modifying in your change, so that you can easily add them as reviewers, as
+you'll need their approval to submit your change.
 
-A code owner is a user whose approval is required to modify files under a
-certain path. Who is a code owner of a path is controlled via `OWNERS` files
-that are checked into the repository. For submitting a change Gerrit requires
-that all files that were touched in the change are approved by a code owner.
-Code owners usually apply their approval by voting with "Code-Review+1" on the
-change. Their approval is to confirm that “This change is appropriate for our
-system and belongs in this directory."
+For each file (or group of files that share the same code owners) you get the 5
+best suitable code owners suggested. Which code owners are best suitable to
+review a file is computed based on multiple [scoring
+factors](rest-api.html#scoringFactors), e.g. the distance of the code owner
+config file that defines the code owner to the path for which code owners are
+listed (the lower the distance the better the code owner). If wanted the code
+owner suggestion can be expanded to all code owners.
 
-### Why do we leverage Code Owners?
+The `@PLUGIN@` plugin also informs you at a glance about the status of the code
+owners approvals for the change and the status of code owner approvals per file.
 
-Owners are gatekeepers before a CL is submitted, they enforce standards across
-the code base, help disseminate knowledge around their specific area of
-ownership, ensure their is appropriate code review coverage, and provide timely
-reviews. Code owners is designed as a code quality feature to ensure someone
-familiar with the code base reviews any changes to the codebase or a subset of
-the codebase they are the Owner of, by making sure the change is appropriate for
-the system.
+## <a id="addCodeOwnersAsReviewers">Add code owners to your change
 
-## What is the `code-owners` plugin?
+1. To add code owners for the files in your change, click on `SUGGEST OWNERS` or
+   `ADD OWNERS` next to the `Code Owners` submit requirement.
+\
+![suggest code owners from change page](./suggest-owners-from-change-page.png "Suggest code owners from change page")
 
-### What is the benefit?
-
-Code owners in Gerrit will be supported by a new `code-owners` plugin which is
-developed as an open-source plugin and maintained by the Gerrit team at Google.
-
-The `code-owners` plugin supports:
-
-- [defining code owners](#definingCodeOwners)
-- requiring owner approvals for submitting changes
-- displaying owner information in the UI & suggesting code owners as reviewers
-- overriding owner checks
-- a [REST API](rest-api.html) to inspect code owners
-
-### How does it work?
-
-The plugin provides suggestions of owners for the directory or files that you
-are modifying in your change based on a score. It also informs you at a glance
-about the status of code owners for the change and the status of code owners per
-file.
-
-#### Score
-
-The `code-owners` plugin suggests a maximum of 5 closest code owners based on
-their score. The code owner score is calculated based on the distance of code
-owners to the files.
-
-## <a id="definingCodeOwners">Defining code owners
-
-If you have used code owners via the `find-owners` plugin before, your code
-owners are already defined in `OWNERS` files and you don’t need to do anything
-since the new `code-owners` plugin just reads the existing `OWNERS` files.
-
-If you haven’t used code owners before, you can now define code owners in
-`OWNERS` files which are stored in the source tree. The code owners that are
-defined in an `OWNERS` file apply to the directory that contains the `OWNERS`
-file, and all its subdirectories (except if a subdirectory contains an `OWNERS`
-file that disables the inheritance of code owners from the parent directories).
-
-The syntax of `OWNERS` file is explained in the [backend
-documentation](backend-find-owners.html#syntax) and examples can be found in the
-[cookbook](backend-find-owners-cookbook.html).
-
-The code-owners plugin does not support an editor to create and edit `OWNERS`
-files from the UI. `OWNERS` files must be created and edited manually in the
-local repository and then be pushed to the remote repository, the same way as
-any other source file.
-
-## <a id="addCodeOwnersAsReviewers">Add owners to your change
-
-1. To add owners of the files in your change, click on `SUGGEST OWNERS` next to
-   the `Code-Owners` submit requirement.
-
-![suggest owners from change page](./suggest-owners-from-change-page.png "Suggest owners from change page")
-
-2. The Reply dialog opens with the code owners section expanded by default with
-   owners suggestions. Code owners are suggested by groups of files which share
-   the same code owners.
-
+2. The Reply dialog opens with the code owners section expanded that shows the
+   code owner suggestions. Code owners are suggested by groups of files which
+   share the same code owners.
+\
 ![owner suggestions](./owner-suggestions.png "owner suggestions")
 
-3. Hover over a file to view the list of files and their full file path.
-
+3. Hover over a file group to view the list of files and their full file paths.
+\
 ![suggestion file groups](./suggestions-file-groups.png "suggestion file groups")
 
-4. Click user chips to select code owners for each file or group of files. The
-   selected code owner is automatically added to the reviewers section and
-   automatically selected on other files the code owner owns in the change (if
-   applicable).
-
+4. Click on user chips to select a code owner for each file or file group.
+   The selected code owner is automatically added to the reviewers section and
+   automatically selected on other files / file groups that this code owner owns
+   (if applicable).
+\
 ![add or modify reviewers from suggestions](./add-owner-to-reviewer.png "add owner to reviewer")
 
 5. Click `SEND` to notify the code owners you selected on your change.
 
-## Reply dialog use cases
+## <a id="replyDialogUseCases">Reply dialog use cases
 
-### Approved files
+### <a id="noCodeOwnersFound">No code owners found
 
-Once a file has received an approval vote by the code owner, the file disappears
-from the file list in the reply dialog. This lets you focus on the files that
-are not yet assigned to a code owner or are pending approval.
-
-### No code owners found
-
-There are 3 possible reasons for encountering a "Not found" text:
+There are several possible reasons for encountering a "Not found" text:
 
 ![no owners found](./no-owners-found.png "no owners found")
 
-- No code owners were defined for these files.
-  Reason: This could be due to missing `OWNERS` defined for these files.
+- No code owners were defined for these files.\
+  Reason: This could be due to missing `OWNERS` files that cover these files.
 
-- None of the code owners of these files are visible.
+- None of the code owners of these files are visible.\
   Reason: The code owners accounts are not visible to you.
 
-- None of the code owners can see the change.
+- None of the code owners can see the change.\
   Reason: The code owners have no read permission on the target branch of the
   change and hence cannot approve the change.
 
-- Code owners defined for these files are invalid.
+- Code owners defined for these files are invalid.\
   Reason: The emails cannot be resolved.
 
 For these cases, we advise you to:
 
-1. Ask someone with override powers (e.g. sheriff) to grant an override vote to
-   unblock the change submission.
+1. Ask someone with override powers (e.g. sheriff) to grant a [code owner
+   override](#applyingCodeOwnerOverride) vote to unblock the change submission.
 2. Contact the project owner to ask them to fix the code owner definitions, or
    permissions if needed.
 
-### Renamed files
+### <a id="renamedFiles">Renamed files
 
 ![renamed file from file list](./renamed-file-from-file-list.png "Renamed files")
 
@@ -181,133 +117,167 @@
 file will be considered as approved only if both old path/name and new path/name
 are approved.
 
-### Failed to fetch file
+### <a id="failedToFetch">Failed to fetch file
 
-This status is informing you about a failed API call.
-**Refresh the page** to recover from this error.
+This status is informing you about a failed API call. **Refresh the page** to
+recover from this error. If the error persists, please [report it](#reportBug).
 
 ![failed to fetch](./failed-to-fetch-owners.png "Failed to fetch owners")
 
-### Large change
+### <a id="largeChange">Large change
 
-In case of a large change containing a large number of files (hundreds or even
-thousands), it will take some time to fetch all suggested code owners. In the
-reply dialog, the plugin will show the overall status of the fetching and
-results as soon as it has results together with the loading indicator. The
-loading will disappear until all files finished fetching, failed files will be
-grouped into a single group.
+If a change contains a large number of files (hundreds or even thousands), it
+will take some time to fetch all suggested code owners. In this case the reply
+dialog will show the overall status of fetching code owners and display results
+as soon as they come in. Files for which the suggestions are still being
+computed have a loading indicator that will disappear as soon as the suggestions
+are available.
 
-The fetching of suggested code owners should not block the reply itself. So you
-still can select from suggestions even when not all files are finished and sent
-for reviewing.
+Fetching code owner suggestions does not block the reply itself. So you can
+select code owners from suggestions that are already available, while
+suggestions for other files are still being fetched. Sending the reply is
+possible even when suggestions for some files are still being fetched.
 
-## Change page overview
+**NOTE:** If retrieving suggestions fails for some files, these files will show
+up as a single group.
+
+## <a id="codeOwnerStatus">Code owner status on change page
 
 In the change page, you can get an overview of the code owner statuses.
 
 If applicable, the code owner status is displayed:
 
-- Next to the `Code-Owners` submit requirement
-
+- Next to the `Code Owners` submit requirement
+\
 ![submit requirement](./submit-requirement.png "Submit requirement")
 
 - Next to each file
-
+\
 ![owner status](./owner-status.png "Owner status")
 
-### Code owner status
+### <a id="codeOwnersSubmitRequirement">`Code Owners` submit requirement
 
-#### `Code-Owners` submit requirement
-
-The `Code-Owners` submit requirement is providing an overview about the code
+The `Code Owners` submit requirement is providing an overview about the code
 owner status at a glance.
 
-- Missing a reviewer that can grant the code owner approval
-- Pending code owner approval
-- Approved by a code owner
-
-**Missing code owner approval**
-
-The change is missing a reviewer that can grant the code owner approval.
-
+- **Missing a reviewer that can grant the code owner approval:**\
+  The change is missing a reviewer that can grant the code owner approval.
+\
 ![missing owner](./owner-status-missing.png "Missing code owner")
 
-**Pending code owner approval**
-
-- The change is pending a vote from a reviewer that can grant the code owner
-  approval. Code owners have been added to the change but have not voted yet.
-
+- **Pending code owner approval:**
+    - The change is pending a vote from a reviewer that can grant the code owner
+      approval. Code owners have been added to the change but have not voted
+      yet.
+\
 ![pending owner's approval 1](./owner-status-pending-1.png "Pending owner's approval")
 
-- A code owner has voted -1 on the change. A -1 doesn't block a file from being
-  approved by another code owner. The status is pending because the change needs
-  another round of review.
-
+    - A code owner has voted -1 on the change. A -1 doesn't block a file from
+      being approved by another code owner. The status is pending because the
+      change needs another round of review.
+\
 ![pending owner's approval 2](./owner-status-pending-2.png "Pending owner's approval")
 
-**Approved by code owner**
-
-Each file in your change was approved by at least one code owner. It's not
-required that all code owners approve a change.
-
+- **Approved by a code owner:**\
+  Each file in your change was approved by at least one code owner. It's not
+  required that all code owner approve a change.
+\
 ![owner approved](./owner-status-approved.png "Code owner approved")
 
-#### File status
+### <a id="perFilCodeOwnerStatuses">Per file code owner statuses
 
-Additionally, the `code-owners` plugin provides a more detailed overview of code
-owner status per file in the change with 3 statuses and you can **hover over the
-icon** to display a tooltip.
+The `@PLUGIN@` plugin also shows the code owner statuses per file in the file
+list.
 
-**Missing code owner approval**
+For each file the code owner status is shown as an icon. You can **hover over
+the icon** to get additional information displayed as a tooltip.
 
-A code owner of this file is missing as a reviewer to the change.
-
+- **Missing code owner approval:**\
+  A code owner for this file is missing as a reviewer.
+\
 ![missing owner tooltip](./tooltip-missing-owner.png "Tooltip for missing status")
 
-**Pending code owner approval**
-
-A code owner of this file has been added to the change but have not voted yet.
-
+- **Pending code owner approval:**\
+  A code owner for this file has been added to the change but has not voted yet.
+\
 ![pending owner tooltip](./tooltip-pending-owner.png "Tooltip for pending status")
 
-**Approved by code owner**
-
-A code owner of this file has approved the change. You can also see this icon if
-you are a code owner of the file as in this case the file is implicitly approved
-by you.
-
+- **Approved by code owner:**\
+  A code owner of this file has approved the change. You can also see this icon
+  if you are a code owner of the file and [implicit code owner
+  approvals](user-guide.html#implicitApprovals) are
+  enabled, as in this case the file is implicitly approved by you, or if the
+  change has been [exempted](user-guide.html#codeOwnerExemptions) from requiring
+  code owner approvals.
+\
 ![approved owner tooltip](./tooltip-approved-owner.png "Tooltip for approved status")
 
-**Failed to fetch status icon**
-
-This status is informing you about a failed API call.
-**Refresh the page** to recover from this error.
-
+- **Failed to fetch status icon:**\
+  This status is informing you about a failed API call. **Refresh the page** to
+  recover from this error. If the error persists, please [report it](#reportBug).
+\
 ![failed owner tooltip](./tooltip-failed-owner.png "Tooltip for failed status")
 
-#### No label and no status
+### <a id="noStatus">No label and no status
 
-When you own all the files in your change, the `code-owners` plugin will:
+When you own all the files in your change and [implicit code owner
+approvals](user-guide.html#implicitApprovals) are enabled, the `@PLUGIN@` plugin
+will:
 
 - Not show the `Code-Owners` submit requirement
 - Not show the file status
 
-### Owners-Override label
+## <a id="applyingCodeOwnerOverride">Applying a code owner override
 
-#### In the reply dialog
+Users with certain permissions (e.g. sheriffs) can bypass the `Code Owners`
+submit requirement by applying a [code owner
+override](user-guide.html#codeOwnerOverride) approval (usually a
+`Owners-Override+1` vote).
 
-The `Owners-Override` label is votable by a user with certain permissions (e.g.
-sheriff). The `Owner-Override` label will show in the reply dialog and you can
-vote on it if you have certain permissions.
+The code owner override approval is applied by voting on the override label in
+the reply dialog, the same way as voting on any other label is done. Voting on
+the override label is only offered to users that have permissions to vote on
+this label.
 
 ![code owner override label in reply dialog](./code-owner-override-label-in-reply.png "Vote on owners-override label")
 
-#### In the change page
-
-When a user with certain permissions has voted "Owners-Override+1" and the
-`Code-Owners` submit requirement returns the status `Approved
-(Owners-Override)`.
+If a code owner override approval has been applied, the `Code Owners` submit
+requirement shows the status `Approved (Owners-Override)`.
 
 ![code owner override label in change page](./code-owner-override-label-in-change.png "Owners-override label")
 
+## <a id="mergeCommit">Merge commits
 
+When viewing the `Auto Merge` results on a merge commit, a file list row is
+added showing the number of cleanly merged files. The code owner status for
+this row indicates the overall approval status of the cleanly merged files.
+
+![merge commit UI](./owner-status-merge-commit.png "Merge Commit UI")
+
+## <a id="definingCodeOwners">Defining code owners
+
+Code owners are defined in [code owner config
+files](user-guide.html#codeOwnerConfigFiles) (e.g.
+[OWNERS](backend-find-owners.html#syntax) files) that are stored in the source
+tree of the repository.
+
+**NOTE:** If you have used code owners via the `find-owners` plugin before, code
+owners are already defined in `OWNERS` files and you don’t need to do anything
+since the `@PLUGIN@` plugin just reads the existing `OWNERS` files.
+
+**NOTE:** The `@PLUGIN@` plugin does not support an editor to create and edit
+`OWNERS` files from the UI. This means `OWNERS` files must be created and edited
+manually in the local repository and then be pushed to the remote repository,
+the same way as any other source file.
+
+---
+
+Back to [@PLUGIN@ documentation index](index.html)
+
+Part of [Gerrit Code Review](../../../Documentation/index.html)
+
+<style>
+img {
+   border: 1px solid rgba(0, 0, 0, .8);
+}
+</style>
diff --git a/resources/Documentation/metrics.md b/resources/Documentation/metrics.md
index 0c4ccf5..7c15669 100644
--- a/resources/Documentation/metrics.md
+++ b/resources/Documentation/metrics.md
@@ -10,8 +10,8 @@
 * `add_change_message_on_add_reviewer`:
   Latency for adding a change message with the owned path when a code owner is
   added as a reviewer.
-* `compute_changed_files`:
-  Latency for computing changed files.
+** `post_type':
+   Whether the change message was posted synchronously or asynchronously.
 * `compute_file_status`:
   Latency for computing the file status for one file.
 * `compute_file_statuses`:
@@ -25,8 +25,6 @@
 * `extend_change_message_on_post_review`:
   Latency for extending the change message with the owned path when a code owner
   approval is applied.
-* `get_auto_merge`:
-  Latency for getting the auto merge commit of a merge commit.
 * `get_changed_files`:
   Latency for getting changed files from diff cache.
 * `prepare_file_status_computation`:
@@ -48,10 +46,14 @@
 
 ## <a id="codeOwnerConfigMetrics"> Code Owner Config Metrics
 
+* `code_owner_cache_reads_per_change`:
+  Number of code owner cache reads per change.
 * `code_owner_config_cache_reads_per_change`:
   Number of code owner config cache reads per change.
 * `code_owner_config_backend_reads_per_change`:
   Number of code owner config backend reads per change.
+* `code_owner_resolutions_per_change`:
+  Number of code owner resolutions per change.
 * `load_code_owner_config`:
   Latency for loading a code owner config file (read + parse).
 * `parse_code_owner_config`:
@@ -61,6 +63,8 @@
 
 ## <a id="counterMetrics"> Counter Metrics
 
+* `count_code_owner_cache_reads`:
+  Total number of code owner reads from cache.
 * `count_code_owner_config_reads`:
   Total number of code owner config reads from backend.
 * `count_code_owner_config_cache_reads`:
@@ -73,6 +77,8 @@
       The result of the validation.
     * `dry_run`:
       Whether the validation was a dry run.
+* `count_code_owner_resolutions
+  Total number of code owner resolutions.
 * `count_code_owner_submit_rule_errors`:
   Total number of code owner submit rule errors.
     * `cause`:
diff --git a/resources/Documentation/owner-status-merge-commit.png b/resources/Documentation/owner-status-merge-commit.png
new file mode 100644
index 0000000..f766421
--- /dev/null
+++ b/resources/Documentation/owner-status-merge-commit.png
Binary files differ
diff --git a/resources/Documentation/path-expressions.md b/resources/Documentation/path-expressions.md
index e5c2dd8..ea306cf 100644
--- a/resources/Documentation/path-expressions.md
+++ b/resources/Documentation/path-expressions.md
@@ -5,20 +5,36 @@
 [per-file](backend-find-owners.html#perFile) rule for the
 [find-owners](backend-find-owners.html) backend).
 
-Which syntax is used depends on the used code owner backend:
+The following path expression syntaxes are supported:
+
+* `GLOB`:
+  Uses [globs](#globs) to match paths.
+* `FIND_OWNERS_GLOB`:
+  Uses [globs](#globs) to match paths, but each glob is automatically prefixed
+  with `{**/,}` so that subfolders are always matched, e.g. `*.md` matches all
+  md files in all subfolders, rather then only md files in the current folder
+  (also see the [caveat](#findOwnersCaveat) section below).
+* `SIMPLE`:
+  Uses [simple path expressions](#simplePathExpressions) to match paths.
+
+Which syntax is used by default depends on the used code owner backend:
 
 * [find-owners](backend-find-owners.html) backend:
-  uses [globs](#globs), but each glob is automatically prefixed with `{**/,}`
-  so that subfolders are always matched (e.g. `*.md` matches all md files in all
-  subfolders, rather then only md files in the current folder)
+  Uses `FIND_OWNERS_GLOB` as path expression syntax.
 * [proto](backend-proto.html) backend:
-  uses [simple path expressions](#simplePathExpressions)
+  Uses `SIMPLE` as path expression syntax.
+
+The default path expression syntax that is derived from the backend can be
+overriden by [global configuration](config.html#pluginCodeOwnersPathExpressions),
+on [project-level](config.html#codeOwnersPathExpressions) or on
+[branch-level](config.html#codeOwnersBranchPathExpressions).
 
 ## <a id="globs">Globs
 
 Globs support the following wildcards:
 
-* `*`: matches any string, including slashes
+* `*`: matches any string that does not include slashes
+* `**`: matches any string, including slashes
 * `?`: matches any character
 * `[abc]`: matches one character given in the bracket
 * `[a-c]`: matches one character from the range given in the bracket
@@ -39,14 +55,61 @@
 
 | To Match | Glob | find-owners | Simple Path Expression |
 | -------- | ---- | ----------- | ---------------------- |
-| Concrete file in current folder | `BUILD` | not possible | `BUILD` |
-| File type in current folder | `*.md` | not possible | `*.md` |
+| Concrete file in current folder | `BUILD` | [not possible](#findOwnersCaveatMatchingAFile) | `BUILD` |
+| File type in current folder | `*.md` | [not possible](#findOwnersCaveatMatchingFilesByType) | `*.md` |
 | Concrete file in the current folder and in all subfolders | `{**/,}BUILD` | `BUILD` | needs 2 expressions: `BUILD` + `.../BUILD` |
 | File type in the current folder and in all subfolders | `**.md` | `*.md` or `**.md` | `....md` |
-| All files in a subfolder | `my-folder/**` | not possible, but you can add a `my-folder/OWNERS` file instead of using a glob | `my-folder/...` |
+| All files in a subfolder | `my-folder/**` | [not possible](#findOwnersCaveatMatchingFilesInSubfolder), but you can add a `my-folder/OWNERS` file instead of using a glob | `my-folder/...` |
 | All “foo-<1-digit-number>.txt” files in all subfolders | `{**/,}foo-[0-9].txt` | `foo-[0-9].txt` |not possible |
 | All “foo-<n-digit-number>.txt” files in all subfolders | not possible | not possible | not possible
 
+## <a id="findOwnersCaveat">Caveat with find-owners path expressions
+
+To be compatible with the `find-owners` plugin find-owners path expressions
+are prefixes with `{**/,}` which matches any folder (see
+[above](path-expressions.html)). This means if path expressions like  `BUILD`,
+`*.md` or `my-folder/**` are used in `OWNERS` files the effective path
+expression are `{**/,}BUILD`, `{**/,}*.md` and `{**/,}my-folder/**`. These path
+expression do not only match `BUILD`, `*.md` and `my-folder/**` directly in the
+folder that contains the `OWNERS` file but also `BUILD`, `*.md` and
+`my-folder/**` in any subfolder (e.g. `foo/bar/BUILD`, `foo/bar/baz.md` and
+`foo/bar/my-folder/`).
+
+### Examples
+
+#### <a id="findOwnersCaveatMatchingAFile">Matching a file
+
+If you have the following `/foo/OWNERS` file:
+
+```
+  per-file BUILD=john.doe@example.com
+```
+\
+John Doe owns the `/foo/BUILD` file, but also all `BUILD` files in
+subfolders of `/foo/`, e.g. `/foo/bar/baz/BUILD`.
+
+#### <a id="findOwnersCaveatMatchingFilesByType">Matching files by type
+
+If you have the following `/foo/OWNERS` file:
+
+```
+  per-file *.md=john.doe@example.com
+```
+\
+John Doe owns all `*.md` files in `/foo/`, but also all `*.md` files in
+subfolders of `/foo/`, e.g. `/foo/bar/baz.md`.
+
+#### <a id="findOwnersCaveatMatchingFilesInSubfolder">Matching files in subfolder
+
+If you have the following `/foo/OWNERS` file:
+
+```
+  per-file my-folder/*=john.doe@example.com
+```
+\
+John Doe owns all files in the `/foo/my-folder/` folder, but also all files in
+any `my-folder/` subfolder, e.g. all files in `/foo/bar/baz/my-folder/`.
+
 ---
 
 Back to [@PLUGIN@ documentation index](index.html)
diff --git a/resources/Documentation/rest-api.md b/resources/Documentation/rest-api.md
index 461232e..d42cf50 100644
--- a/resources/Documentation/rest-api.md
+++ b/resources/Documentation/rest-api.md
@@ -483,6 +483,9 @@
 * distance of the code owner config file that defines the code owner to the
   path for which code owners are listed (the lower the distance the better the
   code owner)
+* whether the user is explicitly mentioned as a code owner in the code owner
+  config file vs. the user being a code owner only because the code ownership
+  has been assigned to all users (aka `*`)
 * whether the code owner is a reviewer of the change (only when listing code
   owners for a change)
 
@@ -518,7 +521,7 @@
 1\. + 2. [score=0] User A and User B (random order since they have the same score)\
 3\. [score=1] User C\
 4\. + 5. [score=1] 2 Random Users (because `*` is resolved to random users since `resolve-all-users` is `true`)\
-* `owned_by_all_users` in the response is `true`
+- `owned_by_all_users` in the response is `true`
 
 If the request is done with `resolve-all-users=false` and `limit=5` the following
 code owners are returned in this order:
@@ -527,7 +530,7 @@
 3\. [score=1] User C\
 4\. [score=2] User D\
 5\. [score=3] User E\
-* `owned_by_all_users` in the response is `true`
+- `owned_by_all_users` in the response is `true`
 
 #### <a id="rootOwnersFaq">Why are root code owners not suggested first?
 
@@ -635,17 +638,50 @@
         "change_type": "ADDED",
         "new_path_status" {
           "path": "docs/readme.md",
-          "status": "APPROVED"
+          "status": "APPROVED",
+          "reasons": [
+            "approved by <GERRIT_ACCOUNT_1001439> who is a default code owner"
+          ]
         }
       },
       {
         "change_type": "DELETED",
         "old_path_status" {
           "path": "docs/todo.txt",
-          "status": "PENDING"
+          "status": "PENDING",
+          "reasons": [
+            "reviewer <GERRIT_ACCOUNT_1000096> is a code owner"
+          ]
+        }
+      },
+      {
+        "change_type": "RENAMED",
+        "old_path_status" {
+          "path": "user-introduction.txt",
+          "status": "INSUFFICIENT_REVIEWERS"
+        },
+        "new_path_status" {
+          "path": "docs/user-intro.md",
+          "status": "APPROVED"
         }
       }
-    ]
+    ],
+    "accounts": {
+      1000096: {
+        "_account_id": 1000025,
+        "name": "John Doe",
+        "email": "john.doe@example.com",
+        "username": "john"
+        "display_name": "John D"
+      },
+      1001439: {
+        "_account_id": 1001439,
+        "name": "John Smith",
+        "email": "john.smith@example.com",
+        "username": "jsmith"
+        "display_name": "Johnny"
+      }
+    }
   }
 ```
 
@@ -673,6 +709,10 @@
 
 * [service users](#serviceUsers) (members of the `Service Users` group)
 * the change owner (since the change owner cannot be added as reviewer)
+* code owners that are annotated with
+  [LAST_RESORT_SUGGESTION](backend-find-owners.html#lastResortSuggestion),
+  except if dropping these code owners would make the suggestion result empty or
+  if these code owners are already reviewers of the change
 
 In addition, by default the change number is used as seed if none was specified.
 This way the sort order on a change is always the same for files that have the
@@ -689,7 +729,7 @@
 | Field Name   |           | Description |
 | ------------ | --------- | ----------- |
 | `start`\|`S` | optional  | Number of owned paths to skip. Allows to page over the owned files. By default 0.
-| `limit`\|`n` | optional  | Limit defining how many owned files should be returned at most. By default 50.
+| `limit`\|`n` | optional  | Limit defining how many [OwnedChangedFileInfo](#owned-changed-file-info) entities should be returned at most. By default 50.
 | `user`       | mandatory | user for which the owned paths should be returned
 
 #### Request
@@ -709,9 +749,45 @@
 
   )]}'
   {
+    "owned_changed_files": [
+      {
+        "new_path": {
+          "path": "/foo/bar/baz.md",
+          "owned": true
+        }
+      },
+      {
+        "old_path": {
+          "path": "/foo/baz/bar.md",
+          "owned": true
+        }
+      },
+      {
+        "new_path": {
+          "path": "/foo/new-name.md",
+          "owned": true
+        },
+        "old_path": {
+          "path": "/foo/old-name.md",
+          "owned": true
+        }
+      },
+      {
+        "new_path": {
+          "path": "/xyz/new-name.md"
+        },
+        "old_path": {
+          "path": "/abc/old-name.md",
+          "owned": true
+        }
+      }
+    ],
     "owned_paths": [
+      "/abc/old-name.md",
       "/foo/bar/baz.md",
       "/foo/baz/bar.md",
+      "/foo/new-name.md",
+      "/foo/old-name.md"
     ]
   }
 ```
@@ -812,6 +888,7 @@
 | `validate_disabled_branches` | optional | Whether code owner config files in branches for which the code owners functionality is disabled should be validated too. By default unset, `false`.
 | `branches`                   | optional | List of branches for which code owner config files should be validated. The `refs/heads/` prefix may be omitted. By default unset, which means that code owner config files in all branches should be validated.
 | `path`                       | optional | Glob that limits the validation to code owner config files that have a path that matches this glob. By default unset, which means that all code owner config files should be validated.
+| `verbosity`                 | optional | Level that controls which code owner config file issues are returned. The following values are supported: `FATAL` - only fatal issues are returned, `ERROR` - only fatal and error issues are returned, `WARNING` - all issues (warning, error and fatal) are returned. If unset, `WARNING` is used.
 
 ---
 
@@ -842,6 +919,7 @@
 | `is_global_code_owner` | Whether the given email is configured as a global
 code owner. Note that if the email is configured as global code owner, but the email is not resolvable (see `is_resolvable` field), the user is not a code owner.
 | `is_owned_by_all_users` | Whether the the specified path in the branch is owned by all users (aka `*`).
+| `annotation` | Annotations that were set for the user. Contains only supported annotations (unsupported annotations are reported in the `debugs_logs`). Sorted alphabetically.
 | `debug_logs` | List of debug logs that may help to understand why the user is or isn't a code owner.
 
 ---
@@ -951,6 +1029,7 @@
 | `patch_set_number` |          | The number of the patch set for which the code owner statuses are returned.
 | `file_code_owner_statuses` |  | List of the code owner statuses for the files in the change as [FileCodeOwnerStatusInfo](#file-code-owner-status-info) entities, sorted by new path, then old path.
 | `more`             | optional | Whether the request would deliver more results if not limited. Not set if `false`.
+| `accounts`         | optional | An account ID to detailed [AccountInfo](../../../Documentation/rest-api-accounts.html#account-info) entities map that contains the accounts that are referenced in the reason messages that are returned with the [PathCodeOwnerStatusInfo](#path-code-owner-status-info) entities in the `file_code_owner_statuses`. Not set if no accounts are referenced from reasons.
 
 ### <a id="code-owners-status-info"> CodeOwnersStatusInfo
 The `CodeOwnersStatusInfo` contains information about whether the code owners
@@ -976,7 +1055,7 @@
 
 | Field Name    |          | Description |
 | ------------- | -------- | ----------- |
-| `change_type` | optional | The type of the file modification. Can be `ADDED`, `MODIFIED`, `DELETED`, `RENAMED` or `COPIED`. Not set if `MODIFIED`. Renamed files might appear as separate addition and deletion or with type=RENAMED. Copied files might appear as addition or with type=COPIED.
+| `change_type` | optional | The type of the file modification. Can be `ADDED`, `MODIFIED`, `DELETED`, `RENAMED` or `COPIED`. Not set if `MODIFIED`.
 | `old_path_status` | optional | The code owner status for the old path as [PathCodeOwnerStatusInfo](#path-code-owner-status-info) entity. Only set if `change_type` is `DELETED` or `RENAMED`.
 | `new_path_status` | optional | The code owner status for the new path as [PathCodeOwnerStatusInfo](#path-code-owner-status-info) entity. Not set if `change_type` is `DELETED`.
 
@@ -992,23 +1071,44 @@
 | `invalid_code_owner_config_info_url` | optional | Optional URL for a page that provides project/host-specific information about how to deal with invalid code owner config files.
 |`fallback_code_owners` || Policy that controls who should own paths that have no code owners defined. Possible values are: `NONE`: Paths for which no code owners are defined are owned by no one. `PROJECT_OWNERS`: Paths for which no code owners are defined are owned by the project owners. `ALL_USERS`: Paths for which no code owners are defined are owned by all users.
 
+### <a id="owned-changed-file-info"> OwnedChangedFileInfo
+The `OwnedChangedFileInfo` entity contains information about a file that was
+changed in a change for which the user owns the new path, the old path or both
+paths.
+
+| Field Name |          | Description |
+| ---------- | -------- | ----------- |
+| `new_path` | optional | Owner information for the new path as a [OwnedPathInfo](#owned-path-info) entity. Not set for deletions.
+| `old_path` | optional | Owner information for the old path as a [OwnedPathInfo](#owned-path-info) entity. Only set for deletions and renames.
+
+### <a id="owned-path-info"> OwnedPathInfo
+The `OwnedPathInfo` entity contains information about a file path the may be
+owned by the user.
+
+| Field Name |          | Description |
+| ---------- | -------- | ----------- |
+| `path`     |          | The absolute file path.
+| `owned`    | optional | `true` is the path is owned by the user. Otherwise unset.
+
 ### <a id="owned-paths-info"> OwnedPathsInfo
 The `OwnedPathsInfo` entity contains paths that are owned by a user.
 
 
 | Field Name    |          | Description |
 | ------------- | -------- | ----------- |
-| `owned_paths` |          |The owned paths as absolute paths, sorted alphabetically.
+| `owned_changed_files`   || List of files that were changed in the revision for which the user owns the new path, the old path or both paths. The entries are sorted alphabetically by new path, and by old path if new path is not present. Contains at most as many entries as the limit that was specified on the request.
+| `owned_paths` |          | The list of the owned new and old paths that are contained in the `owned_changed_files` field. The paths are returned as absolute paths and are sorted alphabetically. May contain more entries than the limit that was specified on the request (if the users owns new and old path of renamed files).
 | `more`        | optional | Whether the request would deliver more results if not limited. Not set if `false`.
 
 ### <a id="path-code-owner-status-info"> PathCodeOwnerStatusInfo
 The `PathCodeOwnerStatusInfo` entity describes the code owner status for a path
 in a change.
 
-| Field Name         | Description |
-| ------------------ | ----------- |
-| `path` | The path to which the code owner status applies.
-| `status` | The code owner status for the path. Can be 'INSUFFICIENT_REVIEWERS' (the path needs a code owner approval, but none of its code owners is currently a reviewer of the change), `PENDING` (a code owner of this path has been added as reviewer, but no code owner approval for this path has been given yet) or `APPROVED` (the path has been approved by a code owner or a code owners override is present).
+| Field Name |          | Description |
+| ---------- | -------- | ----------- |
+| `path`     |          | The path to which the code owner status applies.
+| `status`   |          | The code owner status for the path. Can be 'INSUFFICIENT_REVIEWERS' (the path needs a code owner approval, but none of its code owners is currently a reviewer of the change), `PENDING` (a code owner of this path has been added as reviewer, but no code owner approval for this path has been given yet) or `APPROVED` (the path has been approved by a code owner or a code owners override is present).
+| `reasons`  | optional | A list of reasons explaining the status. The reasons may contain placeholders for accounts as `<GERRIT_ACCOUNT_XXXXXXX>` (where `XXXXXXX` is the account ID). The referenced accounts are returned in the [CodeOwnerStatusInfo](#code-owner-status-info) entity that contains this PathCodeOwnerStatusInfo (see field `accounts`). Not set if there are no reasons.
 
 ---
 
diff --git a/resources/Documentation/setup-guide.md b/resources/Documentation/setup-guide.md
index d912ea4..63aa7e0 100644
--- a/resources/Documentation/setup-guide.md
+++ b/resources/Documentation/setup-guide.md
@@ -140,7 +140,7 @@
 [plugin.code-owners.disabled](config.html#pluginCodeOwnersDisabled) in
 `gerrit.config` and [codeOwners.disabled](config.html#codeOwnersDisabled) in the
 [code-owners.config](config-faqs.html#updateCodeOwnersConfig) file in the
-`refs/meta/config` branch of the `All-Projects` project).
+`refs/meta/config` branch of the `All-Projects` project.
 
 ###### b) Disable the code owners functionality in the projects/repositories that should not use code owners
 
@@ -165,7 +165,7 @@
 
 To opt-out branches from using code owners set
 [codeOwners.disabledBranch](config.html#codeOwnersDisabledBranch) in the
-[code-owners.config](config.faqs.html#updateCodeOwnersConfig) file in the
+[code-owners.config](config-faqs.html#updateCodeOwnersConfig) file in the
 `refs/meta/config` branch to a regular expression that matches the branches that
 should be opted-out (requires to be a project owner).
 
@@ -177,8 +177,9 @@
 
 ### <a id="configureCodeOwnerApproval">4. Configure the label vote that should count as code owner approval
 
-By default `Code-Review+1` votes from code owners count as code owner approval.
-If this is what you want, you can skip this step.
+By default `Code-Review+1` votes from [code owners](user-guide.html#codeOwners)
+count as [code owner approval](user-guide.html#codeOwnerApproval). If this is
+what you want, you can skip this step.
 
 Otherwise you can configure the required code owner approval globally by setting
 [plugin.code-owners.requiredApproval](config.html#pluginCodeOwnersRequiredApproval)
@@ -218,7 +219,7 @@
 to labels that are otherwise required for submission. If you want to rely solely
 on code owner approvals, you may need to reconfigure existing label definitions
 (e.g. change the `Code-Review` label definition to not require a `Code-Review+2`
-vote for change submission.
+vote for change submission).
 
 **NOTE:** Whether code owner approvals are sticky across patch sets depends on
 the definition of the required label. If the label definition has [copy
@@ -229,7 +230,7 @@
 ### <a id="grantCodeOwnerPermissions">5. Grant code owners permission to vote on the label that counts as code owner approval
 
 Code owners must be granted permissions to vote on the label that counts as code
-owner approval (see [previous step](#configureCodeOwnerApproval) in order to be
+owner approval (see [previous step](#configureCodeOwnerApproval)) in order to be
 able grant the code owner approval on changes so that they can be submitted.
 
 As for any other permission, the
@@ -243,12 +244,13 @@
 code owner config file). In this case changes for these files cannot be code
 owner approved and hence cannot be submitted.
 
-To avoid that this leads to unsubmittable changes it is recommended to configure
-code owner overrides and/or fallback code owners.
+To avoid that this leads to unsubmittable changes, it is recommended to
+configure code owner overrides and/or fallback code owners.
 
 #### <a id="configureCodeOwnerOverrides">Configure code owner overrides
 
-It's possible to configure code owner overrides that allow privileged users to
+It's possible to configure [code owner
+overrides](user-guide.html#codeOwnerOverride) that allow privileged users to
 override code owner approvals. This means they can approve changes without being
 a code owner.
 
@@ -290,11 +292,6 @@
     value = +1 Override
     defaultValue = 0
 ```
-\
-**NOTE:** Defining the label and configuring it as override approval must be
-done by 2 separate commits that are pushed one after another (not being able to
-add both configurations in one commit is a known issue that still needs to be
-fixed).
 
 #### <a id="configureFallbackCodeOwners">Configure fallback code owners
 
@@ -312,7 +309,7 @@
 
 By default, the emails in code owner config files that make users code owners
 can have any email domain. It is strongly recommended to limit the allowed email
-domains to trusted email providers (e.g. email providers that gurantee that an
+domains to trusted email providers (e.g. email providers that guarantee that an
 email is never reassigned to a different user, since otherwise the user to which
 the email is reassigned automatically takes over the code ownerships that are
 assigned to this email, which is a security issue).
@@ -328,6 +325,8 @@
     allowedEmailDomain = chromium.org
 ```
 
+**NOTE:** Allowed email domains cannot be configured on project level.
+
 ### <a id="optionalConfiguration">8. Optional Configuration
 
 Other optional configuration parameters are described in the [config
@@ -343,18 +342,35 @@
   decides which files of merge commits require code owner approvals
 * [File extension](config.html#codeOwnersFileExtension) that should be used for
   code owner config files.
+* Whether [pure reverts should be exempted from requiring code owner
+  approvals](config.html#pluginCodeOwnersExemptPureReverts).
+* [Users that are exempted from requiring code owner
+  approvals](config.html#pluginCodeOwnersExemptedUser)
+* [configure](config.html#pluginCodeOwnersPathExpressions) a different syntax
+  for [path expressions](path-expressions.html) in code owner config files (e.g.
+  use plain globs)
 
 ### <a id="stopUsingFindOwners">9.Stop using the find-owners Prolog submit rule
 
 This section can be skipped if you haven't used the `find-owners` plugin so far.
 
-The `find-owners` plugin comes with a Prolog submit rules that prevents the
+The `find-owners` plugin comes with a Prolog submit rule that prevents the
 submission of changes that have insufficient code owner approvals. With the
 `code-owners` plugin this is now being checked by a submit rule that is
 implemented in Java. Hence the Prolog submit rule from the `find-owners` plugin
 is no longer needed and you should stop using it before you start using the
 `code-owners` plugin.
 
+**NOTE:** It's possible to use the submit rules from the `code-owners` plugin
+and `find-owners` plugin at the same time, but then certains things like [code
+owner overrides](user-guide.html#codeOwnerOverride) and
+[exemptions](user-guide.html#codeOwnerExemptions) are not working (as they are
+not supported by the `find-owners` plugin).
+
+**NOTE:** Do not yet disable/uninstall the `find-owners` plugin, see
+[below](#disableFindOwnersPlugin) which preconditions needs to be fulfilled for
+this.
+
 ### <a id="configureCodeOwners">10. Add an initial code owner configuration at root level
 
 By enabling the code owners functionality, a code owner approval from code
@@ -371,13 +387,14 @@
 is recommended to add an initial code owner configuration at the root level that
 defines the code owners for the project/branch explicitly.
 
+**NOTE:** Submitting the initial code owner configuration requires an override
+or an approval from a fallback code owner (see above).
+
 **NOTE:** It's recommended to add the initial code owner configuration only
 after enabling the code owners functionality so that the code owner
 configuration is [validated](validation.html) on upload, which prevents
 submitting an invalid code owner config that may block the submission of all
-changes (e.g. if it is not parseable). Submitting the initial code owner
-configuration requires an override or an approval from a fallback code owner
-(see above).
+changes (e.g. if it is not parseable).
 
 **NOTE** If the repository contains pre-existing code owner config files, it is
 recommended to validate them via the [Check code owners files REST
@@ -389,6 +406,10 @@
 code owners functionality as otherwise changes can become unsubmittable (they
 require code-owner approvals, but noone can provide nor override them).
 
+**NOTE:** Instead of defining root code owners in all branches, you may also
+define default code owners in the `refs/meta/config` branch, that then apply to
+all branches (also see [config guide](config-guide.html#codeOwners)).
+
 ### <a id="disableFindOwnersPlugin">11. Disable/uninstall the find-owners plugin
 
 If the `find-owners` plugin has been used so far, you likely want to
diff --git a/resources/Documentation/submit-requirement-operators.md b/resources/Documentation/submit-requirement-operators.md
new file mode 100644
index 0000000..1561b45
--- /dev/null
+++ b/resources/Documentation/submit-requirement-operators.md
@@ -0,0 +1,22 @@
+# Submit Requirement Operators
+
+The @PLUGIN@ plugin contributes the following operators. These operators can
+only be used in submit requirements expressions and cannot be used in search:
+
+ * **has:enabled_code-owners**
+
+   Matches with changes that have the code-owners functionality enabled. For
+   example, if code-owners is disabled for a specific branch, changes in this
+   branch will not be matched against this operator.
+
+ * **has:approval_code-owners**
+
+   Matches with changes that have all necessary code-owner approvals or a
+   code-owner override. This operator does not match with closed (merged)
+   changes.
+
+---
+
+Back to [@PLUGIN@ documentation index](index.html)
+
+Part of [Gerrit Code Review](../../../Documentation/index.html)
diff --git a/resources/Documentation/toc.md b/resources/Documentation/toc.md
new file mode 100644
index 0000000..da7b044
--- /dev/null
+++ b/resources/Documentation/toc.md
@@ -0,0 +1,27 @@
+### User Guides
+
+* [Intro](how-to-use.html)
+* [Feature Set](feature-set.html)
+* [User Guide](user-guide.html)
+* [Syntax for OWNERS files](backend-find-owners.html#syntax)
+* [Cookbook for OWNERS files](backend-find-owners-cookbook.html)
+* [Path Expressions](path-expressions.html)
+* [REST API](rest-api.html)
+* [Validation](validation.html)
+* [Submit Requirement Operators](submit-requirement-operators.html)
+
+### Admin Guides
+
+* [Setup Guide](setup-guide.html)
+* [Config Guide](config-guide.html)
+* [Config FAQs](config-faqs.html)
+* [Configuration](config.html)
+* [Backends](backends.html)
+* [Metrics](metrics.html)
+* [Disclaimer](disclaimer.html)
+* [Alternative Plugins](alternative-plugins.html)
+
+### Contributor Guides
+
+* [Build](build.html)
+
diff --git a/resources/Documentation/user-guide.md b/resources/Documentation/user-guide.md
index 5a665cb..37049fc 100644
--- a/resources/Documentation/user-guide.md
+++ b/resources/Documentation/user-guide.md
@@ -18,10 +18,25 @@
 ## <a id="codeOwners">What are code owners?
 
 A code owner is a user that is configured as owner of a path (directory or file)
-and whose approval is required to modify the path or files under that path.
+and whose [approval](#codeOwnerApproval) is required to modify the directory or
+files under that path.
 
 Who is a code owner of a path is controlled via [code owner config
-files](#codeOwnerConfigFiles).
+files](#codeOwnerConfigFiles) (e.g. `OWNERS` files).
+
+## <a id="whyCodeOwners">Why should code owners be used?
+
+Code owners are gatekeepers before a change is submitted, they enforce standards
+across the code base, help disseminate knowledge around their specific area of
+ownership, ensure there is appropriate code review coverage, and provide timely
+reviews.
+
+Enforcing code owner approvals is designed as a code quality feature. Code
+owners are defined to ensure someone familiar with the codebase reviews and
+approves all changes that are done to the codebase.
+
+By granting a code owner approvel the code owner confirms that the change is
+appropriate for the system and is done correctly.
 
 ## <a id="codeOwnerConfigFiles">Code owner config files
 
@@ -29,9 +44,9 @@
 define the [code owners](#codeOwners) for a path.
 
 In which files code owners are defined and which syntax is used depends on the
-configured [code owner backend](backends.html). Example: if the
-[find-owners](backend-find-owners.html) backend is used, code owners are defined
-in [OWNERS](backend-find-owners.html#syntax) files.
+configured [code owner backend](backends.html#codeOwnerConfigFiles). Example: if
+the [find-owners](backend-find-owners.html) backend is used, code owners are
+defined in [OWNERS](backend-find-owners.html#syntax) files.
 
 To create/edit code owner config files, clone the repository, edit the code
 owner config files locally and then push the new commit to the remote repository
@@ -59,11 +74,16 @@
 host administrators or the project owners have [configured a different label/vote
 that is required as code owner approval](setup-guide.html#configureCodeOwnerApproval).
 
+By granting a code owner approvel the code owner confirms that the change is
+appropriate for the system and is done correctly.
+
 The code owner check for a file is satisfied as soon as one of its code owners
 grants the code owner approval. Negative votes from other code owners do not
-block the submission (unless it's a veto vote which is configured independently
-of the `@PLUGIN@` plugin).
+block the submission (unless it's a veto vote which is
+[configured](/Documentation/config-labels.html#label_function) independently of
+the `@PLUGIN@` plugin).
 
+### <a id="implicitApprovals">
 It's possible to [configure implicit
 approvals](config.html#codeOwnersEnableImplicitApprovals) for changes/patch-sets
 that are owned and uploaded by a code owner. In this case, if a code owner only
@@ -76,7 +96,7 @@
 change owner and uploader).
 
 **NOTE:** Implicit approvals are applied on changes that are owned by a code
-owner, but only if the current patch set was uploader by the change owner
+owner, but only if the current patch set was uploaded by the change owner
 (change owner == last patch set uploader).
 
 For files that are [renamed/moved](#renames) Gerrit requires a code owner
@@ -133,6 +153,20 @@
 
 **NOTE:** It's possible that overrides are disabled for a project.
 
+## <a id="codeOwnerExemptions">Code owner exemptions
+
+Some changes may be exempted from requiring [code owner
+approvals](#codeOwnerApproval):
+
+* changes of [projects](config.html#pluginCodeOwnersDisabled) /
+  [branches](config.html#pluginCodeOwnersDisabledBranch) for which the code
+  owners functionality has been disabled
+* changes that were uploaded by users that are
+  [exempted](config.html#pluginCodeOwnersExemptedUser) from requiring code owner
+  approvals
+* changes that are pure revert, if
+  [configured](config.html#pluginCodeOwnersExemptPureReverts)
+
 ## <a id="codeOwnerSuggestion">Code owner Suggestion
 
 As a change owner, you need to request code owner approvals for the files that
@@ -156,15 +190,20 @@
   [allowedEmailDomain configuration](config.html#pluginCodeOwnersAllowedEmailDomain))
 * do not have read access to the destination branch of the change
 * are service users (members of the `Service Users` group)
+* code owners that are annotated with
+  [LAST_RESORT_SUGGESTION](backend-find-owners.html#lastResortSuggestion),
+  except if dropping these code owners would make the suggestion result empty
 
 The suggested code owners are sorted by score, so that the best suitable code
-owners appear first. The following criteria are taken into account for computing
-the score:
+owners appear first. To compute the score multiple [scoring
+factors](rest-api.html#scoringFactors) are taken into account, e.g. the distance
+of the [code owner config file](#codeOwnerConfigFiles) that defines the code
+owner to the path for which code owners are listed (the lower the distance the
+better the code owner).
 
-* The distance of the [code owner config file](#codeOwnerConfigFiles) that
-  defines the code owner from the owned path.\
-  The smaller the distance the better we consider the code owner as
-  reviewer/approver for the path.
+**NOTE:** Fallback code owners, if
+[configured](config.html#pluginCodeOwnersFallbackCodeOwners), are not included
+in the suggestion.
 
 ## <a id="noCodeOwnersDefined">How to submit changes with files that have no code owners?
 
@@ -211,9 +250,9 @@
 destination branch (the first parent commit). This includes all files that have
 been touched in other branches and that are now being integrated into the
 destination branch (regardless of whether there was a conflict resolution or
-whether the auto-merge succeeded without conflicts). To see these files in the
-change screen, `Parent 1` needs to be selected as base for the comparison
-(instead of the `Auto Merge` that is selected as base by default).
+whether the auto-merge succeeded without conflicts). The overall approval value
+for the automatically merged files is shown on the `Auto Merge` base along with
+a button to switch to the `Parent 1` base which shows the files individually.
 
 By [configuration](config.html#codeOwnersMergeCommitStrategy) it is possible,
 that changes for merge commits only require code owner approvals for files that
@@ -230,7 +269,7 @@
 The logic that checks whether a change has sufficient [code owner
 approvals](#codeOwnerApproval) to be submitted is implemented in the code owners
 submit rule. If the code owners submit rule finds that code owner approvals are
-missing the submission of the change is blocked. In this case it's possible to
+missing, the submission of the change is blocked. In this case it's possible to
 use a [code owner override](#codeOwnerOverride) to unblock the change
 submission.
 
diff --git a/resources/Documentation/validation.md b/resources/Documentation/validation.md
index 1c660a5..d7a5548 100644
--- a/resources/Documentation/validation.md
+++ b/resources/Documentation/validation.md
@@ -55,22 +55,6 @@
   blocking all uploads, to reduce the risk of breaking the plugin configuration
   `code-owner.config` files are validated too)
 
-## <a id="skipCodeOwnerConfigValidationOnDemand">Skip code owner config validation on demand
-
-By setting the `--code-owners~skip-validation` push option it is possible to
-skip the code owner config validation on push.
-
-Using this push option requires the calling user to have to
-`Can Skip Code Owner Config Validation` global capability. Host administrators
-have this capability implicitly assigned via the `Administrate Server` global
-capability.
-
-**NOTE:** Using this option only makes sense if the [code owner config validation
-on submit](config.html#pluginCodeOwnersEnableValidationOnSubmit) is disabled, as
-otherwise it's not possible to submit the created change (using the push option
-only skips the validation for the push, but not for the submission of the
-change).
-
 ## <a id="howCodeOwnerConfigsCanGetIssuesAfterSubmit">
 In addition it is possible that [code owner config
 files](user-guide.hmtl#codeOwnerConfigFiles) get issues after they have been
@@ -82,7 +66,9 @@
   configured](setup-guide.html#configureCodeOwnersBackend) which now uses a
   different syntax or different names for code owner config files, the [file
   extension for code owner config file is set/changed](config.html#codeOwnersFileExtension),
-  or the [allowed email domains are changed](config.html#pluginCodeOwnersAllowedEmailDomain))
+  [arbitrary file extensions for code owner config files](config.html#codeOwnersEnableCodeOwnerConfigFilesWithFileExtensions)
+  get enabled/disabled or the [allowed email domains are
+  changed](config.html#pluginCodeOwnersAllowedEmailDomain))
 * emails of users may change so that emails in code owner configs can no longer
   be resolved
 * imported code owner config files may get deleted or renamed so that import
@@ -109,6 +95,29 @@
 validation that was done on upload. This means, all visibility checks will be
 done from the perspective of the uploader.
 
+## <a id="skipCodeOwnerConfigValidationOnDemand">Skip code owner config validation on demand
+
+By setting the `code-owners~skip-validation` push option it is possible to skip
+the code owner config validation on push:
+`git push -o code-owners~skip-validation origin HEAD:refs/for/master`
+
+For the [Create Change](../../../Documentation/rest-api-changes.html#create-change)
+REST endpoint skipping the code owner config validation is possible by setting
+`code-owners~skip-validation` with the value `true` as a validation option in
+the [ChangeInput](../../../Documentation/rest-api-changes.html#change-input)
+(see field `validation_options`).
+
+Using the push option or the validation option requires the calling user to
+have the `Can Skip Code Owner Config Validation` global capability. Host
+administrators have this capability implicitly assigned via the `Administrate
+Server` global capability.
+
+**NOTE:** Using this option only makes sense if the [code owner config validation
+on submit](config.html#pluginCodeOwnersEnableValidationOnSubmit) is disabled, as
+otherwise it's not possible to submit the created change (using the push option
+only skips the validation for the push, but not for the submission of the
+change).
+
 ### <a id="codeOwnerConfigFileChecks">Validation checks for code owner config files
 
 For [code owner config files](user-guide.html#codeOwnerConfigFiles) the
diff --git a/ui/code-owners-api.js b/ui/code-owners-api.js
index e186ede..80b3a2b 100644
--- a/ui/code-owners-api.js
+++ b/ui/code-owners-api.js
@@ -82,7 +82,7 @@
    * @param {string} changeId
    */
   listOwnerStatus(changeId) {
-    return this._get(`/changes/${changeId}/code_owners.status`);
+    return this._get(`/changes/${changeId}/code_owners.status?limit=100000`);
   }
 
   /**
diff --git a/ui/code-owners-banner.js b/ui/code-owners-banner.js
index 1aa9e58..9bbf64f 100644
--- a/ui/code-owners-banner.js
+++ b/ui/code-owners-banner.js
@@ -184,7 +184,7 @@
     banner.pluginStatus = status;
   }
 
-  _loadDataAfterStateChanged() {
+  loadPropertiesAfterModelChanged() {
     this.modelLoader.loadPluginStatus();
     this.modelLoader.loadBranchConfig();
   }
diff --git a/ui/owner-status-column.js b/ui/owner-status-column.js
index ae56d36..c9c9d91 100644
--- a/ui/owner-status-column.js
+++ b/ui/owner-status-column.js
@@ -28,6 +28,15 @@
   ERROR: 'error',
   ERROR_OLD_PATH: 'error-old-path',
 };
+const STATUS_PRIORITY_ORDER = [
+  STATUS_CODE.ERROR,
+  STATUS_CODE.ERROR_OLD_PATH,
+  STATUS_CODE.MISSING,
+  STATUS_CODE.PENDING,
+  STATUS_CODE.MISSING_OLD_PATH,
+  STATUS_CODE.PENDING_OLD_PATH,
+  STATUS_CODE.APPROVED,
+];
 const STATUS_ICON = {
   [STATUS_CODE.PENDING]: 'gr-icons:schedule',
   [STATUS_CODE.MISSING]: 'gr-icons:close',
@@ -35,19 +44,34 @@
   [STATUS_CODE.MISSING_OLD_PATH]: 'gr-icons:close',
   [STATUS_CODE.APPROVED]: 'gr-icons:check',
   [STATUS_CODE.ERROR]: 'gr-icons:info-outline',
+  [STATUS_CODE.ERROR]: 'gr-icons:info-outline',
 };
 const STATUS_TOOLTIP = {
   [STATUS_CODE.PENDING]: 'Pending code owner approval',
   [STATUS_CODE.MISSING]: 'Missing code owner approval',
   [STATUS_CODE.PENDING_OLD_PATH]:
-      'Pending code owner approval on pre-renamed file',
+    'Pending code owner approval on pre-renamed file',
   [STATUS_CODE.MISSING_OLD_PATH]:
-      'Missing code owner approval on pre-renamed file',
+    'Missing code owner approval on pre-renamed file',
   [STATUS_CODE.APPROVED]: 'Approved by code owner',
   [STATUS_CODE.ERROR]: 'Failed to fetch code owner status',
+  [STATUS_CODE.ERROR_OLD_PATH]: 'Failed to fetch code owner status',
 };
 
 class BaseEl extends CodeOwnersModelMixin(Polymer.Element) {
+  static get properties() {
+    return {
+      patchRange: Object,
+
+      hidden: {
+        type: Boolean,
+        reflectToAttribute: true,
+        computed: 'computeHidden(change, patchRange, ' +
+            'model.status.newerPatchsetUploaded)',
+      },
+    };
+  }
+
   computeHidden(change, patchRange, newerPatchsetUploaded) {
     if ([change, patchRange, newerPatchsetUploaded].includes(undefined)) {
       return true;
@@ -61,10 +85,8 @@
     if (newerPatchsetUploaded) return true;
 
     const latestPatchset = change.revisions[change.current_revision];
-    // only show if its comparing against base
-    if (patchRange.basePatchNum !== 'PARENT') return true;
-    // Note: in some special cases, patchNum is undefined on latest patchset like
-    // after publishing the edit, still show for them
+    // Note: in some special cases, patchNum is undefined on latest patchset
+    // like after publishing the edit, still show for them
     // TODO: this should be fixed in Gerrit
     if (patchRange.patchNum === undefined) return false;
     // only show if its latest patchset
@@ -93,19 +115,6 @@
         <div></div>
       `;
   }
-
-  static get properties() {
-    return {
-      patchRange: Object,
-
-      hidden: {
-        type: Boolean,
-        reflectToAttribute: true,
-        computed: 'computeHidden(change, patchRange, ' +
-          'model.status.newerPatchsetUploaded)',
-      },
-    };
-  }
 }
 
 customElements.define(OwnerStatusColumnHeader.is, OwnerStatusColumnHeader);
@@ -122,12 +131,8 @@
     return {
       path: String,
       oldPath: String,
-      patchRange: Object,
-      hidden: {
-        type: Boolean,
-        reflectToAttribute: true,
-        computed: 'computeHidden(change, patchRange)',
-      },
+      cleanlyMergedPaths: Array,
+      cleanlyMergedOldPaths: Array,
       ownerService: Object,
       statusIcon: {
         type: String,
@@ -175,7 +180,8 @@
 
   static get observers() {
     return [
-      'computeStatusIcon(model.status, path, oldPath)',
+      'computeStatusIcon(model.status,path, oldPath, cleanlyMergedPaths, ' +
+        'cleanlyMergedOldPaths)',
     ];
   }
 
@@ -184,36 +190,40 @@
     this.modelLoader.loadStatus();
   }
 
-  computeStatusIcon(modelStatus, path, oldPath) {
-    if ([modelStatus, path, oldPath].includes(undefined)) return;
-    if (MAGIC_FILES.includes(path)) return;
-
-    const codeOwnerStatusMap = modelStatus.codeOwnerStatusMap;
-    const statusItem = codeOwnerStatusMap.get(path);
-    if (!statusItem) {
-      this.status = STATUS_CODE.ERROR;
+  computeStatusIcon(
+      modelStatus,
+      path,
+      oldPath,
+      cleanlyMergedPaths,
+      cleanlyMergedOldPaths
+  ) {
+    if (
+      modelStatus === undefined ||
+      ([path, oldPath].includes(undefined) && cleanlyMergedPaths === undefined)
+    ) {
       return;
     }
+    const codeOwnerStatusMap = modelStatus.codeOwnerStatusMap;
+    const paths = path === undefined ? cleanlyMergedPaths : [path];
+    const oldPaths = oldPath === undefined ? cleanlyMergedOldPaths : [oldPath];
 
-    const status = statusItem.status;
-    let oldPathStatus = null;
-    if (oldPath !== path) {
-      const oldStatusItem = codeOwnerStatusMap.get(oldPath);
-      if (!oldStatusItem) {
-        this.status = STATUS_CODE.ERROR;
-      } else {
-        oldPathStatus = oldStatusItem.status;
-      }
+    const statuses = paths
+        .filter(path => !MAGIC_FILES.includes(path))
+        .map(path => this._computeStatus(codeOwnerStatusMap.get(path)));
+    // oldPath may contain null, so filter that as well.
+    const oldStatuses = oldPaths
+        .filter(path => !MAGIC_FILES.includes(path) && !!path)
+        .map(path => this._computeStatus(codeOwnerStatusMap.get(path), true));
+    const allStatuses = statuses.concat(oldStatuses);
+    if (allStatuses.length === 0) {
+      return;
     }
-
-    const newPathStatus = this._computeStatus(status);
-    if (!oldPathStatus) {
-      this.status = newPathStatus;
-    } else {
-      this.status = newPathStatus === STATUS_CODE.APPROVED ?
-        this._computeStatus(oldPathStatus, /* oldPath= */ true) :
-        newPathStatus;
-    }
+    this.status = allStatuses.reduce((a, b) => {
+      return STATUS_PRIORITY_ORDER.indexOf(a) <
+        STATUS_PRIORITY_ORDER.indexOf(b)
+        ? a
+        : b;
+    });
   }
 
   _computeIcon(status) {
@@ -224,10 +234,12 @@
     return STATUS_TOOLTIP[status];
   }
 
-  _computeStatus(status, oldPath = false) {
-    if (status === OwnerStatus.INSUFFICIENT_REVIEWERS) {
+  _computeStatus(statusItem, oldPath = false) {
+    if (statusItem === undefined) {
+      return oldPath ? STATUS_CODE.ERROR_OLD_PATH : STATUS_CODE.ERROR;
+    } else if (statusItem.status === OwnerStatus.INSUFFICIENT_REVIEWERS) {
       return oldPath ? STATUS_CODE.MISSING_OLD_PATH : STATUS_CODE.MISSING;
-    } else if (status === OwnerStatus.PENDING) {
+    } else if (statusItem.status === OwnerStatus.PENDING) {
       return oldPath ? STATUS_CODE.PENDING_OLD_PATH : STATUS_CODE.PENDING;
     } else {
       return STATUS_CODE.APPROVED;
diff --git a/ui/plugin.js b/ui/plugin.js
index 124d986..2539908 100644
--- a/ui/plugin.js
+++ b/ui/plugin.js
@@ -72,7 +72,7 @@
         view.reporting = reporting;
       });
 
-  // submit requirement value for owner's requirement
+  // old submit requirement value for owner's requirement
   plugin.registerCustomComponent(
       'submit-requirement-item-code-owners',
       OwnerRequirementValue.is, {slot: 'value'}
@@ -82,6 +82,16 @@
         view.reporting = reporting;
       });
 
+  // new submit requirement value for owner's requirement
+  plugin.registerCustomComponent(
+      'submit-requirement-codeowners',
+      OwnerRequirementValue.is, {replace: true}
+  )
+      .onAttached(view => {
+        view.restApi = restApi;
+        view.reporting = reporting;
+      });
+
   // suggest owners for reply dialog
   plugin.registerCustomComponent(
       'reply-reviewers', SuggestOwnersTrigger.is, {slot: 'right'})
diff --git a/ui/suggest-owners-trigger.js b/ui/suggest-owners-trigger.js
index 3b48b44..518210d 100644
--- a/ui/suggest-owners-trigger.js
+++ b/ui/suggest-owners-trigger.js
@@ -43,7 +43,7 @@
             text-decoration: none;
           }
           gr-button {
-            --padding: var(--spacing-xs) var(--spacing-s);
+            --gr-button-padding: var(--spacing-xs) var(--spacing-s);
           }
         </style>
         <gr-button