Merge changes Iacfa133f,I8da4d776,I905b9968,Ie078f540

* changes:
  Add REST API for migrating label functions to submit requirements
  MigrateLabelFunctionsToSubmitRequirement: use ProjectConfig API instead of Config
  MigrateLabelFunctionsToSubmitRequirement: use @Inject from Guava
  MigrateLabelFunctionsToSubmitRequirement: remove redundant variable
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index f85f284..16dab80 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -989,7 +989,7 @@
 ----
 
 [[create-config-change]]
-=== Create Config Change for review.
+=== Create Config Change for review
 --
 'PUT /projects/link:#project-name[\{project-name\}]/config:review'
 --
@@ -1342,7 +1342,7 @@
 ----
 
 [[create-change]]
-=== Create Change for review.
+=== Create Change for review
 
 This endpoint is functionally equivalent to
 link:rest-api-changes.html#create-change[create change in the change
@@ -1393,7 +1393,7 @@
 ----
 
 [[create-access-change]]
-=== Create Access Rights Change for review.
+=== Create Access Rights Change for review
 --
 'PUT /projects/link:rest-api-projects.html#project-name[\{project-name\}]/access:review
 --
@@ -3781,7 +3781,7 @@
 ----
 
 [[create-labels-change]]
-=== Create Labels Change for review.
+=== Create Labels Change for review
 --
 'POST /projects/link:#project-name[\{project-name\}]/labels:review'
 --
@@ -4095,7 +4095,7 @@
 ----
 
 [[create-submit-requirements-change]]
-=== Create Submit Requirements Change for review.
+=== Create Submit Requirements Change for review
 --
 'POST /projects/link:#project-name[\{project-name\}]/submit_requirements:review'
 --
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 2ef499f..2ed32e7 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -19,6 +19,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
@@ -27,12 +28,14 @@
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.accounts.AccountApi;
 import com.google.gerrit.extensions.api.config.Server;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.httpd.raw.IndexPreloadingUtil.RequestedPage;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gson.Gson;
 import com.google.template.soy.data.SanitizedContent;
 import java.net.URI;
@@ -41,6 +44,7 @@
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedHashSet;
 import java.util.Map;
 import java.util.Set;
 import java.util.function.Function;
@@ -68,10 +72,13 @@
       String requestedURL)
       throws URISyntaxException, RestApiException {
     ImmutableMap.Builder<String, Object> data = ImmutableMap.builder();
+    boolean asyncSubmitRequirements =
+        experimentFeatures.isFeatureEnabled(ExperimentFeaturesConstants.ASYNC_SUBMIT_REQUIREMENTS);
     data.putAll(
             staticTemplateData(
                 canonicalURL, cdnPath, faviconPath, urlParameterMap, urlInScriptTagOrdainer))
-        .putAll(dynamicTemplateData(gerritApi, requestedURL, canonicalURL));
+        .putAll(
+            dynamicTemplateData(gerritApi, requestedURL, canonicalURL, asyncSubmitRequirements));
     Set<String> enabledExperiments = new HashSet<>();
     enabledExperiments.addAll(experimentFeatures.getEnabledExperimentFeatures());
     // Add all experiments enabled through url
@@ -107,7 +114,10 @@
 
   /** Returns dynamic parameters of {@code index.html}. */
   public static ImmutableMap<String, Object> dynamicTemplateData(
-      GerritApi gerritApi, String requestedURL, String canonicalURL)
+      GerritApi gerritApi,
+      String requestedURL,
+      String canonicalURL,
+      boolean asyncSubmitRequirements)
       throws RestApiException, URISyntaxException {
     ImmutableMap.Builder<String, Object> data = ImmutableMap.builder();
     Map<String, SanitizedContent> initialData = new HashMap<>();
@@ -127,15 +137,21 @@
     Integer basePatchNum = computeBasePatchNum(requestedPath);
     switch (page) {
       case CHANGE, DIFF -> {
-        if (basePatchNum.equals(0)) {
+        LinkedHashSet<ListChangesOption> changeDetailOptions =
+            new LinkedHashSet<>(
+                basePatchNum.equals(0)
+                    ? IndexPreloadingUtil.CHANGE_DETAIL_OPTIONS_WITHOUT_PARENTS
+                    : IndexPreloadingUtil.CHANGE_DETAIL_OPTIONS_WITH_PARENTS);
+        if (asyncSubmitRequirements) {
+          changeDetailOptions.remove(ListChangesOption.SUBMIT_REQUIREMENTS);
+          changeDetailOptions.remove(ListChangesOption.SUBMITTABLE);
           data.put(
-              "defaultChangeDetailHex",
-              ListOption.toHex(IndexPreloadingUtil.CHANGE_DETAIL_OPTIONS_WITHOUT_PARENTS));
-        } else {
-          data.put(
-              "defaultChangeDetailHex",
-              ListOption.toHex(IndexPreloadingUtil.CHANGE_DETAIL_OPTIONS_WITH_PARENTS));
+              "submitRequirementsHex",
+              ListOption.toHex(
+                  ImmutableSet.of(
+                      ListChangesOption.SUBMIT_REQUIREMENTS, ListChangesOption.SUBMITTABLE)));
         }
+        data.put("defaultChangeDetailHex", ListOption.toHex(changeDetailOptions));
         data.put(
             "changeRequestsPath",
             IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index 039a33e..ab4c852 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -146,19 +146,20 @@
           cleanup.shutdownNow();
         }
 
-        List<Runnable> pending = executor.shutdownNow();
-        if (executor.awaitTermination(15, TimeUnit.MINUTES)) {
-          if (pending != null && !pending.isEmpty()) {
-            logger.atInfo().log("Finishing %d disk cache updates", pending.size());
-            for (Runnable update : pending) {
-              update.run();
-            }
+        executor.shutdown();
+        if (!executor.awaitTermination(15, TimeUnit.MINUTES)) {
+          logger.atInfo().log(
+              "Timeout elapsed waiting for storage tasks to terminate, shutting down now.");
+          List<Runnable> pending = executor.shutdownNow();
+          logger.atInfo().log("Canceling %d pending storage tasks.", pending.size());
+          if (!executor.awaitTermination(1, TimeUnit.MINUTES)) {
+            logger.atInfo().log("Timeout elapsed waiting for cancelled tasks to terminate.");
           }
-        } else {
-          logger.atInfo().log("Timeout waiting for disk cache to close");
         }
       } catch (InterruptedException e) {
         logger.atWarning().log("Interrupted waiting for disk cache to shutdown");
+        executor.shutdownNow();
+        Thread.currentThread().interrupt();
       }
     }
     synchronized (caches) {
@@ -223,6 +224,7 @@
     if (h2AutoServer) {
       url.append(";AUTO_SERVER=TRUE");
     }
+    url.append(";DB_CLOSE_DELAY=-1");
     Duration refreshAfterWrite = def.refreshAfterWrite();
     if (has(def.configKey(), "refreshAfterWrite")) {
       long refreshAfterWriteInSec =
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index eb557b8..39afb38 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -161,7 +161,7 @@
             () -> {
               ValueHolder<V> h = store.getIfPresent(key);
               if (h == null) {
-                h = new ValueHolder<>(valueLoader.call(), Instant.ofEpochMilli(TimeUtil.nowMs()));
+                h = new ValueHolder<>(valueLoader.call(), TimeUtil.now());
                 ValueHolder<V> fh = h;
                 executor.execute(() -> store.put(key, fh));
               }
@@ -172,7 +172,7 @@
 
   @Override
   public void put(K key, V val) {
-    final ValueHolder<V> h = new ValueHolder<>(val, Instant.ofEpochMilli(TimeUtil.nowMs()));
+    final ValueHolder<V> h = new ValueHolder<>(val, TimeUtil.now());
     mem.put(key, h);
     executor.execute(() -> store.put(key, h));
   }
@@ -260,7 +260,7 @@
               "Loading value from cache", Metadata.builder().cacheKey(key.toString()).build())) {
         ValueHolder<V> h = store.getIfPresent(key);
         if (h == null) {
-          h = new ValueHolder<>(loader.load(key), Instant.ofEpochMilli(TimeUtil.nowMs()));
+          h = new ValueHolder<>(loader.load(key), TimeUtil.now());
           ValueHolder<V> fh = h;
           executor.execute(() -> store.put(key, fh));
         }
@@ -283,7 +283,7 @@
         }
         try {
           Map<K, V> remaining = loader.loadAll(notInMemory);
-          Instant instant = Instant.ofEpochMilli(TimeUtil.nowMs());
+          Instant instant = TimeUtil.now();
           storeInDatabase(remaining, instant);
           remaining
               .entrySet()
@@ -345,6 +345,7 @@
     private final boolean buildBloomFilter;
     private boolean trackLastAccess;
     private final AtomicBoolean isDiskCacheReadOnly;
+    private volatile boolean ensuredSchemaCreation;
 
     SqlStore(
         String jdbcUrl,
@@ -389,6 +390,38 @@
       return new ObjectKeyTypeImpl<>(serializer);
     }
 
+    private void createSchema() throws SQLException {
+      if (!ensuredSchemaCreation) {
+        synchronized (this) {
+          if (ensuredSchemaCreation) {
+            return;
+          }
+          try (SqlHandle h = new SqlHandle(url)) {
+            try (Statement stmt = h.conn.createStatement()) {
+              stmt.addBatch(
+                  "CREATE TABLE IF NOT EXISTS data"
+                      + "(k "
+                      + keyType.columnType()
+                      + " NOT NULL PRIMARY KEY HASH"
+                      + ",v OTHER NOT NULL"
+                      + ",created TIMESTAMP NOT NULL"
+                      + ",accessed TIMESTAMP NOT NULL"
+                      + ")");
+              stmt.addBatch(
+                  "ALTER TABLE data ADD COLUMN IF NOT EXISTS "
+                      + "space BIGINT AS OCTET_LENGTH(k) + OCTET_LENGTH(v)");
+              stmt.addBatch(
+                  "ALTER TABLE data ADD COLUMN IF NOT EXISTS version INT DEFAULT 0 NOT NULL");
+              stmt.addBatch("CREATE INDEX IF NOT EXISTS version_key ON data(version, k)");
+              stmt.addBatch("CREATE INDEX IF NOT EXISTS accessed ON data(accessed)");
+              stmt.executeBatch();
+              ensuredSchemaCreation = true;
+            }
+          }
+        }
+      }
+    }
+
     void open() {
       bloomFilter.initIfNeeded();
     }
@@ -398,6 +431,17 @@
       while ((h = handles.poll()) != null) {
         h.close();
       }
+      shutdown();
+    }
+
+    private void shutdown() {
+      try (SqlHandle h = new SqlHandle(url)) {
+        try (Statement stmt = h.conn.createStatement()) {
+          stmt.execute("SHUTDOWN");
+        }
+      } catch (SQLException e) {
+        logger.atSevere().withCause(e).log("Cannot shutdown cache %s", url);
+      }
     }
 
     boolean mightContain(K key) {
@@ -728,8 +772,9 @@
     }
 
     private SqlHandle acquire() throws SQLException {
+      createSchema();
       SqlHandle h = handles.poll();
-      return h != null ? h : new SqlHandle(url, keyType);
+      return h != null ? h : new SqlHandle(url);
     }
 
     private void release(SqlHandle h) {
@@ -747,7 +792,7 @@
     }
   }
 
-  static class SqlHandle {
+  static class SqlHandle implements AutoCloseable {
     private final String url;
     Connection conn;
     PreparedStatement get;
@@ -755,30 +800,13 @@
     PreparedStatement touch;
     PreparedStatement invalidate;
 
-    SqlHandle(String url, KeyType<?> type) throws SQLException {
+    SqlHandle(String url) throws SQLException {
       this.url = url;
       this.conn = org.h2.Driver.load().connect(url, null);
-      try (Statement stmt = conn.createStatement()) {
-        stmt.addBatch(
-            "CREATE TABLE IF NOT EXISTS data"
-                + "(k "
-                + type.columnType()
-                + " NOT NULL PRIMARY KEY HASH"
-                + ",v OTHER NOT NULL"
-                + ",created TIMESTAMP NOT NULL"
-                + ",accessed TIMESTAMP NOT NULL"
-                + ")");
-        stmt.addBatch(
-            "ALTER TABLE data ADD COLUMN IF NOT EXISTS "
-                + "space BIGINT AS OCTET_LENGTH(k) + OCTET_LENGTH(v)");
-        stmt.addBatch("ALTER TABLE data ADD COLUMN IF NOT EXISTS version INT DEFAULT 0 NOT NULL");
-        stmt.addBatch("CREATE INDEX IF NOT EXISTS version_key ON data(version, k)");
-        stmt.addBatch("CREATE INDEX IF NOT EXISTS accessed ON data(accessed)");
-        stmt.executeBatch();
-      }
     }
 
-    void close() {
+    @Override
+    public void close() {
       get = closeStatement(get);
       put = closeStatement(put);
       touch = closeStatement(touch);
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index cd91745..ce96132 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -60,4 +60,7 @@
   /** Whether we allow fix suggestions in HumanComments. */
   public static final String ALLOW_FIX_SUGGESTIONS_IN_COMMENTS =
       "GerritBackendFeature__allow_fix_suggestions_in_comments";
+
+  /** Whether UI should request Submit Requirements separately from change detail. */
+  public static final String ASYNC_SUBMIT_REQUIREMENTS = "UiFeature__async_submit_requirements";
 }
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index ff729b7..291d5a9 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -321,7 +321,16 @@
       }
 
       if (footersToAdd.length() > 0) {
-        message = message.trim() + "\n\n" + footersToAdd.toString().trim();
+        String trimmedMsg = message.trim();
+        List<String> lines = Splitter.on('\n').splitToList(trimmedMsg);
+        String lastLine = lines.isEmpty() ? "" : lines.get(lines.size() - 1);
+        boolean endsWithFooter = lastLine.matches("^[a-zA-Z0-9-]+:.*");
+
+        if (endsWithFooter) {
+          message = trimmedMsg + "\n" + footersToAdd.toString().trim();
+        } else {
+          message = trimmedMsg + "\n\n" + footersToAdd.toString().trim();
+        }
       }
     }
 
diff --git a/java/com/google/gerrit/server/restapi/flow/IsFlowsEnabled.java b/java/com/google/gerrit/server/restapi/flow/IsFlowsEnabled.java
index 941e87e..a4e2c24 100644
--- a/java/com/google/gerrit/server/restapi/flow/IsFlowsEnabled.java
+++ b/java/com/google/gerrit/server/restapi/flow/IsFlowsEnabled.java
@@ -19,9 +19,11 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.flow.FlowService;
 import com.google.gerrit.server.flow.FlowServiceUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.Optional;
 
 /**
  * REST endpoint to check if Flows is enabled for the user.
@@ -39,11 +41,13 @@
 
   @Override
   public Response<IsFlowsEnabledInfo> apply(ChangeResource changeResource) throws RestApiException {
+    Optional<FlowService> flowService = flowServiceUtil.getFlowService();
+    if (flowService.isEmpty()) {
+      return Response.ok(new IsFlowsEnabledInfo(false));
+    }
     IsFlowsEnabledInfo enabledInfo =
         new IsFlowsEnabledInfo(
-            flowServiceUtil
-                .getFlowServiceOrThrow()
-                .isFlowsEnabled(changeResource.getProject(), changeResource.getId()));
+            flowService.get().isFlowsEnabled(changeResource.getProject(), changeResource.getId()));
     return Response.ok(enabledInfo);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index a8f3db4..b3b56db 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -1757,10 +1757,54 @@
 
     // Check that the footers are not duplicated
     String commitMessage = gApi.changes().id(revertChange.id).current().commit(false).message;
-    int bugOccurrences = commitMessage.split("Bug: 12345", -1).length - 1;
-    assertThat(bugOccurrences).isEqualTo(1);
-    int issueOccurrences = commitMessage.split("Issue: 67890", -1).length - 1;
-    assertThat(issueOccurrences).isEqualTo(1);
+    String expectedMessage =
+        String.format(
+            """
+            Reverting this change
+
+            Bug: 12345
+            Issue: 67890
+            Change-Id: %s
+            """,
+            revertChange.changeId);
+    assertThat(commitMessage).isEqualTo(expectedMessage);
+  }
+
+  @Test
+  public void revertChangeWithSkipPresubmitFooter() throws Exception {
+    // Create a change with bug and issue footers
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Change with bug and issue\n" + "\n" + "Bug: 12345\n" + "Issue: 67890",
+            "a.txt",
+            "content");
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    // Revert the change
+    RevertInput revertInput = new RevertInput();
+    revertInput.message = "Reverting this change\n\nSkip-Presubmit: true";
+    ChangeInfo revertChange = gApi.changes().id(r.getChangeId()).revert(revertInput).get();
+
+    // Check that the revert commit message contains all footers and no extra newline
+    String commitMessage = gApi.changes().id(revertChange.id).current().commit(false).message;
+    String expectedMessage =
+        String.format(
+            """
+            Reverting this change
+
+            Skip-Presubmit: true
+            Bug: 12345
+            Issue: 67890
+            Change-Id: %s
+            """,
+            revertChange.changeId);
+    assertThat(commitMessage).isEqualTo(expectedMessage);
   }
 
   @Test
@@ -1780,8 +1824,6 @@
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
 
     // Revert the submission with a message that already contains the footers
-    RevertInput revertInput = new RevertInput();
-    revertInput.message = "Reverting this change\n\nBug: 12345\nIssue: 67890";
     List<ChangeInfo> revertChanges =
         gApi.changes().id(r.getChangeId()).revertSubmission().revertChanges;
     assertThat(revertChanges).hasSize(1);
@@ -1789,10 +1831,19 @@
 
     // Check that the footers are not duplicated
     String commitMessage = gApi.changes().id(revertChange.id).current().commit(false).message;
-    int bugOccurrences = commitMessage.split("Bug: 12345", -1).length - 1;
-    assertThat(bugOccurrences).isEqualTo(1);
-    int issueOccurrences = commitMessage.split("Issue: 67890", -1).length - 1;
-    assertThat(issueOccurrences).isEqualTo(1);
+    String expectedMessage =
+        String.format(
+            """
+            Revert "Change with bug and issue"
+
+            This reverts commit %s.
+
+            Bug: 12345
+            Issue: 67890
+            Change-Id: %s
+            """,
+            r.getCommit().name(), revertChange.changeId);
+    assertThat(commitMessage).isEqualTo(expectedMessage);
   }
 
   @Override
diff --git a/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java b/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
index fe3cb00..8dcd0f3 100644
--- a/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
@@ -52,7 +52,8 @@
                 .GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE,
             ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION,
             ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE,
-            ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE)
+            ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE,
+            ExperimentFeaturesConstants.ASYNC_SUBMIT_REQUIREMENTS)
         .inOrder();
 
     // "GerritBackendFeature__check_implicit_merges_on_merge",
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
index 2f6d565..68be60b 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
@@ -117,7 +117,7 @@
     String requestedPath = "/c/project/+/123/4..6";
     assertThat(IndexHtmlUtil.computeBasePatchNum(requestedPath)).isEqualTo(4);
 
-    assertThat(dynamicTemplateData(gerritApi, requestedPath, ""))
+    assertThat(dynamicTemplateData(gerritApi, requestedPath, "", false))
         .containsAtLeast(
             "defaultChangeDetailHex", "9996394",
             "changeRequestsPath", "changes/project~123");
@@ -145,12 +145,41 @@
     String requestedPath = "/c/project/+/123";
     assertThat(IndexHtmlUtil.computeBasePatchNum(requestedPath)).isEqualTo(0);
 
-    assertThat(dynamicTemplateData(gerritApi, requestedPath, ""))
+    assertThat(dynamicTemplateData(gerritApi, requestedPath, "", false))
         .containsAtLeast(
             "defaultChangeDetailHex", "1996394",
             "changeRequestsPath", "changes/project~123");
   }
 
+  @Test
+  public void usePreloadRestWithAsyncSRs() throws Exception {
+    Accounts accountsApi = mock(Accounts.class);
+    when(accountsApi.self()).thenThrow(new AuthException("user needs to be authenticated"));
+
+    Server serverApi = mock(Server.class);
+    when(serverApi.getVersion()).thenReturn("123");
+    when(serverApi.topMenus()).thenReturn(ImmutableList.of());
+    ServerInfo serverInfo = new ServerInfo();
+    serverInfo.defaultTheme = "my-default-theme";
+    when(serverApi.getInfo()).thenReturn(serverInfo);
+
+    Config configApi = mock(Config.class);
+    when(configApi.server()).thenReturn(serverApi);
+
+    GerritApi gerritApi = mock(GerritApi.class);
+    when(gerritApi.accounts()).thenReturn(accountsApi);
+    when(gerritApi.config()).thenReturn(configApi);
+
+    String requestedPath = "/c/project/+/123";
+    assertThat(IndexHtmlUtil.computeBasePatchNum(requestedPath)).isEqualTo(0);
+
+    assertThat(dynamicTemplateData(gerritApi, requestedPath, "", true))
+        .containsAtLeast(
+            "defaultChangeDetailHex", "896394",
+            "submitRequirementsHex", "1100000",
+            "changeRequestsPath", "changes/project~123");
+  }
+
   private static SanitizedContent ordain(String s) {
     return UnsafeSanitizedContentOrdainer.ordainAsSafe(
         s, SanitizedContent.ContentKind.TRUSTED_RESOURCE_URI);
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
index 8b6ba2a..cf8bdda 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -64,7 +64,7 @@
       @Nullable Duration expireAfterWrite,
       @Nullable Duration refreshAfterWrite) {
     return new SqlStore<>(
-        "jdbc:h2:mem:Test_" + id,
+        "jdbc:h2:mem:Test_" + id + ";DB_CLOSE_DELAY=-1",
         KEY_TYPE,
         StringCacheSerializer.INSTANCE,
         StringCacheSerializer.INSTANCE,
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index ac51959..fc7bfba 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -233,6 +233,12 @@
 yarn eslint
 ```
 
+* To run ESLint and apply changes on the whole app:
+
+```sh
+yarn eslintfix
+```
+
 * To run ESLint on just the subdirectory you modified:
 
 ```sh
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index ed85fe2..7695b60 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -33,6 +33,7 @@
   // Fires GerritView values such as 'change', 'dashboard', 'admin', ...
   VIEW_CHANGE = 'view-change',
   SHOW_REVISION_ACTIONS = 'show-revision-actions',
+  BEFORE_COMMIT_MSG_EDIT = 'before-commit-msg-edit',
   COMMIT_MSG_EDIT = 'commitmsgedit',
   CUSTOM_EMOJIS = 'custom-emojis',
   REVERT = 'revert',
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index cb09c45..1e31feb 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -402,6 +402,9 @@
   labels?: LabelNameToInfoMap;
   permitted_labels?: LabelNameToValuesMap;
   removable_reviewers?: AccountInfo[];
+  removable_labels?: {
+    [labelName: string]: {[labelValue: string]: AccountInfo[]};
+  };
   // This is documented as optional, but actually always set.
   reviewers: Reviewers;
   pending_reviewers?: AccountInfo[];
@@ -1523,3 +1526,11 @@
   delete?: string[];
   commit_message?: string;
 }
+
+/**
+ * The IsFlowsEnabledInfo entity contains information about whether flows are
+ * enabled.
+ */
+export declare interface IsFlowsEnabledInfo {
+  enabled: boolean;
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index 29fdb92..9b06622 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -360,7 +360,7 @@
         <label class="selectionLabel">
           <md-checkbox
             ?checked=${this.checked}
-            @click=${this.toggleCheckbox}
+            @change=${this.toggleCheckbox}
           ></md-checkbox>
         </label>
       </td>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
index bd53670..d8c1a5d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
@@ -296,7 +296,7 @@
           class="selection-checkbox"
           ?checked=${checked}
           .indeterminate=${indeterminate}
-          @click=${this.handleSelectAllCheckboxClicked}
+          @change=${this.handleSelectAllCheckboxClicked}
         ></md-checkbox>
       </td>
     `;
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 216559c..0393242 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -54,6 +54,7 @@
   RequestPayload,
   RevertSubmissionInfo,
   ReviewInput,
+  RevisionInfo,
 } from '../../../types/common';
 import {GrConfirmAbandonDialog} from '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
@@ -1361,7 +1362,10 @@
   }
 
   // private but used in test
-  getRevision(change: ChangeInfo, patchNum?: PatchSetNumber) {
+  getRevision(
+    change: ChangeInfo,
+    patchNum?: PatchSetNumber
+  ): RevisionInfo | null {
     for (const rev of Object.values(change.revisions ?? {})) {
       if (rev._number === patchNum) {
         return rev;
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index 7da1513..ed4c9c5 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -1676,7 +1676,7 @@
           assert.equal(confirmRevertDialog.message, expectedMsg);
           const radioInputs = queryAll<HTMLInputElement>(
             confirmRevertDialog,
-            'input[name="revertOptions"]'
+            'md-radio[name="revertOptions"]'
           );
           radioInputs[0].click();
           await element.updateComplete;
@@ -1728,7 +1728,7 @@
           await element.updateComplete;
           const radioInputs = queryAll<HTMLInputElement>(
             confirmRevertDialog,
-            'input[name="revertOptions"]'
+            'md-radio[name="revertOptions"]'
           );
           const revertSubmissionMsg =
             'Revert submission 199 0' +
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_screenshot_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_screenshot_test.ts
index f45f5ae..87b50a2 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_screenshot_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_screenshot_test.ts
@@ -60,10 +60,11 @@
     await element.updateComplete;
   });
 
-  test('normal view', async () => {
-    await visualDiff(element, 'gr-change-metadata');
-    await visualDiffDarkTheme(element, 'gr-change-metadata-dark');
-  });
+  // TODO(b/447590232): Fix test flakiness and re-enable
+  // test('normal view', async () => {
+  //   await visualDiff(element, 'gr-change-metadata');
+  //   await visualDiffDarkTheme(element, 'gr-change-metadata-dark');
+  // });
 
   test('show all sections with more data', async () => {
     const changeModel = testResolver(changeModelToken);
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index 017adca..591845a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import './gr-checks-chip';
+import './gr-summary-chip';
 import '../gr-comments-summary/gr-comments-summary';
 import '../../shared/gr-icon/gr-icon';
 import '../../checks/gr-checks-action';
@@ -29,6 +30,7 @@
 } from '../../../models/checks/checks-util';
 import {getMentionedThreads, isUnresolved} from '../../../utils/comment-util';
 import {AccountInfo, CommentThread, DropdownLink} from '../../../types/common';
+import {FlowInfo, FlowStageState} from '../../../api/rest-api';
 import {Tab} from '../../../constants/constants';
 import {ChecksTabState} from '../../../types/events';
 import {modifierPressed} from '../../../utils/dom-util';
@@ -43,6 +45,7 @@
 import {userModelToken} from '../../../models/user/user-model';
 import {assertIsDefined} from '../../../utils/common-util';
 import {GrAiPromptDialog} from '../gr-ai-prompt-dialog/gr-ai-prompt-dialog';
+import {flowsModelToken} from '../../../models/flows/flows-model';
 
 function handleSpaceOrEnter(e: KeyboardEvent, handler: () => void) {
   if (modifierPressed(e)) return;
@@ -98,6 +101,9 @@
   @state()
   draftCount = 0;
 
+  @state()
+  flows: FlowInfo[] = [];
+
   @query('#aiPromptModal')
   aiPromptModal?: HTMLDialogElement;
 
@@ -114,6 +120,8 @@
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
+  private readonly getFlowsModel = resolve(this, flowsModelToken);
+
   private readonly reporting = getAppContext().reportingService;
 
   constructor() {
@@ -189,6 +197,11 @@
         this.mentionCount = unresolvedThreadsMentioningSelf.length;
       }
     );
+    subscribe(
+      this,
+      () => this.getFlowsModel().flows$,
+      x => (this.flows = x)
+    );
   }
 
   static override get styles() {
@@ -584,7 +597,7 @@
               </div>
             </td>
           </tr>
-          ${this.renderChecksSummary()}
+          ${this.renderChecksSummary()} ${this.renderFlowsSummary()}
         </table>
       </div>
       <dialog id="aiPromptModal" tabindex="-1">
@@ -596,6 +609,81 @@
     `;
   }
 
+  private getFlowOverallStatus(flow: FlowInfo): FlowStageState {
+    if (
+      flow.stages.some(
+        stage =>
+          stage.state === FlowStageState.FAILED ||
+          stage.state === FlowStageState.TERMINATED
+      )
+    ) {
+      return FlowStageState.FAILED;
+    }
+    if (
+      flow.stages.some(
+        stage =>
+          stage.state === FlowStageState.PENDING ||
+          stage.state === FlowStageState.TERMINATED
+      )
+    ) {
+      return FlowStageState.PENDING; // Using PENDING to represent running/in-progress
+    }
+    if (flow.stages.every(stage => stage.state === FlowStageState.DONE)) {
+      return FlowStageState.DONE;
+    }
+    return FlowStageState.PENDING; // Default or unknown state
+  }
+
+  private renderFlowsSummary() {
+    if (this.flows.length === 0) return nothing;
+    const handler = () => fireShowTab(this, Tab.FLOWS, true);
+    const failed = this.flows.filter(
+      f => this.getFlowOverallStatus(f) === FlowStageState.FAILED
+    ).length;
+    const running = this.flows.filter(
+      f => this.getFlowOverallStatus(f) === FlowStageState.PENDING
+    ).length;
+    const done = this.flows.filter(
+      f => this.getFlowOverallStatus(f) === FlowStageState.DONE
+    ).length;
+    return html`
+      <tr>
+        <td class="key">Flows</td>
+        <td class="value">
+          <div class="flowsSummary">
+            ${failed > 0
+              ? html`<gr-checks-chip
+                  .statusOrCategory=${Category.ERROR}
+                  .text=${`${failed}`}
+                  @click=${handler}
+                  @keydown=${(e: KeyboardEvent) =>
+                    handleSpaceOrEnter(e, handler)}
+                ></gr-checks-chip>`
+              : ''}
+            ${running > 0
+              ? html`<gr-checks-chip
+                  .statusOrCategory=${RunStatus.RUNNING}
+                  .text=${`${running}`}
+                  @click=${handler}
+                  @keydown=${(e: KeyboardEvent) =>
+                    handleSpaceOrEnter(e, handler)}
+                ></gr-checks-chip>`
+              : ''}
+            ${done > 0
+              ? html`<gr-checks-chip
+                  .statusOrCategory=${Category.SUCCESS}
+                  .text=${`${done}`}
+                  @click=${handler}
+                  @keydown=${(e: KeyboardEvent) =>
+                    handleSpaceOrEnter(e, handler)}
+                ></gr-checks-chip>`
+              : ''}
+          </div>
+        </td>
+      </tr>
+    `;
+  }
+
   private renderChecksSummary() {
     const hasNonRunningChip = this.runs.some(
       run => hasCompletedWithoutResults(run) || hasResults(run)
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
index 2770ada..fd60d95 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
@@ -16,7 +16,7 @@
   createDraft,
   createRun,
 } from '../../../test/test-data-generators';
-import {Timestamp} from '../../../api/rest-api';
+import {FlowInfo, FlowStageState, Timestamp} from '../../../api/rest-api';
 import {testResolver} from '../../../test/common-test-setup';
 import {UserModel, userModelToken} from '../../../models/user/user-model';
 import {
@@ -26,16 +26,29 @@
 import {GrChecksChip} from './gr-checks-chip';
 import {CheckRun} from '../../../models/checks/checks-model';
 import {Category, RunStatus} from '../../../api/checks';
+import {FlowsModel, flowsModelToken} from '../../../models/flows/flows-model';
+
+function createFlow(partial: Partial<FlowInfo> = {}): FlowInfo {
+  return {
+    uuid: 'test-uuid',
+    owner: createAccountWithEmail(),
+    created: '2020-01-01 00:00:00.000000000' as Timestamp,
+    stages: [],
+    ...partial,
+  };
+}
 
 suite('gr-change-summary test', () => {
   let element: GrChangeSummary;
   let commentsModel: CommentsModel;
   let userModel: UserModel;
+  let flowsModel: FlowsModel;
 
   setup(async () => {
     element = await fixture(html`<gr-change-summary></gr-change-summary>`);
     commentsModel = testResolver(commentsModelToken);
     userModel = testResolver(userModelToken);
+    flowsModel = testResolver(flowsModelToken);
   });
 
   test('is defined', () => {
@@ -58,7 +71,8 @@
     await element.updateComplete;
     assert.shadowDom.equal(
       element,
-      /* HTML */ `<div>
+      /* HTML */ `
+        <div>
           <table class="info">
             <tbody>
               <tr>
@@ -86,7 +100,8 @@
         <dialog id="aiPromptModal" tabindex="-1">
           <gr-ai-prompt-dialog id="aiPromptDialog" role="dialog">
           </gr-ai-prompt-dialog>
-        </dialog> `
+        </dialog>
+      `
     );
   });
 
@@ -181,6 +196,62 @@
     });
   });
 
+  suite('flows summary', () => {
+    test('renders', async () => {
+      flowsModel.setState({
+        flows: [
+          createFlow({
+            stages: [
+              {expression: {condition: ''}, state: FlowStageState.PENDING},
+            ],
+          }),
+          createFlow({
+            stages: [{expression: {condition: ''}, state: FlowStageState.DONE}],
+          }),
+          createFlow({
+            stages: [{expression: {condition: ''}, state: FlowStageState.DONE}],
+          }),
+          createFlow({
+            stages: [
+              {expression: {condition: ''}, state: FlowStageState.FAILED},
+            ],
+          }),
+          createFlow({
+            stages: [
+              {expression: {condition: ''}, state: FlowStageState.FAILED},
+            ],
+          }),
+          createFlow({
+            stages: [
+              {expression: {condition: ''}, state: FlowStageState.FAILED},
+            ],
+          }),
+        ],
+        loading: false,
+      });
+      await element.updateComplete;
+      const flowsSummary = queryAndAssert(element, '.flowsSummary');
+      assert.dom.equal(
+        flowsSummary,
+        /* HTML */ `
+          <div class="flowsSummary">
+            <gr-checks-chip> </gr-checks-chip>
+            <gr-checks-chip> </gr-checks-chip>
+            <gr-checks-chip> </gr-checks-chip>
+          </div>
+        `
+      );
+      const chips = queryAll<GrChecksChip>(element, 'gr-checks-chip');
+      assert.equal(chips.length, 3);
+      assert.equal(chips[0].statusOrCategory, Category.ERROR);
+      assert.equal(chips[0].text, '3');
+      assert.equal(chips[1].statusOrCategory, RunStatus.RUNNING);
+      assert.equal(chips[1].text, '1');
+      assert.equal(chips[2].statusOrCategory, Category.SUCCESS);
+      assert.equal(chips[2].text, '2');
+    });
+  });
+
   test('renders mentions summary', async () => {
     commentsModel.setState({
       drafts: {
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index b65c827..934fd65 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -370,6 +370,9 @@
   @state()
   private revertingChange?: ChangeInfo;
 
+  @state()
+  isFlowsEnabled = false;
+
   // Private but used in tests.
   @state()
   scrollCommentId?: UrlEncodedCommentId;
@@ -626,7 +629,12 @@
       changeNum => {
         // The change view is tied to a specific change number, so don't update
         // changeNum to undefined and only set it once.
-        if (changeNum && !this.changeNum) this.changeNum = changeNum;
+        if (changeNum && !this.changeNum) {
+          this.changeNum = changeNum;
+          this.restApiService.getIfFlowsIsEnabled(this.changeNum).then(res => {
+            this.isFlowsEnabled = res?.enabled ?? false;
+          });
+        }
       }
     );
     subscribe(
@@ -1381,7 +1389,8 @@
             `
           )}
           ${when(
-            this.flagService.isEnabled(KnownExperimentId.SHOW_FLOWS_TAB),
+            this.flagService.isEnabled(KnownExperimentId.SHOW_FLOWS_TAB) &&
+              this.isFlowsEnabled,
             () => html`
               <md-secondary-tab
                 data-name=${Tab.FLOWS}
@@ -1649,7 +1658,7 @@
   }
 
   // Private but used in tests.
-  handleCommitMessageSave(e: EditableContentSaveEvent) {
+  async handleCommitMessageSave(e: EditableContentSaveEvent) {
     assertIsDefined(this.change, 'change');
     assertIsDefined(this.changeNum, 'changeNum');
     // to prevent 2 requests at the same time
@@ -1658,6 +1667,19 @@
     const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
     const committerEmail = e.detail.committerEmail;
 
+    // The BEFORE event handlers are async and can potentially block
+    // the message edit from going through.  By contrast, the second
+    // set of event handlers always fire (and should probably fire
+    // after the message has been saved successfully, but the current
+    // behavior is what it is).
+    if (
+      !(await this.getPluginLoader().jsApiService.handleBeforeCommitMessage(
+        this.change,
+        message
+      ))
+    )
+      return;
+
     this.getPluginLoader().jsApiService.handleCommitMessage(
       this.change,
       message
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 644cf02..0409f80 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -264,6 +264,9 @@
     stubRestApi('getAccount').returns(
       Promise.resolve(createAccountDetailWithId(5))
     );
+    stubRestApi('getIfFlowsIsEnabled').returns(
+      Promise.resolve({enabled: true})
+    );
     stubRestApi('getDiffComments').returns(Promise.resolve({}));
     stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
 
@@ -475,9 +478,11 @@
   });
 
   test('renders flows tab if experiment is enabled', async () => {
+    element.isFlowsEnabled = true;
     stubFlags('isEnabled').returns(true);
     element.requestUpdate();
     await element.updateComplete;
+    await waitUntil(() => !!element.isFlowsEnabled);
     queryAndAssert(element, '[data-name="flows"]');
   });
 
@@ -1039,15 +1044,17 @@
       });
 
     assertIsDefined(element.commitMessageEditor);
-    element.handleCommitMessageSave(
+    await element.handleCommitMessageSave(
       mockEvent('test \n  test ', committerEmail)
     );
     assert.equal(putStub.lastCall.args[1], 'test\n  test');
     element.commitMessageEditor.disabled = false;
-    element.handleCommitMessageSave(mockEvent('  test\ntest', committerEmail));
+    await element.handleCommitMessageSave(
+      mockEvent('  test\ntest', committerEmail)
+    );
     assert.equal(putStub.lastCall.args[1], '  test\ntest');
     element.commitMessageEditor.disabled = false;
-    element.handleCommitMessageSave(
+    await element.handleCommitMessageSave(
       mockEvent('\n\n\n\n\n\n\n\n', committerEmail)
     );
     assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index 67603ce..fd44c59 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-account-chip/gr-account-chip';
-import {css, html, LitElement, PropertyValues} from 'lit';
+import {css, html, LitElement} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {when} from 'lit/directives/when.js';
 import {
@@ -179,15 +179,6 @@
     this.loadCommitterEmailDropdownItems();
   }
 
-  override willUpdate(changedProperties: PropertyValues): void {
-    if (
-      changedProperties.has('rebaseOnCurrent') ||
-      changedProperties.has('hasParent')
-    ) {
-      this.updateSelectedOption();
-    }
-  }
-
   static override get styles() {
     return [
       formStyles,
@@ -221,10 +212,10 @@
           display: flex;
           align-items: center;
           gap: var(--spacing-m);
-          margin-top: var(--spacing-l);
+          margin-top: var(--spacing-xl);
         }
         .rebaseCheckbox {
-          margin-top: var(--spacing-m);
+          margin-top: var(--spacing-xl);
         }
         .rebaseOnBehalfMsg {
           margin-top: var(--spacing-m);
@@ -232,6 +223,10 @@
         .rebaseWithCommitterEmail {
           margin-top: var(--spacing-m);
         }
+        .main {
+          margin-top: var(--spacing-xl);
+          margin-bottom: var(--spacing-xl);
+        }
         @media screen and (max-width: 50em) {
           #confirmDialog {
             height: 90vh;
@@ -252,8 +247,11 @@
       >
         <div class="header" slot="header">Confirm rebase</div>
         <div class="main" slot="main">
-          ${when(this.loading, () => html`<div>Loading...</div>`)}
-          ${when(!this.loading, () => this.renderRebaseDialog())}
+          ${when(
+            this.loading,
+            () => html`<div>Loading...</div>`,
+            () => this.renderRebaseDialog()
+          )}
         </div>
       </gr-dialog>
     `;
@@ -265,7 +263,12 @@
         class="rebaseOption loading"
         ?hidden=${!this.displayParentOption() || this.loading}
       >
-        <md-radio id="rebaseOnParentInput" name="rebaseOptions"> </md-radio>
+        <md-radio
+          id="rebaseOnParentInput"
+          name="rebaseOptions"
+          ?checked=${this.displayParentOption()}
+        >
+        </md-radio>
         <label id="rebaseOnParentLabel" for="rebaseOnParentInput">
           Rebase on parent change
         </label>
@@ -288,6 +291,7 @@
         <md-radio
           id="rebaseOnTipInput"
           name="rebaseOptions"
+          ?checked=${!this.displayParentOption() && this.displayTipOption()}
           ?disabled=${!this.displayTipOption()}
         >
         </md-radio>
@@ -310,6 +314,7 @@
         <md-radio
           id="rebaseOnOtherInput"
           name="rebaseOptions"
+          ?checked=${!this.displayParentOption() && !this.displayTipOption()}
           @click=${this.handleRebaseOnOther}
         >
         </md-radio>
@@ -566,25 +571,6 @@
   private handleEnterChangeNumberClick() {
     if (this.rebaseOnOtherInput) this.rebaseOnOtherInput.checked = true;
   }
-
-  /**
-   * Sets the default radio button based on the state of the app and
-   * the corresponding value to be submitted.
-   */
-  private updateSelectedOption() {
-    const {rebaseOnCurrent, hasParent} = this;
-    if (rebaseOnCurrent === undefined || hasParent === undefined) {
-      return;
-    }
-
-    if (this.displayParentOption()) {
-      if (this.rebaseOnParentInput) this.rebaseOnParentInput.checked = true;
-    } else if (this.displayTipOption()) {
-      if (this.rebaseOnTipInput) this.rebaseOnTipInput.checked = true;
-    } else {
-      if (this.rebaseOnOtherInput) this.rebaseOnOtherInput.checked = true;
-    }
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
index c2965cf..30cf475 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
@@ -63,7 +63,7 @@
             <md-radio
               id="rebaseOnParentInput"
               name="rebaseOptions"
-              tabindex="0"
+              tabindex="-1"
             >
             </md-radio>
             <label for="rebaseOnParentInput" id="rebaseOnParentLabel">
@@ -81,7 +81,7 @@
               disabled=""
               id="rebaseOnTipInput"
               name="rebaseOptions"
-              tabindex="0"
+              tabindex="-1"
             >
             </md-radio>
             <label for="rebaseOnTipInput" id="rebaseOnTipLabel">
@@ -93,7 +93,12 @@
             Change is up to date with the target branch already (test)
           </div>
           <div class="rebaseOption" id="rebaseOnOther">
-            <md-radio id="rebaseOnOtherInput" name="rebaseOptions" tabindex="0">
+            <md-radio
+              checked=""
+              id="rebaseOnOtherInput"
+              name="rebaseOptions"
+              tabindex="0"
+            >
             </md-radio>
             <label for="rebaseOnOtherInput" id="rebaseOnOtherLabel">
               Rebase on a specific change, ref, or commit
@@ -428,7 +433,7 @@
     );
     assert.deepEqual((fireStub.lastCall.args[0] as CustomEvent).detail, {
       allowConflicts: false,
-      base: '123',
+      base: '',
       rebaseChain: true,
       onBehalfOfUploader: true,
       committerEmail: null,
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
index fd9204c..52ae1c2 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
@@ -24,6 +24,8 @@
 import {formStyles} from '../../../styles/form-styles';
 import {GrValidationOptions} from '../gr-validation-options/gr-validation-options';
 import {GrAutogrowTextarea} from '../../shared/gr-autogrow-textarea/gr-autogrow-textarea';
+import '@material/web/radio/radio';
+import {materialStyles} from '../../../styles/gr-material-styles';
 
 const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
 const SPECIFY_REASON_STRING = '<MUST SPECIFY REASON HERE>';
@@ -85,6 +87,7 @@
     return [
       formStyles,
       sharedStyles,
+      materialStyles,
       css`
         :host {
           display: block;
@@ -101,6 +104,8 @@
         .revertSubmissionLayout {
           display: flex;
           align-items: center;
+          margin-top: var(--spacing-l);
+          margin-bottom: var(--spacing-m);
         }
         .label {
           margin-left: var(--spacing-m);
@@ -121,6 +126,9 @@
         label[for='messageInput'] {
           margin-top: var(--spacing-m);
         }
+        gr-validation-options {
+          margin-bottom: var(--spacing-m);
+        }
       `,
     ];
   }
@@ -140,13 +148,13 @@
           ${this.showRevertSubmission
             ? html`
                 <div class="revertSubmissionLayout">
-                  <input
-                    name="revertOptions"
-                    type="radio"
+                  <md-radio
                     id="revertSingleChange"
-                    @change=${() => this.handleRevertSingleChangeClicked()}
+                    name="revertOptions"
                     ?checked=${this.computeIfSingleRevert()}
-                  />
+                    @change=${() => this.handleRevertSingleChangeClicked()}
+                  >
+                  </md-radio>
                   <label
                     for="revertSingleChange"
                     class="label revertSingleChange"
@@ -155,13 +163,13 @@
                   </label>
                 </div>
                 <div class="revertSubmissionLayout">
-                  <input
-                    name="revertOptions"
-                    type="radio"
+                  <md-radio
                     id="revertSubmission"
+                    name="revertOptions"
+                    ?checked=${this.computeIfRevertSubmission()}
                     @change=${() => this.handleRevertSubmissionClicked()}
-                    .checked=${this.computeIfRevertSubmission()}
-                  />
+                  >
+                  </md-radio>
                   <label for="revertSubmission" class="label revertSubmission">
                     Revert entire submission (${this.changesCount} Changes)
                   </label>
diff --git a/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow.ts b/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow.ts
index d2656d0..f152806 100644
--- a/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow.ts
+++ b/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow.ts
@@ -18,6 +18,7 @@
 import {MdOutlinedTextField} from '@material/web/textfield/outlined-text-field';
 import {resolve} from '../../../models/dependency';
 import {configModelToken} from '../../../models/config/config-model';
+import {flowsModelToken} from '../../../models/flows/flows-model';
 import {subscribe} from '../../lit/subscription-controller';
 import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {
@@ -60,6 +61,8 @@
 
   private readonly getConfigModel = resolve(this, configModelToken);
 
+  private readonly getFlowsModel = resolve(this, flowsModelToken);
+
   private readonly projectSuggestions: SuggestionProvider = (
     predicate,
     expression
@@ -347,17 +350,12 @@
         return {condition: stage.condition};
       }),
     };
-    await this.restApiService.createFlow(this.changeNum, flowInput, e => {
-      console.error(e);
-    });
+    await this.getFlowsModel().createFlow(flowInput);
     this.stages = [];
     this.currentCondition = '';
     this.currentAction = '';
     this.currentParameter = '';
     this.loading = false;
-    this.dispatchEvent(
-      new CustomEvent('flow-created', {bubbles: true, composed: true})
-    );
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow_test.ts b/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow_test.ts
index 1640709..ac71093 100644
--- a/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow_test.ts
@@ -7,21 +7,20 @@
 import './gr-create-flow';
 import {assert, fixture, html} from '@open-wc/testing';
 import {GrCreateFlow} from './gr-create-flow';
-import {
-  mockPromise,
-  queryAll,
-  queryAndAssert,
-  stubRestApi,
-} from '../../../test/test-utils';
+import {queryAll, queryAndAssert} from '../../../test/test-utils';
 import {NumericChangeId} from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {MdOutlinedTextField} from '@material/web/textfield/outlined-text-field';
 import {GrSearchAutocomplete} from '../../core/gr-search-autocomplete/gr-search-autocomplete';
+import {FlowsModel, flowsModelToken} from '../../../models/flows/flows-model';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-create-flow tests', () => {
   let element: GrCreateFlow;
+  let flowsModel: FlowsModel;
 
   setup(async () => {
+    flowsModel = testResolver(flowsModelToken);
     element = await fixture<GrCreateFlow>(
       html`<gr-create-flow></gr-create-flow>`
     );
@@ -122,7 +121,7 @@
   });
 
   test('creates a flow with one stage', async () => {
-    const createFlowStub = stubRestApi('createFlow').returns(mockPromise());
+    const createFlowStub = sinon.stub(flowsModel, 'createFlow');
 
     const searchAutocomplete = queryAndAssert<GrSearchAutocomplete>(
       element,
@@ -146,7 +145,7 @@
     await element.updateComplete;
 
     assert.isTrue(createFlowStub.calledOnce);
-    const flowInput = createFlowStub.lastCall.args[1];
+    const flowInput = createFlowStub.lastCall.args[0];
     assert.deepEqual(flowInput.stage_expressions, [
       {
         condition:
@@ -157,7 +156,7 @@
   });
 
   test('creates a flow with parameters', async () => {
-    const createFlowStub = stubRestApi('createFlow').returns(mockPromise());
+    const createFlowStub = sinon.stub(flowsModel, 'createFlow');
 
     const searchAutocomplete = queryAndAssert<GrSearchAutocomplete>(
       element,
@@ -188,7 +187,7 @@
     await element.updateComplete;
 
     assert.isTrue(createFlowStub.calledOnce);
-    const flowInput = createFlowStub.lastCall.args[1];
+    const flowInput = createFlowStub.lastCall.args[0];
     assert.deepEqual(flowInput.stage_expressions, [
       {
         condition:
@@ -199,7 +198,7 @@
   });
 
   test('creates a flow with multiple stages', async () => {
-    const createFlowStub = stubRestApi('createFlow').returns(mockPromise());
+    const createFlowStub = sinon.stub(flowsModel, 'createFlow');
 
     const searchAutocomplete = queryAndAssert<GrSearchAutocomplete>(
       element,
@@ -238,7 +237,7 @@
     await element.updateComplete;
 
     assert.isTrue(createFlowStub.calledOnce);
-    const flowInput = createFlowStub.lastCall.args[1];
+    const flowInput = createFlowStub.lastCall.args[0];
     assert.deepEqual(flowInput.stage_expressions, [
       {
         condition:
@@ -254,7 +253,7 @@
   });
 
   test('create flow with added stages and current input', async () => {
-    const createFlowStub = stubRestApi('createFlow').returns(mockPromise());
+    const createFlowStub = sinon.stub(flowsModel, 'createFlow');
 
     const searchAutocomplete = queryAndAssert<GrSearchAutocomplete>(
       element,
@@ -290,7 +289,7 @@
     await element.updateComplete;
 
     assert.isTrue(createFlowStub.calledOnce);
-    const flowInput = createFlowStub.lastCall.args[1];
+    const flowInput = createFlowStub.lastCall.args[0];
     assert.deepEqual(flowInput.stage_expressions, [
       {
         condition:
diff --git a/polygerrit-ui/app/elements/change/gr-flows/gr-flows.ts b/polygerrit-ui/app/elements/change/gr-flows/gr-flows.ts
index 8136722..e272eda 100644
--- a/polygerrit-ui/app/elements/change/gr-flows/gr-flows.ts
+++ b/polygerrit-ui/app/elements/change/gr-flows/gr-flows.ts
@@ -11,12 +11,13 @@
 import {changeModelToken} from '../../../models/change/change-model';
 import {subscribe} from '../../lit/subscription-controller';
 import {FlowInfo, FlowStageState} from '../../../api/rest-api';
-
-import {getAppContext} from '../../../services/app-context';
+import {flowsModelToken} from '../../../models/flows/flows-model';
 import {NumericChangeId} from '../../../types/common';
 import './gr-create-flow';
 import {when} from 'lit/directives/when.js';
 import '../../shared/gr-dialog/gr-dialog';
+import '@material/web/select/filled-select';
+import '@material/web/select/select-option';
 
 const iconForFlowStageState = (status: FlowStageState) => {
   switch (status) {
@@ -46,9 +47,11 @@
 
   @state() private flowIdToDelete?: string;
 
+  @state() private statusFilter: FlowStageState | 'all' = 'all';
+
   private readonly getChangeModel = resolve(this, changeModelToken);
 
-  private readonly restApiService = getAppContext().restApiService;
+  private readonly getFlowsModel = resolve(this, flowsModelToken);
 
   static override get styles() {
     return [
@@ -132,23 +135,27 @@
       () => this.getChangeModel().changeNum$,
       changeNum => {
         this.changeNum = changeNum;
-        this.loadFlows();
+      }
+    );
+    subscribe(
+      this,
+      () => this.getFlowsModel().flows$,
+      flows => {
+        this.flows = flows;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getFlowsModel().loading$,
+      loading => {
+        this.loading = loading;
       }
     );
   }
 
-  async loadFlows() {
-    if (!this.changeNum) return;
-    this.loading = true;
-    const flows = await this.restApiService.listFlows(this.changeNum);
-    this.flows = flows ?? [];
-    this.loading = false;
-  }
-
   private async deleteFlow() {
-    if (!this.changeNum || !this.flowIdToDelete) return;
-    await this.restApiService.deleteFlow(this.changeNum, this.flowIdToDelete);
-    await this.loadFlows();
+    if (!this.flowIdToDelete) return;
+    await this.getFlowsModel().deleteFlow(this.flowIdToDelete);
     this.closeConfirmDialog();
   }
 
@@ -166,10 +173,7 @@
     return html`
       <div class="container">
         <h2 class="main-heading">Create new flow</h2>
-        <gr-create-flow
-          .changeNum=${this.changeNum}
-          @flow-created=${this.loadFlows}
-        ></gr-create-flow>
+        <gr-create-flow .changeNum=${this.changeNum}></gr-create-flow>
         <hr />
         ${this.renderFlowsList()}
       </div>
@@ -210,13 +214,19 @@
     if (this.flows.length === 0) {
       return html`<p>No flows found for this change.</p>`;
     }
+    const filteredFlows = this.flows.filter(flow => {
+      if (this.statusFilter === 'all') return true;
+      const lastStage = flow.stages[flow.stages.length - 1];
+      return lastStage.state === this.statusFilter;
+    });
+
     return html`
       <div>
         <div class="heading-with-button">
           <h2 class="main-heading">Existing Flows</h2>
           <gr-button
             link
-            @click=${this.loadFlows}
+            @click=${() => this.getFlowsModel().reload()}
             aria-label="Refresh flows"
             title="Refresh flows"
             class="refresh"
@@ -224,7 +234,26 @@
             <gr-icon icon="refresh"></gr-icon>
           </gr-button>
         </div>
-        ${this.flows.map(
+        <md-filled-select
+          label="Filter by status"
+          @request-selection=${(e: CustomEvent) => {
+            this.statusFilter = (e.target as HTMLSelectElement).value as
+              | FlowStageState
+              | 'all';
+          }}
+        >
+          <md-select-option value="all">
+            <div slot="headline">All</div>
+          </md-select-option>
+          ${Object.values(FlowStageState).map(
+            status => html`
+              <md-select-option value=${status}>
+                <div slot="headline">${status}</div>
+              </md-select-option>
+            `
+          )}
+        </md-filled-select>
+        ${filteredFlows.map(
           (flow: FlowInfo) => html`
             <div class="flow">
               <div class="flow-header">
diff --git a/polygerrit-ui/app/elements/change/gr-flows/gr-flows_test.ts b/polygerrit-ui/app/elements/change/gr-flows/gr-flows_test.ts
index cb14d9e..b21b090 100644
--- a/polygerrit-ui/app/elements/change/gr-flows/gr-flows_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-flows/gr-flows_test.ts
@@ -8,19 +8,27 @@
 import {assert, fixture, html} from '@open-wc/testing';
 import {GrFlows} from './gr-flows';
 import {FlowInfo, FlowStageState, Timestamp} from '../../../api/rest-api';
-import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {queryAndAssert} from '../../../test/test-utils';
 import {NumericChangeId} from '../../../types/common';
-import {GrCreateFlow} from './gr-create-flow';
 import sinon from 'sinon';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {FlowsModel, flowsModelToken} from '../../../models/flows/flows-model';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-flows tests', () => {
   let element: GrFlows;
   let clock: sinon.SinonFakeTimers;
+  let flowsModel: FlowsModel;
 
   setup(async () => {
     clock = sinon.useFakeTimers();
+
+    flowsModel = testResolver(flowsModelToken);
+    // The model is created by the DI system. The test setup replaces the real
+    // model with a mock. To prevent real API calls, we stub the reload method.
+    sinon.stub(flowsModel, 'reload');
+
     element = await fixture<GrFlows>(html`<gr-flows></gr-flows>`);
     element['changeNum'] = 123 as NumericChangeId;
     await element.updateComplete;
@@ -31,8 +39,7 @@
   });
 
   test('renders create flow component and no flows', async () => {
-    stubRestApi('listFlows').returns(Promise.resolve([]));
-    await element['loadFlows']();
+    flowsModel.setState({flows: [], loading: false});
     await element.updateComplete;
     assert.shadowDom.equal(
       element,
@@ -85,8 +92,7 @@
         ],
       },
     ];
-    stubRestApi('listFlows').returns(Promise.resolve(flows));
-    await element['loadFlows']();
+    flowsModel.setState({flows, loading: false});
     await element.updateComplete;
 
     // prettier formats the spacing for "last evaluated" incorrectly
@@ -108,6 +114,23 @@
                 <gr-icon icon="refresh"></gr-icon>
               </gr-button>
             </div>
+            <md-filled-select label="Filter by status">
+              <md-select-option value="all">
+                <div slot="headline">All</div>
+              </md-select-option>
+              <md-select-option value="DONE">
+                <div slot="headline">DONE</div>
+              </md-select-option>
+              <md-select-option value="FAILED">
+                <div slot="headline">FAILED</div>
+              </md-select-option>
+              <md-select-option value="PENDING">
+                <div slot="headline">PENDING</div>
+              </md-select-option>
+              <md-select-option value="TERMINATED">
+                <div slot="headline">TERMINATED</div>
+              </md-select-option>
+            </md-filled-select>
             <div class="flow">
               <div class="flow-header">
                 <gr-button link title="Delete flow">
@@ -205,6 +228,7 @@
           'aria-disabled',
           'role',
           'tabindex',
+          'md-menu-item',
         ],
       }
     );
@@ -224,11 +248,8 @@
         ],
       },
     ];
-    stubRestApi('listFlows').returns(Promise.resolve(flows));
-    const deleteFlowStub = sinon
-      .stub(element['restApiService'], 'deleteFlow')
-      .returns(Promise.resolve(new Response()));
-    await element['loadFlows']();
+    const deleteFlowStub = sinon.stub(flowsModel, 'deleteFlow');
+    flowsModel.setState({flows, loading: false});
     await element.updateComplete;
 
     const deleteButton = queryAndAssert<GrButton>(element, '.flow gr-button');
@@ -246,7 +267,7 @@
     confirmButton.click();
     await element.updateComplete;
 
-    assert.isTrue(deleteFlowStub.calledOnceWith(123, 'flow1'));
+    assert.isTrue(deleteFlowStub.calledOnceWith('flow1'));
   });
 
   test('cancel deleting a flow', async () => {
@@ -263,11 +284,8 @@
         ],
       },
     ];
-    stubRestApi('listFlows').returns(Promise.resolve(flows));
-    const deleteFlowStub = sinon
-      .stub(element['restApiService'], 'deleteFlow')
-      .returns(Promise.resolve(new Response()));
-    await element['loadFlows']();
+    const deleteFlowStub = sinon.stub(flowsModel, 'deleteFlow');
+    flowsModel.setState({flows, loading: false});
     await element.updateComplete;
 
     const deleteButton = queryAndAssert<GrButton>(element, '.flow gr-button');
@@ -289,44 +307,19 @@
     assert.isFalse(dialog.open);
   });
 
-  test('reloads flows on flow-created event', async () => {
-    const listFlowsStub = stubRestApi('listFlows').returns(Promise.resolve([]));
-    await element['loadFlows']();
-    await element.updateComplete;
-
-    assert.isTrue(listFlowsStub.calledOnce);
-
-    const createFlow = queryAndAssert<GrCreateFlow>(element, 'gr-create-flow');
-    createFlow.dispatchEvent(
-      new CustomEvent('flow-created', {bubbles: true, composed: true})
-    );
-
-    await element.updateComplete;
-
-    assert.isTrue(listFlowsStub.calledTwice);
-  });
-
   test('refreshes flows on button click', async () => {
-    const flows: FlowInfo[] = [
-      {
-        uuid: 'flow1',
-        owner: {name: 'owner1'},
-        created: '2025-01-01T10:00:00.000Z' as Timestamp,
-        stages: [
-          {
-            expression: {condition: 'label:Code-Review=+1'},
-            state: FlowStageState.DONE,
-          },
-        ],
-      },
-    ];
-    const listFlowsStub = stubRestApi('listFlows').returns(
-      Promise.resolve(flows)
-    );
-    await element.loadFlows();
+    const flow = {
+      uuid: 'flow1',
+      owner: {name: 'owner1'},
+      created: '2025-01-01T10:00:00.000Z' as Timestamp,
+      stages: [],
+    } as FlowInfo;
+    flowsModel.setState({flows: [flow], loading: false});
     await element.updateComplete;
 
-    assert.isTrue(listFlowsStub.calledOnce);
+    const reloadStub = flowsModel.reload as sinon.SinonStub;
+    reloadStub.resetHistory();
+
     const refreshButton = queryAndAssert<GrButton>(
       element,
       '.heading-with-button gr-button'
@@ -334,6 +327,111 @@
     refreshButton.click();
     await element.updateComplete;
 
-    assert.isTrue(listFlowsStub.calledTwice);
+    assert.isTrue(reloadStub.calledOnce);
+  });
+
+  suite('filter', () => {
+    const flows: FlowInfo[] = [
+      {
+        uuid: 'flow-done',
+        owner: {name: 'owner1'},
+        created: '2025-01-01T10:00:00.000Z' as Timestamp,
+        stages: [
+          {expression: {condition: 'cond-done'}, state: FlowStageState.DONE},
+        ],
+      },
+      {
+        uuid: 'flow-pending',
+        owner: {name: 'owner2'},
+        created: '2025-01-02T10:00:00.000Z' as Timestamp,
+        stages: [
+          {
+            expression: {condition: 'cond-pending'},
+            state: FlowStageState.PENDING,
+          },
+        ],
+      },
+      {
+        uuid: 'flow-failed',
+        owner: {name: 'owner3'},
+        created: '2025-01-03T10:00:00.000Z' as Timestamp,
+        stages: [
+          {
+            expression: {condition: 'cond-failed'},
+            state: FlowStageState.FAILED,
+          },
+        ],
+      },
+      {
+        uuid: 'flow-terminated',
+        owner: {name: 'owner4'},
+        created: '2025-01-04T10:00:00.000Z' as Timestamp,
+        stages: [
+          {
+            expression: {condition: 'cond-terminated'},
+            state: FlowStageState.TERMINATED,
+          },
+        ],
+      },
+    ];
+
+    setup(async () => {
+      flowsModel.setState({flows, loading: false});
+      await element.updateComplete;
+    });
+
+    test('shows all flows by default', () => {
+      const flowElements = element.shadowRoot!.querySelectorAll('.flow');
+      assert.equal(flowElements.length, 4);
+    });
+
+    test('filters by DONE', async () => {
+      element['statusFilter'] = FlowStageState.DONE;
+      await element.updateComplete;
+
+      const flowElements = element.shadowRoot!.querySelectorAll('.flow');
+      assert.equal(flowElements.length, 1);
+      assert.include(flowElements[0].textContent, 'cond-done');
+    });
+
+    test('filters by PENDING', async () => {
+      element['statusFilter'] = FlowStageState.PENDING;
+      await element.updateComplete;
+
+      const flowElements = element.shadowRoot!.querySelectorAll('.flow');
+      assert.equal(flowElements.length, 1);
+      assert.include(flowElements[0].textContent, 'cond-pending');
+    });
+
+    test('filters by FAILED', async () => {
+      element['statusFilter'] = FlowStageState.FAILED;
+      await element.updateComplete;
+
+      const flowElements = element.shadowRoot!.querySelectorAll('.flow');
+      assert.equal(flowElements.length, 1);
+      assert.include(flowElements[0].textContent, 'cond-failed');
+    });
+
+    test('filters by TERMINATED', async () => {
+      element['statusFilter'] = FlowStageState.TERMINATED;
+      await element.updateComplete;
+
+      const flowElements = element.shadowRoot!.querySelectorAll('.flow');
+      assert.equal(flowElements.length, 1);
+      assert.include(flowElements[0].textContent, 'cond-terminated');
+    });
+
+    test('shows all when filter is changed to all', async () => {
+      element['statusFilter'] = FlowStageState.DONE;
+      await element.updateComplete;
+      let flowElements = element.shadowRoot!.querySelectorAll('.flow');
+      assert.equal(flowElements.length, 1);
+
+      element['statusFilter'] = 'all';
+      await element.updateComplete;
+
+      flowElements = element.shadowRoot!.querySelectorAll('.flow');
+      assert.equal(flowElements.length, 4);
+    });
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 623fe40..04c8618 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -1425,6 +1425,7 @@
       );
       if (!this.ccs.find(cc => getUserId(cc) === getUserId(accountToMove))) {
         this.ccs = [...this.ccs, accountToMove];
+        this.reviewersMutated = true;
       }
     } else {
       this.ccs = this.ccs.filter(
@@ -1434,6 +1435,7 @@
         !this.reviewers.find(r => getUserId(r) === getUserId(accountToMove))
       ) {
         this.reviewers = [...this.reviewers, accountToMove];
+        this.reviewersMutated = true;
       }
     }
 
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
index 8a58551..3963888 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
@@ -47,6 +47,7 @@
   countRunningRunsForLabel,
 } from '../../checks/gr-checks-util';
 import {subscribe} from '../../lit/subscription-controller';
+import {when} from 'lit/directives/when.js';
 
 /**
  * @attr {Boolean} suppress-title - hide titles, currently for hovercard view
@@ -153,7 +154,6 @@
     const submit_requirements = orderSubmitRequirements(
       getRequirements(this.change)
     );
-    if (submit_requirements.length === 0) return nothing;
 
     const requirementKey = (req: SubmitRequirementResultInfo, index: number) =>
       `${index}-${req.name}`;
@@ -162,34 +162,44 @@
         id="submit-requirements-caption"
       >
         Submit Requirements
+        ${submit_requirements.length === 0 ? '(Loading...)' : ''}
       </h3>
-      <table class="requirements" aria-labelledby="submit-requirements-caption">
-        <thead hidden>
-          <tr>
-            <th>Status</th>
-            <th>Name</th>
-            <th>Votes</th>
-          </tr>
-        </thead>
-        <tbody>
-          ${repeat(submit_requirements, requirementKey, (requirement, index) =>
-            this.renderRequirement(requirement, index)
-          )}
-        </tbody>
-      </table>
-      ${this.disableHovercards
-        ? ''
-        : submit_requirements.map(
-            (requirement, index) => html`
-              <gr-submit-requirement-hovercard
-                for="requirement-${index}-${charsOnly(requirement.name)}"
-                .requirement=${requirement}
-                .change=${this.change}
-                .account=${this.account}
-                .mutable=${this.mutable ?? false}
-              ></gr-submit-requirement-hovercard>
-            `
-          )}`;
+      ${when(
+        submit_requirements.length !== 0,
+        () => html`<table
+            class="requirements"
+            aria-labelledby="submit-requirements-caption"
+          >
+            <thead hidden>
+              <tr>
+                <th>Status</th>
+                <th>Name</th>
+                <th>Votes</th>
+              </tr>
+            </thead>
+            <tbody>
+              ${repeat(
+                submit_requirements,
+                requirementKey,
+                (requirement, index) =>
+                  this.renderRequirement(requirement, index)
+              )}
+            </tbody>
+          </table>
+          ${this.disableHovercards
+            ? ''
+            : submit_requirements.map(
+                (requirement, index) => html`
+                  <gr-submit-requirement-hovercard
+                    for="requirement-${index}-${charsOnly(requirement.name)}"
+                    .requirement=${requirement}
+                    .change=${this.change}
+                    .account=${this.account}
+                    .mutable=${this.mutable ?? false}
+                  ></gr-submit-requirement-hovercard>
+                `
+              )}`
+      )}`;
   }
 
   private renderRequirement(
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
index 6812251..aee2017 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
@@ -23,24 +23,33 @@
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
 } from '../../../api/rest-api';
-import {ParsedChangeInfo} from '../../../types/types';
+import {LoadingStatus, ParsedChangeInfo} from '../../../types/types';
 import {RunStatus} from '../../../api/checks';
+import {testResolver} from '../../../test/common-test-setup';
+import {
+  ChangeModel,
+  changeModelToken,
+} from '../../../models/change/change-model';
 
 suite('gr-submit-requirements tests', () => {
   let element: GrSubmitRequirements;
   let change: ParsedChangeInfo;
+  let changeModel: ChangeModel;
+
   setup(async () => {
     const submitRequirement: SubmitRequirementResultInfo = {
       ...createSubmitRequirementResultInfo(),
       description: 'Test Description',
       submittability_expression_result: createSubmitRequirementExpressionInfo(),
     };
+    const submitRequirements: SubmitRequirementResultInfo[] = [
+      submitRequirement,
+      createNonApplicableSubmitRequirementResultInfo(),
+    ];
     change = {
       ...createParsedChange(),
-      submit_requirements: [
-        submitRequirement,
-        createNonApplicableSubmitRequirementResultInfo(),
-      ],
+      submittable: false,
+      submit_requirements: submitRequirements,
       labels: {
         Verified: {
           ...createDetailedLabelInfo(),
@@ -54,12 +63,23 @@
       },
     };
     const account = createAccountWithIdNameAndEmail();
+    changeModel = testResolver(changeModelToken);
+    changeModel.setState({
+      change,
+      submittabilityInfo: {
+        changeNum: change._number,
+        submitRequirements,
+        submittable: false,
+      },
+      loadingStatus: LoadingStatus.LOADED,
+    });
     element = await fixture<GrSubmitRequirements>(
       html`<gr-submit-requirements
         .change=${change}
         .account=${account}
       ></gr-submit-requirements>`
     );
+    await element.updateComplete;
   });
 
   test('renders', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-validation-options/gr-validation-options.ts b/polygerrit-ui/app/elements/change/gr-validation-options/gr-validation-options.ts
index de37852..6fdb74e 100644
--- a/polygerrit-ui/app/elements/change/gr-validation-options/gr-validation-options.ts
+++ b/polygerrit-ui/app/elements/change/gr-validation-options/gr-validation-options.ts
@@ -67,7 +67,7 @@
           class="selectionLabel"
           id=${option.name}
           ?checked=${!!this.isOptionSelected.get(option.name)}
-          @click=${() => this.toggleCheckbox(option)}
+          @change=${() => this.toggleCheckbox(option)}
         ></md-checkbox>
         <label for=${option.name}
           >${capitalizeFirstLetter(option.description)}</label
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 5f92d16..54f636e 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -59,6 +59,8 @@
 import {when} from 'lit/directives/when.js';
 import {changeViewModelToken} from '../../models/views/change';
 import {formStyles} from '../../styles/form-styles';
+import '@material/web/radio/radio';
+import {materialStyles} from '../../styles/gr-material-styles';
 
 @customElement('gr-checks-run')
 export class GrChecksRun extends LitElement {
@@ -66,6 +68,7 @@
     return [
       formStyles,
       sharedStyles,
+      materialStyles,
       css`
         :host {
           display: block;
@@ -173,7 +176,7 @@
         .attemptDetail {
           /* This is thick-border (6) + spacing-m (8) + icon (20) + padding. */
           padding-left: 39px;
-          padding-top: var(--spacing-s);
+          padding-top: var(--spacing-l);
         }
         .attemptDetail input {
           width: 14px;
@@ -320,14 +323,14 @@
           .attempt=${attempt as number}
         ></gr-hovercard-run>`
       )}
-      <input
-        type="radio"
+      <md-radio
         id=${id}
         name=${`${checkNameId}-attempt-choice`}
-        .checked=${selected}
+        ?checked=${selected}
         ?disabled=${!selected && wasNotRun}
         @change=${() => this.handleAttemptChange(attempt)}
-      />
+      >
+      </md-radio>
       <gr-icon
         icon=${icon.name}
         class=${icon.name}
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
index 13a2545..871a598 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
@@ -53,7 +53,7 @@
               role="switch"
             >
               <div>
-                <gr-icon icon="chevron_left" class="expandIcon"></gr-icon>
+                <gr-icon class="expandIcon" icon="chevron_left"> </gr-icon>
               </div>
             </gr-button>
           </gr-tooltip-content>
@@ -64,7 +64,8 @@
           </div>
           <div class="right">
             <div class="message">
-              Error while fetching results for test-plugin-name: <br />
+              Error while fetching results for test-plugin-name:
+              <br />
               test-error-message
             </div>
           </div>
@@ -76,32 +77,32 @@
         />
         <div class="expanded running">
           <div class="sectionHeader">
-            <gr-icon icon="expand_less" class="expandIcon"></gr-icon>
+            <gr-icon class="expandIcon" icon="expand_less"> </gr-icon>
             <h3 class="heading-3">Running / Scheduled (2)</h3>
           </div>
           <div class="sectionRuns">
-            <gr-checks-run></gr-checks-run>
-            <gr-checks-run></gr-checks-run>
+            <gr-checks-run> </gr-checks-run>
+            <gr-checks-run> </gr-checks-run>
           </div>
         </div>
         <div class="completed expanded">
           <div class="sectionHeader">
-            <gr-icon icon="expand_less" class="expandIcon"></gr-icon>
+            <gr-icon class="expandIcon" icon="expand_less"> </gr-icon>
             <h3 class="heading-3">Completed (3)</h3>
           </div>
           <div class="sectionRuns">
-            <gr-checks-run></gr-checks-run>
-            <gr-checks-run></gr-checks-run>
-            <gr-checks-run></gr-checks-run>
+            <gr-checks-run> </gr-checks-run>
+            <gr-checks-run> </gr-checks-run>
+            <gr-checks-run> </gr-checks-run>
           </div>
         </div>
         <div class="expanded runnable">
           <div class="sectionHeader">
-            <gr-icon icon="expand_less" class="expandIcon"></gr-icon>
+            <gr-icon class="expandIcon" icon="expand_less"> </gr-icon>
             <h3 class="heading-3">Not run (1)</h3>
           </div>
           <div class="sectionRuns">
-            <gr-checks-run></gr-checks-run>
+            <gr-checks-run> </gr-checks-run>
           </div>
         </div>
       `,
@@ -216,26 +217,28 @@
             <gr-checks-attempt> </gr-checks-attempt>
           </div>
           <div class="right"></div>
+        </div>
+        <div class="attemptDetails" hidden="">
+          <div class="attemptDetail">
+            <md-radio
+              checked=""
+              id="attempt-latest"
+              name="fakeerrorfinderfinderfinderfinderfinderfinderfinder-attempt-choice"
+              tabindex="0"
+            >
+            </md-radio>
+            <gr-icon icon=""> </gr-icon>
+            <label for="attempt-latest"> Latest Attempt </label>
           </div>
-          <div class="attemptDetails" hidden="">
-            <div class="attemptDetail">
-              <input
-                id="attempt-latest"
-                name="fakeerrorfinderfinderfinderfinderfinderfinderfinder-attempt-choice"
-                type="radio"
-              />
-              <gr-icon icon=""> </gr-icon>
-              <label for="attempt-latest"> Latest Attempt </label>
-            </div>
-            <div class="attemptDetail">
-              <input
-                id="attempt-all"
-                name="fakeerrorfinderfinderfinderfinderfinderfinderfinder-attempt-choice"
-                type="radio"
-              />
-              <gr-icon icon=""> </gr-icon>
-              <label for="attempt-all"> All Attempts </label>
-            </div>
+          <div class="attemptDetail">
+            <md-radio
+              id="attempt-all"
+              name="fakeerrorfinderfinderfinderfinderfinderfinderfinder-attempt-choice"
+              tabindex="-1"
+            >
+            </md-radio>
+            <gr-icon icon=""> </gr-icon>
+            <label for="attempt-all"> All Attempts </label>
           </div>
         </div>
       `
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
index 6471d1e..f348a80 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -10,9 +10,12 @@
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-default-editor/gr-default-editor';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {
   Base64FileContent,
+  ChangeInfo,
   EditPreferencesInfo,
+  RevisionInfo,
   RevisionPatchSetNum,
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../../types/types';
@@ -94,6 +97,8 @@
 
   private readonly reporting = getAppContext().reportingService;
 
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
   private readonly getStorage = resolve(this, storageServiceToken);
 
   private readonly getUserModel = resolve(this, userModelToken);
@@ -541,11 +546,27 @@
             return;
           }
           assertIsDefined(this.change, 'change');
+
+          this.getPluginLoader().jsApiService.handlePublishEdit(
+            this.change as ChangeInfo,
+            this.getLatestRevision(this.change as ChangeInfo)
+          );
+
           this.getChangeModel().navigateToChangeResetReload();
         });
     });
   };
 
+  private getLatestRevision(change: ChangeInfo): RevisionInfo | null {
+    const patchNum = this.latestPatchsetNumber;
+    for (const rev of Object.values(change.revisions ?? {})) {
+      if (rev._number === patchNum) {
+        return rev;
+      }
+    }
+    return null;
+  }
+
   private handleContentChange(e: CustomEvent<{value: string}>) {
     this.storeTask = debounce(
       this.storeTask,
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
index 9e970e6..8fb6e88 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
@@ -82,7 +82,7 @@
                 id="numberCheckbox"
                 name="number"
                 ?checked=${!!this.showNumber}
-                @click=${this.handleNumberCheckboxClick}
+                @change=${this.handleNumberCheckboxClick}
               ></md-checkbox>
             </td>
           </tr>
@@ -100,7 +100,7 @@
           id=${column}
           name=${column}
           ?checked=${!this.computeIsColumnHidden(column)}
-          @click=${this.handleTargetClick}
+          @change=${this.handleTargetClick}
         ></md-checkbox>
       </td>
     </tr>`;
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
index b322d46..a0a747d 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
@@ -160,7 +160,7 @@
     assert.equal(element.displayedColumns.length, displayedLength - 1);
   });
 
-  test('show item', async () => {
+  test('show and hide item', async () => {
     element.displayedColumns = [
       ColumnNames.STATUS,
       ColumnNames.OWNER,
@@ -171,20 +171,35 @@
     // trigger computation of enabled displayed columns
     element.serverConfig = createServerInfo();
     await element.updateComplete;
-    const checkbox = queryAndAssert<MdCheckbox>(
+
+    const checkboxSubject = queryAndAssert<MdCheckbox>(
       element,
       'table tr:nth-child(2) md-checkbox'
     );
-    const isChecked = checkbox.checked;
-    const displayedLength = element.displayedColumns.length;
-    assert.isFalse(isChecked);
-    const table = queryAndAssert<HTMLTableElement>(element, 'table');
-    assert.equal(table.style.display, '');
+    assert.equal(checkboxSubject.name, 'Subject');
+    const checkboxOwner = queryAndAssert<MdCheckbox>(
+      element,
+      'table tr:nth-child(3) md-checkbox'
+    );
+    assert.equal(checkboxOwner.name, 'Owner');
 
-    checkbox.click();
+    assert.equal(element.displayedColumns.length, 5);
+    assert.isFalse(checkboxSubject.checked);
+    assert.isTrue(checkboxOwner.checked);
+
+    checkboxSubject.click();
     await element.updateComplete;
 
-    assert.equal(element.displayedColumns.length, displayedLength + 1);
+    assert.equal(element.displayedColumns.length, 6);
+    assert.isTrue(checkboxSubject.checked);
+    assert.isTrue(checkboxOwner.checked);
+
+    checkboxOwner.click();
+    await element.updateComplete;
+
+    assert.equal(element.displayedColumns.length, 5);
+    assert.isTrue(checkboxSubject.checked);
+    assert.isFalse(checkboxOwner.checked);
   });
 
   test('getDisplayedColumns', () => {
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
index 566ed93..f43ea62 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
@@ -20,6 +20,7 @@
 import {ifDefined} from 'lit/directives/if-defined.js';
 import '@material/web/textfield/outlined-text-field';
 import {materialStyles} from '../../../styles/gr-material-styles';
+import '@material/web/radio/radio';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -111,15 +112,15 @@
   private renderAgreementsButton(item: ContributorAgreementInfo) {
     return html`
       <span class="contributorAgreementButton">
-        <input
+        <md-radio
           id="claNewAgreementsInput${item.name}"
           name="claNewAgreementsRadio"
-          type="radio"
           data-name=${ifDefined(item.name)}
           data-url=${ifDefined(item.url)}
-          @click=${this.handleShowAgreement}
           ?disabled=${this.disableAgreements(item)}
-        />
+          @change=${this.handleShowAgreement}
+        >
+        </md-radio>
         <label id="claNewAgreementsLabel">${item.name}</label>
       </span>
       ${this.renderAlreadySubmittedText(item)}
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.ts
index 37e21ec..b842027 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.ts
@@ -132,31 +132,32 @@
           <h1 class="heading-1">New Contributor Agreement</h1>
           <h3 class="heading-3">Select an agreement type:</h3>
           <span class="contributorAgreementButton">
-            <input
+            <md-radio
               data-name="Individual"
               data-url="static/cla_individual.html"
               id="claNewAgreementsInputIndividual"
               name="claNewAgreementsRadio"
-              type="radio"
-            />
+            >
+            </md-radio>
             <label id="claNewAgreementsLabel"> Individual </label>
           </span>
           <div class="agreementsUrl">test-description</div>
           <span class="contributorAgreementButton">
-            <input
+            <md-radio
               data-name="CLA"
               data-url="static/cla.html"
               disabled=""
               id="claNewAgreementsInputCLA"
               name="claNewAgreementsRadio"
-              type="radio"
-            />
+            >
+            </md-radio>
             <label id="claNewAgreementsLabel"> CLA </label>
           </span>
           <div class="alreadySubmittedText">Agreement already submitted.</div>
           <div class="agreementsUrl">Contributor License Agreement</div>
         </main>
-      `
+      `,
+      {ignoreAttributes: ['tabindex']}
     );
   });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 6308b12..df26cb8 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -95,6 +95,8 @@
 } from '../../../services/suggestions/suggestions-service';
 import {ResponseCode, SuggestionsProvider} from '../../../api/suggestions';
 import {pluginLoaderToken} from '../gr-js-api-interface/gr-plugin-loader';
+import '@material/web/checkbox/checkbox';
+import {materialStyles} from '../../../styles/gr-material-styles';
 
 // visible for testing
 export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
@@ -517,6 +519,7 @@
       formStyles,
       sharedStyles,
       modalStyles,
+      materialStyles,
       css`
         :host {
           display: block;
@@ -591,7 +594,7 @@
           margin-left: var(--spacing-s);
         }
         /* just for a11y */
-        input.show-hide {
+        md-checkbox.show-hide {
           display: none;
         }
         label.show-hide {
@@ -874,12 +877,11 @@
     return html`
       <div class="show-hide" tabindex="0">
         <label class="show-hide" aria-label=${ariaLabel}>
-          <input
-            type="checkbox"
+          <md-checkbox
             class="show-hide"
             ?checked=${!!this.collapsed}
             @change=${() => (this.collapsed = !this.collapsed)}
-          />
+          ></md-checkbox>
           <gr-icon icon=${icon} id="icon"></gr-icon>
         </label>
       </div>
@@ -1026,12 +1028,11 @@
         <div class="leftActions">
           <div class="action resolve">
             <label>
-              <input
-                type="checkbox"
+              <md-checkbox
                 id="resolvedCheckbox"
-                .checked=${!this.unresolved}
+                ?checked=${!this.unresolved}
                 @change=${this.handleToggleResolved}
-              />
+              ></md-checkbox>
               Resolved
             </label>
           </div>
@@ -1178,8 +1179,7 @@
     return html`
       <div class="action">
         <label title=${tooltip} class="suggestEdit">
-          <input
-            type="checkbox"
+          <md-checkbox
             id="generateSuggestCheckbox"
             ?checked=${this.generateSuggestion}
             @change=${() => {
@@ -1213,7 +1213,7 @@
                 }
               );
             }}
-          />
+          ></md-checkbox>
           Attach AI-suggested fix
           ${when(
             this.suggestionLoading,
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 230b1b6..1f5a9eb 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -98,30 +98,30 @@
         initiallyCollapsedElement,
         /* HTML */ `
           <gr-endpoint-decorator name="comment">
-            <gr-endpoint-param name="comment"></gr-endpoint-param>
-            <gr-endpoint-param name="editing"></gr-endpoint-param>
-            <gr-endpoint-param name="message"></gr-endpoint-param>
-            <gr-endpoint-param name="isDraft"></gr-endpoint-param>
+            <gr-endpoint-param name="comment"> </gr-endpoint-param>
+            <gr-endpoint-param name="editing"> </gr-endpoint-param>
+            <gr-endpoint-param name="message"> </gr-endpoint-param>
+            <gr-endpoint-param name="isDraft"> </gr-endpoint-param>
             <div class="container" id="container">
               <div class="header" id="header">
                 <div class="headerLeft">
-                  <gr-account-label deselected=""></gr-account-label>
+                  <gr-account-label deselected=""> </gr-account-label>
                 </div>
                 <div class="headerMiddle">
                   <span class="collapsedContent">
                     This is the test comment message.
                   </span>
                 </div>
-                <span class="patchset-text">Patchset 1</span>
+                <span class="patchset-text"> Patchset 1 </span>
                 <div class="show-hide" tabindex="0">
                   <label aria-label="Expand" class="show-hide">
-                    <input checked="" class="show-hide" type="checkbox" />
-                    <gr-icon id="icon" icon="expand_more"></gr-icon>
+                    <md-checkbox checked="" class="show-hide"> </md-checkbox>
+                    <gr-icon icon="expand_more" id="icon"> </gr-icon>
                   </label>
                 </div>
               </div>
               <div class="body">
-                <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+                <gr-endpoint-slot name="above-actions"> </gr-endpoint-slot>
               </div>
             </div>
           </gr-endpoint-decorator>
@@ -140,31 +140,31 @@
         element,
         /* HTML */ `
           <gr-endpoint-decorator name="comment">
-            <gr-endpoint-param name="comment"></gr-endpoint-param>
-            <gr-endpoint-param name="editing"></gr-endpoint-param>
-            <gr-endpoint-param name="message"></gr-endpoint-param>
-            <gr-endpoint-param name="isDraft"></gr-endpoint-param>
+            <gr-endpoint-param name="comment"> </gr-endpoint-param>
+            <gr-endpoint-param name="editing"> </gr-endpoint-param>
+            <gr-endpoint-param name="message"> </gr-endpoint-param>
+            <gr-endpoint-param name="isDraft"> </gr-endpoint-param>
             <div class="container" id="container">
               <div class="header" id="header">
                 <div class="headerLeft">
-                  <gr-account-label deselected=""></gr-account-label>
+                  <gr-account-label deselected=""> </gr-account-label>
                 </div>
                 <div class="headerMiddle"></div>
-                <span class="patchset-text">Patchset 1</span>
-                <span class="separator"></span>
+                <span class="patchset-text"> Patchset 1 </span>
+                <span class="separator"> </span>
                 <span class="date" tabindex="0">
-                  <gr-date-formatter withtooltip=""></gr-date-formatter>
+                  <gr-date-formatter withtooltip=""> </gr-date-formatter>
                 </span>
                 <div class="show-hide" tabindex="0">
                   <label aria-label="Collapse" class="show-hide">
-                    <input class="show-hide" type="checkbox" />
-                    <gr-icon id="icon" icon="expand_less"></gr-icon>
+                    <md-checkbox class="show-hide"> </md-checkbox>
+                    <gr-icon icon="expand_less" id="icon"> </gr-icon>
                   </label>
                 </div>
               </div>
               <div class="body">
-                <gr-formatted-text class="message"></gr-formatted-text>
-                <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+                <gr-formatted-text class="message"> </gr-formatted-text>
+                <gr-endpoint-slot name="above-actions"> </gr-endpoint-slot>
               </div>
             </div>
           </gr-endpoint-decorator>
@@ -206,10 +206,10 @@
         element,
         /* HTML */ `
           <gr-endpoint-decorator name="comment">
-            <gr-endpoint-param name="comment"></gr-endpoint-param>
-            <gr-endpoint-param name="editing"></gr-endpoint-param>
-            <gr-endpoint-param name="message"></gr-endpoint-param>
-            <gr-endpoint-param name="isDraft"></gr-endpoint-param>
+            <gr-endpoint-param name="comment"> </gr-endpoint-param>
+            <gr-endpoint-param name="editing"> </gr-endpoint-param>
+            <gr-endpoint-param name="message"> </gr-endpoint-param>
+            <gr-endpoint-param name="isDraft"> </gr-endpoint-param>
             <div class="container draft" id="container">
               <div class="header" id="header">
                 <div class="headerLeft">
@@ -219,31 +219,32 @@
                     max-width="20em"
                     title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'a' key."
                   >
-                    <gr-icon filled icon="rate_review"></gr-icon>
-                    <span class="draftLabel">Draft</span>
+                    <gr-icon filled="" icon="rate_review"> </gr-icon>
+                    <span class="draftLabel"> Draft </span>
                   </gr-tooltip-content>
                 </div>
                 <div class="headerMiddle"></div>
-                <span class="patchset-text">Patchset 1</span>
-                <span class="separator"></span>
+                <span class="patchset-text"> Patchset 1 </span>
+                <span class="separator"> </span>
                 <span class="date" tabindex="0">
-                  <gr-date-formatter withtooltip=""></gr-date-formatter>
+                  <gr-date-formatter withtooltip=""> </gr-date-formatter>
                 </span>
                 <div class="show-hide" tabindex="0">
                   <label aria-label="Collapse" class="show-hide">
-                    <input class="show-hide" type="checkbox" />
-                    <gr-icon id="icon" icon="expand_less"></gr-icon>
+                    <md-checkbox class="show-hide"> </md-checkbox>
+                    <gr-icon icon="expand_less" id="icon"> </gr-icon>
                   </label>
                 </div>
               </div>
               <div class="body">
-                <gr-formatted-text class="message"></gr-formatted-text>
-                <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+                <gr-formatted-text class="message"> </gr-formatted-text>
+                <gr-endpoint-slot name="above-actions"> </gr-endpoint-slot>
                 <div class="actions">
                   <div class="leftActions">
                     <div class="action resolve">
                       <label>
-                        <input id="resolvedCheckbox" type="checkbox" />
+                        <md-checkbox checked="" id="resolvedCheckbox">
+                        </md-checkbox>
                         Resolved
                       </label>
                     </div>
@@ -291,10 +292,10 @@
         element,
         /* HTML */ `
           <gr-endpoint-decorator name="comment">
-            <gr-endpoint-param name="comment"></gr-endpoint-param>
-            <gr-endpoint-param name="editing"></gr-endpoint-param>
-            <gr-endpoint-param name="message"></gr-endpoint-param>
-            <gr-endpoint-param name="isDraft"></gr-endpoint-param>
+            <gr-endpoint-param name="comment"> </gr-endpoint-param>
+            <gr-endpoint-param name="editing"> </gr-endpoint-param>
+            <gr-endpoint-param name="message"> </gr-endpoint-param>
+            <gr-endpoint-param name="isDraft"> </gr-endpoint-param>
             <div class="container draft" id="container">
               <div class="header" id="header">
                 <div class="headerLeft">
@@ -304,8 +305,8 @@
                     max-width="20em"
                     title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'a' key."
                   >
-                    <gr-icon filled icon="rate_review"></gr-icon>
-                    <span class="draftLabel">Draft</span>
+                    <gr-icon filled="" icon="rate_review"> </gr-icon>
+                    <span class="draftLabel"> Draft </span>
                   </gr-tooltip-content>
                 </div>
                 <div class="headerMiddle"></div>
@@ -320,15 +321,15 @@
                   <gr-icon filled="" icon="edit" id="icon"> </gr-icon>
                   Suggest Edit
                 </gr-button>
-                <span class="patchset-text">Patchset 1</span>
-                <span class="separator"></span>
+                <span class="patchset-text"> Patchset 1 </span>
+                <span class="separator"> </span>
                 <span class="date" tabindex="0">
-                  <gr-date-formatter withtooltip=""></gr-date-formatter>
+                  <gr-date-formatter withtooltip=""> </gr-date-formatter>
                 </span>
                 <div class="show-hide" tabindex="0">
                   <label aria-label="Collapse" class="show-hide">
-                    <input class="show-hide" type="checkbox" />
-                    <gr-icon id="icon" icon="expand_less"></gr-icon>
+                    <md-checkbox class="show-hide"> </md-checkbox>
+                    <gr-icon icon="expand_less" id="icon"> </gr-icon>
                   </label>
                 </div>
               </div>
@@ -342,12 +343,13 @@
                   rows="4"
                 >
                 </gr-suggestion-textarea>
-                <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+                <gr-endpoint-slot name="above-actions"> </gr-endpoint-slot>
                 <div class="actions">
                   <div class="leftActions">
                     <div class="action resolve">
                       <label>
-                        <input id="resolvedCheckbox" type="checkbox" />
+                        <md-checkbox checked="" id="resolvedCheckbox">
+                        </md-checkbox>
                         Resolved
                       </label>
                     </div>
diff --git a/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
index 66aafbb..7c8bcf4 100644
--- a/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
+++ b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
@@ -30,6 +30,8 @@
 import {stringToReplacements} from '../../../utils/comment-util';
 import {ReportSource} from '../../../services/suggestions/suggestions-service';
 import {getFileExtension} from '../../../utils/file-util';
+import '@material/web/checkbox/checkbox';
+import {materialStyles} from '../../../styles/gr-material-styles';
 
 export const COLLAPSE_SUGGESTION_STORAGE_KEY = 'collapseSuggestionStorageKey';
 
@@ -143,6 +145,7 @@
 
   static override get styles() {
     return [
+      materialStyles,
       css`
         :host {
           display: block;
@@ -154,7 +157,7 @@
           margin-left: var(--spacing-s);
         }
         /* just for a11y */
-        input.show-hide {
+        md-checkbox.show-hide {
           display: none;
         }
         label.show-hide {
@@ -360,10 +363,9 @@
     return html`
       <div class="show-hide" tabindex="0">
         <label class="show-hide" aria-label=${ariaLabel}>
-          <input
-            type="checkbox"
+          <md-checkbox
             class="show-hide"
-            .checked=${this.collapsed}
+            ?checked=${this.collapsed}
             @change=${() => {
               this.collapsed = !this.collapsed;
               if (this.collapsed) {
@@ -382,7 +384,7 @@
                 );
               }
             }}
-          />
+          ></md-checkbox>
           <gr-icon icon=${icon} id="icon"></gr-icon>
         </label>
       </div>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
index 6d8496d..8417a94 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -195,6 +195,23 @@
     }
   }
 
+  async handleBeforeCommitMessage(
+    change: ChangeInfo | ParsedChangeInfo,
+    msg: string
+  ): Promise<boolean> {
+    let okay = true;
+    for (const cb of this._getEventCallbacks(
+      EventType.BEFORE_COMMIT_MSG_EDIT
+    )) {
+      try {
+        okay = (await cb(change, msg)) && okay;
+      } catch (err: unknown) {
+        this.reportError(err, EventType.BEFORE_COMMIT_MSG_EDIT);
+      }
+    }
+    return okay;
+  }
+
   handleCommitMessage(change: ChangeInfo | ParsedChangeInfo, msg: string) {
     for (const cb of this._getEventCallbacks(EventType.COMMIT_MSG_EDIT)) {
       try {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
index a000456..9f40f7c 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
@@ -70,6 +70,17 @@
   ): string;
   addElement(key: TargetElement, el: HTMLElement): void;
   getAdminMenuLinks(): MenuLink[];
+  /**
+   * This method is called before handling a commit message edit.
+   * It allows plugins to conditionally block edits.
+   * @param change The relevant change.
+   * @param msg The new commit message text.
+   * @return A promise that resolves to true if the action should proceed.
+   */
+  handleBeforeCommitMessage(
+    change: ChangeInfo | ParsedChangeInfo,
+    msg: string
+  ): Promise<boolean>;
   handleCommitMessage(change: ChangeInfo | ParsedChangeInfo, msg: string): void;
   canSubmitChange(change: ChangeInfo, revision?: RevisionInfo | null): boolean;
   getReviewPostRevert(change?: ChangeInfo): ReviewInput;
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index 44eda05..28f6d6e 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -170,7 +170,7 @@
       ></gr-account-chip>
       ${noVoteYet
         ? this.renderVoteAbility(reviewer)
-        : html`${this.renderRemoveVote(reviewer)}`}
+        : html`${this.renderRemoveVote(reviewer, approvalInfo)}`}
     </div>`;
   }
 
@@ -187,12 +187,16 @@
     return html`<span class="no-votes">No votes</span>`;
   }
 
-  private renderRemoveVote(reviewer: AccountInfo) {
+  private renderRemoveVote(
+    reviewer: AccountInfo,
+    approvalInfo: ApprovalInfo | undefined
+  ) {
     const accountId = reviewer._account_id;
     const canDeleteVote = this.canDeleteVote(
       reviewer,
       this.mutable,
-      this.change
+      this.change,
+      approvalInfo
     );
     if (!accountId || !canDeleteVote) return;
 
@@ -253,22 +257,30 @@
 
   /**
    * A user is able to delete a vote iff the mutable property is true and the
-   * reviewer that left the vote exists in the list of removable_reviewers
+   * reviewer that left the vote exists in the list of removable_labels
    * received from the backend.
    */
   private canDeleteVote(
-    reviewer: ApprovalInfo,
+    reviewer: AccountInfo,
     mutable: boolean,
-    change?: ParsedChangeInfo
+    change?: ParsedChangeInfo,
+    approvalInfo?: ApprovalInfo
   ) {
-    if (!mutable || !change || !change.removable_reviewers) {
+    if (
+      !mutable ||
+      !change ||
+      !approvalInfo ||
+      !approvalInfo.value ||
+      !change.removable_labels
+    ) {
       return false;
     }
-    const removable = change.removable_reviewers;
-    if (removable.find(r => r._account_id === reviewer?._account_id)) {
-      return true;
+    const removableAccounts =
+      change.removable_labels[this.label]?.[valueString(approvalInfo.value)];
+    if (!removableAccounts) {
+      return false;
     }
-    return false;
+    return removableAccounts.find(r => r._account_id === reviewer?._account_id);
   }
 
   private async onDeleteVote(accountId: AccountId) {
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
index 0514b68..14561ea 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
@@ -90,7 +90,7 @@
       await element.updateComplete;
       assert.isUndefined(query<GrButton>(element, 'gr-button'));
 
-      element.change!.removable_reviewers = [account];
+      element.change!.removable_labels = {'Code-Review': {'+1': [account]}};
       element.mutable = true;
       await element.updateComplete;
       assert.isDefined(query<GrButton>(element, 'gr-button'));
@@ -100,7 +100,7 @@
       const mock = mockPromise();
       const deleteResponse = mock.then(() => new Response(null, {status: 200}));
       const deleteStub = stubRestApi('deleteVote').returns(deleteResponse);
-      element.change!.removable_reviewers = [account];
+      element.change!.removable_labels = {'Code-Review': {'+1': [account]}};
       element.change!.labels!['Code-Review'] = {
         ...label,
         recommended: account,
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index df09209..a71d225 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -21,7 +21,14 @@
   RevisionPatchSetNum,
 } from '../../types/common';
 import {ChangeStatus, DefaultBase} from '../../constants/constants';
-import {combineLatest, forkJoin, from, Observable, of} from 'rxjs';
+import {
+  BehaviorSubject,
+  combineLatest,
+  forkJoin,
+  from,
+  Observable,
+  of,
+} from 'rxjs';
 import {
   catchError,
   filter,
@@ -43,12 +50,16 @@
   ParsedChangeInfo,
 } from '../../types/types';
 import {fire, fireAlert, fireTitleChange} from '../../utils/event-util';
-import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {
+  RestApiService,
+  SubmittabilityInfo,
+} from '../../services/gr-rest-api/gr-rest-api';
 import {select} from '../../utils/observable-util';
 import {assertIsDefined} from '../../utils/common-util';
 import {Model} from '../base/model';
 import {UserModel} from '../user/user-model';
 import {define} from '../dependency';
+import {FlagsService, KnownExperimentId} from '../../services/flags/flags';
 import {
   isOwner,
   isUploader,
@@ -86,6 +97,12 @@
   loadingStatus: LoadingStatus;
   change?: ParsedChangeInfo;
   /**
+   * Information about submittablity and evaluation of SRs
+   *
+   * Corresponding values in `change` are always kept in sync.
+   */
+  submittabilityInfo?: SubmittabilityInfo;
+  /**
    * The list of reviewed files, kept in the model because we want changes made
    * in one view to reflect on other views without re-rendering the other views.
    * Undefined means it's still loading and empty set means no files reviewed.
@@ -254,6 +271,32 @@
 }
 
 /**
+ * Returns new change object with the fields with submittability related fields
+ * updated.
+ *
+ * - if change is undefined return undefined.
+ * - if change number is different than the one in submittability info, no
+ * updates made
+ */
+export function fillFromSubmittabilityInfo(
+  change?: ParsedChangeInfo,
+  submittabilityInfo?: SubmittabilityInfo
+): ParsedChangeInfo | undefined {
+  if (
+    !change ||
+    !submittabilityInfo ||
+    submittabilityInfo.changeNum !== change._number
+  ) {
+    return change;
+  }
+  return {
+    ...change,
+    submittable: submittabilityInfo.submittable,
+    submit_requirements: submittabilityInfo.submitRequirements,
+  };
+}
+
+/**
  * Derives the base patchset number from all the data that can potentially
  * influence it. Mostly just returns `viewModelBasePatchNum` or PARENT, but has
  * some special logic when looking at merge commits.
@@ -308,17 +351,38 @@
 export class ChangeModel extends Model<ChangeState> {
   private change?: ParsedChangeInfo;
 
+  private submittabilityInfo?: SubmittabilityInfo;
+
   private patchNum?: RevisionPatchSetNum;
 
   private basePatchNum?: BasePatchSetNum;
 
   private latestPatchNum?: PatchSetNumber;
 
+  private readonly reloadSubmittabilityTrigger$ = new BehaviorSubject<void>(
+    undefined
+  );
+
   public readonly change$ = select(
     this.state$,
     changeState => changeState.change
   );
 
+  public readonly submittabilityInfo$ = select(
+    this.state$,
+    changeState => changeState.submittabilityInfo
+  );
+
+  public readonly submittable$ = select(
+    this.state$,
+    changeState => changeState.submittabilityInfo?.submittable
+  );
+
+  public readonly submitRequirements$ = select(
+    this.state$,
+    changeState => changeState.submittabilityInfo?.submitRequirements
+  );
+
   public readonly changeLoadingStatus$ = select(
     this.state$,
     changeState => changeState.loadingStatus
@@ -468,7 +532,8 @@
     private readonly restApiService: RestApiService,
     private readonly userModel: UserModel,
     private readonly pluginLoader: PluginLoader,
-    private readonly reporting: ReportingService
+    private readonly reporting: ReportingService,
+    private readonly flagsService: FlagsService
   ) {
     super(initialState);
     this.patchNum$ = select(
@@ -551,6 +616,7 @@
     );
     this.subscriptions = [
       this.loadChange(),
+      this.loadSubmittabilityInfo(),
       this.loadMergeable(),
       this.loadReviewedFiles(),
       this.setOverviewTitle(),
@@ -561,6 +627,9 @@
       this.refuseEditForOpenChange(),
       this.refuseEditForClosedChange(),
       this.change$.subscribe(change => (this.change = change)),
+      this.submittabilityInfo$.subscribe(
+        submittabilityInfo => (this.submittabilityInfo = submittabilityInfo)
+      ),
       this.patchNum$.subscribe(patchNum => (this.patchNum = patchNum)),
       this.basePatchNum$.subscribe(
         basePatchNum => (this.basePatchNum = basePatchNum)
@@ -742,6 +811,48 @@
       .subscribe(mergeable => this.updateState({mergeable}));
   }
 
+  public reloadSubmittability() {
+    this.reloadSubmittabilityTrigger$.next();
+  }
+
+  private loadSubmittabilityInfo() {
+    // Use the same trigger as loadChange, to run SR loading in parallel.
+    return combineLatest([
+      this.viewModel.changeNum$,
+      this.reloadSubmittabilityTrigger$,
+    ])
+      .pipe(
+        map(([changeNum, _]) => changeNum),
+        switchMap(changeNum => {
+          if (!changeNum) {
+            // On change reload changeNum is set to undefined to reset change
+            // state. We propagate undefined and reset the state in this case.
+            return of(undefined);
+          }
+          return from(this.restApiService.getSubmittabilityInfo(changeNum));
+        })
+      )
+      .subscribe(submittabilityInfo => {
+        // TODO(b/445644919): Remove once the submit_requirements is never
+        // requested as part of the change detail.
+        if (
+          !this.flagsService.isEnabled(
+            KnownExperimentId.ASYNC_SUBMIT_REQUIREMENTS
+          )
+        ) {
+          return;
+        }
+        const change = fillFromSubmittabilityInfo(
+          this.change,
+          submittabilityInfo
+        );
+        this.updateState({
+          change,
+          submittabilityInfo,
+        });
+      });
+  }
+
   private loadChange() {
     return this.viewModel.changeNum$
       .pipe(
@@ -1006,11 +1117,24 @@
     if (this.change && change?._number !== this.change?._number) {
       return;
     }
+    if (!change) {
+      this.updateState({
+        change: undefined,
+        loadingStatus: LoadingStatus.NOT_LOADED,
+      });
+      return;
+    }
     change = updateRevisionsWithCommitShas(change);
+    // TODO(b/445644919): Remove once the submit_requirements is never requested
+    // as part of the change detail.
+    if (
+      this.flagsService.isEnabled(KnownExperimentId.ASYNC_SUBMIT_REQUIREMENTS)
+    ) {
+      change = fillFromSubmittabilityInfo(change, this.submittabilityInfo);
+    }
     this.updateState({
       change,
-      loadingStatus:
-        change === undefined ? LoadingStatus.NOT_LOADED : LoadingStatus.LOADED,
+      loadingStatus: LoadingStatus.LOADED,
     });
   }
 }
diff --git a/polygerrit-ui/app/models/change/change-model_test.ts b/polygerrit-ui/app/models/change/change-model_test.ts
index 2fc8f8a..ce3533e 100644
--- a/polygerrit-ui/app/models/change/change-model_test.ts
+++ b/polygerrit-ui/app/models/change/change-model_test.ts
@@ -16,6 +16,7 @@
   createMergeable,
   createParsedChange,
   createRevision,
+  createSubmitRequirementResultInfo,
   TEST_NUMERIC_CHANGE_ID,
 } from '../../test/test-data-generators';
 import {
@@ -60,6 +61,8 @@
 import {SinonStub} from 'sinon';
 import {pluginLoaderToken} from '../../elements/shared/gr-js-api-interface/gr-plugin-loader';
 import {ShowChangeDetail} from '../../elements/shared/gr-js-api-interface/gr-js-api-types';
+import {SubmittabilityInfo} from '../../services/gr-rest-api/gr-rest-api';
+import {FlagsService, KnownExperimentId} from '../../services/flags/flags';
 
 suite('updateRevisionsWithCommitShas() tests', () => {
   test('undefined edit', async () => {
@@ -219,11 +222,26 @@
   });
 });
 
+class TestFlagService implements FlagsService {
+  public experiments: Set<string> = new Set();
+
+  finalize() {}
+
+  isEnabled(experimentId: string): boolean {
+    return this.experiments.has(experimentId);
+  }
+
+  get enabledExperiments() {
+    return [...this.experiments];
+  }
+}
+
 suite('change model tests', () => {
   let changeViewModel: ChangeViewModel;
   let changeModel: ChangeModel;
   let knownChange: ParsedChangeInfo;
   let knownChangeNoRevision: ChangeInfo;
+  let testFlagService: TestFlagService;
   const testCompleted = new Subject<void>();
 
   async function waitForLoadingStatus(
@@ -237,6 +255,7 @@
   }
 
   setup(() => {
+    testFlagService = new TestFlagService();
     changeViewModel = testResolver(changeViewModelToken);
     changeModel = new ChangeModel(
       testResolver(navigationToken),
@@ -244,7 +263,8 @@
       getAppContext().restApiService,
       testResolver(userModelToken),
       testResolver(pluginLoaderToken),
-      getAppContext().reportingService
+      getAppContext().reportingService,
+      testFlagService
     );
     knownChangeNoRevision = {
       ...createChange(),
@@ -487,6 +507,132 @@
     assert.deepEqual(state?.change, updateRevisionsWithCommitShas(knownChange));
   });
 
+  test('load submit requirements (SRs load first)', async () => {
+    testFlagService.experiments.add(
+      KnownExperimentId.ASYNC_SUBMIT_REQUIREMENTS
+    );
+    const promiseDetail = mockPromise<ParsedChangeInfo | undefined>();
+    const stubDetail = stubRestApi('getChangeDetail').callsFake(
+      () => promiseDetail
+    );
+    const promiseSrs = mockPromise<SubmittabilityInfo | undefined>();
+    const stubSrs = stubRestApi('getSubmittabilityInfo').callsFake(
+      () => promiseSrs
+    );
+    testResolver(changeViewModelToken).setState(createChangeViewState());
+    promiseSrs.resolve({
+      changeNum: knownChange._number,
+      submittable: false,
+      submitRequirements: [createSubmitRequirementResultInfo()],
+    });
+    await waitUntilObserved(
+      changeModel.state$,
+      state => state.submittabilityInfo !== undefined,
+      'SubmitRequirements was never loaded'
+    );
+    promiseDetail.resolve(knownChange);
+    const state = await waitForLoadingStatus(LoadingStatus.LOADED);
+    assert.isTrue(state.submittabilityInfo?.submittable === false);
+    assert.isTrue(state.submittabilityInfo?.submitRequirements.length === 1);
+    assert.isTrue(state.change?.submittable === false);
+    assert.isTrue(state.change?.submit_requirements?.length === 1);
+    assert.equal(stubDetail.callCount, 1);
+    assert.equal(stubSrs.callCount, 1);
+  });
+
+  test('load submit requirements (Detail load first, experiment enabled)', async () => {
+    testFlagService.experiments.add(
+      KnownExperimentId.ASYNC_SUBMIT_REQUIREMENTS
+    );
+    const promiseDetail = mockPromise<ParsedChangeInfo | undefined>();
+    const stubDetail = stubRestApi('getChangeDetail').callsFake(
+      () => promiseDetail
+    );
+    const promiseSrs = mockPromise<SubmittabilityInfo | undefined>();
+    const stubSrs = stubRestApi('getSubmittabilityInfo').callsFake(
+      () => promiseSrs
+    );
+    let state: ChangeState;
+    testResolver(changeViewModelToken).setState(createChangeViewState());
+    promiseDetail.resolve(knownChange);
+    state = await waitForLoadingStatus(LoadingStatus.LOADED);
+    promiseSrs.resolve({
+      changeNum: knownChange._number,
+      submittable: false,
+      submitRequirements: [createSubmitRequirementResultInfo()],
+    });
+    state = await waitUntilObserved(
+      changeModel.state$,
+      state => state.submittabilityInfo !== undefined,
+      'SubmitRequirements was never loaded'
+    );
+    assert.isTrue(state.submittabilityInfo?.submittable === false);
+    assert.isTrue(state.submittabilityInfo?.submitRequirements.length === 1);
+    assert.isTrue(state.change?.submittable === false);
+    assert.isTrue(state.change?.submit_requirements?.length === 1);
+    assert.equal(stubDetail.callCount, 1);
+    assert.equal(stubSrs.callCount, 1);
+  });
+
+  test('load submit requirements (experiment disabled)', async () => {
+    const promiseDetail = mockPromise<ParsedChangeInfo | undefined>();
+    const stubDetail = stubRestApi('getChangeDetail').callsFake(
+      () => promiseDetail
+    );
+    const promiseSrs = mockPromise<SubmittabilityInfo | undefined>();
+    const stubSrs = stubRestApi('getSubmittabilityInfo').callsFake(
+      () => promiseSrs
+    );
+    testResolver(changeViewModelToken).setState(createChangeViewState());
+    promiseSrs.resolve(undefined);
+    promiseDetail.resolve({
+      ...knownChange,
+      submittable: false,
+      submit_requirements: [createSubmitRequirementResultInfo()],
+    });
+    const state = await waitForLoadingStatus(LoadingStatus.LOADED);
+    // Validate that submit requirements didn't get reset to undefined.
+    assert.isTrue(state.change?.submittable === false);
+    assert.isTrue(state.change?.submit_requirements?.length === 1);
+    assert.equal(stubDetail.callCount, 1);
+    assert.equal(stubSrs.callCount, 1);
+  });
+
+  test('reload submit requirements', async () => {
+    testFlagService.experiments.add(
+      KnownExperimentId.ASYNC_SUBMIT_REQUIREMENTS
+    );
+    // Set initial state
+    const stubDetail = stubRestApi('getChangeDetail').resolves(knownChange);
+    const stubSrs = stubRestApi('getSubmittabilityInfo');
+    stubSrs.resolves({
+      changeNum: knownChange._number,
+      submittable: false,
+      submitRequirements: [createSubmitRequirementResultInfo()],
+    });
+    testResolver(changeViewModelToken).setState(createChangeViewState());
+    await waitUntilObserved(
+      changeModel.state$,
+      state => state.submittabilityInfo !== undefined,
+      'SubmitRequirements was never loaded'
+    );
+    await waitForLoadingStatus(LoadingStatus.LOADED);
+    stubSrs.resolves({
+      changeNum: knownChange._number,
+      submittable: true,
+      submitRequirements: [createSubmitRequirementResultInfo()],
+    });
+    changeModel.reloadSubmittability();
+    const state = await waitUntilObserved(
+      changeModel.state$,
+      state => state.submittabilityInfo?.submittable === true,
+      'Submittability never reloaded'
+    );
+    assert.isTrue(state.change?.submittable);
+    assert.equal(stubDetail.callCount, 1);
+    assert.equal(stubSrs.callCount, 2);
+  });
+
   test('navigating to another change', async () => {
     // setting up a loaded change
     let promise = mockPromise<ParsedChangeInfo | undefined>();
diff --git a/polygerrit-ui/app/models/flows/flows-model.ts b/polygerrit-ui/app/models/flows/flows-model.ts
new file mode 100644
index 0000000..460cec6
--- /dev/null
+++ b/polygerrit-ui/app/models/flows/flows-model.ts
@@ -0,0 +1,97 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {BehaviorSubject, combineLatest, from, of} from 'rxjs';
+import {catchError, map, switchMap} from 'rxjs/operators';
+import {ChangeModel} from '../change/change-model';
+import {FlowInfo, FlowInput} from '../../api/rest-api';
+import {Model} from '../base/model';
+import {define} from '../dependency';
+
+import {NumericChangeId} from '../../types/common';
+import {getAppContext} from '../../services/app-context';
+import {KnownExperimentId} from '../../services/flags/flags';
+
+export interface FlowsState {
+  flows: FlowInfo[];
+  loading: boolean;
+  errorMessage?: string;
+}
+
+export const flowsModelToken = define<FlowsModel>('flows-model');
+
+export class FlowsModel extends Model<FlowsState> {
+  readonly flows$ = this.state$.pipe(map(s => s.flows));
+
+  readonly loading$ = this.state$.pipe(map(s => s.loading));
+
+  private readonly reload$ = new BehaviorSubject<void>(undefined);
+
+  private changeNum?: NumericChangeId;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  private flagService = getAppContext().flagsService;
+
+  constructor(private readonly changeModel: ChangeModel) {
+    super({
+      flows: [],
+      loading: true,
+    });
+
+    this.subscriptions.push(
+      this.changeModel.changeNum$.subscribe(changeNum => {
+        this.changeNum = changeNum;
+      })
+    );
+
+    this.subscriptions.push(
+      combineLatest([this.changeModel.changeNum$, this.reload$])
+        .pipe(
+          switchMap(([changeNum]) => {
+            if (
+              !changeNum ||
+              !this.flagService.isEnabled(KnownExperimentId.SHOW_FLOWS_TAB)
+            )
+              return of([]);
+            this.setState({...this.getState(), loading: true});
+            return from(this.restApiService.listFlows(changeNum)).pipe(
+              catchError(err => {
+                this.setState({
+                  ...this.getState(),
+                  errorMessage: `Failed to load flows: ${err}`,
+                  loading: false,
+                });
+                return of([]);
+              })
+            );
+          })
+        )
+        .subscribe(flows => {
+          this.setState({
+            ...this.getState(),
+            flows: flows ?? [],
+            loading: false,
+          });
+        })
+    );
+  }
+
+  reload() {
+    this.reload$.next();
+  }
+
+  async deleteFlow(flowId: string) {
+    if (!this.changeNum) return;
+    await this.restApiService.deleteFlow(this.changeNum, flowId);
+    this.reload();
+  }
+
+  async createFlow(flowInput: FlowInput) {
+    if (!this.changeNum) return;
+    await this.restApiService.createFlow(this.changeNum, flowInput);
+    this.reload();
+  }
+}
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index ab2ae52..a0ab3a3 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -75,6 +75,7 @@
 import {Finalizable} from '../types/types';
 import {GrSuggestionsService} from './suggestions/suggestions-service_impl';
 import {suggestionsServiceToken} from './suggestions/suggestions-service';
+import {FlowsModel, flowsModelToken} from '../models/flows/flows-model';
 /**
  * The AppContext lazy initializator for all services
  */
@@ -160,7 +161,8 @@
           appContext.restApiService,
           resolver(userModelToken),
           resolver(pluginLoaderToken),
-          appContext.reportingService
+          appContext.reportingService,
+          appContext.flagsService
         ),
     ],
     [
@@ -249,5 +251,6 @@
           resolver(changeModelToken)
         ),
     ],
+    [flowsModelToken, () => new FlowsModel(resolver(changeModelToken))],
   ]);
 }
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 33fa6f2..2c7993c 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -24,4 +24,5 @@
   ML_SUGGESTED_EDIT_FEEDBACK = 'UiFeature__ml_suggested_edit_feedback',
   ML_SUGGESTED_EDIT_EDITABLE_SUGGESTION = 'UiFeature__ml_suggested_edit_editable_suggestion',
   SHOW_FLOWS_TAB = 'UiFeature__show_flows_tab',
+  ASYNC_SUBMIT_REQUIREMENTS = 'UiFeature__async_submit_requirements',
 }
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
index 0650a50..ada11f9 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -110,7 +110,11 @@
   DiffPreferencesInfo,
   IgnoreWhitespaceType,
 } from '../../types/diff';
-import {GetDiffCommentsOutput, RestApiService} from './gr-rest-api';
+import {
+  GetDiffCommentsOutput,
+  RestApiService,
+  SubmittabilityInfo,
+} from './gr-rest-api';
 import {
   CommentSide,
   createDefaultDiffPrefs,
@@ -136,6 +140,7 @@
   FixReplacementInfo,
   FlowInfo,
   FlowInput,
+  IsFlowsEnabledInfo,
   LabelDefinitionInfo,
   LabelDefinitionInput,
   SubmitRequirementInput,
@@ -151,6 +156,7 @@
   SiteBasedCache,
   throwingErrorCallback,
 } from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {getAppContext} from '../app-context';
 
 const MAX_PROJECT_RESULTS = 25;
 
@@ -264,6 +270,8 @@
   // Used to serialize requests for certain RPCs
   readonly _serialScheduler: Scheduler<Response>;
 
+  private readonly flags = getAppContext().flagsService;
+
   constructor(
     private readonly authService: AuthService,
     private readonly flagService: FlagsService
@@ -1418,15 +1426,17 @@
       ListChangesOption.DOWNLOAD_COMMANDS,
       ListChangesOption.MESSAGES,
       ListChangesOption.REVIEWER_UPDATES,
-      ListChangesOption.SUBMITTABLE,
       ListChangesOption.WEB_LINKS,
       ListChangesOption.SKIP_DIFFSTAT,
-      ListChangesOption.SUBMIT_REQUIREMENTS,
       ListChangesOption.PARENTS,
     ];
     if (config?.receive?.enable_signed_push) {
       options.push(ListChangesOption.PUSH_CERTIFICATES);
     }
+    if (!this.flags.isEnabled(KnownExperimentId.ASYNC_SUBMIT_REQUIREMENTS)) {
+      options.push(ListChangesOption.SUBMITTABLE);
+      options.push(ListChangesOption.SUBMIT_REQUIREMENTS);
+    }
     return options;
   }
 
@@ -1485,6 +1495,35 @@
     );
   }
 
+  async getSubmittabilityInfo(
+    changeNum: NumericChangeId
+  ): Promise<SubmittabilityInfo | undefined> {
+    if (!this.flags.isEnabled(KnownExperimentId.ASYNC_SUBMIT_REQUIREMENTS)) {
+      return undefined;
+    }
+    const optionsHex = listChangesOptionsToHex(
+      ListChangesOption.SUBMITTABLE,
+      ListChangesOption.SUBMIT_REQUIREMENTS
+    );
+    const change = await this.getChange(
+      changeNum,
+      /* errFn=*/ undefined,
+      optionsHex
+    );
+    if (
+      !change ||
+      change.submittable === undefined ||
+      change.submit_requirements === undefined
+    ) {
+      return undefined;
+    }
+    return {
+      changeNum,
+      submittable: change.submittable,
+      submitRequirements: change.submit_requirements,
+    };
+  }
+
   async getChangeCommitInfo(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum
@@ -3502,15 +3541,10 @@
         )
       ) as Promise<ChangeInfo | undefined>;
     } else {
-      const params: FetchParams = {q: `change:${changeNum}`};
-      if (optionsHex) {
-        params['O'] = optionsHex;
-      }
       return this._restApiHelper
         .fetchJSON(
           {
             url: `/changes/?q=change:${changeNum}`,
-            params,
             errFn,
             anonymizedUrl: '/changes/?q=change:*',
           },
@@ -3738,6 +3772,18 @@
     }) as Promise<FlowInfo[] | undefined>;
   }
 
+  async getIfFlowsIsEnabled(
+    changeNum: NumericChangeId,
+    errFn?: ErrorCallback
+  ): Promise<IsFlowsEnabledInfo | undefined> {
+    const url = await this._changeBaseURL(changeNum);
+    return this._restApiHelper.fetchJSON({
+      url: `${url}/is-flows-enabled`,
+      errFn,
+      anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/is-flows-enabled`,
+    }) as Promise<IsFlowsEnabledInfo | undefined>;
+  }
+
   async createFlow(
     changeNum: NumericChangeId,
     flow: FlowInput,
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
index bfeaa8c..4e33381 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
@@ -2035,6 +2035,15 @@
       );
     });
 
+    test('getIfFlowsIsEnabled', async () => {
+      await element.getIfFlowsIsEnabled(changeNum);
+      assert.isTrue(fetchJSONStub.calledOnce);
+      assert.equal(
+        fetchJSONStub.lastCall.args[0].url,
+        `/changes/test-project~${changeNum}/is-flows-enabled`
+      );
+    });
+
     test('createFlow', async () => {
       const flow: FlowInput = {
         stage_expressions: [{condition: 'branch:refs/heads/main'}],
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index b68f7e6..3a37599 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -104,9 +104,11 @@
   FixReplacementInfo,
   FlowInfo,
   FlowInput,
+  IsFlowsEnabledInfo,
   LabelDefinitionInfo,
   LabelDefinitionInput,
   SubmitRequirementInput,
+  SubmitRequirementResultInfo,
 } from '../../api/rest-api';
 
 export interface GetDiffCommentsOutput {
@@ -114,6 +116,12 @@
   comments: CommentInfo[];
 }
 
+export interface SubmittabilityInfo {
+  changeNum: NumericChangeId;
+  submittable: boolean;
+  submitRequirements: SubmitRequirementResultInfo[];
+}
+
 export interface RestApiService extends Finalizable {
   getConfig(
     noCache?: boolean,
@@ -206,6 +214,13 @@
   ): Promise<ParsedChangeInfo | undefined>;
 
   /**
+   * Returns information about submittability and Submit Requirements.
+   */
+  getSubmittabilityInfo(
+    changeNum: NumericChangeId
+  ): Promise<SubmittabilityInfo | undefined>;
+
+  /**
    * For every revision of the change returns the list of FileInfo for files
    * which are modified compared to revision's parent.
    */
@@ -949,6 +964,11 @@
     errFn?: ErrorCallback
   ): Promise<FlowInfo[] | undefined>;
 
+  getIfFlowsIsEnabled(
+    changeNum: NumericChangeId,
+    errFn?: ErrorCallback
+  ): Promise<IsFlowsEnabledInfo | undefined>;
+
   createFlow(
     changeNum: NumericChangeId,
     flow: FlowInput,
diff --git a/polygerrit-ui/app/styles/gr-material-styles.ts b/polygerrit-ui/app/styles/gr-material-styles.ts
index 0045108..0621432 100644
--- a/polygerrit-ui/app/styles/gr-material-styles.ts
+++ b/polygerrit-ui/app/styles/gr-material-styles.ts
@@ -95,6 +95,7 @@
 
   /* These colours come from paper-checkbox */
   md-checkbox {
+    background-color: var(--background-color-primary);
     --md-sys-color-primary: var(--checkbox-primary);
     --md-sys-color-on-primary: var(--checkbox-on-primary);
     --md-sys-color-on-surface: var(--checkbox-on-surface);
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index 31c8660..818dbef 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -3,7 +3,10 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {
+  RestApiService,
+  SubmittabilityInfo,
+} from '../../services/gr-rest-api/gr-rest-api';
 import {FlowInfo} from '../../api/rest-api';
 import {
   AccountCapabilityInfo,
@@ -79,7 +82,7 @@
 } from '../../constants/constants';
 import {ParsedChangeInfo} from '../../types/types';
 import {ErrorCallback} from '../../api/rest';
-import {LabelDefinitionInfo} from '../../api/rest-api';
+import {IsFlowsEnabledInfo, LabelDefinitionInfo} from '../../api/rest-api';
 
 export const grRestApiMock: RestApiService = {
   addAccountEmail(): Promise<Response> {
@@ -248,6 +251,9 @@
     if (changeNum === undefined) return Promise.resolve(undefined);
     return Promise.resolve(createChange() as ParsedChangeInfo);
   },
+  getSubmittabilityInfo(): Promise<SubmittabilityInfo | undefined> {
+    return Promise.resolve(undefined);
+  },
   getChangeEdit(): Promise<EditInfo | undefined> {
     return Promise.resolve(undefined);
   },
@@ -339,6 +345,9 @@
   getFileContent(): Promise<Response | Base64FileContent | undefined> {
     return Promise.resolve(new Response());
   },
+  getIfFlowsIsEnabled(): Promise<IsFlowsEnabledInfo | undefined> {
+    return Promise.resolve({enabled: true});
+  },
   getRepoName(): Promise<RepoName> {
     throw new Error('getRepoName() not implemented by RestApiMock.');
   },
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index db02f29..ee7aa3a 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -351,7 +351,16 @@
  * Show only applicable.
  */
 export function getRequirements(change?: ParsedChangeInfo | ChangeInfo) {
-  return (change?.submit_requirements ?? []).filter(
+  return getApplicableRequirements(change?.submit_requirements);
+}
+
+/**
+ * Get applicable requirements.
+ */
+export function getApplicableRequirements(
+  requirements?: SubmitRequirementResultInfo[]
+) {
+  return (requirements ?? []).filter(
     req => req.status !== SubmitRequirementStatus.NOT_APPLICABLE
   );
 }
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-metadata-dark-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-metadata-dark-dark.png
index 10423eb..b94a510 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-metadata-dark-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-metadata-dark-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-metadata-show-all-dark-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-metadata-show-all-dark-dark.png
index 6da3f5a..d09e5cd 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-metadata-show-all-dark-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-metadata-show-all-dark-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-metadata-show-all.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-metadata-show-all.png
index 173cdb4..fb4c9cf 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-metadata-show-all.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-metadata-show-all.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-metadata.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-metadata.png
index c2eaf99..7bea9b3 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-metadata.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-metadata.png
Binary files differ
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index bc6f619..eb84a2c 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -33,6 +33,7 @@
   {@param? defaultDashboardHex: ?}
   {@param? dashboardQuery: ?}
   {@param? userIsAuthenticated: ?}
+  {@param? submitRequirementsHex: ?}
   <!DOCTYPE html>{\n}
   <html lang="en">{\n}
   <meta charset="utf-8">{\n}
@@ -95,6 +96,9 @@
       {if $userIsAuthenticated}
         <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/edit/?download-commands=true" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
       {/if}
+      {if $submitRequirementsHex}
+        <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}?O={$submitRequirementsHex}" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
+      {/if}
     {/if}
     <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/comments?enable-context=true&context-padding=3" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
     <link rel="preload" href="{$canonicalPath}/changes/?q=change:{$changeNum}" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 6815993..73305c1 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>3.12.3-SNAPSHOT</version>
+  <version>3.13.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index 9a9ed45..8a12804 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>3.12.3-SNAPSHOT</version>
+  <version>3.13.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index 40349e3..0d254b3 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>3.12.3-SNAPSHOT</version>
+  <version>3.13.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index ddc370b..045a594 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>3.12.3-SNAPSHOT</version>
+  <version>3.13.0-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 5eae305..11125d7 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -385,8 +385,8 @@
 
     maven_jar(
         name = "h2",
-        artifact = "com.h2database:h2:2.3.232",
-        sha1 = "4fcc05d966ccdb2812ae8b9a718f69226c0cf4e2",
+        artifact = "com.h2database:h2:2.4.240",
+        sha1 = "686180ad33981ad943fdc0ab381e619b2c2fdfe5",
     )
 
     # JGit's transitive dependencies
diff --git a/version.bzl b/version.bzl
index 02ae444..65c00f3 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "3.12.3-SNAPSHOT"
+GERRIT_VERSION = "3.13.0-SNAPSHOT"