Merge "ApprovalContext: Drop checkState that verified a wrong assumption"
diff --git a/java/com/google/gerrit/entities/SubmitRequirement.java b/java/com/google/gerrit/entities/SubmitRequirement.java
index df03fd5..13e0b53 100644
--- a/java/com/google/gerrit/entities/SubmitRequirement.java
+++ b/java/com/google/gerrit/entities/SubmitRequirement.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
 import java.util.Optional;
 
 /** Entity describing a requirement that should be met for a change to become submittable. */
@@ -62,6 +64,10 @@
     return new AutoValue_SubmitRequirement.Builder();
   }
 
+  public static TypeAdapter<SubmitRequirement> typeAdapter(Gson gson) {
+    return new AutoValue_SubmitRequirement.GsonTypeAdapter(gson);
+  }
+
   @AutoValue.Builder
   public abstract static class Builder {
 
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpression.java b/java/com/google/gerrit/entities/SubmitRequirementExpression.java
index c978347..2af1379 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpression.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpression.java
@@ -17,6 +17,8 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
 import java.util.Optional;
 
 /** Describe a applicability, blocking or override expression of a {@link SubmitRequirement}. */
@@ -41,4 +43,8 @@
 
   /** Returns the underlying String representing this {@link SubmitRequirementExpression}. */
   public abstract String expressionString();
+
+  public static TypeAdapter<SubmitRequirementExpression> typeAdapter(Gson gson) {
+    return new AutoValue_SubmitRequirementExpression.GsonTypeAdapter(gson);
+  }
 }
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
index f7a883e..58eb4ac 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
@@ -16,57 +16,78 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
 import java.util.Optional;
 
 /** Result of evaluating a submit requirement expression on a given Change. */
 @AutoValue
 public abstract class SubmitRequirementExpressionResult {
 
-  /**
-   * Entity detailing the result of evaluating a Submit requirement expression. Contains an empty
-   * {@link Optional} if {@link #status()} is equal to {@link Status#ERROR}.
-   */
-  public abstract Optional<PredicateResult> predicateResult();
+  /** Submit requirement expression for which this result is evaluated. */
+  public abstract SubmitRequirementExpression expression();
 
+  /** Status of evaluation. */
+  public abstract Status status();
+
+  /**
+   * Optional error message. Populated if the evaluator fails to evaluate the expression for a
+   * certain change.
+   */
   public abstract Optional<String> errorMessage();
 
-  public Status status() {
-    if (predicateResult().isPresent()) {
-      return predicateResult().get().status() ? Status.PASS : Status.FAIL;
-    }
-    return Status.ERROR;
-  }
-
-  public static SubmitRequirementExpressionResult create(PredicateResult predicateResult) {
-    return new AutoValue_SubmitRequirementExpressionResult(
-        Optional.of(predicateResult), Optional.empty());
-  }
-
-  public static SubmitRequirementExpressionResult error(String errorMessage) {
-    return new AutoValue_SubmitRequirementExpressionResult(
-        Optional.empty(), Optional.of(errorMessage));
-  }
+  /**
+   * List leaf predicates that are fulfilled, for example the expression
+   *
+   * <p><i>label:code-review=+2 and branch:refs/heads/master</i>
+   *
+   * <p>has two leaf predicates:
+   *
+   * <ul>
+   *   <li>label:code-review=+2
+   *   <li>branch:refs/heads/master
+   * </ul>
+   *
+   * This method will return the leaf predicates that were fulfilled, for example if only the first
+   * predicate was fulfilled, the returned list will be equal to ["label:code-review=+2"].
+   */
+  public abstract ImmutableList<String> passingAtoms();
 
   /**
-   * Returns a list of leaf predicate results whose {@link PredicateResult#status()} is true. If
-   * {@link #status()} is equal to {@link Status#ERROR}, an empty list is returned.
+   * List of leaf predicates that are not fulfilled. See {@link #passingAtoms()} for more details.
    */
-  public ImmutableList<PredicateResult> getPassingAtoms() {
-    if (predicateResult().isPresent()) {
-      return predicateResult().get().getAtoms(/* status= */ true);
-    }
-    return ImmutableList.of();
+  public abstract ImmutableList<String> failingAtoms();
+
+  public static SubmitRequirementExpressionResult create(
+      SubmitRequirementExpression expression, PredicateResult predicateResult) {
+    return create(
+        expression,
+        predicateResult.status() ? Status.PASS : Status.FAIL,
+        predicateResult.getPassingAtoms(),
+        predicateResult.getFailingAtoms());
   }
 
-  /**
-   * Returns a list of leaf predicate results whose {@link PredicateResult#status()} is false. If
-   * {@link #status()} is equal to {@link Status#ERROR}, an empty list is returned.
-   */
-  public ImmutableList<PredicateResult> getFailingAtoms() {
-    if (predicateResult().isPresent()) {
-      return predicateResult().get().getAtoms(/* status= */ false);
-    }
-    return ImmutableList.of();
+  public static SubmitRequirementExpressionResult create(
+      SubmitRequirementExpression expression,
+      Status status,
+      ImmutableList<String> passingAtoms,
+      ImmutableList<String> failingAtoms) {
+    return new AutoValue_SubmitRequirementExpressionResult(
+        expression, status, Optional.empty(), passingAtoms, failingAtoms);
+  }
+
+  public static SubmitRequirementExpressionResult error(
+      SubmitRequirementExpression expression, String errorMessage) {
+    return new AutoValue_SubmitRequirementExpressionResult(
+        expression,
+        Status.ERROR,
+        Optional.of(errorMessage),
+        ImmutableList.of(),
+        ImmutableList.of());
+  }
+
+  public static TypeAdapter<SubmitRequirementExpressionResult> typeAdapter(Gson gson) {
+    return new AutoValue_SubmitRequirementExpressionResult.GsonTypeAdapter(gson);
   }
 
   public enum Status {
@@ -103,11 +124,25 @@
     /** true if the predicate is passing for a given change. */
     abstract boolean status();
 
+    /** Returns a list of leaf predicate results whose {@link PredicateResult#status()} is true. */
+    ImmutableList<String> getPassingAtoms() {
+      return getAtoms(/* status= */ true).stream()
+          .map(PredicateResult::predicateString)
+          .collect(ImmutableList.toImmutableList());
+    }
+
+    /** Returns a list of leaf predicate results whose {@link PredicateResult#status()} is false. */
+    ImmutableList<String> getFailingAtoms() {
+      return getAtoms(/* status= */ false).stream()
+          .map(PredicateResult::predicateString)
+          .collect(ImmutableList.toImmutableList());
+    }
+
     /**
      * Returns the list of leaf {@link PredicateResult} whose {@link #status()} is equal to the
      * {@code status} parameter.
      */
-    ImmutableList<PredicateResult> getAtoms(boolean status) {
+    private ImmutableList<PredicateResult> getAtoms(boolean status) {
       ImmutableList.Builder<PredicateResult> atomsList = ImmutableList.builder();
       getAtomsRecursively(atomsList, status);
       return atomsList.build();
diff --git a/java/com/google/gerrit/entities/SubmitRequirementResult.java b/java/com/google/gerrit/entities/SubmitRequirementResult.java
index e28c86f..e1d5f39 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementResult.java
@@ -16,11 +16,17 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.auto.value.extension.memoized.Memoized;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
 import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Result of evaluating a {@link SubmitRequirement} on a given Change. */
 @AutoValue
 public abstract class SubmitRequirementResult {
+  /** Submit requirement for which this result is evaluated. */
+  public abstract SubmitRequirement submitRequirement();
+
   /** Result of evaluating a {@link SubmitRequirement#applicabilityExpression()} on a change. */
   public abstract Optional<SubmitRequirementExpressionResult> applicabilityExpressionResult();
 
@@ -32,6 +38,9 @@
   /** Result of evaluating a {@link SubmitRequirement#overrideExpression()} ()} on a change. */
   public abstract Optional<SubmitRequirementExpressionResult> overrideExpressionResult();
 
+  /** SHA-1 of the patchset commit ID for which the submit requirement was evaluated. */
+  public abstract ObjectId patchSetCommitId();
+
   @Memoized
   public Status status() {
     if (assertError(submittabilityExpressionResult())
@@ -53,6 +62,10 @@
     return new AutoValue_SubmitRequirementResult.Builder();
   }
 
+  public static TypeAdapter<SubmitRequirementResult> typeAdapter(Gson gson) {
+    return new AutoValue_SubmitRequirementResult.GsonTypeAdapter(gson);
+  }
+
   public enum Status {
     /** Submit requirement is fulfilled. */
     SATISFIED,
@@ -84,6 +97,7 @@
 
   @AutoValue.Builder
   public abstract static class Builder {
+    public abstract Builder submitRequirement(SubmitRequirement submitRequirement);
 
     public abstract Builder applicabilityExpressionResult(
         Optional<SubmitRequirementExpressionResult> value);
@@ -93,6 +107,8 @@
     public abstract Builder overrideExpressionResult(
         Optional<SubmitRequirementExpressionResult> value);
 
+    public abstract Builder patchSetCommitId(ObjectId value);
+
     public abstract SubmitRequirementResult build();
   }
 
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
new file mode 100644
index 0000000..4e997b4
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
@@ -0,0 +1,45 @@
+// 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.server.cache.serialize.entities;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementExpressionResultProto;
+
+/**
+ * Serializer of a {@link SubmitRequirementExpressionResult} to {@link
+ * SubmitRequirementExpressionResultProto}.
+ */
+public class SubmitRequirementExpressionResultSerializer {
+  public static SubmitRequirementExpressionResult deserialize(
+      SubmitRequirementExpressionResultProto proto) {
+    return SubmitRequirementExpressionResult.create(
+        SubmitRequirementExpression.create(proto.getExpression()),
+        SubmitRequirementExpressionResult.Status.valueOf(proto.getStatus()),
+        proto.getPassingAtomsList().stream().collect(ImmutableList.toImmutableList()),
+        proto.getFailingAtomsList().stream().collect(ImmutableList.toImmutableList()));
+  }
+
+  public static SubmitRequirementExpressionResultProto serialize(
+      SubmitRequirementExpressionResult r) {
+    return SubmitRequirementExpressionResultProto.newBuilder()
+        .setExpression(r.expression().expressionString())
+        .setStatus(r.status().name())
+        .addAllPassingAtoms(r.passingAtoms())
+        .addAllFailingAtoms(r.failingAtoms())
+        .build();
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index e9c9946..ff8d8cb 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -63,7 +63,6 @@
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
-import com.google.gerrit.entities.SubmitRequirementExpressionResult.PredicateResult;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
@@ -411,10 +410,8 @@
     SubmitRequirementExpressionInfo info = new SubmitRequirementExpressionInfo();
     info.expression = expression.expressionString();
     info.fulfilled = result.status().equals(SubmitRequirementExpressionResult.Status.PASS);
-    info.passingAtoms =
-        result.getPassingAtoms().stream().map(PredicateResult::predicateString).collect(toList());
-    info.failingAtoms =
-        result.getFailingAtoms().stream().map(PredicateResult::predicateString).collect(toList());
+    info.passingAtoms = result.passingAtoms();
+    info.failingAtoms = result.failingAtoms();
     return info;
   }
 
diff --git a/java/com/google/gerrit/server/config/PluginConfigFactory.java b/java/com/google/gerrit/server/config/PluginConfigFactory.java
index 2d0f9a5..c49e928 100644
--- a/java/com/google/gerrit/server/config/PluginConfigFactory.java
+++ b/java/com/google/gerrit/server/config/PluginConfigFactory.java
@@ -28,13 +28,11 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.File;
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.HashMap;
 import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.internal.storage.file.FileSnapshot;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
@@ -52,7 +50,6 @@
   private final SecureStore secureStore;
   private final Map<String, Config> pluginConfigs;
 
-  private volatile FileSnapshot cfgSnapshot;
   private volatile Config cfg;
 
   @Inject
@@ -69,7 +66,6 @@
     this.secureStore = secureStore;
 
     this.pluginConfigs = new HashMap<>();
-    this.cfgSnapshot = FileSnapshot.save(site.gerrit_config.toFile());
     this.cfg = cfgProvider.get();
   }
 
@@ -103,12 +99,10 @@
    * @return the plugin configuration from the 'gerrit.config' file
    */
   public PluginConfig getFromGerritConfig(String pluginName, boolean refresh) {
-    if (refresh && secureStore.isOutdated()) {
-      secureStore.reload();
-    }
-    File configFile = site.gerrit_config.toFile();
-    if (refresh && cfgSnapshot.isModified(configFile)) {
-      cfgSnapshot = FileSnapshot.save(configFile);
+    if (refresh) {
+      if (secureStore.isOutdated()) {
+        secureStore.reload();
+      }
       cfg = cfgProvider.get();
     }
     return PluginConfig.createFromGerritConfig(pluginName, cfg);
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
index 483b2e9..4c41a12 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
@@ -14,9 +14,17 @@
 
 package com.google.gerrit.server.notedb;
 
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.EntitiesAdapterFactory;
+import com.google.gerrit.json.EnumTypeAdapterFactory;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
+import com.google.gson.TypeAdapter;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonWriter;
 import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
+import java.io.IOException;
 import java.sql.Timestamp;
 
 @Singleton
@@ -26,6 +34,11 @@
   static Gson newGson() {
     return new GsonBuilder()
         .registerTypeAdapter(Timestamp.class, new CommentTimestampAdapter().nullSafe())
+        .registerTypeAdapterFactory(new EnumTypeAdapterFactory())
+        .registerTypeAdapterFactory(EntitiesAdapterFactory.create())
+        .registerTypeAdapter(
+            new TypeLiteral<ImmutableList<String>>() {}.getType(),
+            new ImmutableListAdapter().nullSafe())
         .setPrettyPrinting()
         .create();
   }
@@ -33,4 +46,27 @@
   public Gson getGson() {
     return gson;
   }
+
+  static class ImmutableListAdapter extends TypeAdapter<ImmutableList<String>> {
+
+    @Override
+    public void write(JsonWriter out, ImmutableList<String> value) throws IOException {
+      out.beginArray();
+      for (String v : value) {
+        out.value(v);
+      }
+      out.endArray();
+    }
+
+    @Override
+    public ImmutableList<String> read(JsonReader in) throws IOException {
+      ImmutableList.Builder<String> builder = ImmutableList.builder();
+      in.beginArray();
+      while (in.hasNext()) {
+        builder.add(in.nextString());
+      }
+      in.endArray();
+      return builder.build();
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 5daf28c..6684493 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -50,6 +50,7 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
@@ -413,6 +414,16 @@
   }
 
   /**
+   * Returns the evaluated submit requirements for the change. We only intend to store submit
+   * requirements in NoteDb for closed changes, hence the result will be an empty list for active
+   * changes, or a list of submit requirements results otherwise. For closed changes, the results
+   * represent the state of evaluating submit requirements for this change when it was merged.
+   */
+  public ImmutableList<SubmitRequirementResult> getSubmitRequirementsResult() {
+    return state.submitRequirementsResult();
+  }
+
+  /**
    * @return an ImmutableSet of Account.Ids of all users that have been assigned to this change. The
    *     order of the set is the order in which they were assigned.
    */
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 8be0d82..2a53c29 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -68,6 +68,7 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
@@ -125,6 +126,7 @@
   private final List<AssigneeStatusUpdate> assigneeUpdates;
   private final List<SubmitRecord> submitRecords;
   private final ListMultimap<ObjectId, HumanComment> humanComments;
+  private final List<SubmitRequirementResult> submitRequirementResults;
   private final Map<PatchSet.Id, PatchSet.Builder> patchSets;
   private final Set<PatchSet.Id> deletedPatchSets;
   private final Map<PatchSet.Id, PatchSetState> patchSetStates;
@@ -187,6 +189,7 @@
     submitRecords = Lists.newArrayListWithExpectedSize(1);
     allChangeMessages = new ArrayList<>();
     humanComments = MultimapBuilder.hashKeys().arrayListValues().build();
+    submitRequirementResults = new ArrayList<>();
     patchSets = new HashMap<>();
     deletedPatchSets = new HashSet<>();
     patchSetStates = new HashMap<>();
@@ -259,6 +262,7 @@
         submitRecords,
         buildAllMessages(),
         humanComments,
+        submitRequirementResults,
         firstNonNull(isPrivate, false),
         firstNonNull(workInProgress, false),
         firstNonNull(hasReviewStarted, true),
@@ -774,6 +778,9 @@
       for (HumanComment c : e.getValue().getEntities()) {
         humanComments.put(e.getKey(), c);
       }
+      for (SubmitRequirementResult sr : e.getValue().getSubmitRequirementsResult()) {
+        submitRequirementResults.add(sr);
+      }
     }
 
     for (PatchSet.Builder b : patchSets.values()) {
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 33bc039..e7da025 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -44,6 +44,7 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.entities.converter.ChangeMessageProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
@@ -60,10 +61,14 @@
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerStatusUpdateProto;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementResultProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.cache.serialize.entities.SubmitRequirementExpressionResultSerializer;
+import com.google.gerrit.server.cache.serialize.entities.SubmitRequirementSerializer;
 import com.google.gerrit.server.index.change.ChangeField.StoredSubmitRecord;
 import com.google.gson.Gson;
+import com.google.protobuf.Descriptors.FieldDescriptor;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.List;
@@ -128,6 +133,7 @@
       List<SubmitRecord> submitRecords,
       List<ChangeMessage> changeMessages,
       ListMultimap<ObjectId, HumanComment> publishedComments,
+      List<SubmitRequirementResult> submitRequirementResults,
       boolean isPrivate,
       boolean workInProgress,
       boolean reviewStarted,
@@ -181,6 +187,7 @@
         .submitRecords(submitRecords)
         .changeMessages(changeMessages)
         .publishedComments(publishedComments)
+        .submitRequirementsResult(submitRequirementResults)
         .updateCount(updateCount)
         .mergedOn(mergedOn)
         .build();
@@ -326,6 +333,8 @@
 
   abstract ImmutableListMultimap<ObjectId, HumanComment> publishedComments();
 
+  abstract ImmutableList<SubmitRequirementResult> submitRequirementsResult();
+
   abstract int updateCount();
 
   @Nullable
@@ -404,6 +413,7 @@
           .submitRecords(ImmutableList.of())
           .changeMessages(ImmutableList.of())
           .publishedComments(ImmutableListMultimap.of())
+          .submitRequirementsResult(ImmutableList.of())
           .updateCount(0);
     }
 
@@ -445,6 +455,9 @@
 
     abstract Builder publishedComments(ListMultimap<ObjectId, HumanComment> publishedComments);
 
+    abstract Builder submitRequirementsResult(
+        List<SubmitRequirementResult> submitRequirementsResult);
+
     abstract Builder updateCount(int updateCount);
 
     abstract Builder mergedOn(Timestamp mergedOn);
@@ -465,6 +478,11 @@
     private static final Converter<String, ReviewerStateInternal> REVIEWER_STATE_CONVERTER =
         Enums.stringConverter(ReviewerStateInternal.class);
 
+    private static final FieldDescriptor SR_APPLICABILITY_EXPR_RESULT_FIELD =
+        SubmitRequirementResultProto.getDescriptor().findFieldByNumber(2);
+    private static final FieldDescriptor SR_OVERRIDE_EXPR_RESULT_FIELD =
+        SubmitRequirementResultProto.getDescriptor().findFieldByNumber(4);
+
     @Override
     public byte[] serialize(ChangeNotesState object) {
       checkArgument(object.metaId() != null, "meta ID is required in: %s", object);
@@ -519,6 +537,9 @@
           .changeMessages()
           .forEach(m -> b.addChangeMessage(ChangeMessageProtoConverter.INSTANCE.toProto(m)));
       object.publishedComments().values().forEach(c -> b.addPublishedComment(GSON.toJson(c)));
+      object
+          .submitRequirementsResult()
+          .forEach(sr -> b.addSubmitRequirementResult(toSubmitRequirementResultProto(sr)));
       b.setUpdateCount(object.updateCount());
       if (object.mergedOn() != null) {
         b.setMergedOnMillis(object.mergedOn().getTime());
@@ -613,6 +634,53 @@
       return builder.build();
     }
 
+    private static SubmitRequirementResultProto toSubmitRequirementResultProto(
+        SubmitRequirementResult r) {
+      SubmitRequirementResultProto.Builder builder = SubmitRequirementResultProto.newBuilder();
+      builder
+          .setSubmitRequirement(SubmitRequirementSerializer.serialize(r.submitRequirement()))
+          .setCommit(ObjectIdConverter.create().toByteString(r.patchSetCommitId()));
+      if (r.applicabilityExpressionResult().isPresent()) {
+        builder.setApplicabilityExpressionResult(
+            SubmitRequirementExpressionResultSerializer.serialize(
+                r.applicabilityExpressionResult().get()));
+      }
+      builder.setSubmittabilityExpressionResult(
+          SubmitRequirementExpressionResultSerializer.serialize(
+              r.submittabilityExpressionResult()));
+      if (r.overrideExpressionResult().isPresent()) {
+        builder.setOverrideExpressionResult(
+            SubmitRequirementExpressionResultSerializer.serialize(
+                r.overrideExpressionResult().get()));
+      }
+      return builder.build();
+    }
+
+    private static SubmitRequirementResult toSubmitRequirementResult(
+        SubmitRequirementResultProto proto) {
+      SubmitRequirementResult.Builder builder =
+          SubmitRequirementResult.builder()
+              .patchSetCommitId(ObjectIdConverter.create().fromByteString(proto.getCommit()))
+              .submitRequirement(
+                  SubmitRequirementSerializer.deserialize(proto.getSubmitRequirement()));
+      if (proto.hasField(SR_APPLICABILITY_EXPR_RESULT_FIELD)) {
+        builder.applicabilityExpressionResult(
+            Optional.of(
+                SubmitRequirementExpressionResultSerializer.deserialize(
+                    proto.getApplicabilityExpressionResult())));
+      }
+      builder.submittabilityExpressionResult(
+          SubmitRequirementExpressionResultSerializer.deserialize(
+              proto.getSubmittabilityExpressionResult()));
+      if (proto.hasField(SR_OVERRIDE_EXPR_RESULT_FIELD)) {
+        builder.overrideExpressionResult(
+            Optional.of(
+                SubmitRequirementExpressionResultSerializer.deserialize(
+                    proto.getOverrideExpressionResult())));
+      }
+      return builder.build();
+    }
+
     @Override
     public ChangeNotesState deserialize(byte[] in) {
       ChangeNotesStateProto proto = Protos.parseUnchecked(ChangeNotesStateProto.parser(), in);
@@ -658,6 +726,10 @@
                   proto.getPublishedCommentList().stream()
                       .map(r -> GSON.fromJson(r, HumanComment.class))
                       .collect(toImmutableListMultimap(HumanComment::getCommitId, c -> c)))
+              .submitRequirementsResult(
+                  proto.getSubmitRequirementResultList().stream()
+                      .map(sr -> toSubmitRequirementResult(sr))
+                      .collect(toImmutableList()))
               .updateCount(proto.getUpdateCount())
               .mergedOn(proto.getHasMergedOn() ? new Timestamp(proto.getMergedOnMillis()) : null);
       return b.build();
diff --git a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
index bf2cf07..44475db 100644
--- a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
+++ b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
@@ -16,7 +16,9 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -34,6 +36,8 @@
   private final HumanComment.Status status;
   private String pushCert;
 
+  private ImmutableList<SubmitRequirementResult> submitRequirementsResult;
+
   ChangeRevisionNote(
       ChangeNoteJson noteJson, ObjectReader reader, ObjectId noteId, HumanComment.Status status) {
     super(reader, noteId);
@@ -41,6 +45,11 @@
     this.status = status;
   }
 
+  public ImmutableList<SubmitRequirementResult> getSubmitRequirementsResult() {
+    checkParsed();
+    return submitRequirementsResult;
+  }
+
   public String getPushCert() {
     checkParsed();
     return pushCert;
@@ -52,20 +61,24 @@
     MutableInteger p = new MutableInteger();
     p.value = offset;
 
-    HumanCommentsRevisionNoteData data = parseJson(noteJson, raw, p.value);
+    ChangeRevisionNoteData data = parseJson(noteJson, raw, p.value);
     if (status == HumanComment.Status.PUBLISHED) {
       pushCert = data.pushCert;
     } else {
       pushCert = null;
     }
+    this.submitRequirementsResult =
+        data.submitRequirementResults == null
+            ? ImmutableList.of()
+            : ImmutableList.copyOf(data.submitRequirementResults);
     return data.comments;
   }
 
-  private HumanCommentsRevisionNoteData parseJson(ChangeNoteJson noteUtil, byte[] raw, int offset)
+  private ChangeRevisionNoteData parseJson(ChangeNoteJson noteUtil, byte[] raw, int offset)
       throws IOException {
     try (InputStream is = new ByteArrayInputStream(raw, offset, raw.length - offset);
         Reader r = new InputStreamReader(is, UTF_8)) {
-      return noteUtil.getGson().fromJson(r, HumanCommentsRevisionNoteData.class);
+      return noteUtil.getGson().fromJson(r, ChangeRevisionNoteData.class);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/HumanCommentsRevisionNoteData.java b/java/com/google/gerrit/server/notedb/ChangeRevisionNoteData.java
similarity index 78%
rename from java/com/google/gerrit/server/notedb/HumanCommentsRevisionNoteData.java
rename to java/com/google/gerrit/server/notedb/ChangeRevisionNoteData.java
index e570412..8e33023 100644
--- a/java/com/google/gerrit/server/notedb/HumanCommentsRevisionNoteData.java
+++ b/java/com/google/gerrit/server/notedb/ChangeRevisionNoteData.java
@@ -15,14 +15,17 @@
 package com.google.gerrit.server.notedb;
 
 import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import java.util.List;
 
 /**
  * Holds the raw data of a RevisionNote.
  *
- * <p>It is intended for deserialization from JSON only. It is used for human comments only.
+ * <p>It is intended for deserialization from JSON only. It is used for human comments. Submit
+ * requirements are also stored but only for closed changes.
  */
-class HumanCommentsRevisionNoteData {
+class ChangeRevisionNoteData {
   String pushCert;
   List<HumanComment> comments;
+  List<SubmitRequirementResult> submitRequirementResults;
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 8b9d2856..971e0a8 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -65,6 +65,7 @@
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.server.CurrentUser;
@@ -78,6 +79,7 @@
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Comparator;
 import java.util.Date;
 import java.util.HashMap;
@@ -129,6 +131,7 @@
   private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>();
   private final Map<Address, ReviewerStateInternal> reviewersByEmail = new LinkedHashMap<>();
   private final List<HumanComment> comments = new ArrayList<>();
+  private final List<SubmitRequirementResult> submitRequirementResults = new ArrayList<>();
 
   private String commitSubject;
   private String subject;
@@ -302,6 +305,10 @@
     this.psDescription = psDescription;
   }
 
+  public void putSubmitRequirementResults(Collection<SubmitRequirementResult> rs) {
+    submitRequirementResults.addAll(rs);
+  }
+
   public void putComment(HumanComment.Status status, HumanComment c) {
     verifyComment(c);
     createDraftUpdateIfNull();
@@ -488,7 +495,7 @@
   /** @return the tree id for the updated tree */
   private ObjectId storeRevisionNotes(RevWalk rw, ObjectInserter inserter, ObjectId curr)
       throws ConfigInvalidException, IOException {
-    if (comments.isEmpty() && pushCert == null) {
+    if (submitRequirementResults.isEmpty() && comments.isEmpty() && pushCert == null) {
       return null;
     }
     RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
@@ -498,6 +505,9 @@
       c.tag = tag;
       cache.get(c.getCommitId()).putComment(c);
     }
+    for (SubmitRequirementResult sr : submitRequirementResults) {
+      cache.get(sr.patchSetCommitId()).putSubmitRequirementResult(sr);
+    }
     if (pushCert != null) {
       checkState(commit != null);
       cache.get(ObjectId.fromString(commit)).setPushCertificate(pushCert);
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
index 3c1d359..7998476 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
@@ -22,16 +22,20 @@
 import com.google.common.collect.Maps;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.io.OutputStreamWriter;
+import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -60,11 +64,16 @@
     }
   }
 
+  /** Submit requirements are sorted w.r.t. their names before storing in NoteDb. */
+  private final Comparator<SubmitRequirementResult> SUBMIT_REQUIREMENT_RESULT_COMPARATOR =
+      Comparator.comparing(sr -> sr.submitRequirement().name());
+
   final byte[] baseRaw;
   private final List<? extends Comment> baseComments;
   final Map<Comment.Key, Comment> put;
   private final Set<Comment.Key> delete;
 
+  private List<SubmitRequirementResult> submitRequirementResults;
   private String pushCert;
 
   private RevisionNoteBuilder(RevisionNote<? extends Comment> base) {
@@ -81,6 +90,7 @@
       put = new HashMap<>();
       pushCert = null;
     }
+    submitRequirementResults = new ArrayList<>();
     delete = new HashSet<>();
   }
 
@@ -99,6 +109,10 @@
     put.put(comment.key, comment);
   }
 
+  void putSubmitRequirementResult(SubmitRequirementResult result) {
+    submitRequirementResults.add(result);
+  }
+
   void deleteComment(Comment.Key key) {
     checkArgument(!put.containsKey(key), "cannot both delete and put %s", key);
     delete.add(key);
@@ -126,13 +140,19 @@
 
   private void buildNoteJson(ChangeNoteJson noteUtil, OutputStream out) throws IOException {
     ListMultimap<Integer, Comment> comments = buildCommentMap();
-    if (comments.isEmpty() && pushCert == null) {
+    if (submitRequirementResults.isEmpty() && comments.isEmpty() && pushCert == null) {
       return;
     }
 
     RevisionNoteData data = new RevisionNoteData();
     data.comments = COMMENT_ORDER.sortedCopy(comments.values());
     data.pushCert = pushCert;
+    if (!submitRequirementResults.isEmpty()) {
+      data.submitRequirementResults =
+          submitRequirementResults.stream()
+              .sorted(SUBMIT_REQUIREMENT_RESULT_COMPARATOR)
+              .collect(Collectors.toList());
+    }
 
     try (OutputStreamWriter osw = new OutputStreamWriter(out, UTF_8)) {
       noteUtil.getGson().toJson(data, osw);
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteData.java b/java/com/google/gerrit/server/notedb/RevisionNoteData.java
index da15b34..c8770f1 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteData.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteData.java
@@ -15,15 +15,17 @@
 package com.google.gerrit.server.notedb;
 
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import java.util.List;
 
 /**
  * Holds the raw data of a RevisionNote.
  *
  * <p>It is intended for serialization to JSON only. It is used for human comments and robot
- * comments.
+ * comments, as well as for storing submit requirements.
  */
 class RevisionNoteData {
   String pushCert;
   List<Comment> comments;
+  List<SubmitRequirementResult> submitRequirementResults;
 }
diff --git a/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java b/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
new file mode 100644
index 0000000..47948d7
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
@@ -0,0 +1,38 @@
+// 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.server.notedb;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+
+/** A {@link BatchUpdateOp} that stores the evaluated submit requirements of a change in NoteDb. */
+public class StoreSubmitRequirementsOp implements BatchUpdateOp {
+  private final ChangeData.Factory changeDataFactory;
+
+  public StoreSubmitRequirementsOp(ChangeData.Factory changeDataFactory) {
+    this.changeDataFactory = changeDataFactory;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws Exception {
+    Change change = ctx.getChange();
+    ChangeData changeData = changeDataFactory.create(change);
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+    update.putSubmitRequirementResults(changeData.submitRequirements().values());
+    return !changeData.submitRequirements().isEmpty();
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
index 1d154dd..65d9d9e 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
@@ -66,6 +66,8 @@
             : Optional.empty();
 
     return SubmitRequirementResult.builder()
+        .submitRequirement(sr)
+        .patchSetCommitId(cd.currentPatchSet().commitId())
         .submittabilityExpressionResult(blockingResult)
         .applicabilityExpressionResult(applicabilityResult)
         .overrideExpressionResult(overrideResult)
@@ -79,9 +81,9 @@
       Predicate<ChangeData> predicate =
           changeQueryBuilderProvider.get().parse(expression.expressionString());
       PredicateResult predicateResult = evaluatePredicateTree(predicate, changeData);
-      return SubmitRequirementExpressionResult.create(predicateResult);
+      return SubmitRequirementExpressionResult.create(expression, predicateResult);
     } catch (QueryParseException e) {
-      return SubmitRequirementExpressionResult.error(e.getMessage());
+      return SubmitRequirementExpressionResult.error(expression, e.getMessage());
     }
   }
 
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 2b4fb3b..363cdca 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -67,6 +67,7 @@
 import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.SubmitRuleOptions;
@@ -96,6 +97,8 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.lib.Constants;
@@ -654,6 +657,16 @@
               toSubmit, updateOrderCalculator, submoduleCommits, subscriptionGraph, dryrun);
       this.allProjects = updateOrderCalculator.getProjectsInOrder();
       List<BatchUpdate> batchUpdates = orm.batchUpdates(allProjects);
+      // Group batch updates by project
+      Map<Project.NameKey, BatchUpdate> batchUpdatesByProject =
+          batchUpdates.stream().collect(Collectors.toMap(b -> b.getProject(), Function.identity()));
+      for (Map.Entry<Change.Id, ChangeData> entry : cs.changesById().entrySet()) {
+        Project.NameKey project = entry.getValue().project();
+        Change.Id changeId = entry.getKey();
+        batchUpdatesByProject
+            .get(project)
+            .addOp(changeId, new StoreSubmitRequirementsOp(changeDataFactory));
+      }
       try {
         submissionExecutor.setAdditionalBatchUpdateListeners(
             ImmutableList.of(new SubmitStrategyListener(submitInput, strategies, commitStatus)));
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 976e828..c08aa7f 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -69,6 +69,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.collect.MoreCollectors;
 import com.google.common.truth.ThrowableSubject;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ChangeIndexedCounter;
@@ -104,6 +105,8 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
@@ -172,6 +175,7 @@
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.patch.IntraLineDiff;
@@ -4287,6 +4291,40 @@
   }
 
   @Test
+  public void submitRequirement_storedForClosedChanges() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange("Add a file", "foo", "content");
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "code-review", 2);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.SATISFIED);
+
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    revision.review(ReviewInput.approve());
+    revision.submit();
+
+    ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
+
+    SubmitRequirementResult result =
+        notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+    assertThat(result.submittabilityExpressionResult().status())
+        .isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+    assertThat(result.submittabilityExpressionResult().expression().expressionString())
+        .isEqualTo("label:code-review=+2");
+  }
+
+  @Test
   public void fourByteEmoji() throws Exception {
     // U+1F601 GRINNING FACE WITH SMILING EYES
     String smile = new String(Character.toChars(0x1f601));
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index 23047a4..e848cef 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
-import com.google.gerrit.entities.SubmitRequirementExpressionResult.PredicateResult;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -102,25 +101,14 @@
 
     assertThat(result.status()).isEqualTo(Status.PASS);
 
-    assertThat(result.getPassingAtoms())
-        .containsExactly(
-            PredicateResult.builder()
-                .predicateString(String.format("project:%s", project.get()))
-                .status(true)
-                .build(),
-            PredicateResult.builder()
-                .predicateString("message:\"Fix a bug\"")
-                .status(true)
-                .build());
+    assertThat(result.passingAtoms())
+        .containsExactly(String.format("project:%s", project.get()), "message:\"Fix a bug\"");
 
-    assertThat(result.getFailingAtoms())
+    assertThat(result.failingAtoms())
         .containsExactly(
-            PredicateResult.builder()
-                // TODO(ghareeb): querying "branch:" creates a RefPredicate. Fix names so that they
-                // match
-                .predicateString(String.format("ref:refs/heads/foo"))
-                .status(false)
-                .build());
+            // TODO(ghareeb): querying "branch:" creates a RefPredicate. Fix names so that they
+            // match
+            String.format("ref:refs/heads/foo"));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index feff89c..ecdb03d 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -36,6 +36,10 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.entities.converter.ChangeMessageProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
@@ -52,6 +56,9 @@
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerStatusUpdateProto;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementExpressionResultProto;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementProto;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementResultProto;
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.notedb.ChangeNotesState.ChangeColumns;
 import com.google.gerrit.server.notedb.ChangeNotesState.Serializer;
@@ -671,6 +678,69 @@
   }
 
   @Test
+  public void serializeSubmitRequirementsResult() throws Exception {
+    assertRoundTrip(
+        newBuilder()
+            .submitRequirementsResult(
+                ImmutableList.of(
+                    SubmitRequirementResult.builder()
+                        .patchSetCommitId(
+                            ObjectId.fromString("26e50c7d315a33a13e5cc00902781fa876bc36cd"))
+                        .submitRequirement(
+                            SubmitRequirement.builder()
+                                .setName("Code-Review")
+                                .setApplicabilityExpression(
+                                    SubmitRequirementExpression.of("project:foo"))
+                                .setSubmittabilityExpression(
+                                    SubmitRequirementExpression.create("label:code-review=+2"))
+                                .setAllowOverrideInChildProjects(false)
+                                .build())
+                        .applicabilityExpressionResult(
+                            Optional.of(
+                                SubmitRequirementExpressionResult.create(
+                                    SubmitRequirementExpression.create("project:foo"),
+                                    SubmitRequirementExpressionResult.Status.PASS,
+                                    ImmutableList.of("project:foo"),
+                                    ImmutableList.of())))
+                        .submittabilityExpressionResult(
+                            SubmitRequirementExpressionResult.create(
+                                SubmitRequirementExpression.create("label:code-review=+2"),
+                                SubmitRequirementExpressionResult.Status.FAIL,
+                                ImmutableList.of(),
+                                ImmutableList.of("label:code-review=+2")))
+                        .build()))
+            .build(),
+        newProtoBuilder()
+            .addSubmitRequirementResult(
+                SubmitRequirementResultProto.newBuilder()
+                    .setCommit(
+                        ObjectIdConverter.create()
+                            .toByteString(
+                                ObjectId.fromString("26e50c7d315a33a13e5cc00902781fa876bc36cd")))
+                    .setSubmitRequirement(
+                        SubmitRequirementProto.newBuilder()
+                            .setName("Code-Review")
+                            .setApplicabilityExpression("project:foo")
+                            .setSubmittabilityExpression("label:code-review=+2")
+                            .setAllowOverrideInChildProjects(false)
+                            .build())
+                    .setApplicabilityExpressionResult(
+                        SubmitRequirementExpressionResultProto.newBuilder()
+                            .setExpression("project:foo")
+                            .setStatus("PASS")
+                            .addPassingAtoms("project:foo")
+                            .build())
+                    .setSubmittabilityExpressionResult(
+                        SubmitRequirementExpressionResultProto.newBuilder()
+                            .setExpression("label:code-review=+2")
+                            .setStatus("FAIL")
+                            .addFailingAtoms("label:code-review=+2")
+                            .build())
+                    .build())
+            .build());
+  }
+
+  @Test
   public void serializeAssigneeUpdates() throws Exception {
     assertRoundTrip(
         newBuilder()
@@ -842,6 +912,9 @@
                 .put(
                     "publishedComments",
                     new TypeLiteral<ImmutableListMultimap<ObjectId, HumanComment>>() {}.getType())
+                .put(
+                    "submitRequirementsResult",
+                    new TypeLiteral<ImmutableList<SubmitRequirementResult>>() {}.getType())
                 .put("updateCount", int.class)
                 .put("mergedOn", Timestamp.class)
                 .build());
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
index a1201c9..9e24a09 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
@@ -249,7 +249,7 @@
         <div class="content messageContent">
           <div class="message hideOnOpen">[[_messageContentCollapsed]]</div>
           <gr-formatted-text
-            no-trailing-margin=""
+            noTrailingMargin
             class="message hideOnCollapsed"
             content="[[_messageContentExpanded]]"
             config="[[_projectConfig.commentlinks]]"
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index b348eb5..f27a383 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -588,10 +588,10 @@
           .value="${this.result}"
         ></gr-endpoint-param>
         <gr-formatted-text
-          no-trailing-margin=""
+          noTrailingMargin
           class="message"
-          content="${this.result.message}"
-          config="${this.repoConfig}"
+          .content="${this.result.message}"
+          .config="${this.repoConfig?.commentlinks}"
         ></gr-formatted-text>
       </gr-endpoint-decorator>
     `;
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index 6b5553d..0193197 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -42,7 +42,7 @@
   @property({type: Object})
   config?: CommentLinks;
 
-  @property({type: Boolean})
+  @property({type: Boolean, reflect: true})
   noTrailingMargin = false;
 
   private readonly reporting = appContext.reportingService;
@@ -67,10 +67,10 @@
         blockquote {
           max-width: var(--gr-formatted-text-prose-max-width, none);
         }
-        :host(.noTrailingMargin) p:last-child,
-        :host(.noTrailingMargin) ul:last-child,
-        :host(.noTrailingMargin) blockquote:last-child,
-        :host(.noTrailingMargin) gr-linked-text.pre:last-child {
+        :host([noTrailingMargin]) p:last-child,
+        :host([noTrailingMargin]) ul:last-child,
+        :host([noTrailingMargin]) blockquote:last-child,
+        :host([noTrailingMargin]) gr-linked-text.pre:last-child {
           margin: 0;
         }
         code,
@@ -103,14 +103,6 @@
     return html`<div id="container">${nodes}</div>`;
   }
 
-  constructor() {
-    super();
-
-    if (this.noTrailingMargin) {
-      this.classList.add('noTrailingMargin');
-    }
-  }
-
   /**
    * Given a source string, parse into an array of block objects. Each block
    * has a `type` property which takes any of the following values.
diff --git a/proto/cache.proto b/proto/cache.proto
index 781538a..aa04555 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -78,7 +78,7 @@
 // Instead, we just take the tedious yet simple approach of having a "has_foo"
 // field for each nullable field "foo", indicating whether or not foo is null.
 //
-// Next ID: 27
+// Next ID: 28
 message ChangeNotesStateProto {
   // Effectively required, even though the corresponding ChangeNotesState field
   // is optional, since the field is only absent when NoteDb is disabled, in
@@ -226,6 +226,8 @@
   // Epoch millis.
   int64 merged_on_millis = 25;
   bool has_merged_on = 26;
+
+  repeated SubmitRequirementResultProto submit_requirement_result = 27;
 }
 
 // Serialized form of com.google.gerrit.server.query.change.ConflictKey
@@ -480,6 +482,28 @@
   bool allow_override_in_child_projects = 6;
 }
 
+// Serialized form of com.google.gerrit.entities.SubmitRequirementResult.
+// Next ID: 6
+message SubmitRequirementResultProto {
+  SubmitRequirementProto submit_requirement = 1;
+  SubmitRequirementExpressionResultProto applicability_expression_result = 2;
+  SubmitRequirementExpressionResultProto submittability_expression_result = 3;
+  SubmitRequirementExpressionResultProto override_expression_result = 4;
+
+  // Patchset commit ID at which the submit requirements are evaluated.
+  bytes commit = 5;
+}
+
+// Serialized form of com.google.gerrit.entities.SubmitRequirementExpressionResult.
+// Next ID: 6
+message SubmitRequirementExpressionResultProto {
+  string expression = 1;
+  string status = 2; // enum as string
+  string error_message = 3;
+  repeated string passing_atoms = 4;
+  repeated string failing_atoms = 5;
+}
+
 // Serialized form of com.google.gerrit.server.project.ConfiguredMimeTypes.
 // Next ID: 4
 message ConfiguredMimeTypeProto {