Merge branch 'stable-3.12'

* stable-3.12:
  Revert "Invoke changeServerId() function when calculating virtual Id"
  Update git submodules
  Fix ref existance check in CREATE ReceiveCommits
  Add "@lezer/highlight" package
  Find changes by change number only if imported server IDs are configured
  AbtractFakeIndex: use changenumber instead of legacy_is_str
  Update git submodules
  Disable robot comments by default
  Revert "Lookup imported change by change number in ChangeFinder::find"
  Set version to 3.12.0-SNAPSHOT
  Set version to 3.12.0-rc1
  GitFileDiffCacheImpl: cancel tasks failing due to timeout or interrupt
  Update git submodules
  feat(Get AI Fix): Attach fixSuggestion to automatic reply when applying Ai Fix
  Disable format warning on current editing line
  AccountIndexerImpl: explicitly use AccountCacheImpl
  Remove net.i2p.crypto:eddsa
  Allow opening edit preference in editor view
  Respect the `enableRobotComments` server config in the UI
  Set version to 3.12.0-SNAPSHOT
  Set version to 3.12.0-rc0
  Set version to 3.11.3-SNAPSHOT
  Set version to 3.11.2
  Set version to 3.10.6-SNAPSHOT
  Set version to 3.10.5
  Set version to 3.9.11-SNAPSHOT
  Set version to 3.9.10

Release-Notes: skip
Change-Id: Ied6c827d57a69adc1b6759104a57480ada9dcf0c
diff --git a/Documentation/config-validation.txt b/Documentation/config-validation.txt
index b0149fe..8b2c9d4 100644
--- a/Documentation/config-validation.txt
+++ b/Documentation/config-validation.txt
@@ -6,7 +6,6 @@
 [[new-commit-validation]]
 == New commit validation
 
-
 Plugins implementing the `CommitValidationListener` interface can
 perform additional validation checks against new commits.
 
@@ -21,6 +20,13 @@
 Out of the box, Gerrit includes a plugin that checks the length of the
 subject and body lines of commit messages on uploaded commits.
 
+[[push-options-validation]]
+== Push options validation
+
+Plugins implementing the `PushOptionsValidator` interface can validate push
+options. For example, they can reject options (or a combination of options) or
+emit a warning when a deprecated option is being used.
+
 [plugin-push-options]]
 === Plugin push options
 
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 596ee96..2d48d81 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -8757,8 +8757,7 @@
 |=================================
 |Field Name          ||Description
 |`message`           |optional|
-Message to be added as review comment to the change when reverting the
-change.
+Commit message of the revert commit. If not specified, a default commit message is set.
 |`notify`            |optional|
 Notify handling that defines to whom email notifications should be sent
 for reverting the change. +
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index 7cc9e29..c5bee19 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -219,6 +219,20 @@
 
 Additional options may be specified when pushing changes.
 
+[[custom_keyed_values]]
+==== Custom Keyed Values
+
+Uploaders can specify custom keyed values for the change using this push option.
+The value can be set more than once to apply multiple custom keyed values.
+Each key,value pair must be separate by a colon (':') otherwise the push fails.
+
+----
+  git push ssh://bot@git.example.com:29418/kernel/common HEAD:refs/for/master -o custom-keyed-value=foo:bar custom-keyed-value=hello:world
+----
+
+Note: Setting the `custom-keyed-value` option in the refname
+(`%custom-keyed-values=foo:bar`) is **not** supported.
+
 [[notify]]
 ==== Email Notifications
 
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index f094b48..4cbaea2 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -53,6 +53,7 @@
 import com.google.gerrit.server.change.ReviewerSuggestion;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.git.ChangeMessageModifier;
+import com.google.gerrit.server.git.receive.PushOptionsValidator;
 import com.google.gerrit.server.git.validators.CommitValidationInfoListener;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
@@ -118,6 +119,7 @@
   private final DynamicSet<ValidationOptionsListener> validationOptionsListeners;
   private final DynamicSet<CommitValidationInfoListener> commitValidationInfoListeners;
   private final DynamicSet<RetryListener> retryListeners;
+  private final DynamicSet<PushOptionsValidator> pushOptionsValidators;
 
   private final DynamicMap<ChangeHasOperandFactory> hasOperands;
   private final DynamicMap<ChangeIsOperandFactory> isOperands;
@@ -172,6 +174,7 @@
       DynamicSet<ValidationOptionsListener> validationOptionsListeners,
       DynamicSet<CommitValidationInfoListener> commitValidationInfoListeners,
       DynamicSet<RetryListener> retryListeners,
+      DynamicSet<PushOptionsValidator> pushOptionsValidator,
       DynamicMap<ReviewerSuggestion> reviewerSuggestions) {
     this.accountIndexedListeners = accountIndexedListeners;
     this.changeIndexedListeners = changeIndexedListeners;
@@ -219,6 +222,7 @@
     this.validationOptionsListeners = validationOptionsListeners;
     this.commitValidationInfoListeners = commitValidationInfoListeners;
     this.retryListeners = retryListeners;
+    this.pushOptionsValidators = pushOptionsValidator;
     this.reviewerSuggestions = reviewerSuggestions;
   }
 
@@ -432,6 +436,11 @@
     }
 
     @CanIgnoreReturnValue
+    public Registration add(PushOptionsValidator pushOptionsValidator) {
+      return add(pushOptionsValidators, pushOptionsValidator);
+    }
+
+    @CanIgnoreReturnValue
     public Registration add(CapabilityDefinition capabilityDefinition, String exportName) {
       return add(capabilityDefinitions, capabilityDefinition, exportName);
     }
diff --git a/java/com/google/gerrit/entities/Permission.java b/java/com/google/gerrit/entities/Permission.java
index 1f2f151..4d926b8 100644
--- a/java/com/google/gerrit/entities/Permission.java
+++ b/java/com/google/gerrit/entities/Permission.java
@@ -31,7 +31,9 @@
 public abstract class Permission implements Comparable<Permission> {
   public static final String ABANDON = "abandon";
   public static final String ADD_PATCH_SET = "addPatchSet";
+  // https://gerrit-review.googlesource.com/Documentation/access-control.html#category_create
   public static final String CREATE = "create";
+  // https://gerrit-review.googlesource.com/Documentation/access-control.html#category_create_signed
   public static final String CREATE_SIGNED_TAG = "createSignedTag";
   public static final String CREATE_TAG = "createTag";
   public static final String DELETE = "delete";
@@ -47,8 +49,10 @@
   public static final String LABEL_AS = "labelAs-";
   public static final String REMOVE_LABEL = "removeLabel-";
   public static final String OWNER = "owner";
+  // https://gerrit-review.googlesource.com/Documentation/access-control.html#category_push
   public static final String PUSH = "push";
   public static final String PUSH_MERGE = "pushMerge";
+  // https://gerrit-review.googlesource.com/Documentation/access-control.html#category_read
   public static final String READ = "read";
   public static final String REBASE = "rebase";
   public static final String REMOVE_REVIEWER = "removeReviewer";
diff --git a/java/com/google/gerrit/extensions/api/projects/BranchInput.java b/java/com/google/gerrit/extensions/api/projects/BranchInput.java
index 1ccbd4f..63a118a 100644
--- a/java/com/google/gerrit/extensions/api/projects/BranchInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/BranchInput.java
@@ -17,6 +17,35 @@
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import java.util.Map;
 
+/**
+ * Input for creating a new branch.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#branch-input
+ *
+ * <p>This class holds the data needed to create a new branch, such as the revision to base the
+ * branch on and whether to create an empty commit.
+ *
+ * <p>The following properties can be set:
+ *
+ * <ul>
+ *   <li>{@code revision}: The base revision of the new branch. If not set and create_empty_commit
+ *       is true the branch is created with an empty initial commit. If not set and
+ *       create_empty_commit is false or unset HEAD will be used as base revision.
+ *   <li>{@code createEmptyCommit}: Whether the branch should be created with an empty initial
+ *       commit. It is not possible to create a branch on a project with no commits. Cannot be used
+ *       in combination with setting a revision. Can be used to review the initial content of a
+ *       branch (create the branch with an empty initial commit, make a second commit with the
+ *       initial content, e.g. by merging in another branch, and push the commit for review)..
+ *   <li>{@code ref}: The name of the branch. The prefix refs/heads/ can be omitted. If set, must
+ *       match the branch ID in the URL.
+ *   <li>{@code validationOptions}: Map with key-value pairs that are forwarded as options to the
+ *       ref operation validation listeners (e.g. can be used to skip certain validations). Which
+ *       validation options are supported depends on the installed ref operation validation
+ *       listeners. Gerrit core doesn’t support any validation options, but ref operation validation
+ *       listeners that are implemented in plugins may. Please refer to the documentation of the
+ *       installed plugins to learn whether they support validation options. Unknown validation
+ *       options are silently ignored.
+ * </ul>
+ */
 public class BranchInput {
   @DefaultInput public String revision;
   public boolean createEmptyCommit;
diff --git a/java/com/google/gerrit/server/account/AccountDelta.java b/java/com/google/gerrit/server/account/AccountDelta.java
index 14ced50..192d9d3 100644
--- a/java/com/google/gerrit/server/account/AccountDelta.java
+++ b/java/com/google/gerrit/server/account/AccountDelta.java
@@ -20,6 +20,8 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.ProjectWatchKey;
@@ -177,6 +179,11 @@
         || !this.getUpdatedExternalIds().isEmpty();
   }
 
+  @UsedAt(Project.GOOGLE)
+  public boolean isEmpty() {
+    return this.equals(AccountDelta.builder().build());
+  }
+
   /**
    * Class to build an {@link AccountDelta}.
    *
diff --git a/java/com/google/gerrit/server/change/ChangeMessages.java b/java/com/google/gerrit/server/change/ChangeMessages.java
index 787f036..c228ebe 100644
--- a/java/com/google/gerrit/server/change/ChangeMessages.java
+++ b/java/com/google/gerrit/server/change/ChangeMessages.java
@@ -14,25 +14,22 @@
 
 package com.google.gerrit.server.change;
 
-import org.eclipse.jgit.nls.NLS;
-import org.eclipse.jgit.nls.TranslationBundle;
+public class ChangeMessages {
+  public static String revertChangeDefaultMessage = "Revert \"{0}\"\n\nThis reverts commit {1}.";
+  public static String revertSubmissionDefaultMessage = "This reverts commit {0}.";
+  public static String revertSubmissionUserMessage = "Revert \"{0}\"\n\n{1}";
+  public static String revertSubmissionOfRevertSubmissionUserMessage = "Revert^{0} \"{1}\"\n\n{2}";
 
-public class ChangeMessages extends TranslationBundle {
-  public static ChangeMessages get() {
-    return NLS.getBundleFor(ChangeMessages.class);
-  }
+  public static String reviewerCantSeeChange = "{0} does not have permission to see this change";
+  public static String reviewerInvalid = "{0} is not a valid user identifier";
+  public static String reviewerNotFoundUserOrGroup =
+      "{0} does not identify a registered user or group";
 
-  public String revertChangeDefaultMessage;
-  public String revertSubmissionDefaultMessage;
-  public String revertSubmissionUserMessage;
-  public String revertSubmissionOfRevertSubmissionUserMessage;
-
-  public String reviewerCantSeeChange;
-  public String reviewerInvalid;
-  public String reviewerNotFoundUserOrGroup;
-
-  public String groupRemovalIsNotAllowed;
-  public String groupIsNotAllowed;
-  public String groupHasTooManyMembers;
-  public String groupManyMembersConfirmation;
+  public static String groupRemovalIsNotAllowed =
+      "Groups can't be removed from reviewers, so can't remove {0}.";
+  public static String groupIsNotAllowed = "The group {0} cannot be added as reviewer.";
+  public static String groupHasTooManyMembers =
+      "The group {0} has too many members to add them all as reviewers.";
+  public static String groupManyMembersConfirmation =
+      "The group {0} has {1} members. Do you want to add them all as reviewers?";
 }
diff --git a/java/com/google/gerrit/server/change/ReviewerModifier.java b/java/com/google/gerrit/server/change/ReviewerModifier.java
index 09bfcd3..6d454ae 100644
--- a/java/com/google/gerrit/server/change/ReviewerModifier.java
+++ b/java/com/google/gerrit/server/change/ReviewerModifier.java
@@ -314,7 +314,7 @@
     return fail(
         input,
         FailureType.OTHER,
-        MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, input.reviewer));
+        MessageFormat.format(ChangeMessages.reviewerCantSeeChange, input.reviewer));
   }
 
   @Nullable
@@ -340,7 +340,7 @@
         return fail(
             input,
             FailureType.NOT_FOUND,
-            MessageFormat.format(ChangeMessages.get().reviewerNotFoundUserOrGroup, input.reviewer));
+            MessageFormat.format(ChangeMessages.reviewerNotFoundUserOrGroup, input.reviewer));
       }
       return null;
     }
@@ -349,14 +349,14 @@
       return fail(
           input,
           FailureType.OTHER,
-          MessageFormat.format(ChangeMessages.get().groupIsNotAllowed, group.getName()));
+          MessageFormat.format(ChangeMessages.groupIsNotAllowed, group.getName()));
     }
 
     if (input.state().equals(REMOVED)) {
       return fail(
           input,
           FailureType.OTHER,
-          MessageFormat.format(ChangeMessages.get().groupRemovalIsNotAllowed, group.getName()));
+          MessageFormat.format(ChangeMessages.groupRemovalIsNotAllowed, group.getName()));
     }
 
     Set<Account> reviewers = new HashSet<>();
@@ -376,7 +376,7 @@
       return fail(
           input,
           FailureType.OTHER,
-          MessageFormat.format(ChangeMessages.get().groupHasTooManyMembers, group.getName()));
+          MessageFormat.format(ChangeMessages.groupHasTooManyMembers, group.getName()));
     }
 
     // if maxWithoutCheck is set to 0, we never ask for confirmation
@@ -391,7 +391,7 @@
           FailureType.OTHER,
           true,
           MessageFormat.format(
-              ChangeMessages.get().groupManyMembersConfirmation, group.getName(), members.size()));
+              ChangeMessages.groupManyMembersConfirmation, group.getName(), members.size()));
     }
 
     for (Account member : members) {
@@ -413,7 +413,7 @@
       return fail(
           input,
           FailureType.OTHER,
-          MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, input.reviewer));
+          MessageFormat.format(ChangeMessages.reviewerCantSeeChange, input.reviewer));
     }
 
     Address adr = Address.tryParse(input.reviewer);
@@ -421,7 +421,7 @@
       return fail(
           input,
           FailureType.NOT_FOUND,
-          MessageFormat.format(ChangeMessages.get().reviewerInvalid, input.reviewer));
+          MessageFormat.format(ChangeMessages.reviewerInvalid, input.reviewer));
     }
     return new ReviewerModification(input, notes, user, null, ImmutableList.of(adr), true, false);
   }
diff --git a/java/com/google/gerrit/server/config/CapabilityConstants.java b/java/com/google/gerrit/server/config/CapabilityConstants.java
index 43c4933..9448725 100644
--- a/java/com/google/gerrit/server/config/CapabilityConstants.java
+++ b/java/com/google/gerrit/server/config/CapabilityConstants.java
@@ -14,37 +14,30 @@
 
 package com.google.gerrit.server.config;
 
-import org.eclipse.jgit.nls.NLS;
-import org.eclipse.jgit.nls.TranslationBundle;
-
-public class CapabilityConstants extends TranslationBundle {
-  public static CapabilityConstants get() {
-    return NLS.getBundleFor(CapabilityConstants.class);
-  }
-
-  public String accessDatabase;
-  public String administrateServer;
-  public String batchChangesLimit;
-  public String createAccount;
-  public String createGroup;
-  public String deleteGroup;
-  public String createProject;
-  public String emailReviewers;
-  public String flushCaches;
-  public String killTask;
-  public String maintainServer;
-  public String modifyAccount;
-  public String priority;
-  public String readAs;
-  public String queryLimit;
-  public String runAs;
-  public String runGC;
-  public String streamEvents;
-  public String viewAccess;
-  public String viewAllAccounts;
-  public String viewCaches;
-  public String viewConnections;
-  public String viewPlugins;
-  public String viewQueue;
-  public String viewSecondaryEmails;
+public class CapabilityConstants {
+  public static String accessDatabase = "Access Database";
+  public static String administrateServer = "Administrate Server";
+  public static String batchChangesLimit = "Batch Changes Limit";
+  public static String createAccount = "Create Account";
+  public static String createGroup = "Create Group";
+  public static String deleteGroup = "Delete Group";
+  public static String createProject = "Create Project";
+  public static String emailReviewers = "Email Reviewers";
+  public static String flushCaches = "Flush Caches";
+  public static String killTask = "Kill Task";
+  public static String maintainServer = "Maintain Server";
+  public static String modifyAccount = "Modify Account";
+  public static String priority = "Priority";
+  public static String readAs = "Read As";
+  public static String queryLimit = "Query Limit";
+  public static String runAs = "Run As";
+  public static String runGC = "Run Garbage Collection";
+  public static String streamEvents = "Stream Events";
+  public static String viewAccess = "View Access";
+  public static String viewAllAccounts = "View All Accounts";
+  public static String viewCaches = "View Caches";
+  public static String viewConnections = "View Connections";
+  public static String viewPlugins = "View Plugins";
+  public static String viewQueue = "View Queue";
+  public static String viewSecondaryEmails = "View Secondary Emails";
 }
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 3d480d9..d5fa605 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -145,6 +145,7 @@
 import com.google.gerrit.server.git.ReceivePackInitializer;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.git.receive.PushOptionsValidator;
 import com.google.gerrit.server.git.receive.ReceiveCommitsModule;
 import com.google.gerrit.server.git.validators.CommentCountValidator;
 import com.google.gerrit.server.git.validators.CommentCumulativeSizeValidator;
@@ -440,6 +441,7 @@
     DynamicSet.setOf(binder(), ExternalIncludedIn.class);
     DynamicSet.setOf(binder(), FilterIncludedIn.class);
     DynamicMap.mapOf(binder(), ProjectConfigEntry.class);
+    DynamicSet.setOf(binder(), PushOptionsValidator.class);
     DynamicSet.setOf(binder(), PluginPushOption.class);
     DynamicSet.setOf(binder(), PatchSetWebLink.class);
     DynamicSet.setOf(binder(), ResolveConflictsWebLink.class);
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index cd91745..eb81ee1 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -60,4 +60,12 @@
   /** Whether we allow fix suggestions in HumanComments. */
   public static final String ALLOW_FIX_SUGGESTIONS_IN_COMMENTS =
       "GerritBackendFeature__allow_fix_suggestions_in_comments";
+
+  /**
+   * Whether the submit operation that is executed to auto-merge a change on push (see
+   * https://gerrit-review.googlesource.com/Documentation/user-upload.html#auto_merge) should use a
+   * {@code RefUpdateContext} with type {@code DIRECT_PUSH}.
+   */
+  public static String GERRIT_BACKEND_FEATURE_USE_DIRECT_PUSH_CONTEXT_FOR_SUBMIT_ON_PUSH =
+      "GerritBackendFeature__use_direct_push_context_for_submit_on_push";
 }
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index 30f2ee8..cb5dc9d 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -288,7 +288,7 @@
     if (message == null) {
       message =
           MessageFormat.format(
-              ChangeMessages.get().revertChangeDefaultMessage, subject, patch.commitId().name());
+              ChangeMessages.revertChangeDefaultMessage, subject, patch.commitId().name());
     }
     if (generatedChangeId != null) {
       message = ChangeIdUtil.insertId(message, generatedChangeId, true);
diff --git a/java/com/google/gerrit/server/git/receive/PushOptionsValidator.java b/java/com/google/gerrit/server/git/receive/PushOptionsValidator.java
new file mode 100644
index 0000000..59caab5
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/PushOptionsValidator.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+
+/**
+ * Extension point to validate push options.
+ *
+ * <p>Possible usages are:
+ *
+ * <ul>
+ *   <li>Reject pushes that use a certain push option or a certain combination of push options.
+ *   <li>Print a warning if a certain push option is used (e.g because the push option is
+ *       deprecated).
+ * </ul>
+ */
+@ExtensionPoint
+public interface PushOptionsValidator {
+  /**
+   * Validate the push options that have been specified for a push to the given ref.
+   *
+   * @param projectName The name of the project to which the user is pushing.
+   * @param refName The target ref of the push.
+   * @param pushOptions The options that have been specified on push. This map includes the Git push
+   *     options (specified by {@code -o <key>=<value>}) and options which have been specified as
+   *     part of the target ref name (specified as {@code refs/for/master%<key>=<value>}).
+   * @return A list of validation messages. If any message with type {@link
+   *     com.google.gerrit.server.git.validators.ValidationMessage.Type#ERROR} or {@link
+   *     com.google.gerrit.server.git.validators.ValidationMessage.Type#FATAL} is returned the push
+   *     is rejected. Validation messages with other type are returned to the client, but let the
+   *     push proceed.
+   */
+  ImmutableList<ValidationMessage> validate(
+      Project.NameKey projectName, String refName, ListMultimap<String, String> pushOptions);
+}
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index cef4497..86985f3 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -142,6 +142,8 @@
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.git.BanCommit;
 import com.google.gerrit.server.git.ChangeReportFormatter;
 import com.google.gerrit.server.git.GroupCollector;
@@ -285,6 +287,8 @@
 
   public static final String DIRECT_PUSH_JUSTIFICATION_OPTION = "push-justification";
 
+  private static final String CUSTOM_KEYED_VALUE_OPTION = "custom-keyed-value";
+
   interface Factory {
     ReceiveCommits create(
         ProjectState projectState,
@@ -413,7 +417,9 @@
   private final CreateRefControl createRefControl;
   private final DeadlineChecker.Factory deadlineCheckerFactory;
   private final DiffOperationsForCommitValidation.Factory diffOperationsForCommitValidationFactory;
+  private final ExperimentFeatures experimentFeatures;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
+  private final DynamicSet<PushOptionsValidator> pushOptionsValidators;
   private final DynamicSet<PluginPushOption> pluginPushOptions;
   private final PluginSetContext<ReceivePackInitializer> initializers;
   private final MergedByPushOp.Factory mergedByPushOpFactory;
@@ -479,6 +485,7 @@
   private boolean setChangeAsPrivate;
   private Optional<NoteDbPushOption> noteDbPushOption;
   private Optional<String> tracePushOption = Optional.empty();
+  private Map<String, String> customKeyedValues = new HashMap<>();
 
   private final TraceIdConsumer traceIdConsumer;
   private MessageSender messageSender;
@@ -513,7 +520,9 @@
       CreateRefControl createRefControl,
       DeadlineChecker.Factory deadlineCheckerFactory,
       DiffOperationsForCommitValidation.Factory diffOperationsForCommitValidationFactory,
+      ExperimentFeatures experimentFeatures,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+      DynamicSet<PushOptionsValidator> pushOptionsValidators,
       DynamicSet<PluginPushOption> pluginPushOptions,
       PluginSetContext<ReceivePackInitializer> initializers,
       PluginSetContext<CommentValidator> commentValidators,
@@ -570,6 +579,7 @@
     this.createGroupPermissionSyncer = createGroupPermissionSyncer;
     this.deadlineCheckerFactory = deadlineCheckerFactory;
     this.diffOperationsForCommitValidationFactory = diffOperationsForCommitValidationFactory;
+    this.experimentFeatures = experimentFeatures;
     this.editUtil = editUtil;
     this.exceptionHooks = exceptionHooks;
     this.hashtagsFactory = hashtagsFactory;
@@ -586,6 +596,7 @@
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.permissionBackend = permissionBackend;
     this.pluginConfigEntries = pluginConfigEntries;
+    this.pushOptionsValidators = pushOptionsValidators;
     this.pluginPushOptions = pluginPushOptions;
     this.projectCache = projectCache;
     this.psUtil = psUtil;
@@ -1169,11 +1180,11 @@
       ObjectInserter ins,
       ImmutableList<CreateRequest> newChanges,
       Task replaceProgress) {
+    ReceiveCommand magicBranchCmd = magicBranch != null ? magicBranch.cmd : null;
     try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
       try (TraceTimer traceTimer =
           newTimer(
               "insertChangesAndPatchSets", Metadata.builder().resourceCount(newChanges.size()))) {
-        ReceiveCommand magicBranchCmd = magicBranch != null ? magicBranch.cmd : null;
         if (magicBranchCmd != null && magicBranchCmd.getResult() != NOT_ATTEMPTED) {
           logger.atWarning().log(
               "Skipping change updates on %s because ref update failed: %s %s",
@@ -1217,8 +1228,26 @@
         } catch (RestApiException | IOException | UpdateException e) {
           throw new StorageException("Can't insert change/patch set for " + project.getName(), e);
         }
+      }
+    }
 
-        if (magicBranch != null && magicBranch.submit) {
+    if (magicBranch != null && magicBranch.submit) {
+      // Using the submit option submits the created change(s) immediately without checking labels
+      // nor submit rules. Since code review is bypassed, same as on direct push, use a direct push
+      // RefUpdateContext to do the direct submit.
+      Optional<String> justification =
+          pushOptions.get(DIRECT_PUSH_JUSTIFICATION_OPTION).stream().findFirst();
+      try (RefUpdateContext ctx =
+          experimentFeatures.isFeatureEnabled(
+                  ExperimentFeaturesConstants
+                      .GERRIT_BACKEND_FEATURE_USE_DIRECT_PUSH_CONTEXT_FOR_SUBMIT_ON_PUSH,
+                  project.getNameKey())
+              ? RefUpdateContext.openDirectPush(justification)
+              : RefUpdateContext.open(CHANGE_MODIFICATION)) {
+        try (TraceTimer traceTimer =
+            newTimer(
+                "insertChangesAndPatchSets#submit",
+                Metadata.builder().resourceCount(newChanges.size()))) {
           try {
             submit(newChanges, replaceByChange.values());
           } catch (ResourceConflictException e) {
@@ -1866,6 +1895,7 @@
     Map<String, Short> labels = new HashMap<>();
     String message;
     List<RevCommit> baseCommit;
+    ListMultimap<String, String> optionMap;
     CmdLineParser cmdLineParser;
     Set<String> hashtags = new HashSet<>();
 
@@ -1873,6 +1903,12 @@
     String trace;
 
     @Option(
+        name = "--push-justification",
+        metaVar = "NAME",
+        usage = "justification for the push if the 'submit' option is used to submit on push")
+    String pushJustification;
+
+    @Option(
         name = "--deadline",
         metaVar = "NAME",
         usage = "deadline after which the push should be aborted")
@@ -1881,6 +1917,16 @@
     @Option(name = "--base", metaVar = "BASE", usage = "merge base of changes")
     List<ObjectId> base;
 
+    // Custom keyed values need to be specified as a push option ('-o
+    // custom-keyed-value=<key>:<value'). Setting the 'custom-keyed-value' option in the refname is
+    // not allowed. This option here is only defined, so that the 'custom-keyed-value' option gets
+    // mentioned in the help output.
+    @Option(
+        name = "--custom-keyed-value",
+        metaVar = "CUSTOM_KEYED_VALUES",
+        usage = "custom keyed value in the format '<key>:<value>'")
+    List<String> customKeyedValues;
+
     @Option(name = "--topic", metaVar = "NAME", usage = "attach topic to changes")
     String topic;
 
@@ -2099,7 +2145,7 @@
     String parse(ListMultimap<String, String> pushOptions) throws CmdLineException {
       String ref = RefNames.fullName(MagicBranch.getDestBranchName(cmd.getRefName()));
 
-      ListMultimap<String, String> options = LinkedListMultimap.create(pushOptions);
+      optionMap = LinkedListMultimap.create();
 
       // Process and lop off the "%OPTION" suffix.
       int optionStart = ref.indexOf('%');
@@ -2107,16 +2153,33 @@
         for (String s : COMMAS.split(ref.substring(optionStart + 1))) {
           int e = s.indexOf('=');
           if (0 < e) {
-            options.put(s.substring(0, e), s.substring(e + 1));
+            optionMap.put(s.substring(0, e), s.substring(e + 1));
           } else {
-            options.put(s, "");
+            optionMap.put(s, "");
           }
         }
         ref = ref.substring(0, optionStart);
       }
 
-      if (!options.isEmpty()) {
-        cmdLineParser.parseOptionMap(options);
+      // We cannot accept the 'custom-key-value' option in the refname (deprecated format), since
+      // key and value are separated by ':' which is a character that is not allowed in the refname.
+      // If a user tries to specify '%custom-key-value=<key>:<value>' in the refname the push fails
+      // due to ':' not being allowed in refnames. In this case the push fails much earlier and we
+      // do not reach here. However if someone tries to specify the '%custom-key-value' option with
+      // allowed characters in the value, we return a message here telling the user that they should
+      // use the push option instead.
+      if (optionMap.containsKey(CUSTOM_KEYED_VALUE_OPTION)) {
+        throw cmdLineParser.reject(
+            String.format(
+                "option '%s' cannot be specified as an option in the ref name, use a push option"
+                    + " instead ('-o %s=<key>:<value>')",
+                CUSTOM_KEYED_VALUE_OPTION, CUSTOM_KEYED_VALUE_OPTION));
+      }
+
+      optionMap.putAll(pushOptions);
+
+      if (!optionMap.isEmpty()) {
+        cmdLineParser.parseOptionMap(optionMap);
       }
       return ref;
     }
@@ -2173,6 +2236,22 @@
       String ref;
       magicBranch.cmdLineParser = optionParserFactory.create(magicBranch);
 
+      // Parse custom keyed values.
+      for (String keyValue : pushOptions.get(CUSTOM_KEYED_VALUE_OPTION)) {
+        List<String> keyValueList = Splitter.on(":").trimResults().splitToList(keyValue);
+        if (keyValueList.size() != 2) {
+          reject(
+              cmd,
+              RejectionReason.create(
+                  MetricBucket.INVALID_OPTION,
+                  String.format(
+                      "the value for option '%s' must be given as '<key>:<format>'",
+                      CUSTOM_KEYED_VALUE_OPTION)));
+          return;
+        }
+        customKeyedValues.put(keyValueList.get(0), keyValueList.get(1));
+      }
+
       // Filter out plugin push options, as the parser would reject them as unknown.
       ImmutableListMultimap<String, String> pushOptionsToParse =
           pushOptions.entries().stream()
@@ -2189,6 +2268,16 @@
         ref = null; // never happens
       }
 
+      if (magicBranch.pushJustification != null && !magicBranch.submit) {
+        reject(
+            cmd,
+            RejectionReason.create(
+                MetricBucket.INVALID_OPTION,
+                "when pushing for a review a push justification can only be set when the"
+                    + " 'submit' option is used"));
+        return;
+      }
+
       if (magicBranch.skipValidation) {
         reject(
             cmd,
@@ -2404,6 +2493,8 @@
             }
           }
         }
+
+        validatePushOptions(cmd, projectState.getNameKey(), ref, magicBranch.optionMap);
       } catch (IOException e) {
         throw new StorageException(
             String.format("Error walking to %s in project %s", destBranch, project.getName()), e);
@@ -2416,6 +2507,28 @@
     }
   }
 
+  private void validatePushOptions(
+      ReceiveCommand cmd,
+      Project.NameKey projectName,
+      String refName,
+      ListMultimap<String, String> pushOptions) {
+    ImmutableList<ValidationMessage> validationMessages =
+        StreamSupport.stream(pushOptionsValidators.entries().spliterator(), /* parallel= */ false)
+            .flatMap(
+                pluginPushValidators ->
+                    pluginPushValidators.get().validate(projectName, refName, pushOptions).stream())
+            .collect(toImmutableList());
+    for (ValidationMessage validationMessage : validationMessages) {
+      if (validationMessage.isError()) {
+        reject(
+            cmd,
+            RejectionReason.create(MetricBucket.INVALID_OPTION, validationMessage.getMessage()));
+      } else {
+        messages.add(validationMessage);
+      }
+    }
+  }
+
   private boolean isPluginPushOption(String pushOptionName) {
     if (transitionalPluginOptions.contains(pushOptionName)) {
       return true;
@@ -3030,6 +3143,7 @@
                 .create(changeId, commit, refName)
                 .setTopic(magicBranch.topic)
                 .setPrivate(setChangeAsPrivate)
+                .setCustomKeyedValues(ImmutableMap.copyOf(customKeyedValues))
                 .setWorkInProgress(magicBranch.shouldSetWorkInProgressOnNewChanges())
                 // The commit has already been validated in
                 // selectNewAndReplacedChangesFromMagicBranch.
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java b/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java
index 5c9106c..484a064 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java
@@ -569,24 +569,18 @@
     return this.authors = authors;
   }
 
-  @Override
-  public void populateEmailContent() throws EmailException {
-    BranchEmailUtils.addBranchData(email, args, branch);
-    setThreadHeaders();
-
-    email.addSoyParam("changeId", change.getKey().get());
-    email.addSoyParam("coverLetter", getCoverLetter());
-    email.addSoyParam("fromName", email.getNameFor(email.getFrom()));
-    email.addSoyParam("fromEmail", email.getNameEmailFor(email.getFrom()));
-    email.addSoyParam("diffLines", ChangeEmail.getDiffTemplateData(getUnifiedDiff()));
-
+  protected void addChangeRelatedSoyParams() {
+    // diffLines is the structured diff format for HTML emails.
+    // unifiedDiff is textual diff for plaintext emails.
+    email.addSoyParam("diffLines", ChangeEmail.getDiffTemplateData(getUnifiedDiff().trim()));
     email.addSoyEmailDataParam("unifiedDiff", getUnifiedDiff());
-    email.addSoyEmailDataParam("changeDetail", getChangeDetail());
-    email.addSoyEmailDataParam("changeUrl", getChangeUrl());
     email.addSoyEmailDataParam("includeDiff", getIncludeDiff());
 
-    Map<String, String> changeData = new HashMap<>();
+    // changeDetail - is a plaintext summary of the change (message, files, delta)
+    email.addSoyEmailDataParam("changeDetail", getChangeDetail());
+    email.addSoyEmailDataParam("changeUrl", getChangeUrl());
 
+    Map<String, String> changeData = new HashMap<>();
     String subject = change.getSubject();
     String originalSubject = change.getOriginalSubject();
     changeData.put("subject", subject);
@@ -611,7 +605,13 @@
     patchSetInfoData.put("authorName", patchSetInfo.getAuthor().getName());
     patchSetInfoData.put("authorEmail", patchSetInfo.getAuthor().getEmail());
     email.addSoyParam("patchSetInfo", patchSetInfoData);
+    // We need names rather than account ids / emails to make it user readable.
+    email.addSoyParam(
+        "attentionSet",
+        currentAttentionSet.stream().map(email::getNameFor).sorted().collect(toImmutableList()));
+  }
 
+  protected void addEmailFooters() {
     email.addFooter(MailHeader.CHANGE_ID.withDelimiter() + change.getKey().get());
     email.addFooter(MailHeader.CHANGE_NUMBER.withDelimiter() + change.getChangeId());
     email.addFooter(MailHeader.PATCH_SET.withDelimiter() + patchSet.number());
@@ -625,13 +625,24 @@
     for (Account.Id attentionUser : currentAttentionSet) {
       email.addFooter(MailHeader.ATTENTION.withDelimiter() + email.getNameEmailFor(attentionUser));
     }
-    // We need names rather than account ids / emails to make it user readable.
-    email.addSoyParam(
-        "attentionSet",
-        currentAttentionSet.stream().map(email::getNameFor).sorted().collect(toImmutableList()));
+  }
 
+  @Override
+  public void populateEmailContent() throws EmailException {
+    BranchEmailUtils.addBranchData(email, args, branch);
+    setThreadHeaders();
+
+    email.addSoyParam("coverLetter", getCoverLetter());
+    email.addSoyParam("fromName", email.getNameFor(email.getFrom()));
+    email.addSoyParam("fromEmail", email.getNameEmailFor(email.getFrom()));
+
+    addChangeRelatedSoyParams();
+    addEmailFooters();
+
+    // Set email subject.
     setChangeSubjectHeader();
 
+    // Set email content
     if (email.useHtml()) {
       email.appendHtml(email.soyHtmlTemplate("ChangeHeaderHtml"));
     }
diff --git a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
index 6a09f0f2..e0fc648 100644
--- a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
+++ b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
@@ -197,24 +197,34 @@
    * Git and not get deleted. These refs point to an empty tree. We delete such refs.
    */
   @Override
-  protected void deleteEmptyDraftsByKey(Collection<Ref> refs) throws IOException {
+  protected void deleteEmptyDraftsByKey(Collection<Ref> refs) {
     long zombieRefsCnt = refs.size();
     long deletedRefsCnt = 0;
     long startTime = System.currentTimeMillis();
 
     for (List<Ref> refsBatch : Iterables.partition(refs, CHUNK_SIZE)) {
-      deleteZombieDraftsBatch(refsBatch);
-      long elapsed = (System.currentTimeMillis() - startTime) / 1000;
-      deletedRefsCnt += refsBatch.size();
-      logProgress(deletedRefsCnt, zombieRefsCnt, elapsed);
+      try {
+        deleteZombieDraftsBatch(refsBatch);
+        long elapsed = (System.currentTimeMillis() - startTime) / 1000;
+        deletedRefsCnt += refsBatch.size();
+        logProgress(deletedRefsCnt, zombieRefsCnt, elapsed);
+      } catch (Exception error) {
+        logger.atWarning().withCause(error).log("Failed to delete drafts as a batch");
+      }
     }
   }
 
   @Override
-  protected void deleteZombieDrafts(ListMultimap<Ref, HumanComment> drafts) throws IOException {
+  protected void deleteZombieDrafts(ListMultimap<Ref, HumanComment> drafts) {
     for (Map.Entry<Ref, Collection<HumanComment>> e : drafts.asMap().entrySet()) {
-      deleteZombieDraftsForChange(
-          getAccountId(e.getKey()), getChangeNotes(getChangeId(e.getKey())), e.getValue());
+      try {
+        deleteZombieDraftsForChange(
+            getAccountId(e.getKey()), getChangeNotes(getChangeId(e.getKey())), e.getValue());
+      } catch (Exception error) {
+        logger.atWarning().withCause(error).log(
+            "Failed to delete draft for change %s account %s",
+            getChangeId(e.getKey()), getAccountId(e.getKey()));
+      }
     }
   }
 
diff --git a/java/com/google/gerrit/server/permissions/AbstractChangeControl.java b/java/com/google/gerrit/server/permissions/AbstractChangeControl.java
new file mode 100644
index 0000000..863f6a6
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/AbstractChangeControl.java
@@ -0,0 +1,299 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.ON_BEHALF_OF;
+import static com.google.gerrit.server.permissions.DefaultPermissionMappings.labelPermissionName;
+
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRange;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Abstract access control management for a user accessing a single change.
+ *
+ * <p>Contains all logic that is common for checking permissions on an existing change and on a
+ * change that is not created yet.
+ */
+abstract class AbstractChangeControl {
+  protected final ProjectControl projectControl;
+  protected final RefControl refControl;
+  protected final boolean isNew;
+
+  /** Is this user the owner of the change? */
+  protected final boolean isOwner;
+
+  private Map<String, PermissionRange> labels;
+
+  AbstractChangeControl(
+      ProjectControl projectControl, RefControl refControl, boolean isNew, boolean isOwner) {
+    this.projectControl = projectControl;
+    this.refControl = refControl;
+    this.isNew = isNew;
+    this.isOwner = isOwner;
+  }
+
+  protected abstract ForChange asForChange();
+
+  protected CurrentUser getUser() {
+    return refControl.getUser();
+  }
+
+  protected boolean can(ChangePermissionOrLabel perm) throws PermissionBackendException {
+    if (perm instanceof ChangePermission) {
+      return can((ChangePermission) perm);
+    } else if (perm instanceof AbstractLabelPermission) {
+      return can((AbstractLabelPermission) perm);
+    } else if (perm instanceof AbstractLabelPermission.WithValue) {
+      return can((AbstractLabelPermission.WithValue) perm);
+    }
+    throw new PermissionBackendException(perm + " unsupported");
+  }
+
+  protected boolean can(ChangePermission perm) throws PermissionBackendException {
+    try {
+      return switch (perm) {
+        case READ -> isVisible();
+        case ABANDON -> canAbandon();
+        case DELETE -> projectControl.isAdmin() || refControl.canDeleteChanges(isOwner);
+        case ADD_PATCH_SET -> canAddPatchSet();
+        case EDIT_DESCRIPTION -> canEditDescription();
+        case EDIT_HASHTAGS -> canEditHashtags();
+        case EDIT_CUSTOM_KEYED_VALUES -> canEditCustomKeyedValues();
+        case EDIT_TOPIC_NAME -> canEditTopicName();
+        case REBASE -> canRebase();
+        case REBASE_ON_BEHALF_OF_UPLOADER -> canRebaseOnBehalfOfUploader();
+        case RESTORE -> canRestore();
+        case REVERT -> canRevert();
+        case SUBMIT -> refControl.canSubmit(isOwner);
+        case TOGGLE_WORK_IN_PROGRESS_STATE -> canToggleWorkInProgressState();
+        case REMOVE_REVIEWER, SUBMIT_AS -> refControl.canPerform(changePermissionName(perm));
+      };
+    } catch (StorageException e) {
+      throw new PermissionBackendException("unavailable", e);
+    }
+  }
+
+  /** Can this user see this change? */
+  protected boolean isVisible() {
+    // Does the user have READ permission on the destination?
+    return refControl.asForRef().testOrFalse(RefPermission.READ);
+  }
+
+  /** Can this user abandon this change? */
+  private boolean canAbandon() {
+    return isOwner // owner (aka creator) of the change can abandon
+        || refControl.isOwner() // branch owner can abandon
+        || projectControl.isOwner() // project owner can abandon
+        || refControl.canPerform(Permission.ABANDON) // user can abandon a specific ref
+        || projectControl.isAdmin();
+  }
+
+  /** Can this user add a patch set to this change? */
+  private boolean canAddPatchSet() {
+    if (!refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE)) {
+      return false;
+    }
+    if (isOwner) {
+      return true;
+    }
+    return refControl.canAddPatchSet();
+  }
+
+  /** Can this user edit the topic name? */
+  private boolean canEditTopicName() {
+    if (isNew) {
+      return isOwner // owner (aka creator) of the change can edit topic
+          || refControl.isOwner() // branch owner can edit topic
+          || projectControl.isOwner() // project owner can edit topic
+          || refControl.canPerform(
+              Permission.EDIT_TOPIC_NAME) // user can edit topic on a specific ref
+          || projectControl.isAdmin();
+    }
+    return refControl.canForceEditTopicName(isOwner);
+  }
+
+  /** Can this user edit the description? */
+  private boolean canEditDescription() {
+    if (isNew) {
+      return isOwner // owner (aka creator) of the change can edit desc
+          || refControl.isOwner() // branch owner can edit desc
+          || projectControl.isOwner() // project owner can edit desc
+          || projectControl.isAdmin();
+    }
+    return false;
+  }
+
+  /** Can this user edit the hashtag name? */
+  private boolean canEditHashtags() {
+    return isOwner // owner (aka creator) of the change can edit hashtags
+        || refControl.isOwner() // branch owner can edit hashtags
+        || projectControl.isOwner() // project owner can edit hashtags
+        || refControl.canPerform(
+            Permission.EDIT_HASHTAGS) // user can edit hashtag on a specific ref
+        || projectControl.isAdmin();
+  }
+
+  /** Can this user edit the custom keyed values? */
+  private boolean canEditCustomKeyedValues() {
+    return isOwner // owner (aka creator) of the change can edit custom keyed values
+        || projectControl.isAdmin();
+  }
+
+  /** Can this user rebase this change? */
+  private boolean canRebase() {
+    return (isOwner || refControl.canSubmit(isOwner) || refControl.canRebase())
+        && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
+  }
+
+  /**
+   * Can this user rebase this change on behalf of the uploader?
+   *
+   * <p>This only checks the permissions of the rebaser (aka the impersonating user).
+   *
+   * <p>In addition rebase on behalf of the uploader requires the uploader (aka the impersonated
+   * user) to have permissions to create the new patch set. These permissions need to be checked
+   * separately.
+   */
+  private boolean canRebaseOnBehalfOfUploader() {
+    return (isOwner || refControl.canSubmit(isOwner) || refControl.canRebase());
+  }
+
+  /** Can this user restore this change? */
+  private boolean canRestore() {
+    // Anyone who can abandon the change can restore it, as long as they can create changes.
+    return canAbandon() && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
+  }
+
+  /** Can this user revert this change? */
+  private boolean canRevert() {
+    return refControl.canRevert() && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
+  }
+
+  /** Can this user toggle WorkInProgress state? */
+  private boolean canToggleWorkInProgressState() {
+    return isOwner
+        || projectControl.isOwner()
+        || refControl.canPerform(Permission.TOGGLE_WORK_IN_PROGRESS_STATE)
+        || projectControl.isAdmin();
+  }
+
+  private boolean can(AbstractLabelPermission perm) {
+    return !label(labelPermissionName(perm)).isEmpty();
+  }
+
+  private boolean can(AbstractLabelPermission.WithValue perm) {
+    PermissionRange r = label(labelPermissionName(perm));
+    if (perm.forUser() == ON_BEHALF_OF && r.isEmpty()) {
+      return false;
+    }
+    return r.contains(perm.value());
+  }
+
+  private PermissionRange label(String permission) {
+    if (labels == null) {
+      labels = Maps.newHashMapWithExpectedSize(4);
+    }
+    PermissionRange r = labels.get(permission);
+    if (r == null) {
+      r = getRange(permission);
+      labels.put(permission, r);
+    }
+    return r;
+  }
+
+  /** The range of permitted values associated with a label permission. */
+  private PermissionRange getRange(String permission) {
+    return refControl.getRange(permission, isOwner);
+  }
+
+  protected class ForChangeImpl extends ForChange {
+    @Nullable private final Change.Id changeId;
+
+    private String resourcePath;
+
+    protected ForChangeImpl(@Nullable Change.Id changeId) {
+      this.changeId = changeId;
+    }
+
+    @Override
+    public String resourcePath() {
+      if (resourcePath == null) {
+        resourcePath =
+            String.format(
+                "/projects/%s/+changes/%s",
+                projectControl.getProjectState().getName(), changeId != null ? changeId.get() : 0);
+      }
+      return resourcePath;
+    }
+
+    @Override
+    public void check(ChangePermissionOrLabel perm)
+        throws AuthException, PermissionBackendException {
+      if (!can(perm)) {
+        throw new AuthException(
+            perm.describeForException()
+                + " not permitted"
+                + perm.hintForException().map(hint -> " (" + hint + ")").orElse(""));
+      }
+    }
+
+    @Override
+    public <T extends ChangePermissionOrLabel> Set<T> test(Collection<T> permSet)
+        throws PermissionBackendException {
+      Set<T> ok = newSet(permSet);
+      for (T perm : permSet) {
+        if (can(perm)) {
+          ok.add(perm);
+        }
+      }
+      return ok;
+    }
+
+    @Override
+    public BooleanCondition testCond(ChangePermissionOrLabel perm) {
+      return new PermissionBackendCondition.ForChange(this, perm, getUser());
+    }
+  }
+
+  protected static <T extends ChangePermissionOrLabel> Set<T> newSet(Collection<T> permSet) {
+    if (permSet instanceof EnumSet) {
+      @SuppressWarnings({"unchecked", "rawtypes"})
+      Set<T> s = ((EnumSet) permSet).clone();
+      s.clear();
+      return s;
+    }
+    return Sets.newHashSetWithExpectedSize(permSet.size());
+  }
+
+  protected static String changePermissionName(ChangePermission changePermission) {
+    // Within this class, it's programmer error to call this method on a
+    // ChangePermission that isn't associated with a permission name.
+    return DefaultPermissionMappings.changePermissionName(changePermission)
+        .orElseThrow(() -> new IllegalStateException("no name for " + changePermission));
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java b/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java
index e4a8160..ba7caed 100644
--- a/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java
+++ b/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java
@@ -21,8 +21,7 @@
 import com.google.gerrit.server.util.LabelVote;
 
 /** Abstract permission representing a label. */
-public abstract class AbstractLabelPermission
-    implements ChangePermissionOrLabel, RefPermissionOrLabel {
+public abstract class AbstractLabelPermission implements ChangePermissionOrLabel {
   public enum ForUser {
     SELF,
     ON_BEHALF_OF
@@ -91,7 +90,7 @@
   }
 
   /** A {@link AbstractLabelPermission} at a specific value. */
-  public abstract static class WithValue implements ChangePermissionOrLabel, RefPermissionOrLabel {
+  public abstract static class WithValue implements ChangePermissionOrLabel {
     private final ForUser forUser;
     private final LabelVote label;
 
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 39a6310..3fe4811 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -14,32 +14,18 @@
 
 package com.google.gerrit.server.permissions;
 
-import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.ON_BEHALF_OF;
-import static com.google.gerrit.server.permissions.DefaultPermissionMappings.labelPermissionName;
-
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Maps;
-import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Permission;
-import com.google.gerrit.entities.PermissionRange;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.conditions.BooleanCondition;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.assistedinject.Assisted;
-import java.util.Collection;
-import java.util.EnumSet;
-import java.util.Map;
-import java.util.Set;
 import javax.inject.Inject;
 
 /** Access control management for a user accessing a single change. */
-public class ChangeControl {
+public class ChangeControl extends AbstractChangeControl {
   public interface Factory {
     ChangeControl create(
         ProjectControl projectControl, RefControl refControl, ChangeData changeData);
@@ -47,8 +33,6 @@
 
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final ProjectControl projectControl;
-  private final RefControl refControl;
   private final ChangeData changeData;
 
   @Inject
@@ -56,155 +40,31 @@
       @Assisted ProjectControl projectControl,
       @Assisted RefControl refControl,
       @Assisted ChangeData changeData) {
-    this.projectControl = projectControl;
-    this.refControl = refControl;
+    super(projectControl, refControl, changeData.change().isNew(), isOwner(refControl, changeData));
     this.changeData = changeData;
   }
 
-  ForChange asForChange() {
-    return new ForChangeImpl();
+  private static boolean isOwner(RefControl refControl, ChangeData changeData) {
+    CurrentUser user = refControl.getUser();
+    if (user.isIdentifiedUser()) {
+      Account.Id id = user.asIdentifiedUser().getAccountId();
+      return id.equals(changeData.change().getOwner());
+    }
+    return false;
   }
 
-  private CurrentUser getUser() {
-    return refControl.getUser();
-  }
-
-  private ProjectControl getProjectControl() {
-    return refControl.getProjectControl();
-  }
-
-  private Change getChange() {
-    return changeData.change();
+  @Override
+  public ForChange asForChange() {
+    return new ForChangeImpl(changeData.getId());
   }
 
   /** Can this user see this change? */
-  boolean isVisible() {
+  @Override
+  protected boolean isVisible() {
     if (changeData.isPrivateOrThrow() && !isPrivateVisible(changeData)) {
       return false;
     }
-    // Does the user have READ permission on the destination?
-    return refControl.asForRef().testOrFalse(RefPermission.READ);
-  }
-
-  /** Can this user abandon this change? */
-  private boolean canAbandon() {
-    return isOwner() // owner (aka creator) of the change can abandon
-        || refControl.isOwner() // branch owner can abandon
-        || getProjectControl().isOwner() // project owner can abandon
-        || refControl.canPerform(Permission.ABANDON) // user can abandon a specific ref
-        || getProjectControl().isAdmin();
-  }
-
-  /** Can this user rebase this change? */
-  private boolean canRebase() {
-    return (isOwner() || refControl.canSubmit(isOwner()) || refControl.canRebase())
-        && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
-  }
-
-  /**
-   * Can this user rebase this change on behalf of the uploader?
-   *
-   * <p>This only checks the permissions of the rebaser (aka the impersonating user).
-   *
-   * <p>In addition rebase on behalf of the uploader requires the uploader (aka the impersonated
-   * user) to have permissions to create the new patch set. These permissions need to be checked
-   * separately.
-   */
-  private boolean canRebaseOnBehalfOfUploader() {
-    return (isOwner() || refControl.canSubmit(isOwner()) || refControl.canRebase());
-  }
-
-  /** Can this user restore this change? */
-  private boolean canRestore() {
-    // Anyone who can abandon the change can restore it, as long as they can create changes.
-    return canAbandon() && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
-  }
-
-  /** Can this user revert this change? */
-  private boolean canRevert() {
-    return refControl.canRevert() && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
-  }
-
-  /** The range of permitted values associated with a label permission. */
-  private PermissionRange getRange(String permission) {
-    return refControl.getRange(permission, isOwner());
-  }
-
-  /** Can this user add a patch set to this change? */
-  private boolean canAddPatchSet() {
-    if (!refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE)) {
-      return false;
-    }
-    if (isOwner()) {
-      return true;
-    }
-    return refControl.canAddPatchSet();
-  }
-
-  /** Is this user the owner of the change? */
-  private boolean isOwner() {
-    if (getUser().isIdentifiedUser()) {
-      Account.Id id = getUser().asIdentifiedUser().getAccountId();
-      return id.equals(getChange().getOwner());
-    }
-    return false;
-  }
-
-  /** Is this user a reviewer for the change? */
-  private boolean isReviewer(ChangeData cd) {
-    if (getUser().isIdentifiedUser()) {
-      ImmutableSet<Account.Id> results = cd.reviewers().all();
-      return results.contains(getUser().getAccountId());
-    }
-    return false;
-  }
-
-  /** Can this user edit the topic name? */
-  private boolean canEditTopicName() {
-    if (getChange().isNew()) {
-      return isOwner() // owner (aka creator) of the change can edit topic
-          || refControl.isOwner() // branch owner can edit topic
-          || getProjectControl().isOwner() // project owner can edit topic
-          || refControl.canPerform(
-              Permission.EDIT_TOPIC_NAME) // user can edit topic on a specific ref
-          || getProjectControl().isAdmin();
-    }
-    return refControl.canForceEditTopicName(isOwner());
-  }
-
-  /** Can this user toggle WorkInProgress state? */
-  private boolean canToggleWorkInProgressState() {
-    return isOwner()
-        || getProjectControl().isOwner()
-        || refControl.canPerform(Permission.TOGGLE_WORK_IN_PROGRESS_STATE)
-        || getProjectControl().isAdmin();
-  }
-
-  /** Can this user edit the description? */
-  private boolean canEditDescription() {
-    if (getChange().isNew()) {
-      return isOwner() // owner (aka creator) of the change can edit desc
-          || refControl.isOwner() // branch owner can edit desc
-          || getProjectControl().isOwner() // project owner can edit desc
-          || getProjectControl().isAdmin();
-    }
-    return false;
-  }
-
-  /** Can this user edit the hashtag name? */
-  private boolean canEditHashtags() {
-    return isOwner() // owner (aka creator) of the change can edit hashtags
-        || refControl.isOwner() // branch owner can edit hashtags
-        || getProjectControl().isOwner() // project owner can edit hashtags
-        || refControl.canPerform(
-            Permission.EDIT_HASHTAGS) // user can edit hashtag on a specific ref
-        || getProjectControl().isAdmin();
-  }
-
-  /** Can this user edit the custom keyed values? */
-  private boolean canEditCustomKeyedValues() {
-    return isOwner() // owner (aka creator) of the change can edit custom keyed values
-        || getProjectControl().isAdmin();
+    return super.isVisible();
   }
 
   private boolean isPrivateVisible(ChangeData cd) {
@@ -215,7 +75,7 @@
       return true;
     }
 
-    if (isOwner()) {
+    if (isOwner) {
       logger.atFine().log(
           "%s can see private change %s because this user is the change owner",
           getUser().getLoggableName(), cd.getId());
@@ -247,125 +107,12 @@
     return false;
   }
 
-  private class ForChangeImpl extends ForChange {
-    private Map<String, PermissionRange> labels;
-    private String resourcePath;
-
-    private ForChangeImpl() {}
-
-    @Override
-    public String resourcePath() {
-      if (resourcePath == null) {
-        resourcePath =
-            String.format(
-                "/projects/%s/+changes/%s",
-                getProjectControl().getProjectState().getName(), changeData.getId().get());
-      }
-      return resourcePath;
+  /** Is this user a reviewer for the change? */
+  private boolean isReviewer(ChangeData cd) {
+    if (getUser().isIdentifiedUser()) {
+      ImmutableSet<Account.Id> results = cd.reviewers().all();
+      return results.contains(getUser().getAccountId());
     }
-
-    @Override
-    public void check(ChangePermissionOrLabel perm)
-        throws AuthException, PermissionBackendException {
-      if (!can(perm)) {
-        throw new AuthException(
-            perm.describeForException()
-                + " not permitted"
-                + perm.hintForException().map(hint -> " (" + hint + ")").orElse(""));
-      }
-    }
-
-    @Override
-    public <T extends ChangePermissionOrLabel> Set<T> test(Collection<T> permSet)
-        throws PermissionBackendException {
-      Set<T> ok = newSet(permSet);
-      for (T perm : permSet) {
-        if (can(perm)) {
-          ok.add(perm);
-        }
-      }
-      return ok;
-    }
-
-    @Override
-    public BooleanCondition testCond(ChangePermissionOrLabel perm) {
-      return new PermissionBackendCondition.ForChange(this, perm, getUser());
-    }
-
-    private boolean can(ChangePermissionOrLabel perm) throws PermissionBackendException {
-      if (perm instanceof ChangePermission) {
-        return can((ChangePermission) perm);
-      } else if (perm instanceof AbstractLabelPermission) {
-        return can((AbstractLabelPermission) perm);
-      } else if (perm instanceof AbstractLabelPermission.WithValue) {
-        return can((AbstractLabelPermission.WithValue) perm);
-      }
-      throw new PermissionBackendException(perm + " unsupported");
-    }
-
-    private boolean can(ChangePermission perm) throws PermissionBackendException {
-      try {
-        return switch (perm) {
-          case READ -> isVisible();
-          case ABANDON -> canAbandon();
-          case DELETE -> getProjectControl().isAdmin() || refControl.canDeleteChanges(isOwner());
-          case ADD_PATCH_SET -> canAddPatchSet();
-          case EDIT_DESCRIPTION -> canEditDescription();
-          case EDIT_HASHTAGS -> canEditHashtags();
-          case EDIT_CUSTOM_KEYED_VALUES -> canEditCustomKeyedValues();
-          case EDIT_TOPIC_NAME -> canEditTopicName();
-          case REBASE -> canRebase();
-          case REBASE_ON_BEHALF_OF_UPLOADER -> canRebaseOnBehalfOfUploader();
-          case RESTORE -> canRestore();
-          case REVERT -> canRevert();
-          case SUBMIT -> refControl.canSubmit(isOwner());
-          case TOGGLE_WORK_IN_PROGRESS_STATE -> canToggleWorkInProgressState();
-          case REMOVE_REVIEWER, SUBMIT_AS -> refControl.canPerform(changePermissionName(perm));
-        };
-      } catch (StorageException e) {
-        throw new PermissionBackendException("unavailable", e);
-      }
-    }
-
-    private boolean can(AbstractLabelPermission perm) {
-      return !label(labelPermissionName(perm)).isEmpty();
-    }
-
-    private boolean can(AbstractLabelPermission.WithValue perm) {
-      PermissionRange r = label(labelPermissionName(perm));
-      if (perm.forUser() == ON_BEHALF_OF && r.isEmpty()) {
-        return false;
-      }
-      return r.contains(perm.value());
-    }
-
-    private PermissionRange label(String permission) {
-      if (labels == null) {
-        labels = Maps.newHashMapWithExpectedSize(4);
-      }
-      PermissionRange r = labels.get(permission);
-      if (r == null) {
-        r = getRange(permission);
-        labels.put(permission, r);
-      }
-      return r;
-    }
-  }
-
-  private static <T extends ChangePermissionOrLabel> Set<T> newSet(Collection<T> permSet) {
-    if (permSet instanceof EnumSet) {
-      @SuppressWarnings({"unchecked", "rawtypes"})
-      Set<T> s = ((EnumSet) permSet).clone();
-      s.clear();
-      return s;
-    }
-    return Sets.newHashSetWithExpectedSize(permSet.size());
-  }
-
-  private static String changePermissionName(ChangePermission changePermission) {
-    // Within this class, it's programmer error to call this method on a
-    // ChangePermission that isn't associated with a permission name.
-    return DefaultPermissionMappings.changePermissionName(changePermission)
-        .orElseThrow(() -> new IllegalStateException("no name for " + changePermission));
+    return false;
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/ChangeControlForChangeToBeCreated.java b/java/com/google/gerrit/server/permissions/ChangeControlForChangeToBeCreated.java
new file mode 100644
index 0000000..b8418f4
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/ChangeControlForChangeToBeCreated.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
+
+/** Access control management for a user accessing a change that has not been created yet. */
+public class ChangeControlForChangeToBeCreated extends AbstractChangeControl {
+
+  public ChangeControlForChangeToBeCreated(
+      ProjectControl projectControl, RefControl refControl, boolean isOwner) {
+    super(projectControl, refControl, /* isNew= */ true, isOwner);
+  }
+
+  @Override
+  public ForChange asForChange() {
+    return new ForChangeImpl(/* changeId= */ null);
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java b/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
index 6a8c92b..0fc9807 100644
--- a/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
@@ -173,18 +173,23 @@
     }
 
     @Override
-    public void check(RefPermissionOrLabel perm) throws PermissionBackendException {
+    public ForChange changeToBeCreated(boolean isChangeOwner) {
+      return new FailedChange(message, cause);
+    }
+
+    @Override
+    public void check(RefPermission perm) throws PermissionBackendException {
       throw new PermissionBackendException(message, cause);
     }
 
     @Override
-    public <T extends RefPermissionOrLabel> Set<T> test(Collection<T> permSet)
+    public Set<RefPermission> test(Collection<RefPermission> permSet)
         throws PermissionBackendException {
       throw new PermissionBackendException(message, cause);
     }
 
     @Override
-    public BooleanCondition testCond(RefPermissionOrLabel perm) {
+    public BooleanCondition testCond(RefPermission perm) {
       throw new UnsupportedOperationException(
           "FailedPermissionBackend does not support conditions");
     }
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index 55cdc3d..8e2461d 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -394,33 +394,24 @@
     /** Returns an instance scoped to change. */
     public abstract ForChange change(ChangeNotes notes);
 
+    /** Returns an instance scoped to change that has not been created yet. */
+    public abstract ForChange changeToBeCreated(boolean isOwner);
+
     /**
      * Verify scoped user can {@code perm}, throwing if denied.
      *
      * <p>Should be used in REST API handlers where the thrown {@link AuthException} can be
      * propagated. In business logic, where the exception would have to be caught, prefer using
-     * {@link #test(RefPermissionOrLabel)}.
+     * {@link #test(RefPermission)}.
      */
-    public abstract void check(RefPermissionOrLabel perm)
-        throws AuthException, PermissionBackendException;
+    public abstract void check(RefPermission perm) throws AuthException, PermissionBackendException;
 
     /** Filter {@code permSet} to permissions scoped user might be able to perform. */
-    public abstract <T extends RefPermissionOrLabel> Set<T> test(Collection<T> permSet)
+    public abstract Set<RefPermission> test(Collection<RefPermission> permSet)
         throws PermissionBackendException;
 
-    public boolean test(RefPermissionOrLabel perm) throws PermissionBackendException {
-      return test(Collections.singleton(perm)).contains(perm);
-    }
-
-    /**
-     * Test which values of a label the user may be able to set.
-     *
-     * @param label definition of the label to test values of.
-     * @return set containing values the user may be able to use; may be empty if none.
-     * @throws PermissionBackendException if failure consulting backend configuration.
-     */
-    public Set<LabelPermission.WithValue> test(LabelType label) throws PermissionBackendException {
-      return test(valuesOf(requireNonNull(label, "LabelType")));
+    public boolean test(RefPermission perm) throws PermissionBackendException {
+      return test(EnumSet.of(perm)).contains(perm);
     }
 
     /**
@@ -433,7 +424,7 @@
      * @return true if the user might be able to perform the permission; false if the user may be
      *     missing the necessary grants or state, or if the backend threw an exception.
      */
-    public boolean testOrFalse(RefPermissionOrLabel perm) {
+    public boolean testOrFalse(RefPermission perm) {
       try {
         return test(perm);
       } catch (PermissionBackendException e) {
@@ -442,13 +433,7 @@
       }
     }
 
-    public abstract BooleanCondition testCond(RefPermissionOrLabel perm);
-
-    private static Set<LabelPermission.WithValue> valuesOf(LabelType label) {
-      return label.getValues().stream()
-          .map(v -> new LabelPermission.WithValue(label, v))
-          .collect(toSet());
-    }
+    public abstract BooleanCondition testCond(RefPermission perm);
   }
 
   /** PermissionBackend scoped to a user, project, reference and change. */
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackendCondition.java b/java/com/google/gerrit/server/permissions/PermissionBackendCondition.java
index 7018fa0..a92e504 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackendCondition.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackendCondition.java
@@ -148,10 +148,10 @@
 
   public static class ForRef extends PermissionBackendCondition {
     private final PermissionBackend.ForRef impl;
-    private final RefPermissionOrLabel perm;
+    private final RefPermission perm;
     private final CurrentUser user;
 
-    public ForRef(PermissionBackend.ForRef impl, RefPermissionOrLabel perm, CurrentUser user) {
+    public ForRef(PermissionBackend.ForRef impl, RefPermission perm, CurrentUser user) {
       this.impl = impl;
       this.perm = perm;
       this.user = user;
@@ -161,7 +161,7 @@
       return impl;
     }
 
-    public RefPermissionOrLabel permission() {
+    public RefPermission permission() {
       return perm;
     }
 
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index 194179e..c86f8f6 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -123,6 +123,11 @@
     return changeControlFactory.create(this, controlForRef(cd.branchOrThrow()), cd);
   }
 
+  ChangeControlForChangeToBeCreated controlForChangeToBeCreated(
+      RefControl refControl, boolean isOwner) {
+    return new ChangeControlForChangeToBeCreated(this, refControl, isOwner);
+  }
+
   RefControl controlForRef(BranchNameKey ref) {
     return controlForRef(ref.branch());
   }
@@ -453,7 +458,7 @@
       throw new PermissionBackendException(perm.describeForException() + " unsupported");
     }
 
-    private boolean can(ProjectPermission perm) throws PermissionBackendException {
+    private boolean can(ProjectPermission perm) {
       return switch (perm) {
         case ACCESS -> user.isInternalUser() || isOwner() || canPerformOnAnyRef(Permission.READ);
         case READ -> allRefsAreVisible(Collections.emptySet());
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index 88e53f7..5dd6be0 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -15,12 +15,8 @@
 package com.google.gerrit.server.permissions;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.ON_BEHALF_OF;
-import static com.google.gerrit.server.permissions.DefaultPermissionMappings.labelPermissionName;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Maps;
-import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
@@ -47,7 +43,6 @@
 import java.util.Collection;
 import java.util.EnumSet;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Constants;
@@ -487,7 +482,6 @@
   }
 
   private class ForRefImpl extends ForRef {
-    private Map<String, PermissionRange> labels;
     private String resourcePath;
 
     @Override
@@ -524,17 +518,14 @@
     }
 
     @Override
-    public void check(RefPermissionOrLabel perm) throws AuthException, PermissionBackendException {
-      if (perm instanceof RefPermission) {
-        check((RefPermission) perm);
-      } else {
-        if (!can(perm)) {
-          throw new AuthException(perm.describeForException() + " not permitted");
-        }
-      }
+    public ForChange changeToBeCreated(boolean isOwner) {
+      return getProjectControl()
+          .controlForChangeToBeCreated(RefControl.this, isOwner)
+          .asForChange();
     }
 
-    private void check(RefPermission perm) throws AuthException, PermissionBackendException {
+    @Override
+    public void check(RefPermission perm) throws AuthException, PermissionBackendException {
       if (!can(perm)) {
         PermissionDeniedException pde = new PermissionDeniedException(perm, refName);
         switch (perm) {
@@ -601,10 +592,10 @@
     }
 
     @Override
-    public <T extends RefPermissionOrLabel> Set<T> test(Collection<T> permSet)
+    public Set<RefPermission> test(Collection<RefPermission> permSet)
         throws PermissionBackendException {
-      Set<T> ok = newSet(permSet);
-      for (T perm : permSet) {
+      EnumSet<RefPermission> ok = EnumSet.noneOf(RefPermission.class);
+      for (RefPermission perm : permSet) {
         if (can(perm)) {
           ok.add(perm);
         }
@@ -613,44 +604,9 @@
     }
 
     @Override
-    public BooleanCondition testCond(RefPermissionOrLabel perm) {
+    public BooleanCondition testCond(RefPermission perm) {
       return new PermissionBackendCondition.ForRef(this, perm, getUser());
     }
-
-    private boolean can(RefPermissionOrLabel perm) throws PermissionBackendException {
-      if (perm instanceof RefPermission) {
-        return RefControl.this.can((RefPermission) perm);
-      } else if (perm instanceof AbstractLabelPermission) {
-        return can((AbstractLabelPermission) perm);
-      } else if (perm instanceof AbstractLabelPermission.WithValue) {
-        return can((AbstractLabelPermission.WithValue) perm);
-      }
-      throw new PermissionBackendException(perm + " unsupported");
-    }
-
-    private boolean can(AbstractLabelPermission perm) {
-      return !label(labelPermissionName(perm)).isEmpty();
-    }
-
-    private boolean can(AbstractLabelPermission.WithValue perm) {
-      PermissionRange r = label(labelPermissionName(perm));
-      if (perm.forUser() == ON_BEHALF_OF && r.isEmpty()) {
-        return false;
-      }
-      return r.contains(perm.value());
-    }
-
-    private PermissionRange label(String permission) {
-      if (labels == null) {
-        labels = Maps.newHashMapWithExpectedSize(4);
-      }
-      PermissionRange r = labels.get(permission);
-      if (r == null) {
-        r = getRange(permission);
-        labels.put(permission, r);
-      }
-      return r;
-    }
   }
 
   protected boolean can(RefPermission perm) throws PermissionBackendException {
@@ -773,15 +729,4 @@
     return DefaultPermissionMappings.refPermissionName(refPermission)
         .orElseThrow(() -> new IllegalStateException("no name for " + refPermission));
   }
-
-  // Same as in ChangeControl
-  private static <T extends RefPermissionOrLabel> Set<T> newSet(Collection<T> permSet) {
-    if (permSet instanceof EnumSet) {
-      @SuppressWarnings({"unchecked", "rawtypes"})
-      Set<T> s = ((EnumSet) permSet).clone();
-      s.clear();
-      return s;
-    }
-    return Sets.newHashSetWithExpectedSize(permSet.size());
-  }
 }
diff --git a/java/com/google/gerrit/server/permissions/RefPermission.java b/java/com/google/gerrit/server/permissions/RefPermission.java
index da5b745..34c46af 100644
--- a/java/com/google/gerrit/server/permissions/RefPermission.java
+++ b/java/com/google/gerrit/server/permissions/RefPermission.java
@@ -18,7 +18,7 @@
 
 import com.google.gerrit.extensions.api.access.GerritPermission;
 
-public enum RefPermission implements RefPermissionOrLabel {
+public enum RefPermission implements GerritPermission {
   READ,
   CREATE,
 
diff --git a/java/com/google/gerrit/server/permissions/RefPermissionOrLabel.java b/java/com/google/gerrit/server/permissions/RefPermissionOrLabel.java
deleted file mode 100644
index 3ec74ba..0000000
--- a/java/com/google/gerrit/server/permissions/RefPermissionOrLabel.java
+++ /dev/null
@@ -1,20 +0,0 @@
-// Copyright (C) 2025 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.permissions;
-
-import com.google.gerrit.extensions.api.access.GerritPermission;
-
-/** A {@link RefPermission} or a {@link AbstractLabelPermission}. */
-public interface RefPermissionOrLabel extends GerritPermission {}
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index 9cd7908..77101ccb 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -392,21 +392,21 @@
     if (initialMessage == null) {
       initialMessage =
           MessageFormat.format(
-              ChangeMessages.get().revertSubmissionDefaultMessage,
+              ChangeMessages.revertSubmissionDefaultMessage,
               changeNotes.getCurrentPatchSet().commitId().name());
     }
 
     // For performance purposes: Almost all cases will end here.
     if (!subject.startsWith("Revert")) {
       return MessageFormat.format(
-          ChangeMessages.get().revertSubmissionUserMessage, subject, initialMessage);
+          ChangeMessages.revertSubmissionUserMessage, subject, initialMessage);
     }
 
     Matcher matcher = patternRevertSubjectWithNum.matcher(subject);
 
     if (matcher.matches()) {
       return MessageFormat.format(
-          ChangeMessages.get().revertSubmissionOfRevertSubmissionUserMessage,
+          ChangeMessages.revertSubmissionOfRevertSubmissionUserMessage,
           Integer.valueOf(matcher.group(1)) + 1,
           matcher.group(2),
           changeNotes.getCurrentPatchSet().commitId().name());
@@ -415,14 +415,14 @@
     matcher = patternRevertSubject.matcher(subject);
     if (matcher.matches()) {
       return MessageFormat.format(
-          ChangeMessages.get().revertSubmissionOfRevertSubmissionUserMessage,
+          ChangeMessages.revertSubmissionOfRevertSubmissionUserMessage,
           2,
           matcher.group(1),
           changeNotes.getCurrentPatchSet().commitId().name());
     }
 
     return MessageFormat.format(
-        ChangeMessages.get().revertSubmissionUserMessage, subject, initialMessage);
+        ChangeMessages.revertSubmissionUserMessage, subject, initialMessage);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/restapi/config/ListCapabilities.java b/java/com/google/gerrit/server/restapi/config/ListCapabilities.java
index 6c8bf74..5b36950 100644
--- a/java/com/google/gerrit/server/restapi/config/ListCapabilities.java
+++ b/java/com/google/gerrit/server/restapi/config/ListCapabilities.java
@@ -68,8 +68,8 @@
   private Map<String, CapabilityInfo> collectCoreCapabilities()
       throws IllegalAccessException, NoSuchFieldException {
     Map<String, CapabilityInfo> output = new HashMap<>();
-    Class<? extends CapabilityConstants> bundleClass = CapabilityConstants.get().getClass();
-    CapabilityConstants c = CapabilityConstants.get();
+    Class<? extends CapabilityConstants> bundleClass = new CapabilityConstants().getClass();
+    CapabilityConstants c = new CapabilityConstants();
     for (String id : GlobalCapability.getAllNames()) {
       String name = (String) bundleClass.getField(id).get(c);
       output.put(id, new CapabilityInfo(id, name));
diff --git a/java/com/google/gerrit/server/restapi/project/ListLabels.java b/java/com/google/gerrit/server/restapi/project/ListLabels.java
index 8be2646..912360c 100644
--- a/java/com/google/gerrit/server/restapi/project/ListLabels.java
+++ b/java/com/google/gerrit/server/restapi/project/ListLabels.java
@@ -125,8 +125,7 @@
 
   public List<LabelDefinitionInfo> filterLabelsThatUserCanVoteOnRef(
       ProjectResource rsrc, List<LabelDefinitionInfo> allLabels)
-      throws AuthException,
-          PermissionBackendException,
+      throws PermissionBackendException,
           ResourceConflictException,
           RepositoryNotFoundException,
           IOException {
@@ -161,11 +160,14 @@
         continue;
       }
 
+      // We assume that user is interested in labels that can be voted on if
+      // the user is the change owner.
       Set<LabelPermission.WithValue> can =
           permissionBackend
               .currentUser()
               .project(rsrc.getNameKey())
               .ref(branchNameKey.branch())
+              .changeToBeCreated(/* isOwner= */ true)
               .test(labelType);
 
       for (LabelValue v : labelType.getValues()) {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 83f36f9..5946725 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -1828,7 +1828,7 @@
     in.state = REMOVED;
     ReviewerResult reviewerResult = gApi.changes().id(r.getChangeId()).addReviewer(in);
     assertThat(reviewerResult.error)
-        .isEqualTo(MessageFormat.format(ChangeMessages.get().groupRemovalIsNotAllowed, groupName));
+        .isEqualTo(MessageFormat.format(ChangeMessages.groupRemovalIsNotAllowed, groupName));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java b/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
index fe3cb00..19c1b81 100644
--- a/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
@@ -52,7 +52,9 @@
                 .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
+                .GERRIT_BACKEND_FEATURE_USE_DIRECT_PUSH_CONTEXT_FOR_SUBMIT_ON_PUSH)
         .inOrder();
 
     // "GerritBackendFeature__check_implicit_merges_on_merge",
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index fe488f5..1bf0f55 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -53,6 +53,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Streams;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
@@ -109,10 +110,12 @@
 import com.google.gerrit.server.PluginPushOption;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.receive.NoteDbPushOption;
+import com.google.gerrit.server.git.receive.PushOptionsValidator;
 import com.google.gerrit.server.git.receive.ReceiveConstants;
 import com.google.gerrit.server.git.validators.CommitValidationInfo;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.ValidationMessage;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -3190,6 +3193,38 @@
   }
 
   @Test
+  public void pushWithMalformedCustomKeyedValuesRejects() throws Exception {
+    List<String> pushOptions = new ArrayList<>();
+    pushOptions.add("custom-keyed-value=foo-bar");
+
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    push.setPushOptions(pushOptions);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertErrorStatus(
+        "the value for option 'custom-keyed-value' must be given as '<key>:<format>'");
+  }
+
+  @Test
+  public void pushWithCustomKeyedValuesPasses() throws Exception {
+    List<String> pushOptions = new ArrayList<>();
+    pushOptions.add("custom-keyed-value=foo:bar");
+
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    push.setPushOptions(pushOptions);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    assertThat(r.getChange().customKeyedValues()).isEqualTo(ImmutableMap.of("foo", "bar"));
+  }
+
+  @Test
+  public void cannotPushWithCustomKeyedValuesInRefName() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master%custom-keyed-value=foo-bar");
+    r.assertErrorStatus(
+        "option 'custom-keyed-value' cannot be specified as an option in the ref name, use a push"
+            + " option instead ('-o custom-keyed-value=<key>:<value>')");
+  }
+
+  @Test
   public void pushForMasterWithUnknownOption() throws Exception {
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     push.setPushOptions(ImmutableList.of("unknown=foo"));
@@ -3334,6 +3369,104 @@
     }
   }
 
+  @Test
+  public void rejectPushOptionFromValidator() throws Exception {
+    PushOptionsValidator pushOptionsValidator =
+        (Project.NameKey projectName, String refName, ListMultimap<String, String> pushOptions) ->
+            pushOptions.containsKey("topic")
+                ? ImmutableList.of(
+                    new ValidationMessage("setting a topic is not allowed", /* isError= */ true))
+                : ImmutableList.of();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(pushOptionsValidator)) {
+      // Push with a push option that is being rejected.
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      List<String> pushOptions = new ArrayList<>();
+      pushOptions.add("topic=myTopic");
+      push.setPushOptions(pushOptions);
+      PushOneCommit.Result r = push.to("refs/for/master");
+      r.assertErrorStatus("setting a topic is not allowed");
+
+      // Push with an option in the refname that is being rejected.
+      r = pushTo("refs/for/master%topic=myTopic");
+      r.assertErrorStatus("setting a topic is not allowed");
+
+      // Pushing with another push options is fine.
+      pushOptions = new ArrayList<>();
+      pushOptions.add("notify=NONE");
+      push.setPushOptions(pushOptions);
+      r = push.to("refs/for/master");
+      r.assertOkStatus();
+
+      // Pushing with another option in the refname is fine.
+      r = pushTo("refs/for/master%notify=NONE");
+      r.assertOkStatus();
+    }
+  }
+
+  @Test
+  public void printMessageForPushOptionFromValidator() throws Exception {
+    PushOptionsValidator pushOptionsValidator =
+        (Project.NameKey projectName, String refName, ListMultimap<String, String> pushOptions) ->
+            pushOptions.containsKey("topic")
+                ? ImmutableList.of(
+                    new ValidationMessage(
+                        "setting a topic is deprecated", ValidationMessage.Type.WARNING))
+                : ImmutableList.of();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(pushOptionsValidator)) {
+      // Push with a push option that is being rejected.
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      List<String> pushOptions = new ArrayList<>();
+      pushOptions.add("topic=myTopic");
+      push.setPushOptions(pushOptions);
+      PushOneCommit.Result r = push.to("refs/for/master");
+      r.assertOkStatus();
+      r.assertMessage("warning: setting a topic is deprecated");
+
+      // Push with an option in the refname that is being rejected.
+      r = pushTo("refs/for/master%topic=myTopic");
+      r.assertOkStatus();
+      r.assertMessage("warning: setting a topic is deprecated");
+
+      // Pushing with another push options doesn't print the message.
+      push = pushFactory.create(admin.newIdent(), testRepo);
+      pushOptions = new ArrayList<>();
+      pushOptions.add("notify=NONE");
+      push.setPushOptions(pushOptions);
+      r = push.to("refs/for/master");
+      r.assertOkStatus();
+      r.assertNotMessage("warning: setting a topic is deprecated");
+
+      // Pushing with another option in the refname doesn't print the message.
+      r = pushTo("refs/for/master%notify=NONE");
+      r.assertOkStatus();
+      r.assertNotMessage("warning: setting a topic is deprecated");
+    }
+  }
+
+  @Test
+  public void cannotSetPushJustificationWhenSubmitOptionIsNotUsed() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    List<String> pushOptions = new ArrayList<>();
+    pushOptions.add("push-justification=test justification");
+    push.setPushOptions(pushOptions);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertErrorStatus(
+        "when pushing for a review a push justification can only be set when the"
+            + " 'submit' option is used");
+
+    // pushing with the submit option works
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
+        .update();
+
+    r = push.to("refs/for/master%submit");
+    r.assertOkStatus();
+  }
+
   private DraftInput newDraft(String path, int line, String message) {
     DraftInput d = new DraftInput();
     d.path = path;
diff --git a/javatests/com/google/gerrit/acceptance/git/DirectPushRefUpdateContextIT.java b/javatests/com/google/gerrit/acceptance/git/DirectPushRefUpdateContextIT.java
index c059ae4..c675e2b 100644
--- a/javatests/com/google/gerrit/acceptance/git/DirectPushRefUpdateContextIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/DirectPushRefUpdateContextIT.java
@@ -14,19 +14,32 @@
 
 package com.google.gerrit.acceptance.git;
 
+import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static java.util.function.Function.identity;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType;
 import com.google.gerrit.testing.RefUpdateContextCollector;
+import com.google.inject.Inject;
 import java.util.Map.Entry;
 import org.junit.Rule;
 import org.junit.Test;
 
 public class DirectPushRefUpdateContextIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
   @Rule
   public RefUpdateContextCollector refUpdateContextCollector = new RefUpdateContextCollector();
 
@@ -69,4 +82,80 @@
     assertThat(ctx.getUpdateType()).isEqualTo(RefUpdateType.DIRECT_PUSH);
     assertThat(ctx.getJustification()).hasValue("test justification");
   }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {"GerritBackendFeature__use_direct_push_context_for_submit_on_push"})
+  public void submitOnPushWithoutJustification_emptyJustification() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
+        .update();
+
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
+    push.setPushOptions(ImmutableList.of("submit"));
+    PushOneCommit.Result result = push.to("refs/for/master");
+
+    result.assertOkStatus();
+    ImmutableList<Entry<String, ImmutableList<RefUpdateContext>>> updates =
+        refUpdateContextCollector.getContextsByUpdateType(RefUpdateType.DIRECT_PUSH);
+    ImmutableMap<String, ImmutableList<RefUpdateContext>> refUpdateContextsByRef =
+        updates.stream().collect(toImmutableMap(Entry::getKey, Entry::getValue));
+    assertThat(refUpdateContextsByRef.keySet())
+        .containsExactly("refs/heads/master", RefNames.changeMetaRef(result.getChange().getId()));
+
+    ImmutableList<RefUpdateContext> ctxts = refUpdateContextsByRef.get("refs/heads/master");
+    ImmutableListMultimap<RefUpdateContext.RefUpdateType, RefUpdateContext>
+        refUpdateContextsByType =
+            ctxts.stream()
+                .collect(toImmutableListMultimap(RefUpdateContext::getUpdateType, identity()));
+    assertThat(refUpdateContextsByType.keySet())
+        .containsExactly(RefUpdateType.DIRECT_PUSH, RefUpdateType.MERGE_CHANGE);
+    ImmutableList<RefUpdateContext> directPushRefUpdatesContexts =
+        refUpdateContextsByType.get(RefUpdateType.DIRECT_PUSH);
+    assertThat(directPushRefUpdatesContexts).hasSize(1);
+    RefUpdateContext directPushUpdateCtx = directPushRefUpdatesContexts.get(0);
+    assertThat(directPushUpdateCtx.getJustification()).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {"GerritBackendFeature__use_direct_push_context_for_submit_on_push"})
+  public void submitOnPushWithJustification_justificationStoredInContext() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
+        .update();
+
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
+    push.setPushOptions(ImmutableList.of("push-justification=test justification", "submit"));
+    PushOneCommit.Result result = push.to("refs/for/master");
+
+    result.assertOkStatus();
+    ImmutableList<Entry<String, ImmutableList<RefUpdateContext>>> updates =
+        refUpdateContextCollector.getContextsByUpdateType(RefUpdateType.DIRECT_PUSH);
+    ImmutableMap<String, ImmutableList<RefUpdateContext>> refUpdateContextsByRef =
+        updates.stream().collect(toImmutableMap(Entry::getKey, Entry::getValue));
+    assertThat(refUpdateContextsByRef.keySet())
+        .containsExactly("refs/heads/master", RefNames.changeMetaRef(result.getChange().getId()));
+
+    ImmutableList<RefUpdateContext> ctxts = refUpdateContextsByRef.get("refs/heads/master");
+    ImmutableListMultimap<RefUpdateContext.RefUpdateType, RefUpdateContext>
+        refUpdateContextsByType =
+            ctxts.stream()
+                .collect(toImmutableListMultimap(RefUpdateContext::getUpdateType, identity()));
+    assertThat(refUpdateContextsByType.keySet())
+        .containsExactly(RefUpdateType.DIRECT_PUSH, RefUpdateType.MERGE_CHANGE);
+    ImmutableList<RefUpdateContext> directPushRefUpdatesContexts =
+        refUpdateContextsByType.get(RefUpdateType.DIRECT_PUSH);
+    assertThat(directPushRefUpdatesContexts).hasSize(1);
+    RefUpdateContext directPushUpdateCtx = directPushRefUpdatesContexts.get(0);
+    assertThat(directPushUpdateCtx.getJustification()).hasValue("test justification");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
index 2667851..d544de1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
@@ -318,6 +319,27 @@
   }
 
   @Test
+  public void voteableOnRefForChangeOwners() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    configLabel("bar", LabelFunction.NO_OP);
+
+    // Grant permissions to read config and vote on 'foo' label with full range (-2 to +2)
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .add(allowLabel("foo").ref("refs/heads/master").group(CHANGE_OWNER).range(-2, 2))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    List<LabelDefinitionInfo> labels =
+        gApi.projects().name(project.get()).labels().withVoteableOnRef("refs/heads/master").get();
+
+    assertThat(labelNames(labels)).containsExactly("foo");
+  }
+
+  @Test
   public void voteableOnRefRespectsBranchRestrictions() throws Exception {
     projectOperations
         .project(project)
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index 5248349..331d172 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit 5248349d072fb4cff7d4a2eeea63bb652836c1f1
+Subproject commit 331d1728d9a74fa9cdffbba3c85645a36419eff6
diff --git a/plugins/webhooks b/plugins/webhooks
index 8cf6f0c..788cf1a 160000
--- a/plugins/webhooks
+++ b/plugins/webhooks
@@ -1 +1 @@
-Subproject commit 8cf6f0c18115f19ddaf6eb7aa2ba624d07ec107b
+Subproject commit 788cf1a335c25a96874b99b74d7e4183b111d2f1
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 000519b..261ca26 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -346,6 +346,13 @@
   range: CommentRange | undefined;
 }
 
+/** The detail of the 'range-selected-mouse-up' event dispatched by gr-diff. */
+export declare interface RangeSelectedEventDetail {
+  side: Side;
+  lineNum: LineNumber;
+  range: CommentRange | undefined;
+}
+
 export declare interface ContentLoadNeededEventDetail {
   lineRange: {
     left: LineRange;
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index a952204..4e0fe9b 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -28,6 +28,8 @@
   LABEL_CHANGE = 'labelchange',
   SHOW_CHANGE = 'showchange',
   SUBMIT_CHANGE = 'submitchange',
+  // Fires GerritView values such as 'change', 'dashboard', 'admin', ...
+  VIEW_CHANGE = 'view-change',
   SHOW_REVISION_ACTIONS = 'show-revision-actions',
   COMMIT_MSG_EDIT = 'commitmsgedit',
   REVERT = 'revert',
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
index 58827fb..1e5736b 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -16,6 +16,7 @@
 import '../gr-repo-commands/gr-repo-commands';
 import '../gr-repo-dashboards/gr-repo-dashboards';
 import '../gr-repo-detail-list/gr-repo-detail-list';
+import '../gr-repo-submit-requirements/gr-repo-submit-requirements';
 import '../gr-repo-list/gr-repo-list';
 import '../gr-server-info/gr-server-info';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
@@ -214,7 +215,7 @@
       ${this.renderGroupMembers()} ${this.renderGroupAuditLog()}
       ${this.renderRepoDetailList()} ${this.renderRepoCommands()}
       ${this.renderRepoAccess()} ${this.renderRepoDashboards()}
-      ${this.renderServerInfo()}
+      ${this.renderRepoSubmitRequirements()} ${this.renderServerInfo()}
     `;
   }
 
@@ -449,6 +450,20 @@
     `;
   }
 
+  private renderRepoSubmitRequirements() {
+    if (this.view !== GerritView.REPO) return nothing;
+    if (this.repoViewState?.detail !== RepoDetailView.SUBMIT_REQUIREMENTS)
+      return nothing;
+
+    return html`
+      <div class="main table breadcrumbs">
+        <gr-repo-submit-requirements
+          .repo=${this.repoViewState.repo}
+        ></gr-repo-submit-requirements>
+      </div>
+    `;
+  }
+
   private renderServerInfo() {
     if (this.view !== GerritView.ADMIN) return nothing;
     if (this.adminViewState?.adminView !== AdminChildView.SERVER_INFO)
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
index 34e8d24..f26610d 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
@@ -170,7 +170,7 @@
     );
     assert.equal(
       queryAndAssert<GrDropdownList>(element, '#pageSelect').items!.length,
-      7
+      8
     );
   });
 
@@ -370,6 +370,12 @@
               detailType: RepoDetailView.DASHBOARDS,
               url: '/admin/repos/my-repo,dashboards',
             },
+            {
+              name: 'Submit Requirements',
+              view: GerritView.REPO,
+              detailType: RepoDetailView.SUBMIT_REQUIREMENTS,
+              url: '/admin/repos/my-repo,submit-requirements',
+            },
           ],
         },
       },
@@ -450,6 +456,14 @@
         detailType: RepoDetailView.DASHBOARDS,
         parent: 'my-repo' as RepoName,
       },
+      {
+        text: 'Submit Requirements',
+        value: 'reposubmit-requirements',
+        view: GerritView.REPO,
+        url: '/admin/repos/my-repo,submit-requirements',
+        detailType: RepoDetailView.SUBMIT_REQUIREMENTS,
+        parent: 'my-repo' as RepoName,
+      },
     ];
     const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
     const selectedIsCurrentPageSpy = sinon.spy(
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
index e099184..de32b7f 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
@@ -10,11 +10,12 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {LitElement, css, html, PropertyValues} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
+import {customElement, property, state} from 'lit/decorators.js';
 import {
   DashboardType,
   createDashboardUrl,
 } from '../../../models/views/dashboard';
+import {when} from 'lit/directives/when.js';
 
 interface DashboardRef {
   section: string;
@@ -26,11 +27,11 @@
   @property({type: String})
   repo?: RepoName;
 
-  @property({type: Boolean})
-  _loading = true;
+  @state()
+  loading = true;
 
-  @property({type: Array})
-  _dashboards?: DashboardRef[];
+  @state()
+  dashboards?: DashboardRef[];
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -43,23 +44,13 @@
           display: block;
           margin-bottom: var(--spacing-xxl);
         }
-        .loading #dashboards,
-        #loadingContainer {
-          display: none;
-        }
-        .loading #loadingContainer {
-          display: block;
-        }
       `,
     ];
   }
 
   override render() {
-    return html` <table
-      id="list"
-      class="genericList ${this._computeLoadingClass(this._loading)}"
-    >
-      <tbody>
+    return html` <table id="list" class="genericList">
+      <tbody id="dashboards">
         <tr class="headerRow">
           <th class="topHeader">Dashboard name</th>
           <th class="topHeader">Dashboard title</th>
@@ -67,36 +58,39 @@
           <th class="topHeader">Inherited from</th>
           <th class="topHeader">Default</th>
         </tr>
-        <tr id="loadingContainer">
-          <td>Loading...</td>
-        </tr>
-      </tbody>
-      <tbody id="dashboards">
-        ${(this._dashboards ?? []).map(
-          item => html`
-            <tr class="groupHeader">
-              <td colspan="5">${item.section}</td>
-            </tr>
-            ${(item.dashboards ?? []).map(
-              info => html`
-                <tr class="table">
-                  <td class="name">
-                    <a href=${this._getUrl(info.project, info.id)}
-                      >${info.path}</a
-                    >
-                  </td>
-                  <td class="title">${info.title}</td>
-                  <td class="desc">${info.description}</td>
-                  <td class="inherited">
-                    ${this._computeInheritedFrom(
-                      info.project,
-                      info.defining_project
-                    )}
-                  </td>
-                  <td class="default">
-                    ${this._computeIsDefault(info.is_default)}
-                  </td>
+        ${when(
+          this.loading,
+          () => html`<tr id="loadingContainer">
+            <td>Loading...</td>
+          </tr>`,
+          () => html`
+            ${(this.dashboards ?? []).map(
+              item => html`
+                <tr class="groupHeader">
+                  <td colspan="5">${item.section}</td>
                 </tr>
+                ${(item.dashboards ?? []).map(
+                  info => html`
+                    <tr class="table">
+                      <td class="name">
+                        <a href=${this.getUrl(info.project, info.id)}
+                          >${info.path}</a
+                        >
+                      </td>
+                      <td class="title">${info.title}</td>
+                      <td class="desc">${info.description}</td>
+                      <td class="inherited">
+                        ${this.computeInheritedFrom(
+                          info.project,
+                          info.defining_project
+                        )}
+                      </td>
+                      <td class="default">
+                        ${this.computeIsDefault(info.is_default)}
+                      </td>
+                    </tr>
+                  `
+                )}
               `
             )}
           `
@@ -113,7 +107,7 @@
 
   private repoChanged() {
     const repo = this.repo;
-    this._loading = true;
+    this.loading = true;
     if (!repo) {
       return Promise.resolve();
     }
@@ -151,26 +145,22 @@
             });
           });
 
-        this._dashboards = dashboardBuilder;
-        this._loading = false;
+        this.dashboards = dashboardBuilder;
+        this.loading = false;
       });
   }
 
-  _getUrl(project?: RepoName, dashboard?: DashboardId) {
+  private getUrl(project?: RepoName, dashboard?: DashboardId) {
     if (!project || !dashboard) return '';
 
     return createDashboardUrl({project, type: DashboardType.REPO, dashboard});
   }
 
-  _computeLoadingClass(loading: boolean) {
-    return loading ? 'loading' : '';
-  }
-
-  _computeInheritedFrom(project: RepoName, definingProject: RepoName) {
+  private computeInheritedFrom(project: RepoName, definingProject: RepoName) {
     return project === definingProject ? '' : definingProject;
   }
 
-  _computeIsDefault(isDefault?: boolean) {
+  private computeIsDefault(isDefault?: boolean) {
     return isDefault ? '✓' : '';
   }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts
index 2bbc28b..b09212e 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts
@@ -9,7 +9,7 @@
 import {
   addListenerForTest,
   mockPromise,
-  queryAndAssert,
+  query,
   stubRestApi,
   waitEventLoop,
 } from '../../../test/test-utils';
@@ -83,8 +83,8 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <table class="genericList loading" id="list">
-            <tbody>
+          <table class="genericList" id="list">
+            <tbody id="dashboards">
               <tr class="headerRow">
                 <th class="topHeader">Dashboard name</th>
                 <th class="topHeader">Dashboard title</th>
@@ -96,34 +96,20 @@
                 <td>Loading...</td>
               </tr>
             </tbody>
-            <tbody id="dashboards"></tbody>
           </table>
         `
       );
     });
 
     test('loading, sections, and ordering', async () => {
-      assert.isTrue(element._loading);
-      assert.notEqual(
-        getComputedStyle(queryAndAssert(element, '#loadingContainer')).display,
-        'none'
-      );
-      assert.equal(
-        getComputedStyle(queryAndAssert(element, '#dashboards')).display,
-        'none'
-      );
+      assert.isTrue(element.loading);
+      assert.isOk(query(element, '#loadingContainer'));
+
       element.repo = 'test' as RepoName;
       await waitEventLoop();
-      assert.equal(
-        getComputedStyle(queryAndAssert(element, '#loadingContainer')).display,
-        'none'
-      );
-      assert.notEqual(
-        getComputedStyle(queryAndAssert(element, '#dashboards')).display,
-        'none'
-      );
+      assert.isNotOk(query(element, '#loadingContainer'));
 
-      const dashboard = element._dashboards!;
+      const dashboard = element.dashboards!;
       assert.equal(dashboard.length, 2);
       assert.equal(dashboard[0].section, 'custom');
       assert.equal(dashboard[1].section, 'default');
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements.ts b/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements.ts
new file mode 100644
index 0000000..6de8705
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements.ts
@@ -0,0 +1,129 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {RepoName, SubmitRequirementInfo} from '../../../types/common';
+import {firePageError} from '../../../utils/event-util';
+import {getAppContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {tableStyles} from '../../../styles/gr-table-styles';
+import {LitElement, css, html, PropertyValues} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {when} from 'lit/directives/when.js';
+
+@customElement('gr-repo-submit-requirements')
+export class GrRepoSubmitRequirements extends LitElement {
+  @property({type: String})
+  repo?: RepoName;
+
+  @state()
+  loading = true;
+
+  @state()
+  submitRequirements?: SubmitRequirementInfo[];
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      tableStyles,
+      css`
+        :host {
+          display: block;
+          margin-bottom: var(--spacing-xxl);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html` <table id="list" class="genericList">
+      <tbody>
+        <tr class="headerRow">
+          <th class="topHeader">Name</th>
+          <th class="topHeader">Description</th>
+          <th class="topHeader">Applicability Expression</th>
+          <th class="topHeader">Submittability Expression</th>
+          <th class="topHeader">Override Expression</th>
+          <th
+            class="topHeader"
+            title="Whether override is allowed in child projects"
+          >
+            Allow Override
+          </th>
+        </tr>
+      </tbody>
+      <tbody id="submit-requirements">
+        ${when(
+          this.loading,
+          () => html`<tr id="loadingContainer">
+            <td>Loading...</td>
+          </tr>`,
+          () =>
+            html` ${(this.submitRequirements ?? []).map(
+              item => html`
+                <tr class="table">
+                  <td class="name">${item.name}</td>
+                  <td class="desc">${item.description}</td>
+                  <td class="applicability">
+                    ${item.applicability_expression}
+                  </td>
+                  <td class="submittability">
+                    ${item.submittability_expression}
+                  </td>
+                  <td class="override">${item.override_expression}</td>
+                  <td class="allowOverride">
+                    ${this.renderCheckmark(
+                      item.allow_override_in_child_projects
+                    )}
+                  </td>
+                </tr>
+              `
+            )}`
+        )}
+      </tbody>
+    </table>`;
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('repo')) {
+      this.repoChanged();
+    }
+  }
+
+  private repoChanged() {
+    const repo = this.repo;
+    this.loading = true;
+    if (!repo) {
+      return Promise.resolve();
+    }
+
+    const errFn: ErrorCallback = response => {
+      firePageError(response);
+    };
+
+    return this.restApiService
+      .getRepoSubmitRequirements(repo, errFn)
+      .then((res?: SubmitRequirementInfo[]) => {
+        if (!res) {
+          return;
+        }
+
+        this.submitRequirements = res;
+        this.loading = false;
+      });
+  }
+
+  private renderCheckmark(check?: boolean) {
+    return check ? '✓' : '';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-repo-submit-requirements': GrRepoSubmitRequirements;
+  }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements_test.ts
new file mode 100644
index 0000000..f438a68
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements_test.ts
@@ -0,0 +1,106 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-repo-submit-requirements';
+import {GrRepoSubmitRequirements} from './gr-repo-submit-requirements';
+import {stubRestApi, waitEventLoop} from '../../../test/test-utils';
+import {RepoName, SubmitRequirementInfo} from '../../../types/common';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-repo-submit-requirements tests', () => {
+  let element: GrRepoSubmitRequirements;
+
+  setup(async () => {
+    element = await fixture(
+      html`<gr-repo-submit-requirements></gr-repo-submit-requirements>`
+    );
+  });
+
+  suite('submit requirements table', () => {
+    setup(() => {
+      stubRestApi('getRepoSubmitRequirements').returns(
+        Promise.resolve([
+          {
+            name: 'Verified',
+            description: 'CI result status for build and tests is passing',
+            submittability_expression:
+              'label:Verified=MAX AND -label:Verified=MIN',
+          },
+        ] as SubmitRequirementInfo[])
+      );
+      element.repo = 'test' as RepoName;
+    });
+
+    test('render loading', () => {
+      element.repo = 'test2' as RepoName;
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `<table class="genericList" id="list">
+          <tbody>
+            <tr class="headerRow">
+              <th class="topHeader">Name</th>
+              <th class="topHeader">Description</th>
+              <th class="topHeader">Applicability Expression</th>
+              <th class="topHeader">Submittability Expression</th>
+              <th class="topHeader">Override Expression</th>
+              <th
+                class="topHeader"
+                title="Whether override is allowed in child projects"
+              >
+                Allow Override
+              </th>
+            </tr>
+          </tbody>
+          <tbody id="submit-requirements">
+            <tr id="loadingContainer">
+              <td>Loading...</td>
+            </tr>
+          </tbody>
+        </table>`
+      );
+    });
+
+    test('render', async () => {
+      await waitEventLoop();
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <table class="genericList" id="list">
+            <tbody>
+              <tr class="headerRow">
+                <th class="topHeader">Name</th>
+                <th class="topHeader">Description</th>
+                <th class="topHeader">Applicability Expression</th>
+                <th class="topHeader">Submittability Expression</th>
+                <th class="topHeader">Override Expression</th>
+                <th
+                  class="topHeader"
+                  title="Whether override is allowed in child projects"
+                >
+                  Allow Override
+                </th>
+              </tr>
+            </tbody>
+            <tbody id="submit-requirements">
+              <tr class="table">
+                <td class="name">Verified</td>
+                <td class="desc">
+                  CI result status for build and tests is passing
+                </td>
+                <td class="applicability"></td>
+                <td class="submittability">
+                  label:Verified=MAX AND -label:Verified=MIN
+                </td>
+                <td class="override"></td>
+                <td class="allowOverride"></td>
+              </tr>
+            </tbody>
+          </table>
+        `
+      );
+    });
+  });
+});
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 ae254be..a26d37f8f 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
@@ -1670,7 +1670,7 @@
           let expectedMsg =
             'Revert submission 199 0' +
             '\n\n' +
-            'Reason for revert: <INSERT REASONING HERE>' +
+            'Reason for revert: <MUST SPECIFY REASON HERE>' +
             '\n\n' +
             'Reverted changes: /q/submissionid:199+0\n';
           assert.equal(confirmRevertDialog.message, expectedMsg);
@@ -1683,7 +1683,7 @@
           expectedMsg =
             'Revert "random commit message"\n\nThis reverts ' +
             'commit 2000.\n\nReason' +
-            ' for revert: <INSERT REASONING HERE>\n';
+            ' for revert: <MUST SPECIFY REASON HERE>\n';
           assert.equal(confirmRevertDialog.message, expectedMsg);
         });
 
@@ -1733,13 +1733,13 @@
           const revertSubmissionMsg =
             'Revert submission 199 0' +
             '\n\n' +
-            'Reason for revert: <INSERT REASONING HERE>' +
+            'Reason for revert: <MUST SPECIFY REASON HERE>' +
             '\n\n' +
             'Reverted changes: /q/submissionid:199+0\n';
           const singleChangeMsg =
             'Revert "random commit message"\n\nThis reverts ' +
             'commit 2000.\n\nReason' +
-            ' for revert: <INSERT REASONING HERE>\n';
+            ' for revert: <MUST SPECIFY REASON HERE>\n';
           assert.equal(confirmRevertDialog.message, revertSubmissionMsg);
           const newRevertMsg = revertSubmissionMsg + 'random';
           const newSingleChangeMsg = singleChangeMsg + 'random';
@@ -1833,7 +1833,7 @@
           const msg =
             'Revert "random commit message"\n\n' +
             'This reverts commit 2000.\n\nReason ' +
-            'for revert: <INSERT REASONING HERE>\n';
+            'for revert: <MUST SPECIFY REASON HERE>\n';
           assert.equal(confirmRevertDialog.message, msg);
           let editedMsg = msg + 'hello';
           confirmRevertDialog.message += 'hello';
@@ -1852,10 +1852,10 @@
           // Contains generic template reason so doesn't submit
           assert.isFalse(fireActionStub.called);
           confirmRevertDialog.message = confirmRevertDialog.message.replace(
-            '<INSERT REASONING HERE>',
+            '<MUST SPECIFY REASON HERE>',
             ''
           );
-          editedMsg = editedMsg.replace('<INSERT REASONING HERE>', '');
+          editedMsg = editedMsg.replace('<MUST SPECIFY REASON HERE>', '');
           confirmButton.click();
           await element.updateComplete;
           assert.equal(fireActionStub.getCall(0).args[0], '/revert');
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 fb0b975..dec8def 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
@@ -563,7 +563,15 @@
     subscribe(
       this,
       () => this.getViewModel().tab$,
-      t => (this.activeTab = t)
+      t => {
+        // We don't want to jump during the initial page load. This scrolling
+        // works only when change is already loaded and user clicks on link
+        // to same page when tabs are not in view, but tabs needs to be rendered.
+        if (this.tabs) {
+          this.tabs.scrollIntoView({block: 'nearest'});
+        }
+        this.activeTab = t;
+      }
     );
     subscribe(
       this,
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 3d69471..59ba256 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,9 +24,10 @@
 import {ParsedChangeInfo} from '../../../types/types';
 import {formStyles} from '../../../styles/form-styles';
 import {GrValidationOptions} from '../gr-validation-options/gr-validation-options';
+import {parseCommitMessageString} from '../../../utils/commit-message-formatter-util';
 
 const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
-const INSERT_REASON_STRING = '<INSERT REASONING HERE>';
+const SPECIFY_REASON_STRING = '<MUST SPECIFY REASON HERE>';
 
 // TODO(dhruvsri): clean up repeated definitions after moving to js modules
 export enum RevertType {
@@ -230,27 +231,39 @@
     commitMessage: string,
     commitHash?: CommitId
   ) {
-    // Figure out what the revert title should be.
-    const originalTitle = (commitMessage || '').split('\n')[0];
-    let revertTitle = `Revert "${originalTitle}"`;
-    const match = originalTitle.match(/^Revert(?:\^([0-9]+))? "(.*)"$/);
-    if (match) {
-      let revertNum = 2;
-      if (match[1]) {
-        revertNum = Number(match[1]) + 1;
-      }
-      revertTitle = `Revert^${revertNum} "${match[2]}"`;
-    }
-
     if (!commitHash) {
       fireAlert(this, ERR_COMMIT_NOT_FOUND);
       return;
     }
+
+    // Figure out what the revert title should be.
+    const originalTitle = (commitMessage || '').split('\n')[0];
+    let revertTitle = `Revert "${originalTitle}"`;
+    const revertTitleRegex = originalTitle.match(
+      /^Revert(?:\^([0-9]+))? "(.*)"$/
+    );
+
+    const footers = parseCommitMessageString(commitMessage).footer.filter(
+      f => f.startsWith('Issue: ') || f.startsWith('Bug: ')
+    );
+
+    if (revertTitleRegex) {
+      let revertNum = 2;
+      if (revertTitleRegex[1]) {
+        revertNum = Number(revertTitleRegex[1]) + 1;
+      }
+      revertTitle = `Revert^${revertNum} "${revertTitleRegex[2]}"`;
+    }
+
     const revertCommitText = `This reverts commit ${commitHash}.`;
 
-    const message =
+    let message =
       `${revertTitle}\n\n${revertCommitText}\n\n` +
-      `Reason for revert: ${INSERT_REASON_STRING}\n`;
+      `Reason for revert: ${SPECIFY_REASON_STRING}\n`;
+
+    if (footers.length > 0) {
+      message += '\n' + footers.join('\n'); // Empty line before the footers begin
+    }
     // This is to give plugins a chance to update message
     this.message = this.modifyRevertMsg(change, commitMessage, message);
     this.revertType = RevertType.REVERT_SINGLE_CHANGE;
@@ -285,8 +298,7 @@
     const message =
       `Revert submission ${change.submission_id}` +
       '\n\n' +
-      'Reason for revert: <INSERT ' +
-      'REASONING HERE>\n\n' +
+      `Reason for revert: ${SPECIFY_REASON_STRING}\n\n` +
       'Reverted changes: ' +
       createSearchUrl({query: `submissionid:${change.submission_id}`}) +
       '\n';
@@ -326,7 +338,7 @@
     e.stopPropagation();
     if (
       this.message === this.originalRevertMessages[this.revertType] ||
-      this.message.includes(INSERT_REASON_STRING)
+      this.message.includes(SPECIFY_REASON_STRING)
     ) {
       this.showErrorMessage = true;
       return;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
index f4abe0c..1e5a194 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
@@ -67,7 +67,7 @@
     const expected =
       'Revert "one line commit"\n\n' +
       'This reverts commit abcd123.\n\n' +
-      'Reason for revert: <INSERT REASONING HERE>\n';
+      'Reason for revert: <MUST SPECIFY REASON HERE>\n';
     assert.equal(element.message, expected);
   });
 
@@ -81,7 +81,65 @@
     const expected =
       'Revert "many lines"\n\n' +
       'This reverts commit abcd123.\n\n' +
-      'Reason for revert: <INSERT REASONING HERE>\n';
+      'Reason for revert: <MUST SPECIFY REASON HERE>\n';
+    assert.equal(element.message, expected);
+  });
+
+  test('populateRevertSingleChangeMessage parses Issues footer from commit message', () => {
+    assert.isNotOk(element.message);
+    element.populateRevertSingleChangeMessage(
+      createParsedChange(),
+      'much lines\nvery\n\ncommit\nIssue: 1234567\nChange-Id: abcdefg\n',
+      'abcd123' as CommitId
+    );
+    const expected =
+      'Revert "much lines"\n\n' +
+      'This reverts commit abcd123.\n\n' +
+      'Reason for revert: <MUST SPECIFY REASON HERE>\n\n' +
+      'Issue: 1234567';
+    assert.equal(element.message, expected);
+  });
+
+  test('populateRevertSingleChangeMessage does not parse Issue: from commit message body', () => {
+    assert.isNotOk(element.message);
+    element.populateRevertSingleChangeMessage(
+      createParsedChange(),
+      'much lines\n\nIssue: 1234567very\n\ncommit\nChange-Id: abcdefg\n',
+      'abcd123' as CommitId
+    );
+    const expected =
+      'Revert "much lines"\n\n' +
+      'This reverts commit abcd123.\n\n' +
+      'Reason for revert: <MUST SPECIFY REASON HERE>\n';
+    assert.equal(element.message, expected);
+  });
+
+  test('populateRevertSingleChangeMessage parses Bug footer from commit message', () => {
+    assert.isNotOk(element.message);
+    element.populateRevertSingleChangeMessage(
+      createParsedChange(),
+      'much lines\nvery\n\ncommit\nBug: 1234567\nChange-Id: abcdefg\n',
+      'abcd123' as CommitId
+    );
+    const expected =
+      'Revert "much lines"\n\n' +
+      'This reverts commit abcd123.\n\n' +
+      'Reason for revert: <MUST SPECIFY REASON HERE>\n\n' +
+      'Bug: 1234567';
+    assert.equal(element.message, expected);
+  });
+
+  test('populateRevertSingleChangeMessage does not parse Bug: from commit message body', () => {
+    assert.isNotOk(element.message);
+    element.populateRevertSingleChangeMessage(
+      createParsedChange(),
+      'much lines\n\nBug: 1234567very\n\ncommit\nChange-Id: abcdefg\n',
+      'abcd123' as CommitId
+    );
+    const expected =
+      'Revert "much lines"\n\n' +
+      'This reverts commit abcd123.\n\n' +
+      'Reason for revert: <MUST SPECIFY REASON HERE>\n';
     assert.equal(element.message, expected);
   });
 
@@ -95,7 +153,8 @@
     const expected =
       'Revert "much lines"\n\n' +
       'This reverts commit abcd123.\n\n' +
-      'Reason for revert: <INSERT REASONING HERE>\n';
+      'Reason for revert: <MUST SPECIFY REASON HERE>\n\n' +
+      'Bug: Issue 42';
     assert.equal(element.message, expected);
   });
 
@@ -109,7 +168,7 @@
     const expected =
       'Revert^2 "one line commit"\n\n' +
       'This reverts commit abcd123.\n\n' +
-      'Reason for revert: <INSERT REASONING HERE>\n';
+      'Reason for revert: <MUST SPECIFY REASON HERE>\n';
     assert.equal(element.message, expected);
   });
 
@@ -123,7 +182,7 @@
     const expected =
       'Revert^3 "one line commit"\n\n' +
       'This reverts commit abcd123.\n\n' +
-      'Reason for revert: <INSERT REASONING HERE>\n';
+      'Reason for revert: <MUST SPECIFY REASON HERE>\n';
     assert.equal(element.message, expected);
   });
 
@@ -140,7 +199,7 @@
 
     const expected =
       'Revert submission 5545\n\n' +
-      'Reason for revert: <INSERT REASONING HERE>\n\n' +
+      'Reason for revert: <MUST SPECIFY REASON HERE>\n\n' +
       'Reverted changes: /q/submissionid:5545\n';
     assert.equal(element.message, expected);
   });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 6a72d41..200db91 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -626,6 +626,11 @@
       this.isOwner &&
       // without fixes
       !this.result?.fixes?.length &&
+      // disable on codepointer with range pointing to 0 line
+      this.result?.codePointers?.[0]?.range.start_line !== 0 &&
+      this.result?.codePointers?.[0]?.range.end_line !== 0 &&
+      this.result?.category !== Category.SUCCESS &&
+      this.result?.category !== Category.INFO &&
       !fixAction
     ) {
       getAiFixAction = createGetAiFixAction(this);
@@ -725,6 +730,8 @@
       fireAlert(this, 'No suitable AI fix could be found');
       return;
     }
+    suggestion.description =
+      ReportSource.GET_AI_FIX_FOR_CHECK + ' ' + suggestion.description;
     this.suggestion = suggestion;
     this.toggleExpanded(/* setExpanded= */ true);
   }
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
index 5a79b8d..0da5336 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
@@ -18,7 +18,7 @@
 import './gr-checks-results';
 import './gr-hovercard-run';
 import {fontStyles} from '../../styles/gr-font-styles';
-import {Action} from '../../api/checks';
+import {Action, Category} from '../../api/checks';
 import {assertIsDefined} from '../../utils/common-util';
 import {resolve} from '../../models/dependency';
 import {commentsModelToken} from '../../models/comments/comments-model';
@@ -298,6 +298,19 @@
     if (this.result?.fixes?.length) {
       return false;
     }
+    if (
+      this.result?.category === Category.SUCCESS ||
+      this.result?.category === Category.INFO
+    ) {
+      return false;
+    }
+    // disable on codepointer with range pointing to 0 line
+    if (
+      this.result?.codePointers?.[0]?.range.start_line === 0 ||
+      this.result?.codePointers?.[0]?.range.end_line === 0
+    ) {
+      return false;
+    }
     return this.isOwner;
   }
 
@@ -383,6 +396,8 @@
       fireAlert(this, 'No suitable AI fix could be found');
       return;
     }
+    suggestion.description =
+      ReportSource.GET_AI_FIX_FOR_CHECK + ' ' + suggestion.description;
     this.suggestion = suggestion;
     this.isExpanded = true;
   }
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index 1528374..9f87d99 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -167,6 +167,8 @@
   // Matches /admin/repos/<repos>,access.
   REPO_DASHBOARDS: /^\/admin\/repos\/(.+),dashboards$/,
 
+  REPO_SUBMIT_REQUIREMENTS: /^\/admin\/repos\/(.+),submit-requirements$/,
+
   // Matches /admin/plugins with optional filter and offset.
   PLUGIN_LIST: /^\/admin\/plugins\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
   // Matches /admin/groups with optional filter and offset.
@@ -786,6 +788,12 @@
       ctx => this.handleRepoDashboardsRoute(ctx)
     );
 
+    this.mapRoute(
+      RoutePattern.REPO_SUBMIT_REQUIREMENTS,
+      'handleRepoSubmitRequirementsRoute',
+      ctx => this.handleRepoSubmitRequirementsRoute(ctx)
+    );
+
     this.mapRoute(RoutePattern.BRANCH_LIST, 'handleBranchListRoute', ctx =>
       this.handleBranchListRoute(ctx)
     );
@@ -1215,6 +1223,19 @@
     this.reporting.setRepoName(repo);
   }
 
+  handleRepoSubmitRequirementsRoute(ctx: PageContext) {
+    const repo = ctx.params[0] as RepoName;
+    const state: RepoViewState = {
+      view: GerritView.REPO,
+      detail: RepoDetailView.SUBMIT_REQUIREMENTS,
+      repo,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.repoViewModel.setState(state);
+    this.reporting.setRepoName(repo);
+  }
+
   handleBranchListRoute(ctx: PageContext) {
     const state: RepoViewState = {
       view: GerritView.REPO,
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
index 6d61f85..81b1616 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -191,6 +191,7 @@
       'handleRepoGeneralRoute',
       'handleRepoListRoute',
       'handleRepoRoute',
+      'handleRepoSubmitRequirementsRoute',
       'handleQueryLegacySuffixRoute',
       'handleQueryRoute',
       'handleRegisterRoute',
@@ -727,6 +728,15 @@
         });
       });
 
+      test('REPO_SUBMIT_REQUIREMENTS', async () => {
+        // REPO_SUBMIT_REQUIREMENTS: /^\/admin\/repos\/(.+),submit-requirements$/,
+        await checkUrlToState('/admin/repos/4321,submit-requirements', {
+          ...createRepoViewState(),
+          detail: RepoDetailView.SUBMIT_REQUIREMENTS,
+          repo: '4321' as RepoName,
+        });
+      });
+
       test('BRANCH_LIST', async () => {
         await checkUrlToState('/admin/repos/4321,branches', {
           ...createRepoBranchesViewState(),
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index 77b7e41..2b3be7f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -64,6 +64,7 @@
   LineNumber,
   LineSelectedEventDetail,
   LOST,
+  RangeSelectedEventDetail,
   RenderPreferences,
 } from '../../../api/diff';
 import {resolve} from '../../../models/dependency';
@@ -114,6 +115,7 @@
     // prettier-ignore
     'render': CustomEvent<{}>;
     'create-comment': CustomEvent<CreateCommentEventDetail>;
+    'range-selected-mouse-up': CustomEvent<RangeSelectedEventDetail>;
     'is-blame-loaded-changed': ValueChangedEvent<boolean>;
     'diff-changed': ValueChangedEvent<DiffInfo | undefined>;
     'edit-weblinks-changed': ValueChangedEvent<WebLinkInfo[] | undefined>;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index b08e9a5..89b2a6e 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -625,22 +625,6 @@
           ${
             this.unresolved
               ? html`
-                  <gr-button
-                    id="ackBtn"
-                    link
-                    class="action ack"
-                    ?disabled=${this.saving}
-                    @click=${this.handleCommentAck}
-                    >Ack</gr-button
-                  >
-                  <gr-button
-                    id="doneBtn"
-                    link
-                    class="action done"
-                    ?disabled=${this.saving}
-                    @click=${this.handleCommentDone}
-                    >Done</gr-button
-                  >
                   ${this.shouldShowAIFixButton()
                     ? html`
                         <gr-button
@@ -657,6 +641,22 @@
                         >
                       `
                     : nothing}
+                  <gr-button
+                    id="ackBtn"
+                    link
+                    class="action ack"
+                    ?disabled=${this.saving}
+                    @click=${this.handleCommentAck}
+                    >Ack</gr-button
+                  >
+                  <gr-button
+                    id="doneBtn"
+                    link
+                    class="action done"
+                    ?disabled=${this.saving}
+                    @click=${this.handleCommentDone}
+                    >Done</gr-button
+                  >
                 `
               : ''
           }
@@ -1014,6 +1014,9 @@
       fireAlert(this, 'No suitable AI fix could be found');
       return;
     }
+    // Description is used to identify the suggestion source in logs.
+    suggestion.description =
+      ReportSource.GET_AI_FIX_FOR_COMMENT + ' ' + suggestion.description;
     this.suggestion = suggestion;
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index 44f4f84..461928e 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -662,6 +662,16 @@
                   </gr-button>
                   <gr-button
                     aria-disabled="false"
+                    class="action ai-fix"
+                    id="aiFixBtn"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Get AI Fix
+                  </gr-button>
+                  <gr-button
+                    aria-disabled="false"
                     class="action ack"
                     id="ackBtn"
                     link=""
@@ -680,16 +690,6 @@
                   >
                     Done
                   </gr-button>
-                  <gr-button
-                    aria-disabled="false"
-                    class="action ai-fix"
-                    id="aiFixBtn"
-                    link=""
-                    role="button"
-                    tabindex="0"
-                  >
-                    Get AI Fix
-                  </gr-button>
                   <gr-icon
                     icon="link"
                     class="copy link-icon"
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts
index d6a01ab..c8365c0 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts
@@ -99,6 +99,16 @@
       );
     });
 
+    test('Within one week', async () => {
+      await testDates(
+        '2015-07-22 03:34:14.985000000',
+        '2015-07-29 02:25:14.985000000',
+        'Jul 29',
+        'Jul 29 02:25',
+        'Wednesday, Jul 29, 2015, 02:25:14'
+      );
+    });
+
     test('More than 24 hours but less than six months', async () => {
       await testDates(
         '2015-07-29 20:34:14.985000000',
@@ -147,6 +157,16 @@
       );
     });
 
+    test('Within one week', async () => {
+      await testDates(
+        '2015-07-22 03:34:14.985000000',
+        '2015-07-29 02:25:14.985000000',
+        '07/29',
+        '07/29 02:25',
+        'Wednesday, 07/29/15, 02:25:14'
+      );
+    });
+
     test('More than 24 hours but less than six months', async () => {
       await testDates(
         '2015-07-29 20:34:14.985000000',
@@ -185,6 +205,16 @@
       );
     });
 
+    test('Within one week', async () => {
+      await testDates(
+        '2015-07-22 03:34:14.985000000',
+        '2015-07-29 02:25:14.985000000',
+        '07-29',
+        '07-29 02:25',
+        'Wednesday, 2015-07-29, 02:25:14'
+      );
+    });
+
     test('More than 24 hours but less than six months', async () => {
       await testDates(
         '2015-07-29 20:34:14.985000000',
@@ -223,6 +253,16 @@
       );
     });
 
+    test('Within one week', async () => {
+      await testDates(
+        '2015-07-22 03:34:14.985000000',
+        '2015-07-29 02:25:14.985000000',
+        '29. Jul',
+        '29. Jul 02:25',
+        'Wednesday, 29.07.2015, 02:25:14'
+      );
+    });
+
     test('More than 24 hours but less than six months', async () => {
       await testDates(
         '2015-07-29 20:34:14.985000000',
@@ -261,6 +301,16 @@
       );
     });
 
+    test('Within one week', async () => {
+      await testDates(
+        '2015-07-22 03:34:14.985000000',
+        '2015-07-29 02:25:14.985000000',
+        '29/07',
+        '29/07 02:25',
+        'Wednesday, 29/07/2015, 02:25:14'
+      );
+    });
+
     test('More than 24 hours but less than six months', async () => {
       await testDates(
         '2015-07-29 20:34:14.985000000',
@@ -270,6 +320,16 @@
         'Monday, 15/06/2015, 03:25:14'
       );
     });
+
+    test('More than six months', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2016-08-15 03:25:14.985000000',
+        '15/08/2016',
+        '15/08/2016 03:25',
+        'Monday, 15/08/2016, 03:25:14'
+      );
+    });
   });
 
   suite('STD + 12 hours time format preference', () => {
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 2ab8bc2..6a2df5f 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
@@ -82,6 +82,17 @@
     }
   }
 
+  async handleViewChange(view: string) {
+    await this.waitForPluginsToLoad();
+    for (const cb of this._getEventCallbacks(EventType.VIEW_CHANGE)) {
+      try {
+        cb(view);
+      } catch (err: unknown) {
+        this.reportError(err, EventType.VIEW_CHANGE);
+      }
+    }
+  }
+
   async handleShowChange(detail: ShowChangeDetail) {
     if (!detail.change) return;
     await this.waitForPluginsToLoad();
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 aa830d8..ba14fd2 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
@@ -49,6 +49,7 @@
   handleShowChange(detail: ShowChangeDetail): Promise<void>;
   handleShowRevisionActions(detail: ShowRevisionActionsDetail): void;
   handleLabelChange(detail: {change?: ParsedChangeInfo}): void;
+  handleViewChange(view?: string): void;
   modifyRevertMsg(
     change: ChangeInfo,
     revertMsg: string,
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
index 75d6a57..7eff2f6e 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -393,6 +393,17 @@
       return;
     }
 
+    const range = {
+      start_line: start.line,
+      start_character: start.column,
+      end_line: end.line,
+      end_character: end.column,
+    };
+    const side = start.side;
+    this.selectedRange = {range, side};
+    if (isMouseUp) {
+      this.diffBuilder?.diffModel.fireRangeSelectedMouseUpEvent(side, range);
+    }
     let actionBox = this.diffTable.querySelector('gr-selection-action-box');
     if (!actionBox) {
       actionBox = document.createElement('gr-selection-action-box');
@@ -403,15 +414,6 @@
     if (hoverCardText) {
       actionBox.setAttribute('hoverCardText', hoverCardText);
     }
-    this.selectedRange = {
-      range: {
-        start_line: start.line,
-        start_character: start.column,
-        end_line: end.line,
-        end_character: end.column,
-      },
-      side: start.side,
-    };
     if (start.line === end.line) {
       this.positionActionBox(actionBox, start.line, domRange);
     } else if (start.node instanceof Text) {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts b/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
index c802c0c..6678078 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
@@ -16,6 +16,7 @@
   DisplayLine,
   LineNumber,
   LineSelectedEventDetail,
+  RangeSelectedEventDetail,
   RenderPreferences,
   Side,
   SyntaxBlock,
@@ -315,4 +316,13 @@
     };
     fire(this.eventTarget, 'create-comment', detail);
   }
+
+  fireRangeSelectedMouseUpEvent(side: Side, range: CommentRange) {
+    const detail: RangeSelectedEventDetail = {
+      side,
+      lineNum: range.end_line,
+      range,
+    };
+    fire(this.eventTarget, 'range-selected-mouse-up', detail);
+  }
 }
diff --git a/polygerrit-ui/app/models/views/admin.ts b/polygerrit-ui/app/models/views/admin.ts
index 164858d..880589a 100644
--- a/polygerrit-ui/app/models/views/admin.ts
+++ b/polygerrit-ui/app/models/views/admin.ts
@@ -275,6 +275,12 @@
         detailType: RepoDetailView.DASHBOARDS,
         url: createRepoUrl({repo, detail: RepoDetailView.DASHBOARDS}),
       },
+      {
+        name: 'Submit Requirements',
+        view: GerritView.REPO,
+        detailType: RepoDetailView.SUBMIT_REQUIREMENTS,
+        url: createRepoUrl({repo, detail: RepoDetailView.SUBMIT_REQUIREMENTS}),
+      },
     ],
   };
 }
diff --git a/polygerrit-ui/app/models/views/admin_test.ts b/polygerrit-ui/app/models/views/admin_test.ts
index eab362a..675706f 100644
--- a/polygerrit-ui/app/models/views/admin_test.ts
+++ b/polygerrit-ui/app/models/views/admin_test.ts
@@ -63,7 +63,7 @@
 
     if (expected.projectPageShown) {
       assert.isOk(res.links[0].subsection);
-      assert.equal(res.links[0].subsection!.children!.length, 6);
+      assert.equal(res.links[0].subsection!.children!.length, 7);
     } else {
       assert.isNotOk(res.links[0].subsection);
     }
@@ -103,7 +103,7 @@
     }
     if (expected.projectPageShown) {
       assert.equal(res.expandedSection!.name, 'my-repo');
-      assert.equal(res.expandedSection!.children!.length, 6);
+      assert.equal(res.expandedSection!.children!.length, 7);
     } else if (expected.groupPageShown) {
       assert.equal(res.expandedSection!.name, 'my-group');
       assert.equal(
diff --git a/polygerrit-ui/app/models/views/repo.ts b/polygerrit-ui/app/models/views/repo.ts
index 1629449..ded065d 100644
--- a/polygerrit-ui/app/models/views/repo.ts
+++ b/polygerrit-ui/app/models/views/repo.ts
@@ -17,6 +17,7 @@
   COMMANDS = 'commands',
   DASHBOARDS = 'dashboards',
   TAGS = 'tags',
+  SUBMIT_REQUIREMENTS = 'submit-requirements',
 }
 
 export interface RepoViewState extends ViewState {
@@ -49,6 +50,8 @@
     url += ',commands';
   } else if (state.detail === RepoDetailView.DASHBOARDS) {
     url += ',dashboards';
+  } else if (state.detail === RepoDetailView.SUBMIT_REQUIREMENTS) {
+    url += ',submit-requirements';
   }
   return getBaseUrl() + url;
 }
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 9996c75..ab2ae52 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -109,7 +109,7 @@
 ): Map<DependencyToken<unknown>, Creator<unknown>> {
   return new Map<DependencyToken<unknown>, Creator<unknown>>([
     [authServiceToken, () => appContext.authService],
-    [routerModelToken, () => new RouterModel()],
+    [routerModelToken, () => new RouterModel(resolver(pluginLoaderToken))],
     [userModelToken, () => new UserModel(appContext.restApiService)],
     [browserModelToken, () => new BrowserModel(resolver(userModelToken))],
     [accountsModelToken, () => new AccountsModel(appContext.restApiService)],
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 6beb2aa..4601bd2 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
@@ -106,6 +106,7 @@
   DraftInfo,
   ListChangesOption,
   ReviewResult,
+  SubmitRequirementInfo,
 } from '../../types/common';
 import {
   DiffInfo,
@@ -1773,6 +1774,17 @@
     });
   }
 
+  getRepoSubmitRequirements(
+    repoName: RepoName,
+    errFn?: ErrorCallback
+  ): Promise<SubmitRequirementInfo[] | undefined> {
+    return this._restApiHelper.fetchJSON({
+      url: `/projects/${encodeURIComponent(repoName)}/submit_requirements`,
+      errFn,
+      anonymizedUrl: '/projects/*/submit_requirements',
+    }) as Promise<SubmitRequirementInfo[] | undefined>;
+  }
+
   getRepoAccessRights(
     repoName: RepoName,
     errFn?: ErrorCallback
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 8dbf1e9..ea6451a 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
@@ -91,6 +91,7 @@
   UserId,
   DraftInfo,
   ReviewResult,
+  SubmitRequirementInfo,
 } from '../../types/common';
 import {
   DiffInfo,
@@ -285,6 +286,11 @@
     errFn?: ErrorCallback
   ): Promise<string[] | undefined>;
 
+  getRepoSubmitRequirements(
+    repoName: RepoName,
+    errFn?: ErrorCallback
+  ): Promise<SubmitRequirementInfo[] | undefined>;
+
   getRepoAccessRights(
     repoName: RepoName,
     errFn?: ErrorCallback
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
index 09ba661..5506fb1 100644
--- a/polygerrit-ui/app/services/router/router-model.ts
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -7,6 +7,7 @@
 import {Model} from '../../models/base/model';
 import {select} from '../../utils/observable-util';
 import {define} from '../../models/dependency';
+import {PluginLoader} from '../../elements/shared/gr-js-api-interface/gr-plugin-loader';
 
 export enum GerritView {
   ADMIN = 'admin',
@@ -39,7 +40,12 @@
     state => state.view
   );
 
-  constructor() {
+  constructor(private readonly pluginLoader: PluginLoader) {
     super({});
+    this.subscriptions = [
+      this.routerView$.subscribe(view => {
+        this.pluginLoader.jsApiService.handleViewChange(view);
+      }),
+    ];
   }
 }
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 30bac0d..95f160d 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -60,6 +60,7 @@
   NumericChangeId,
   PreferencesInput,
   DraftInfo,
+  SubmitRequirementInfo,
 } from '../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../types/diff';
 import {
@@ -393,6 +394,9 @@
   getRepoAccess(): Promise<RepoAccessInfoMap | undefined> {
     return Promise.resolve({});
   },
+  getRepoSubmitRequirements(): Promise<SubmitRequirementInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
   getRepoAccessRights(): Promise<ProjectAccessInfo | undefined> {
     return Promise.resolve(undefined);
   },
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index c88c07b..47669f4 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -727,6 +727,19 @@
 }
 
 /**
+ * The SubmitRequirementInfo entity describes a submit requirement.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#submit-requirement-info
+ */
+export interface SubmitRequirementInfo {
+  name: string;
+  description?: string;
+  applicability_expression?: string;
+  submittability_expression: string;
+  override_expression?: string;
+  allow_override_in_child_projects: boolean;
+}
+
+/**
  * The ProjectAccessInfo entity contains information about the access rights for
  * a project.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-access.html#project-access-info
diff --git a/polygerrit-ui/app/utils/commit-message-formatter-util.ts b/polygerrit-ui/app/utils/commit-message-formatter-util.ts
index e1b3e27..02d8d2f 100644
--- a/polygerrit-ui/app/utils/commit-message-formatter-util.ts
+++ b/polygerrit-ui/app/utils/commit-message-formatter-util.ts
@@ -319,7 +319,7 @@
   return errors;
 }
 
-function parseCommitMessageString(messageString: string): CommitMessage {
+export function parseCommitMessageString(messageString: string): CommitMessage {
   const lines = messageString.split('\n');
   // Remove leading blank lines
   while (lines.length > 0 && lines[0].trim() === '') {
diff --git a/polygerrit-ui/app/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts
index d95d24a..b0222d9 100644
--- a/polygerrit-ui/app/utils/date-util.ts
+++ b/polygerrit-ui/app/utils/date-util.ts
@@ -61,7 +61,7 @@
  * Return true if date is within 24 hours and on the same day.
  */
 export function isWithinDay(now: Date, date: Date) {
-  const diff = now.valueOf() - date.valueOf();
+  const diff = Math.abs(now.valueOf() - date.valueOf());
   return diff < Duration.DAY && date.getDay() === now.getDay();
 }
 
@@ -79,7 +79,7 @@
  * Returns true if date is from one to six months.
  */
 export function isWithinHalfYear(now: Date, date: Date) {
-  const diff = now.valueOf() - date.valueOf();
+  const diff = Math.abs(now.valueOf() - date.valueOf());
   return diff < 180 * Duration.DAY;
 }
 
diff --git a/resources/com/google/gerrit/server/change/ChangeMessages.properties b/resources/com/google/gerrit/server/change/ChangeMessages.properties
deleted file mode 100644
index c78f5a3..0000000
--- a/resources/com/google/gerrit/server/change/ChangeMessages.properties
+++ /dev/null
@@ -1,13 +0,0 @@
-revertChangeDefaultMessage = Revert \"{0}\"\n\nThis reverts commit {1}.
-revertSubmissionDefaultMessage = This reverts commit {0}.
-revertSubmissionUserMessage = Revert \"{0}\"\n\n{1}
-revertSubmissionOfRevertSubmissionUserMessage = Revert^{0} \"{1}\"\n\n{2}
-
-reviewerCantSeeChange = {0} does not have permission to see this change
-reviewerInvalid = {0} is not a valid user identifier
-reviewerNotFoundUserOrGroup = {0} does not identify a registered user or group
-
-groupRemovalIsNotAllowed = Groups can't be removed from reviewers, so can't remove {0}.
-groupIsNotAllowed = The group {0} cannot be added as reviewer.
-groupHasTooManyMembers = The group {0} has too many members to add them all as reviewers.
-groupManyMembersConfirmation = The group {0} has {1} members. Do you want to add them all as reviewers?
diff --git a/resources/com/google/gerrit/server/config/CapabilityConstants.properties b/resources/com/google/gerrit/server/config/CapabilityConstants.properties
deleted file mode 100644
index 95206f9..0000000
--- a/resources/com/google/gerrit/server/config/CapabilityConstants.properties
+++ /dev/null
@@ -1,25 +0,0 @@
-accessDatabase = Access Database
-administrateServer = Administrate Server
-batchChangesLimit = Batch Changes Limit
-createAccount = Create Account
-createGroup = Create Group
-deleteGroup = Delete Group
-createProject = Create Project
-emailReviewers = Email Reviewers
-flushCaches = Flush Caches
-killTask = Kill Task
-maintainServer = Maintain Server
-modifyAccount = Modify Account
-priority = Priority
-readAs = Read As
-queryLimit = Query Limit
-runAs = Run As
-runGC = Run Garbage Collection
-streamEvents = Stream Events
-viewAccess = View Access
-viewAllAccounts = View All Accounts
-viewCaches = View Caches
-viewConnections = View Connections
-viewPlugins = View Plugins
-viewQueue = View Queue
-viewSecondaryEmails = View Secondary Emails