Merge branch 'stable-3.8' into stable-3.9

* stable-3.8:
  Add metrics for git/upload-pack/*bitmap* misses
  Add timer for git/upload-pack/phase_searching_for_*
  Fix: CommentTimestampAdapter can't parse some old comments
  Add timer for git/upload-pack/phase_negotiating
  H2CacheImpl: Lower logging level for no pruning

Release-Notes: skip
Change-Id: I3a2ffe3ea247ad757b4a629dad0e6ca6a3d9c97a
diff --git a/.bazelrc b/.bazelrc
index cf5403d..6828f9e 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -4,28 +4,30 @@
 build --disk_cache=~/.gerritcodereview/bazel-cache/cas
 
 # Builds using remotejdk_11, executes using remotejdk_11 or local_jdk
-build --java_language_version=11
-build --java_runtime_version=remotejdk_11
-build --tool_java_language_version=11
-build --tool_java_runtime_version=remotejdk_11
+build:java11 --java_language_version=11
+build:java11 --java_runtime_version=remotejdk_11
+build:java11 --tool_java_language_version=11
+build:java11 --tool_java_runtime_version=remotejdk_11
 
 # Builds using remotejdk_17, executes using remotejdk_17 or local_jdk
-build:java17 --java_language_version=17
-build:java17 --java_runtime_version=remotejdk_17
-build:java17 --tool_java_language_version=17
-build:java17 --tool_java_runtime_version=remotejdk_17
+build --java_language_version=17
+build --java_runtime_version=remotejdk_17
+build --tool_java_language_version=17
+build --tool_java_runtime_version=remotejdk_17
 
 # Builds and executes on RBE using remotejdk_11
-build:remote --java_language_version=11
-build:remote --java_runtime_version=remotejdk_11
-build:remote --tool_java_language_version=11
-build:remote --tool_java_runtime_version=remotejdk_11
+build:remote11 --java_language_version=11
+build:remote11 --java_runtime_version=remotejdk_11
+build:remote11 --tool_java_language_version=11
+build:remote11 --tool_java_runtime_version=remotejdk_11
+build:remote11 --config=remote_shared
 
 # Builds and executes on RBE using remotejdk_17
-build:remote17 --java_language_version=17
-build:remote17 --java_runtime_version=remotejdk_17
-build:remote17 --tool_java_language_version=17
-build:remote17 --tool_java_runtime_version=remotejdk_17
+build:remote --java_language_version=17
+build:remote --java_runtime_version=remotejdk_17
+build:remote --tool_java_language_version=17
+build:remote --tool_java_runtime_version=remotejdk_17
+build:remote --config=remote_shared
 
 # Enable strict_action_env flag to. For more information on this feature see
 # https://groups.google.com/forum/#!topic/bazel-discuss/_VmRfMyyHBk.
diff --git a/.bazelversion b/.bazelversion
index 6abaeb2..91e4a9f 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-6.2.0
+6.3.2
diff --git a/.gitmodules b/.gitmodules
index 6217b4d..7579477 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,7 @@
+[submodule "modules/java-prettify"]
+	path = modules/java-prettify
+	url = ../java-prettify
+
 [submodule "modules/jgit"]
 	path = modules/jgit
 	url = ../jgit
diff --git a/.mailmap b/.mailmap
deleted file mode 100644
index 721f3c0..0000000
--- a/.mailmap
+++ /dev/null
@@ -1,97 +0,0 @@
-Adrian Görler <adrian.goerler@sap.com>                                                      Adrian Goerler <adrian.goerler@sap.com>
-Ahaan Ugale <ahaanugale@gmail.com>                                                          <augale@codeaurora.org>
-Alex Blewitt <alex.blewitt@gmail.com>                                                       <alex.blewitt@gs.com>
-Alex Blewitt <alex.blewitt@gmail.com>                                                       <alex.blewitt@credit-suisse.com>
-Alex Ryazantsev <alex.ryazantsev@gmail.com>                                                 alex <alex.ryazantsev@gmail.com>
-Alex Ryazantsev <alex.ryazantsev@gmail.com>                                                 alex.ryazantsev <alex.ryazantsev@gmail.com>
-Alice Kober-Sotzek <aliceks@google.com>                                                     <aliceks@google.com>
-Alexandre Philbert <alexandre.philbert@ericsson.com>                                        <alexandre.philbert@hotmail.com>
-Andrew Bonventre <andybons@chromium.org>                                                    <andybons@google.com>
-Becky Siegel <beckysiegel@google.com>                                                       beckysiegel <beckysiegel@google.com>
-Ben Rohlfs <brohlfs@google.com>                                                             brohlfs <brohlfs@google.com>
-Brad Larson <bklarson@gmail.com>                                                            <brad.larson@garmin.com>
-Bruce Zu <bruce.zu.run10@gmail.com>                                                         <bruce.zu@sonyericsson.com>
-Bruce Zu <bruce.zu.run10@gmail.com>                                                         <bruce.zu@sonymobile.com>
-Carlos Eduardo Baldacin <carloseduardo.baldacin@sonyericsson.com>                           carloseduardo.baldacin <carloseduardo.baldacin@sonyericsson.com>
-Chad Horohoe <chorohoe@wikimedia.org>                                                       <chadh@wikimedia.org>
-Changcheng Xiao <xchangcheng@google.com>                                                    xchangcheng
-Cheng Ke <chengke.info@gmail.com>                                                           <chengke.info@gmail.com>
-Dariusz Luksza <dluksza@collab.net>                                                         <dariusz@luksza.org>
-Darrien Glasser <darrien@arista.com>                                                        darrien <darrien@arista.com>
-Dave Borowitz <dborowitz@google.com>                                                        <dborowitz@google.com>
-David Ostrovsky <david@ostrovsky.org>                                                       <d.ostrovsky@gmx.de>
-David Ostrovsky <david@ostrovsky.org>                                                       <david.ostrovsky@gmail.com>
-David Pursehouse <dpursehouse@collab.net>                                                   <david.pursehouse@sonymobile.com>
-Deniz Türkoglu <deniz@spotify.com>                                                          Deniz Türkoglu <deniz@spotify.com>
-Deniz Türkoglu <deniz@spotify.com>                                                          Deniz Turkoglu <deniz@spotify.com>
-Doug Kelly <dougk.ff7@gmail.com>                                                            <doug.kelly@garmin.com>
-Edwin Kempin <ekempin@google.com>                                                           Edwin Kempin <edwin.kempin@gmail.com>
-Edwin Kempin <ekempin@google.com>                                                           Edwin Kempin <edwin.kempin@sap.com>
-Edwin Kempin <ekempin@google.com>                                                           ekempin <ekempin@google.com>
-Eryk Szymanski <eryksz@gmail.com>                                                           <eryksz@google.com>
-Fredrik Luthander <fredrik.luthander@sonymobile.com>                                        <fredrik@gandaraj.com>
-Fredrik Luthander <fredrik.luthander@sonymobile.com>                                        <fredrik.luthander@sonyericsson.com>
-Gerrit Code Review <no-reply@gerritcodereview.com>                                          <noreply-gerritcodereview@google.com>
-Gustaf Lundh <gustaflh@axis.com>                                                            <gustaf.lundh@axis.com>
-Gustaf Lundh <gustaflh@axis.com>                                                            <gustaf.lundh@sonyericsson.com>
-Gustaf Lundh <gustaflh@axis.com>                                                            <gustaf.lundh@sonymobile.com>
-Han-Wen Nienhuys <hanwen@google.com>                                                        <hanwen@google.com>
-Hector Oswaldo Caballero <hector.caballero@ericsson.com>                                    <hector.caballero@ericsson.com>
-Hugo Arès <hugo.ares@ericsson.com>                                                          Hugo Ares <hugo.ares@ericsson.com>
-Hugo Arès <hugo.ares@ericsson.com>                                                          <hugares@gmail.com>
-Jacek Centkowski <jcentkowski@collab.net>                                                   <gemincia.programs@gmail.com>
-Jacek Centkowski <jcentkowski@collab.net>                                                   <geminica.programs@gmail.com>
-James E. Blair <jeblair@redhat.com>                                                         <jeblair@hp.com>
-Jason Huntley <jhuntley@houghtonassociates.com>                                             jhuntley <jhuntley@houghtonassociates.com>
-Jiří Engelthaler <EngyCZ@gmail.com>                                                         <engycz@gmail.com>
-Joe Onorato <onoratoj@gmail.com>                                                            <joeo@android.com>
-Joel Dodge <dodgejoel@gmail.com>                                                            dodgejoel <dodgejoel@gmail.com>
-Johan Björk <jbjoerk@gmail.com>                                                             Johan Bjork <phb@spotify.com>
-JT Olds <hello@jtolds.com>                                                                  <jtolds@gmail.com>
-Kasper Nilsson <kaspern@google.com>                                                         <kaspern@google.com>
-Lawrence Dubé <ldube@audiokinetic.com>                                                      <ldube@audiokinetic.com>
-Lei Sun <lei.sun01@sap.com>                                                                 LeiSun <lei.sun01@sap.com>
-Lincoln Oliveira Campos Do Nascimento <lincoln.oliveiracamposdonascimento@sonyericsson.com> lincoln <lincoln.oliveiracamposdonascimento@sonyericsson.com>
-Luca Milanesio <luca.milanesio@gmail.com>                                                   <luca@gitent-scm.com>
-Magnus Bäck <magnus.back@axis.com>                                                          <baeck@google.com>
-Magnus Bäck <magnus.back@axis.com>                                                          <magnus.back@sonyericsson.com>
-Marco Miller <marco.miller@ericsson.com>                                                    <marco.mmiller@gmail.com>
-Mark Derricutt <mark.derricutt@smxemail.com>                                                <mark@talios.com>
-Martin Fick <mfick@codeaurora.org>                                                          <mogulguy10@gmail.com>
-Martin Fick <mfick@codeaurora.org>                                                          <mogulguy@yahoo.com>
-Martin Wallgren <martinwa@axis.com>                                                         <martin.wallgren@axis.com>
-Matthias Sohn <matthias.sohn@sap.com>                                                       <matthias.sohn@gmail.com>
-Maxime Guerreiro <maximeg@google.com>                                                       <maximeg@google.com>
-Michael Zhou <moz@google.com>                                                               <zhoumotongxue008@gmail.com>
-Monty Taylor <mordred@inaugust.com>                                                         <monty.taylor@gmail.com>
-Mônica Dionísio <monica.dionisio@sonyericsson.com>                                          monica.dionisio <monica.dionisio@sonyericsson.com>
-Nasser Grainawi <nasser@grainawi.org>                                                       <nasser@codeaurora.org>
-Nasser Grainawi <nasser@grainawi.org>                                                       <nasserg@quicinc.com>
-Orgad Shaneh <orgads@gmail.com>                                                             <orgad.shaneh@audiocodes.com>
-Paladox <thomasmulhall410@yahoo.com>                                                        <thomasmulhall410@yahoo.com>
-Patrick Hiesel <hiesel@google.com>                                                          <hiesel@hiesel-macbookpro2.roam.corp.google.com>
-Peter Jönsson <peter.joensson@gmail.com>                                                    Peter Jönsson <peter.joensson@gmail.com>
-Rafael Rabelo Silva <rafael.rabelosilva@sonyericsson.com>                                   rafael.rabelosilva <rafael.rabelosilva@sonyericsson.com>
-Réda Housni Alaoui <reda.housnialaoui@gmail.com>                                            <alaoui.rda@gmail.com>
-Richard Möhn <richard.moehn@posteo.de>                                                      <richard.moehn@fu-berlin.de>
-Sam Saccone <samccone@google.com>                                                           <samccone@gmail.com>
-Sam Saccone <samccone@google.com>                                                           <samccone@google.com>
-Saša Živkov <sasa.zivkov@sap.com>                                                           Sasa Zivkov <sasa.zivkov@sap.com>
-Saša Živkov <sasa.zivkov@sap.com>                                                           Saša Živkov <zivkov@gmail.com>
-Saša Živkov <sasa.zivkov@sap.com>                                                           Sasa Zivkov <zivkov@gmail.com>
-Scott Dial <scott@scottdial.com>                                                            <geekmug@gmail.com>
-Shawn Pearce <sop@google.com>                                                               Shawn O. Pearce <sop@google.com>
-Sixin Li <sixin210@gmail.com>                                                               sixin li <sixin210@gmail.com>
-Sven Selberg <svense@axis.com>                                                              <sven.selberg@axis.com>
-Sven Selberg <svense@axis.com>                                                              <sven.selberg@sonymobile.com>
-Thomas Dräbing <thomas.draebing@sap.com>                                                    <thomas.draebing@sap.com>
-Tom Wang <twang10@gmail.com>                                                                Tom <twang10@gmail.com>
-Tomas Westling <thomas.westling@sonyericsson.com>                                           thomas.westling <thomas.westling@sonyericsson.com>
-Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>                                                <ulrik.sjolin@gmail.com>
-Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>                                                Ulrik Sjolin <ulrik.sjolin@gmail.com>
-Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>                                                Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>
-Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>                                                Ulrik Sjolin <ulrik.sjolin@sonyericsson.com>
-Viktar Donich <viktard@google.com>                                                          viktard
-Yuxuan 'fishy' Wang <fishywang@google.com>                                                  Yuxuan Wang <fishywang@google.com>
-Zalán Blénessy <zalanb@axis.com>                                                            Zalan Blenessy <zalanb@axis.com>
-飞 李 <lifei@7v1.net>                                                                       lifei <lifei@7v1.net>
diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs
index 09ce63b..ba37f19 100644
--- a/.settings/org.eclipse.jdt.core.prefs
+++ b/.settings/org.eclipse.jdt.core.prefs
@@ -1,4 +1,5 @@
 eclipse.preferences.version=1
+org.eclipse.jdt.core.builder.annotationPath.allLocations=disabled
 org.eclipse.jdt.core.compiler.annotation.inheritNullAnnotations=disabled
 org.eclipse.jdt.core.compiler.annotation.missingNonNullByDefaultAnnotation=ignore
 org.eclipse.jdt.core.compiler.annotation.nonnull=org.eclipse.jdt.annotation.NonNull
@@ -10,9 +11,9 @@
 org.eclipse.jdt.core.compiler.annotation.nullanalysis=disabled
 org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled
 org.eclipse.jdt.core.compiler.codegen.methodParameters=do not generate
-org.eclipse.jdt.core.compiler.codegen.targetPlatform=11
+org.eclipse.jdt.core.compiler.codegen.targetPlatform=17
 org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve
-org.eclipse.jdt.core.compiler.compliance=11
+org.eclipse.jdt.core.compiler.compliance=17
 org.eclipse.jdt.core.compiler.debug.lineNumber=generate
 org.eclipse.jdt.core.compiler.debug.localVariable=generate
 org.eclipse.jdt.core.compiler.debug.sourceFile=generate
@@ -102,7 +103,7 @@
 org.eclipse.jdt.core.compiler.problem.uncheckedTypeOperation=warning
 org.eclipse.jdt.core.compiler.problem.unclosedCloseable=warning
 org.eclipse.jdt.core.compiler.problem.undocumentedEmptyBlock=ignore
-org.eclipse.jdt.core.compiler.problem.unhandledWarningToken=warning
+org.eclipse.jdt.core.compiler.problem.unhandledWarningToken=ignore
 org.eclipse.jdt.core.compiler.problem.unlikelyCollectionMethodArgumentType=warning
 org.eclipse.jdt.core.compiler.problem.unlikelyCollectionMethodArgumentTypeStrict=disabled
 org.eclipse.jdt.core.compiler.problem.unlikelyEqualsArgumentType=warning
@@ -129,4 +130,4 @@
 org.eclipse.jdt.core.compiler.problem.varargsArgumentNeedCast=warning
 org.eclipse.jdt.core.compiler.processAnnotations=enabled
 org.eclipse.jdt.core.compiler.release=enabled
-org.eclipse.jdt.core.compiler.source=11
+org.eclipse.jdt.core.compiler.source=17
diff --git a/BUILD b/BUILD
index 984fd955..0c10d76 100644
--- a/BUILD
+++ b/BUILD
@@ -3,13 +3,6 @@
 
 package(default_visibility = ["//visibility:public"])
 
-config_setting(
-    name = "java17",
-    values = {
-        "java_language_version": "17",
-    },
-)
-
 genrule(
     name = "gen_version",
     outs = ["version.txt"],
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index cf89982..19a19dd 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -906,7 +906,7 @@
 the Work In Progress bit of the change (even without having the
 `Toggle Work In Progress state` access right assigned).
 
-Must be assigned on the target branch ref (i.e. on 'refs/heads/*', not on
+Must be assigned on the target branch ref (i.e. on 'refs/heads/\*', not on
 'refs/for/*').
 
 [[category_delete_own_changes]]
@@ -951,6 +951,20 @@
 can always edit or remove hashtags (even without having the `Edit Hashtags`
 access right assigned).
 
+
+[[category_edit_custom_keyed_values]]
+=== Edit Custom Keyed Values
+
+This category permits users to add or remove
+custom keyed values on a change that is uploaded for review. Custom Keyed Values
+are used by plugins to store extra data. They are not surfaced in the UI, unless
+a plugin explicitly does so.
+
+The change owner and site administrators can always edit or remove custom
+keyed values (even without having the `Edit Custom Keyed Values` access right
+assigned).
+
+
 [[example_roles]]
 == Examples of typical roles in a project
 
@@ -970,7 +984,7 @@
 
 Suggested access rights to grant:
 
-* xref:category_read[`Read`] on 'refs/heads/\*'
+* xref:category_read[`Read`] on 'refs/heads/*'
 * xref:category_push[`Push`] to 'refs/for/refs/heads/*'
 * link:config-labels.html#label_Code-Review[`Code-Review`] with range '-1' to '+1' for 'refs/heads/*'
 
@@ -998,7 +1012,7 @@
 
 Suggested access rights to grant:
 
-* xref:category_read[`Read`] on 'refs/heads/\*'
+* xref:category_read[`Read`] on 'refs/heads/*'
 * xref:category_push[`Push`] to 'refs/for/refs/heads/*'
 * xref:category_push_merge[`Push merge commit`] to 'refs/for/refs/heads/*'
 * xref:category_forge_author[`Forge Author Identity`] to 'refs/heads/*'
@@ -1053,7 +1067,7 @@
 
 Suggested access rights to grant, that won't block changes:
 
-* xref:category_read[`Read`] on 'refs/heads/\*'
+* xref:category_read[`Read`] on 'refs/heads/*'
 * link:config-labels.html#label_Code-Review[`Label: Code-Review`] with range '-1' to '0' for 'refs/heads/*'
 * link:config-labels.html#label_Verified[`Label: Verified`] with range '0' to '+1' for 'refs/heads/*'
 
@@ -1076,7 +1090,7 @@
 * <<examples_developer,Developer rights>>
 * <<category_push,`Push`>> to 'refs/heads/*'
 * <<category_push_merge,`Push merge commit`>> to 'refs/heads/*'
-* <<category_forge_committer,`Forge Committer Identity`>> to 'refs/for/refs/heads/*'
+* <<category_forge_committer,`Forge Committer Identity`>> to 'refs/heads/*'
 * <<category_create,`Create Reference`>> to 'refs/heads/*'
 * <<category_create_annotated,`Create Annotated Tag`>> to 'refs/tags/*'
 
diff --git a/Documentation/backend_licenses.txt b/Documentation/backend_licenses.txt
index 586406f..9ea01cf 100755
--- a/Documentation/backend_licenses.txt
+++ b/Documentation/backend_licenses.txt
@@ -1113,31 +1113,7 @@
 [[flexmark]]
 flexmark
 
-* flexmark
-* flexmark-ext-abbreviation
-* flexmark-ext-anchorlink
-* flexmark-ext-autolink
-* flexmark-ext-definition
-* flexmark-ext-emoji
-* flexmark-ext-escaped-character
-* flexmark-ext-footnotes
-* flexmark-ext-gfm-issues
-* flexmark-ext-gfm-strikethrough
-* flexmark-ext-gfm-tables
-* flexmark-ext-gfm-tasklist
-* flexmark-ext-gfm-users
-* flexmark-ext-ins
-* flexmark-ext-jekyll-front-matter
-* flexmark-ext-superscript
-* flexmark-ext-tables
-* flexmark-ext-toc
-* flexmark-ext-typographic
-* flexmark-ext-wikilink
-* flexmark-ext-yaml-front-matter
-* flexmark-formatter
-* flexmark-html-parser
-* flexmark-profile-pegdown
-* flexmark-util
+* flexmark-all-lib
 
 [[flexmark_license]]
 ----
diff --git a/Documentation/cmd-show-caches.txt b/Documentation/cmd-show-caches.txt
index 050118b..65f05b1 100644
--- a/Documentation/cmd-show-caches.txt
+++ b/Documentation/cmd-show-caches.txt
@@ -7,7 +7,6 @@
 [verse]
 --
 _ssh_ -p <port> <host> _gerrit show-caches_
-  [--gc]
   [--show-jvm]
 --
 
@@ -15,10 +14,6 @@
 Display statistics about the size and hit ratio of in-memory caches.
 
 == OPTIONS
---gc::
-	Request Java garbage collection before displaying information
-	about the Java memory heap.
-
 --show-jvm::
 	List the name and version of the Java virtual machine, host
 	operating system, and other details about the environment
diff --git a/Documentation/cmd-stream-events.txt b/Documentation/cmd-stream-events.txt
index 2456662..e34ba6a 100644
--- a/Documentation/cmd-stream-events.txt
+++ b/Documentation/cmd-stream-events.txt
@@ -207,6 +207,24 @@
 eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
 created.
 
+=== Batch Ref Updated
+
+Sent when a reference is updated in a git repository. A `batch-ref-updated` event contains all refs
+updated in a single operation. Thus, the refUpdated-field contains a list of 1 (in case of a `RefUpdate`)
+to n (in case of a `BatchRefUpdate`) ref-updates, i.e. listeners of `batch-ref-updated` events will be
+notified about every ref update and not just about batch ref updates.
+You may want to listen to individual or batch ref-updates, but not both of them. Listening to both
+`batch-ref-updates` and `ref-updates` events will cause processing the same ref updates twice.
+
+type:: "batch-ref-updated"
+
+submitter:: link:json.html#account[account attribute]
+
+refUpdates:: list of link:json.html#refUpdates[refUpdate attributes]
+
+eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
+created.
+
 === Reviewer Added
 
 Sent when a reviewer is added to a change.
diff --git a/Documentation/cmd-version.txt b/Documentation/cmd-version.txt
index cdfc779..3e53678 100644
--- a/Documentation/cmd-version.txt
+++ b/Documentation/cmd-version.txt
@@ -7,6 +7,8 @@
 [verse]
 --
 _ssh_ -p <port> <host> _gerrit version_
+  [--verbose | -v]
+  [--json]
 --
 
 == DESCRIPTION
@@ -31,6 +33,14 @@
 == SCRIPTING
 This command is intended to be used in scripts.
 
+== OPTIONS
+--verbose::
+-v::
+  Verbose output, include also the NoteDb version and the version of each index.
+
+--json::
+  Json output format. Assumes verbose output.
+
 == EXAMPLES
 
 ----
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 23455b2..fb5904b 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -886,6 +886,7 @@
 +
 If set to 0 the cache is disabled; entries are loaded but not stored
 in-memory.
+
 +
 **NOTE**: When the cache is disabled, there is no locking when accessing
 the same key/value, and therefore multiple threads may
@@ -1416,6 +1417,13 @@
 +
 The default limit is 16kiB.
 
+[[change.topicLimit]]change.topicLimit::
++
+Maximum allowed number of changes with the same topic. 0 or negative values
+mean "unlimited".
++
+By default 5,000.
+
 [[change.cumulativeCommentSizeLimit]]change.cumulativeCommentSizeLimit::
 +
 Maximum allowed size in characters of all comments (including robot comments)
@@ -1440,10 +1448,21 @@
 [[change.maxFiles]]change.maxFiles::
 +
 Maximum number of files allowed per change. Larger changes are rejected and must
-be split up.
+be split up. For merge changes we are comparing against the auto-merge commit,
+so we allow large merges, if they merge cleanly.
 +
 By default 100,000.
 
+[[change.maxFileSizeDownload]]change.maxFileSizeDownload::
++
+The link:rest-api-changes.html#get-content[GetContent] and
+link:rest-api-changes.html#get-safe-content[DownloadContent] REST APIs will
+refuse to load files larger than this limit (in bytes). 0 or negative values
+mean "unlimited".
+
++
+By default 0 (unlimited).
+
 [[change.maxPatchSets]]change.maxPatchSets::
 +
 Maximum number of patch sets allowed per change. If this is insufficient,
@@ -1545,6 +1564,21 @@
 +
 By default true.
 
+[[change.propagateSubmitRequirementErrors]]change.propagateSubmitRequirementErrors::
++
+If a SubmitRequirement evaluation for a given change results in an
+ERROR status, abort the REST response with an HTTP 500 error.
++
+The ERROR status can occur if a SubmitRequirement uses a
+plugin-provided predicate (and the plugin is not available), due to
+bugs, or due to bypassing the validation that normally happens when
+updating `refs/meta/config`.
++
+Enabling this flag  makes gerrit unusuable under such conditions, so
+it is generally not recommended. However, this makes the
+application-specific ERROR status into a generic HTTP error, and can
+thus be acted on by automated deployment and monitoring infrastructure.
+
 [[change.robotCommentSizeLimit]]change.robotCommentSizeLimit::
 +
 Maximum allowed size in characters of a robot comment. Robot comments which
@@ -1656,6 +1690,23 @@
 +
 Default is 5 minutes.
 
+[[change.diff3ConflictView]]change.diff3ConflictView::
++
+Use the diff3 formatter for merge commits with conflicts. With diff3 when
+the conflicts are shown in the "Auto Merge" view, the base section from the
+common parents will be shown as well.
+This setting takes effect when generating the automerge, which happens on upload.
+Changing the setting leaves existing changes unaffected.
++
+Default is false.
+
+[[change.maxFileSizeDiff]]change.maxFileSizeDiff::
++
+The threshold of file sizes in megabytes beyond which a
+link:rest-api-changes.html#get-diff[file diff] request will fail.
++
+If not set or set to zero, no limits are applied on file sizes.
+
 [[change.skipCurrentRulesEvaluationOnClosedChanges]]
 +
 If false, Gerrit will always take latest project configuration to
@@ -1738,6 +1789,54 @@
 link:#schedule-configuration-examples[Schedule examples] can be found
 in the link:#schedule-configuration[Schedule Configuration] section.
 
+[[attentionSet]]
+=== Section attentionSet
+
+This section allows to configure readding of change owners and schedules them to
+run periodically.
+
+[[attentionSet.readdAfter]]attentionSet.readdAfter::
++
+Period of inactivity after which open no-WIP/private changes should have change owner
+added to attention-set automatically (if they are not already).
++
+By default `0`, never readd change owner.
++
+[WARNING] Auto-readding change owners may confuse/annoy users. When
+enabling this, make sure to choose a reasonably large grace period and
+inform users in advance.
++
+The following suffixes are supported to define the time unit:
++
+* `d, day, days`
+* `w, week, weeks` (`1 week` is treated as `7 days`)
+* `mon, month, months` (`1 month` is treated as `30 days`)
+* `y, year, years` (`1 year` is treated as `365 days`)
+
+[[attentionSet.readdMessage]]attentionSet.readdMessage::
++
+Attention-set message that should be shown as reason when an change owner is readded.
++
+'${URL}' can be used as a placeholder for the Gerrit web URL.
++
+By default "Owner readded to attention-set due to inactivity, see
+${URL}Documentation/user-attention-set.html#auto-readd-owner\n\n
+If you do not want to be readded to the attention-set when the timer has counted down.
+Set this change as WIP or private.".
+
+[[attentionSet.startTime]]attentionSet.startTime::
++
+The link:#schedule-configuration-startTime[start time] for running
+readd owner to attention-set.
+
+[[attentionSet.interval]]attentionSet.interval::
++
+The link:#schedule-configuration-interval[interval] for running
+readd owner to attention-set.
+
+link:#schedule-configuration-examples[Schedule examples] can be found
+in the link:#schedule-configuration[Schedule Configuration] section.
+
 [[commentlink]]
 === Section commentlink
 
@@ -1759,7 +1858,7 @@
 ----
 [commentlink "changeid"]
   match = (I[0-9a-f]{8,40})
-  link = "#/q/$1"
+  link = "/q/$1"
 
 [commentlink "bugzilla"]
   match = "(^|\\s)(bug\\s+#?)(\\d+)($|\\s)"
@@ -2415,7 +2514,7 @@
 [gerrit]
   installIndexModule = com.google.gerrit.elasticsearch.ElasticIndexModule
 ----
-+
+
 [[gerrit.installModule]]gerrit.installModule::
 +
 Repeatable list of class name of additional Guice modules to load at
@@ -2749,6 +2848,21 @@
 [[groups]]
 === Section groups
 
+[[groups.auditLog.ignoreRecordsFromUnidentifiedUsers]]groups.auditLog.ignoreRecordsFromUnidentifiedUsers::
++
+Controls whether AuditLogReader should ignore commits created by unidentified users.
+If true, then AuditLogReader ignores commits in the refs/groups/* made by unidentified users (i.e.
+when the author of a commit can't be parsed as account id).
++
+The current version of Gerrit writes identified users as authors for new refs/groups/* commits.
+However, some old versions used a server identity as the author (e.g. "Gerrit Code Review
+<server@googlesource.com>") for such commits. Such string can't be converted to account id but
+usually the commit shouldn't be ignored.
++
+By default, false.
++
+Setting it to true may lead to some unexpected results in audit log and must be set carefully.
+
 [[groups.includeExternalUsersInRegisteredUsersGroup]]groups.includeExternalUsersInRegisteredUsersGroup::
 +
 Controls whether external users (these are users we have sufficient
@@ -3343,6 +3457,15 @@
 +
 Defaults to true.
 
+[[index.excludeProjectFromChangeReindex]]index.excludeProjectFromChangeReindex::
++
+A list of projects that will be excluded from reindexing. This can be used
+to exclude projects which are expensive to reindex to prioritize the other
+projects.
++
+Excluded projects can later be reindexed by for example using the
+link:cmd-index-changes-in-project.html[index changes in project command].
+
 [[index.paginationType]]index.paginationType::
 +
 The pagination type to use when index queries are repeated to
@@ -3376,6 +3499,15 @@
 +
 Defaults to `OFFSET`.
 
+[[index.defaultLimit]]index.defaultLimit::
++
+Default limit, if the user does not provide a limit. If this is not set or set
+to 0, then index queries are executed with the maximum permitted limit for the
+user, which may be really high and cause too much load on the index. Thus
+setting this default limit to something smaller like 100 allows you to control
+the load, while not taking away any permission from the user. If the user
+provides a limit themselves, then `defaultLimit` is ignored.
+
 [[index.maxLimit]]index.maxLimit::
 +
 Maximum limit to allow for search queries. Requesting results above this
@@ -3457,6 +3589,16 @@
 +
 Defaults to false.
 
+[[index.indexChangesAsync]]index.indexChangesAsync::
++
+On BatchUpdate, do not await indexing completion before returning the request
+to the user (WEB_BROWSER requests only).
+This has an advantage of faster UI (because indexing latency does not contribute
+to the write request latency) and disadvantage that the indexing result might not be
+immediately available after the write request.
++
+Defaults to false.
+
 [[index.scheduledIndexer]]
 ==== Subsection index.scheduledIndexer
 
@@ -3661,6 +3803,37 @@
 +
 By default, true.
 
+[[event.stream-events.enableRefUpdatedEvents]]event.stream-events.enableRefUpdatedEvents::
++
+Enable streaming of `ref-updated` event which represents a single ref update operation.
+Batch ref updates are represented as a series of `ref-updated` events.
+This allows event listeners to react on a ref update.
+Please consider switching to `batch-ref-updated` event which provides better control on grouping and
+preserving order of the ref updates.
++
+By default, true.
+
+[[event.stream-events.enableBatchRefUpdatedEvents]]event.stream-events.enableBatchRefUpdatedEvents::
++
+Enable streaming of `batch-ref-updated` event which represents group of
+refs updated during a single batch ref update operation.
+Single ref updates are also streamed as a `batch-ref-updated` events with a single ref specified.
+This allows event listeners to react on all ref updated events and disable individual `ref-updated`
+events by setting <<event.stream-events.enableRefUpdatedEvents, event.stream-events.enableRefUpdatedEvents>> to false.
++
+By default, false.
+
+[[event.stream-events.enableDraftCommentEvents]]event.stream-events.enableDraftCommentEvents::
++
+Enable streaming of `ref-updated` events for `refs/draft-comments` refs.
+Enable this flag in case listeners in your system are supposed to react on draft operations.
++
+NOTE: Due to the nature of drafts, the amount of `ref-updated` events created on draft operations could be high.
+The extra amount of events depends on the usage pattern of the installation. It is worth evaluating
+the amount of extra events produced before enabling this flag by counting the calls to the draft APIs.
++
+By default, false.
+
 [[experiments]]
 === Section experiments
 
@@ -3857,11 +4030,12 @@
 example to join given name and surname together, use the pattern
 `${givenName} ${SN}`.
 +
-If set, users will be unable to modify their full name field, as
-Gerrit will populate it only from the LDAP data.
-+
 Default is `displayName` for FreeIPA and RFC 2307 servers,
 and `${givenName} ${sn}` for Active Directory.
++
+A non-empty or default value prevents users from modifying their full
+name field.  To allow edits to the full name field, set to the empty
+string.
 
 [[ldap.accountEmailAddress]]ldap.accountEmailAddress::
 +
@@ -4000,6 +4174,9 @@
 All users must be a member of this group to allow account creation or
 authentication.
 +
+For example, setting to `ldap/gerritaccess` limits account creation or
+authentication to members of the ldap group `gerritaccess`.
++
 Setting mandatoryGroup implies enabling of `ldap.fetchMemberOfEagerly`
 +
 By default, unset.
@@ -4598,6 +4775,35 @@
 If no keys are specified, web-of-trust checks are disabled. This is the
 default behavior.
 
+[[receive.enableChangeIdLinkFooters]]receive.enableChangeIdLinkFooters::
++
+Enables a `Link` footer to be used as an alternative change ID footer.
++
+In some projects it may be desirable for the footer to contain a link to
+the Gerrit review page so that it is convenient to access the review
+page starting from the commit message. The `Link` footer is a standard
+footer used for inserting links in the commit message (e.g. used by the
+Linux kernel).
++
+This option makes Gerrit interoperate well with `Link` footers. If
+change ID `Link` footers are enabled Gerrit recognizes footers of the
+form:
++
+----
+  Link: https://<host>/id/<change-ID>
+----
++
+Example:
+----
+  Link: https://gerrit-review.googlesource.com/id/I78e884a944cedb5144f661a057e4829c8f84e933
+----
++
+For Gerrit to recognize the change ID, the part of the URL before the
+'/id/' part must match with the link:#gerrit.canonicalWebUrl[canonical
+web URL].
++
+Default is `true`.
+
 [[repository]]
 === Section repository
 
@@ -5544,18 +5750,6 @@
 +
 By default, false.
 
-[[tracing.exportPerformanceMetrics]]tracing.exportPerformanceMetrics::
-+
-Whether to export performance metrics.
-+
-Performace logged when link:#tracing.performanceLogging[`performanceLogging`] is
-enabled, can be exported as metrics.
-+
-NOTE: Since the payload returned could be of tens of thousands metrics,
-assess the latency of the metrics endpoint before enabling this option.
-+
-By default, false.
-
 [[tracing.traceid]]
 ==== Subsection tracing.<trace-id>
 
@@ -5592,12 +5786,42 @@
 should not be enabled even if they match
 link:#tracing.traceid.requestUriPattern[requestUriPattern].
 Request URIs are only available for REST requests. Request URIs never include
-the '/a' prefix.
+the '/a' prefix and don't contain the query string with the request parameters.
 +
 May be specified multiple times.
 +
 By default, unset (no request URIs are excluded).
 
+[[tracing.traceid.requestQueryStringPattern]]tracing.<trace-id>.requestQueryStringPattern::
++
+Regular expression to match request query strings for which request tracing
+should be enabled. The query string is the portion of the URL that contains
+the request parameters.
++
+May be specified multiple times.
++
+Example:
+----
+  requestQueryStringPattern = .*limit=.*
+----
++
+By default, unset (all request query strings are matched).
+
+[[tracing.traceid.headerPattern]]tracing.<trace-id>.headerPattern::
++
+Regular expression to match headers for which request tracing should be
+enabled. The regular expression is matched against the headers in the
+format '<header-name>=<header-value>'.
++
+May be specified multiple times.
++
+Example:
+----
+  requestQueryStringPattern = User-Agent=foo-.*
+----
++
+By default, unset (all headers are matched).
+
 [[tracing.traceid.account]]tracing.<trace-id>.account::
 +
 Account ID of an account for which request tracing should be always
diff --git a/Documentation/config-groups.txt b/Documentation/config-groups.txt
index 4abb223..40f64da 100644
--- a/Documentation/config-groups.txt
+++ b/Documentation/config-groups.txt
@@ -93,10 +93,12 @@
 
 == Pushing to group refs
 
-Validation on push for changes to the group ref is not implemented, so
-pushes are rejected. Pushes that bypass Gerrit should be avoided since
-the names, IDs and UUIDs must be internally consistent between all the
-branches involved. In addition, group references should not be created
+Users can push changes to `refs/for/refs/groups/*`, but submit is rejected
+for changes which update group files (i.e. group.config, members, subgroups).
+It is possible for users to upload and submit changes on the named destination
+or named query files in a group ref. Pushes that bypass Gerrit should be
+avoided since the names, IDs and UUIDs must be internally consistent between
+all the branches involved. In addition, group references should not be created
 or deleted manually either. If you attempt any of these actions
 anyway, don't forget to link:rest-api-groups.html#index-group[Index
 Group] reindex the affected groups manually.
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 25fe9f3..187cd0f 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -69,8 +69,8 @@
 
 The link:#project-section[+project+ section] appears once per project.
 
-The link:#access-section[+access+ section] appears once per reference pattern,
-such as `+refs/*+` or `+refs/heads/*+`.  Only one access section per pattern is
+The link:#access-subsection[+access+ section] appears once per reference pattern,
+such as `+refs/*+` or `+refs/heads/*+`. Only one access section per pattern is
 allowed.
 
 The link:#receive-section[+receive+ section] appears once per project.
@@ -318,7 +318,9 @@
 The submit section includes configuration of project-specific
 submit settings:
 
-[[content_merge]]submit.mergeContent::
+[[content_merge]]
+
+[[submit.mergeContent]]submit.mergeContent::
 +
 Defines whether Gerrit will try to do a content merge when a path conflict
 occurs while submitting a change.
@@ -483,7 +485,7 @@
 to the change in the web UI (see link:#submit-footers[below]).
 +
 The footers that are added are exactly the same footers that are also added by
-the link:cherry_pick[cherry pick] action. Thus, the `rebase always` action can
+the link:#cherry_pick[cherry pick] action. Thus, the `rebase always` action can
 be considered similar to the `cherry pick` action, but with the important
 distinction that `rebase always` does not ignore dependencies, which is why
 using the `rebase always` action should be preferred over the `cherry pick`
@@ -636,8 +638,17 @@
 [[access-section]]
 === Access section
 
-Each +access+ section includes a reference and access rights connected
-to groups.  Each group listed must exist in the link:#file-groups[+groups+ file].
+[[access.inheritFrom]]access.inheritFrom::
++
+Name of the parent project from which access rights are inherited.
++
+If not set, access rights are inherited from the `All-Projects` root project.
+
+[[access-subsection]]
+==== Access subsection
+
++access+ subsections for references connect access rights to groups. Each group
+listed must exist in the link:#file-groups[+groups+ file].
 
 Please refer to the
 link:access-control.html#access_categories[Access Categories]
diff --git a/Documentation/config-submit-requirements.txt b/Documentation/config-submit-requirements.txt
index fb12ff3..1bcda63 100644
--- a/Documentation/config-submit-requirements.txt
+++ b/Documentation/config-submit-requirements.txt
@@ -127,7 +127,7 @@
 link:#submit_requirement_submittable_if[submittableIf] expression evaluates to
 true or not.
 
-* `BYPASSED`
+* `FORCED`
 +
 The change was merged directly bypassing code review by supplying the
 link:user-upload.html#auto_merge[submit] push option while doing a git push.
diff --git a/Documentation/config-themes.txt b/Documentation/config-themes.txt
index 73cfc55..7cf61e4 100644
--- a/Documentation/config-themes.txt
+++ b/Documentation/config-themes.txt
@@ -41,8 +41,14 @@
 
 Static image files can also be served from `'$site_path'/static`,
 and may be referenced in `GerritSite{Header,Footer}.html`
-or `GerritSite.css` by the relative URL `static/$name`
-(e.g. `static/logo.png`).
+or `GerritSite.css`.  For example, `GerritSiteHeader.html` may
+display a company logo like so:
+
+```
+<div>
+  <img src="/static/logo.png" alt="Our Cool Logo" />
+</div>
+```
 
 To simplify security management, files are only served from
 `'$site_path'/static`.  Subdirectories are explicitly forbidden from
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 1c3fd78..314740e 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -35,8 +35,14 @@
 link:https://github.com/bazelbuild/bazelisk[Bazelisk,role=external,window=_blank] is a version
 manager for link:https://bazel.build/[Bazel,role=external,window=_blank], similar to how `nvm`
 manages `npm` versions. It takes care of downloading and installing Bazel itself, so you don't have
-to worry about using the correct version of Bazel. Bazelisk can be installed in different
-ways: link:https://docs.bazel.build/install-bazelisk.html[Install,role=external,window=_blank]
+to worry about using the correct version of Bazel. One particular advantage to
+using Bazelisk is that you can jump between different versions of Gerrit and not
+worry about which version of Bazel you need.
+
+Bazelisk can be installed in different ways:
+link:https://docs.bazel.build/install-bazelisk.html[Bazelisk Installation,role=external,window=_blank].
+To execute the correct version of Bazel using Bazelisk you simply replace
+the `bazel` command with `bazelisk`.
 
 [[java]]
 === Java
@@ -54,7 +60,7 @@
 To build Gerrit with Java 11 language level, run:
 
 ```
-  $ bazel build :release
+  $ bazelisk build --config=java11 :release
 ```
 
 [[java-17]]
@@ -63,13 +69,13 @@
 Java 17 is supported. To build Gerrit with Java 17, run:
 
 ```
-  $ bazel build --config=java17 :release
+  $ bazelisk build :release
 ```
 
 To run the tests with Java 17, run:
 
 ```
-  $ bazel test --config=java17 //...
+  $ bazelisk test //...
 ```
 
 === Node.js and npm packages
@@ -83,7 +89,7 @@
 To build the Gerrit web application:
 
 ----
-  bazel build gerrit
+  bazelisk build gerrit
 ----
 
 The output executable WAR will be placed in:
@@ -99,7 +105,7 @@
 core plugins and documentation:
 
 ----
-  bazel build release
+  bazelisk build release
 ----
 
 The output executable WAR will be placed in:
@@ -113,7 +119,7 @@
 To build Gerrit in headless mode, i.e. without the Gerrit UI:
 
 ----
-  bazel build headless
+  bazelisk build headless
 ----
 
 The output executable WAR will be placed in:
@@ -127,7 +133,7 @@
 To build the extension, plugin and acceptance-framework JAR files:
 
 ----
-  bazel build api
+  bazelisk build api
 ----
 
 The output archive that contains Java binaries, Java sources and
@@ -153,7 +159,7 @@
 === Plugins
 
 ----
-  bazel build plugins:core
+  bazelisk build plugins:core
 ----
 
 The output JAR files for individual plugins will be placed in:
@@ -171,7 +177,7 @@
 To build a specific plugin:
 
 ----
-  bazel build plugins/<name>
+  bazelisk build plugins/<name>
 ----
 
 The output JAR file will be be placed in:
@@ -216,7 +222,7 @@
 To build only the documentation for testing or static hosting:
 
 ----
-  bazel build Documentation:searchfree
+  bazelisk build Documentation:searchfree
 ----
 
 The html files will be bundled into `searchfree.zip` in this location:
@@ -241,7 +247,7 @@
 To generate HTML files skipping the zip archiving:
 
 ----
-  bazel build Documentation
+  bazelisk build Documentation
 ----
 
 And open `bazel-bin/Documentation/index.html`.
@@ -249,7 +255,7 @@
 To build the Gerrit executable WAR with the documentation included:
 
 ----
-  bazel build withdocs
+  bazelisk build withdocs
 ----
 
 The WAR file will be placed in:
@@ -261,7 +267,7 @@
 Alternatively, one can generate the documentation as flat files:
 
 ----
-  bazel build Documentation:Documentation
+  bazelisk build Documentation:Documentation
 ----
 
 The html, css, js files are placed in:
@@ -273,64 +279,23 @@
 [[tests]]
 == Running Unit Tests
 
-----
-  bazel test --build_tests_only //...
-----
-
-Debugging tests:
+Bazel BUILD files define test targets for Gerrit. You can run all declared
+test targets with:
 
 ----
-  bazel test --test_output=streamed --test_filter=com.gerrit.TestClass.testMethod testTarget
+  bazelisk test --build_tests_only //...
 ----
 
-Debug test example:
+[[testgroups]]
+=== Running Test Groups
+
+To run one or more specific labeled groups of tests:
 
 ----
-  bazel test --test_output=streamed --test_filter=com.google.gerrit.acceptance.api.change.ChangeIT.getAmbiguous //javatests/com/google/gerrit/acceptance/api/change:api_change
+  bazelisk test --test_tag_filters=api,git //...
 ----
 
-To run a specific test group, e.g. the rest-account test group:
-
-----
-  bazel test //javatests/com/google/gerrit/acceptance/rest/account:rest_account
-----
-
-To run only tests that do not use SSH:
-
-----
-  bazel test --test_env=GERRIT_USE_SSH=NO //...
-----
-
-To exclude tests that have been marked as flaky:
-
-----
-  bazel test --test_tag_filters=-flaky //...
-----
-
-To exclude tests that require very recent git client version:
-
-----
-  bazel test --test_tag_filters=-git-protocol-v2 //...
-----
-
-To ignore cached test results:
-
-----
-  bazel test --cache_test_results=NO //...
-----
-
-To run one or more specific groups of tests:
-
-----
-  bazel test --test_tag_filters=api,git //...
-----
-
-To run the tests against a specific index backend (LUCENE, FAKE):
-----
-  bazel test --test_env=GERRIT_INDEX_TYPE=LUCENE //...
-----
-
-The following values are currently supported for the group name:
+The following label values are currently supported for the group name:
 
 * annotation
 * api
@@ -344,11 +309,90 @@
 * server
 * ssh
 
+We can also select tests within a specific BUILD target group. For example
+`javatests/com/google/gerrit/acceptance/rest/account/BUILD` declares a
+rest_account test target group:
+
+----
+  bazelisk test //javatests/com/google/gerrit/acceptance/rest/account:rest_account
+----
+
+[[debugtests]]
+=== Debugging Tests
+
+To debug specific tests you will need to select the test target containing
+that test then use `--test_filter` to select the specific test you want.
+This `--test_filter` is a regex and can be used to select multiple tests
+out of the target:
+
+----
+  bazelisk test --test_output=streamed --test_filter=com.gerrit.TestClass.testMethod testTarget
+----
+
+For example `javatests/com/google/gerrit/acceptance/api/change/BUILD`
+defines a test target group for every `*IT.java` file in the directory.
+We can execute the single `getAmbiguous()` test found in ChangeIT.java using
+this `--test_filter` and target:
+
+----
+  bazelisk test --test_output=streamed \
+    --test_filter=com.google.gerrit.acceptance.api.change.ChangeIT.getAmbiguous \
+    //javatests/com/google/gerrit/acceptance/api/change:ChangeIT
+----
+
+[[additionaltestfiltering]]
+=== Additional Test Filtering
+
+To run only tests that do not use SSH:
+
+----
+  bazelisk test --test_env=GERRIT_USE_SSH=NO //...
+----
+
+To exclude tests that have been marked as flaky:
+
+----
+  bazelisk test --test_tag_filters=-flaky //...
+----
+
+To exclude tests that require very recent git client version:
+
+----
+  bazelisk test --test_tag_filters=-git-protocol-v2 //...
+----
+
+To run the tests against a specific index backend (LUCENE, FAKE):
+----
+  bazelisk test --test_env=GERRIT_INDEX_TYPE=LUCENE //...
+----
+
 Bazel itself supports a multitude of ways to
-link:https://docs.bazel.build/versions/master/guide.html#specifying-targets-to-build[specify targets,role=external,window=_blank]
+link:https://bazel.build/run/build#specifying-build-targets[specify targets,role=external,window=_blank]
 for fine-grained test selection that can be combined with many of the examples
 above.
 
+[[testcaching]]
+=== Test Caching
+
+By default Bazel caches test results and will not reexecute tests unless they
+or their dependencies have been modified. To ignore cached test results and
+force the tests to rerun:
+
+----
+  bazelisk test --cache_test_results=NO //...
+----
+
+[[plugintests]]
+=== Running Plugin Tests
+
+Running tests for Gerrit plugins follows the process above. From within the
+Gerrit project root with the desired plugins checked out into `plugins/` we
+execute Bazel with the appropriate target:
+
+----
+  bazelisk test //plugins/replication/...
+----
+
 [[debugging-tests]]
 == Debugging Unit Tests
 In some cases it may be necessary to debug a test while running it in bazel. For example, when we
@@ -358,7 +402,7 @@
 Example:
 [source,bash]
 ----
-  bazel test --java_debug --test_tag_filters=delete-project //...
+  bazelisk test --java_debug --test_tag_filters=delete-project //...
   ...
   Listening for transport dt_socket at address: 5005
   ...
@@ -377,9 +421,9 @@
 `GERRIT_LOG_LEVEL=debug` environment variable:
 
 ----
-  bazel test --test_filter=com.google.gerrit.server.notedb.ChangeNotesTest \
-  --test_env=GERRIT_LOG_LEVEL=debug \
-  javatests/com/google/gerrit/server:server_tests
+  bazelisk test --test_filter=com.google.gerrit.server.notedb.ChangeNotesTest \
+    --test_env=GERRIT_LOG_LEVEL=debug \
+    javatests/com/google/gerrit/server:server_tests
 ----
 
 The log results can be found in:
@@ -393,7 +437,7 @@
 subsequent builds to run without network access:
 
 ----
-  bazel fetch //...
+  bazelisk fetch //...
 ----
 
 When downloading from behind a proxy (which is common in some corporate
@@ -498,7 +542,7 @@
 
 The `downloaded-artifacts` cache can be relocated by setting the
 `GERRIT_CACHE_HOME` environment variable. The other two can be adjusted with
-`bazel build` options `--repository_cache` and `--disk_cache` respectively.
+`bazelisk build` options `--repository_cache` and `--disk_cache` respectively.
 
 Currently none of these caches have a maximum size limit. See
 link:https://github.com/bazelbuild/bazel/issues/5139[this bazel issue,role=external,window=_blank] for
@@ -560,7 +604,7 @@
 ----
 # Add to ui_npm. Other packages.json can be updated in the same way
 cd $gerrit_repo/polygerrit-ui/app
-bazel run @nodejs//:yarn add $package
+bazelisk run @nodejs//:yarn add $package
 ----
 
 Update the `polygerrit-ui/app/node_modules_licenses/licenses.ts` file. You should add licenses
@@ -590,7 +634,7 @@
 === Update NPM Binaries
 To update a NPM binary the same actions as for a new one must be done (check licenses,
 update `licenses.ts` file, etc...). The only difference is a command to install a package: instead
-of `bazel run @nodejs//:yarn add $package` you should run the `bazel run @nodejs//:yarn upgrade ...`
+of `bazelisk run @nodejs//:yarn add $package` you should run the `bazelisk run @nodejs//:yarn upgrade ...`
 command with correct arguments. You can find the list of arguments in the
 link:https://classic.yarnpkg.com/en/docs/cli/upgrade/[yarn upgrade doc,role=external,window=_blank].
 
@@ -657,7 +701,7 @@
 To use RBE, execute
 
 ```
-bazel test --config=remote \
+bazelisk test --config=remote \
     --remote_instance_name=projects/${PROJECT}/instances/default_instance \
     javatests/...
 ```
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 3c4e9ea..42edc1f 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -127,6 +127,74 @@
 In this runtime, only the module designated by `Gerrit-BatchModule` is
 enabled, not `Gerrit-SysModule`.
 
+=== Cross-Plugin communication
+
+A plugin can optionally declare an API to be used by other plugins.
+
+---
+Gerrit-ApiModule: tld.example.project.APIClassName
+---
+
+Notably, when injecting 'DynamicItem,' 'DynamicSet,' or 'DynamicMap' defined by
+the API, a plugin can register new concrete implementations or replace existing
+ones.
+However, these are not the only classes available for consumption; API plugins
+can also define and provide interfaces and concrete classes for other plugins.
+
+This enables plugins to influence other plugins by customizing or extending the
+their behaviour.
+
+*Gotchas and Limitations*:
+
+- A `plugin A` depending on a `plugin B` (declaring a `Gerrit-ApiModule`),
+  should include `plugin B` as a `neverlink` library in its BUILD file, as
+  follows:
+
+  ```
+  java_library(
+      name = "gerrit-pluginB-neverlink",
+      neverlink = True,
+      exports = ["//plugins/pluginB"],
+  )
+  ```
+
+  The reason is that the `plugin B` dependency should not be included as a
+  shaded jar of the plugin: Gerrit will load the dependency dynamically at
+  runtime instead of packaging it in the consumer plugin fat jar.
+
+- Removing/renaming the plugin jar that defines the classes declared in the
+  `Gerrit-ApiModule` is currently not supported.
+  The behaviour in this case is unpredictable and depends on the specifics of
+  the classes involved.
+
+- An API plugin cannot depend on another API plugin.
+
+*Gerrit CI and Validation*:
+
+To build this as part of the gerrit-ci the plugin Api (in our example,
+`plugin B`) needs to be added as a dependency via the `extra-plugins` property.
+For example:
+
+```
+ project:
+    name: plugin-A
+    jobs:
+      - 'plugin-{name}-bazel-{branch}':
+          extra-plugins: 'plugin-B'
+          branch:
+            - master
+```
+
+Additionally, for `plugin A` to be verified by the gerrit-ci, the `Jenkinsfile`
+also needs to be inclusive of the API dependency, as such:
+
+```
+pluginPipeline(
+  formatCheckId: 'gerritforge:plugin-A-format-3852e64366bb37d13b8baf8af9b15cfd38eb9227',
+  buildCheckId: 'gerritforge:plugin-A-3852e64366bb37d13b8baf8af9b15cfd38eb9227',
+  extraPlugins: ['plugin-B'])
+```
+
 [[plugin_name]]
 === Plugin Name
 
@@ -457,6 +525,10 @@
 +
 Update of the change secondary index
 
+* `com.google.gerrit.server.extensions.events.CustomKeyedValueValidationListener`:
++
+Updates to custom keyed values
+
 * `com.google.gerrit.server.extensions.events.AccountIndexedListener`:
 +
 Update of the account secondary index
@@ -2213,15 +2285,18 @@
 @Listen
 public class MyWeblinkPlugin implements PatchSetWebLink {
   private String name = "MyLink";
-  private String placeHolderUrlProjectCommit = "http://my.tool.com/project=%s/commit=%s";
+  private String placeHolderUrlProjectCommit =
+  "http://my.tool.com/project=%s/commit=%s/changeKey=%s/numericChangeId=%s";
   private String imageUrl = "http://placehold.it/16x16.gif";
 
   @Override
   public WebLinkInfo getPatchSetWebLink(String projectName, String commit,
-   String commitMessage, String branchName) {
+   String commitMessage, String branchName, String changeKey, int
+   numericChangeId) {
     return new WebLinkInfo(name,
         imageUrl,
-        String.format(placeHolderUrlProjectCommit, project, commit));
+        String.format(placeHolderUrlProjectCommit, project, commit, changeKey,
+	numericChangeId));
   }
 }
 ----
@@ -3033,6 +3108,43 @@
 `com.google.gerrit.server.RequestListener` is an extension point that is
 invoked each time the server executes a request from a user.
 
+[[custom-keyed-values]]
+== Custom Keyed values
+
+It is possible to associate custom keyed values with a change. This is a map
+from string to string, allowing for the storage of small keys and values. An
+example would be for storing an associated workspace with the given change.
+
+As an example:
+```
+  private void setWorkspace(ChangeResource rsrc, String workspaceId)
+      throws RestApiException, UpdateException {
+    try (RefUpdateContext pluginCtx = RefUpdateContext.open(PLUGIN);
+        RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION);
+        BatchUpdate bu =
+            updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
+      SetCustomKeyedValuesOp op =
+          customKeyedValuesFactory.create(
+              new CustomKeyedValuesInput(ImmutableMap.of("workspace", workspaceId)));
+      bu.addOp(rsrc.getId(), op);
+      bu.execute();
+    }
+  }
+```
+
+These custom-keyed-values can be fetched by passing the option `o=CUSTOM_KEYED_VALUES`
+to a change details fetch.
+
+[[diff-validators]]
+== Diff Validators
+
+`com.google.gerrit.server.patch.DiffValidator` is an extension point that is
+invoked after the file diff is computed through the
+link:rest-api-changes.html#get-diff[Get Diff] REST endpoint.
+
+Implementors can write logic to validate the diff before it's returned on the
+API.
+
 == SEE ALSO
 
 * link:pg-plugin-dev.html[JavaScript Plugin API]
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
index 175a159..8b7ba4b 100644
--- a/Documentation/dev-processes.txt
+++ b/Documentation/dev-processes.txt
@@ -53,15 +53,14 @@
 === Election of non-Google steering committee members
 
 The election of the non-Google steering committee members happens once
-a year in May. Non-Google link:dev-roles.html#maintainer[maintainers]
+a year in June. Non-Google link:dev-roles.html#maintainer[maintainers]
 can nominate themselves by posting an informal application on the
 non-public mailto:gerritcodereview-community-managers@googlegroups.com[
-community manager mailing list] by end of April (deadline for 2020
-is Thu 30th of April EOD).
+community manager mailing list] when the call for nominations is sent to
+the maintainers list by a community manager.
 
 The list with all candidates will be published at the beginning of the
-voting period (for 2020 the start of the voting is planned for Mon 4th
-of May).
+voting period.
 
 Keeping the candidates private during the nomination phase and
 publishing all candidates at once only at the start of the voting
@@ -83,7 +82,7 @@
 happens by posting on the
 mailto:gerritcodereview-maintainers@googlegroups.com[maintainer mailing
 list]. The voting period is 14 calendar days from the start of the
-voting (for 2020 the voting period ends on Mon 18th May EOD).
+voting.
 
 Google maintainers do not take part in this vote, because Google
 already has dedicated seats in the steering committee (see section
diff --git a/Documentation/dev-roles.txt b/Documentation/dev-roles.txt
index f3a81e7..36fd46e 100644
--- a/Documentation/dev-roles.txt
+++ b/Documentation/dev-roles.txt
@@ -329,10 +329,10 @@
 This is a group that remains private between the individual community
 member and community managers.
 
-The community managers should be a pair or trio that shares the work:
+The community managers should be at least a pair that shares the work:
 
 * One Googler that is appointed by Google.
-* One or two non-Googlers, elected by the community if there are more
+* One or more non-Googlers, elected by the community if there are more
   than two candidates. If there is no candidate, we only have the one
   community manager from Google.
 
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-normal-edits.png b/Documentation/images/user-review-ui-side-by-side-diff-normal-edits.png
new file mode 100644
index 0000000..f89a905
--- /dev/null
+++ b/Documentation/images/user-review-ui-side-by-side-diff-normal-edits.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-rebase-edits.png b/Documentation/images/user-review-ui-side-by-side-diff-rebase-edits.png
new file mode 100644
index 0000000..7edd688
--- /dev/null
+++ b/Documentation/images/user-review-ui-side-by-side-diff-rebase-edits.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-rebase.png b/Documentation/images/user-review-ui-side-by-side-diff-rebase.png
new file mode 100644
index 0000000..3a47f8c
--- /dev/null
+++ b/Documentation/images/user-review-ui-side-by-side-diff-rebase.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-squash.png b/Documentation/images/user-review-ui-side-by-side-diff-squash.png
new file mode 100644
index 0000000..6119742
--- /dev/null
+++ b/Documentation/images/user-review-ui-side-by-side-diff-squash.png
Binary files differ
diff --git a/Documentation/intro-project-owner.txt b/Documentation/intro-project-owner.txt
index f13bc22..97b58af 100644
--- a/Documentation/intro-project-owner.txt
+++ b/Documentation/intro-project-owner.txt
@@ -125,7 +125,7 @@
 and link:access-control.html#references_magic[magic refs].
 
 Gerrit only supports tags that are reachable by any ref not owned by
-Gerrit. This includes branches (refs/heads/*) or custom ref namespaces
+Gerrit. This includes branches (refs/heads/\*) or custom ref namespaces
 (refs/my-company/*). Tagging a change ref is not supported.
 When filtering tags by visibility, Gerrit performs a reachability check
 and will present the user ony with tags that are reachable by any ref
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index 4642247..7825e050 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -282,7 +282,7 @@
 a dependency between the changes in Gerrit and each change can only be
 applied if all its predecessor are applied as well. Dependencies
 between changes can be seen from the
-link:user-review-ui.html#related-changes-tab[Related Changes] tab on
+link:user-review-ui.html#related-changes[Related Changes] tab on
 the change screen.
 
 [[watch]]
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index c032c36..8b6049e 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -285,6 +285,7 @@
 [[Lit]]
 Lit
 
+* @lit-labs/ssr-dom-shim
 * @lit/reactive-element
 * lit
 * lit-element
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 2d5ab1d..6c3c459 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -63,6 +63,7 @@
 * guice:guice-assistedinject
 * guice:guice-library
 * guice:guice-servlet
+* guice:jakarta-inject
 * guice:javax_inject
 * httpcomponents:httpclient
 * httpcomponents:httpcore
@@ -99,6 +100,7 @@
 * javaewah
 * jsr305
 * mime-util
+* roaringbitmap
 * servlet-api
 * servlet-api-without-neverlink
 * soy
@@ -1082,6 +1084,7 @@
 [[bouncycastle]]
 bouncycastle
 
+* bouncycastle:bcpg
 * bouncycastle:bcpg-neverlink
 * bouncycastle:bcpkix-neverlink
 * bouncycastle:bcprov-neverlink
@@ -1116,31 +1119,7 @@
 [[flexmark]]
 flexmark
 
-* flexmark
-* flexmark-ext-abbreviation
-* flexmark-ext-anchorlink
-* flexmark-ext-autolink
-* flexmark-ext-definition
-* flexmark-ext-emoji
-* flexmark-ext-escaped-character
-* flexmark-ext-footnotes
-* flexmark-ext-gfm-issues
-* flexmark-ext-gfm-strikethrough
-* flexmark-ext-gfm-tables
-* flexmark-ext-gfm-tasklist
-* flexmark-ext-gfm-users
-* flexmark-ext-ins
-* flexmark-ext-jekyll-front-matter
-* flexmark-ext-superscript
-* flexmark-ext-tables
-* flexmark-ext-toc
-* flexmark-ext-typographic
-* flexmark-ext-wikilink
-* flexmark-ext-yaml-front-matter
-* flexmark-formatter
-* flexmark-html-parser
-* flexmark-profile-pegdown
-* flexmark-util
+* flexmark-all-lib
 
 [[flexmark_license]]
 ----
@@ -3189,6 +3168,7 @@
 [[Lit]]
 Lit
 
+* @lit-labs/ssr-dom-shim
 * @lit/reactive-element
 * lit
 * lit-element
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 8b21ca2..df0cc42b 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -73,30 +73,6 @@
 ** `cancellation_type`:
    The cancellation type (graceful or forceful).
 
-[[performance]]
-=== Performance
-
-* `performance/operations`: Latency of performing operations
-** `operation_name`:
-   The operation that was performed.
-** `request`:
-   The request for which the operation was performed (format = '<request-type>
-   <redacted-request-uri>').
-** `plugin`:
-   The name of the plugin that performed the operation.
-* `performance/operations_count`: Number of performed operations
-** `operation_name`:
-   The operation that was performed.
-** `request`:
-   The request for which the operation was performed (format = '<request-type>
-   <redacted-request-uri>').
-** `plugin`:
-   The name of the plugin that performed the operation.
-
-Performance metrics can be enabled via the
-link:config.gerrit.html#tracing.exportPerformanceMetrics[`tracing.exportPerformanceMetrics`]
-setting.
-
 === Pushes
 
 * `receivecommits/changes`: histogram of number of changes processed in a single
@@ -303,6 +279,9 @@
    view implementation class
 * `http/server/rest_api/change_json/to_change_info_latency`: Latency for
   toChangeInfo invocations in ChangeJson.
+* `http/server/rest_api/change_json/to_change_info_latency/parent_data_computation`:
+   Latency for computing parent data information in toRevisionInfo invocations
+   in RevisionJson. See link:rest-api-changes.html#parent-info[ParentInfo].
 * `http/server/rest_api/change_json/to_change_infos_latency`: Latency for
   toChangeInfos invocations in ChangeJson.
 * `http/server/rest_api/change_json/format_query_results_latency`: Latency for
diff --git a/Documentation/pg-plugin-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
index 0429f91..04e1b96 100644
--- a/Documentation/pg-plugin-endpoints.txt
+++ b/Documentation/pg-plugin-endpoints.txt
@@ -43,6 +43,11 @@
 
 The following endpoints are available to plugins.
 
+=== auth-link
+The `auth-link` extension point is located in the top right corner of anonymous
+pages. The purpose is to improve user experience for custom OAuth providers by
+providing custom components and/or visual feedback of authentication progress.
+
 === banner
 The `banner` extension point is located at the top of all pages. The purpose
 is to allow plugins to show outage information and important announcements to
diff --git a/Documentation/repository-maintenance.txt b/Documentation/repository-maintenance.txt
index 4bf84b5..61fb6a4 100644
--- a/Documentation/repository-maintenance.txt
+++ b/Documentation/repository-maintenance.txt
@@ -106,7 +106,8 @@
 existing pack files from the `objects/pack` directory into the
 `preserved` directory right before calling the real Git command. This
 approach will then behave similarly to `jgit gc` with respect to
-preserving pack files.
+preserving pack files. An implementation is available
+link:https://gerrit.googlesource.com/gerrit/+/refs/heads/master/contrib/git-gc-preserve[here].
 
 GERRIT
 ------
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 7646777..9ecef3f 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -186,6 +186,27 @@
   }
 ----
 
+[[delete-account]]
+=== Delete Account
+--
+'DELETE /accounts/link:#account-id[\{account-id\}]'
+--
+
+Deletes the given account.
+
+Currently only supporting self deletion (regardless of the way
+link:#account-id[\{account-id\}] is provided).
+
+.Request
+----
+  DELETE /accounts/self HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
 [[get-detail]]
 === Get Account Details
 --
@@ -1293,6 +1314,7 @@
     "publish_comments_on_push": true,
     "work_in_progress_by_default": true,
     "allow_browser_notifications": true,
+    "diff_page_sidebar": "plugin-foo",
     "default_base_for_merges": "FIRST_PARENT",
     "my": [
       {
@@ -1346,6 +1368,7 @@
     "disable_keyboard_shortcuts": true,
     "disable_token_highlighting": true,
     "allow_browser_notifications": false,
+    "diff_page_sidebar": "NONE",
     "diff_view": "SIDE_BY_SIDE",
     "mute_common_path_prefixes": true,
     "my": [
@@ -2693,6 +2716,10 @@
 inline edit feature.
 |`allow_browser_notifications`  |not set if `false`|
 Whether to prompt user to enable browser notification in browser.
+|`diff_page_sidebar`            |optional|
+String indicating which sidebar should be open on the diff page. Set to "NONE"
+if no sidebars should be open. Plugin-supplied sidebars will be prefixed with
+"plugin-".
 |`my`                           ||
 The menu items of the `MY` top menu as a list of
 link:rest-api-config.html#top-menu-item-info[TopMenuItemInfo] entities.
@@ -2764,6 +2791,10 @@
 inline edit feature.
 |`allow_browser_notifications`  |not set if `false`|
 Whether to prompt user to enable browser notification in browser.
+|`diff_page_sidebar`            |optional|
+String indicating which sidebar should be open on the diff page. Set to "NONE"
+if no sidebars should be open. Plugin-supplied sidebars will be prefixed with
+"plugin-".
 |`my`                           |optional|
 The menu items of the `MY` top menu as a list of
 link:rest-api-config.html#top-menu-item-info[TopMenuItemInfo] entities.
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 92d4030..df5566f 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -161,6 +161,15 @@
 are filtered out. REST requests with the skip-visibility option are rejected when the current
 user doesn't have the ADMINISTRATE_SERVER capability.
 
+The `allow-incomplete-results` query parameter can be used. This is a boolean
+parameter that can optionally be set to true. If set, the server can tolerate
+handling faulty records when parsed from the change index, for example if a
+field contained a value of a wrong format. For faulty records, the server
+will return a canonical empty record where only the fields {project, branch,
+change_id, _number, owner} are set and the subject will be set to
+"\*\**ERROR***". All other fields will be empty.
+Note that the handling of this parameter is up to the index implementation.
+
 Clients are allowed to specify more than one query by setting the `q`
 parameter multiple times. In this case the result is an array of
 arrays, one per query in the same order the queries were given in.
@@ -379,6 +388,11 @@
   as link:#tracking-id-info[TrackingIdInfo].
 --
 
+[[custom-keyed-values]]
+--
+* `CUSTOM_KEYED_VALUES`: include the custom key-value map
+--
+
 [[star]]
 --
 * `STAR`: include the `starred` field in
@@ -386,6 +400,14 @@
    by the current user or not.
 --
 
+[[parents-data]]
+--
+* `PARENTS`: include the `parents_data` field in
+   link:#revision-info[RevisionInfo], which provides information of whether the
+   parent commit of this revision, e.g. if it's merged in the target branch
+   and whether it points to a patch-set of another change.
+--
+
 .Request
 ----
   GET /changes/?q=97&o=CURRENT_REVISION&o=CURRENT_COMMIT&o=CURRENT_FILES&o=DOWNLOAD_COMMANDS HTTP/1.0
@@ -973,6 +995,10 @@
 MergePatchSetInput and add a new patch set to the change corresponding
 to the new merge commit.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the current patch set, the same email will be used as the committer email in the
+new patch set; otherwise, the user's preferred email will be used.
+
 .Request
 ----
   POST /changes/test~master~Ic5466d107c5294414710935a8ef3b0180fb848dc/merge  HTTP/1.0
@@ -1026,6 +1052,10 @@
 
 Creates a new patch set with a new commit message.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the current patch set, the same email will be used as the committer email in the
+new patch set; otherwise, the user's preferred email will be used.
+
 The new commit message must be provided in the request body inside a
 link:#commit-message-input[CommitMessageInput] entity. If a Change-Id
 footer is specified, it must match the current Change-Id footer. If
@@ -1173,7 +1203,7 @@
 The request body does not need to include a link:#abandon-input[
 AbandonInput] entity if no review comment is added.
 
-Abandoning a change also removes all users from the link:#attention-set[attention set].
+Abandoning a change also removes all users from the link:user-attention-set.html[attention set].
 
 .Request
 ----
@@ -1301,6 +1331,10 @@
 
 Rebases a change.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the current patch set, the same email will be used as the committer email in the
+new patch set; otherwise, the user's preferred email will be used.
+
 Optionally, the parent revision can be changed to another patch set through the
 link:#rebase-input[RebaseInput] entity.
 
@@ -1427,6 +1461,10 @@
 
 Rebases an ancestry chain of changes.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the current patch set, the same email will be used as the committer email in the
+new patch set; otherwise, the user's preferred email will be used.
+
 The operated change is treated as the chain tip. All unsubmitted ancestors are rebased.
 
 Requires a linear ancestry relation (single parenting throughout the chain).
@@ -1434,6 +1472,9 @@
 Optionally, the parent revision (of the oldest ancestor to be rebased) can be changed to another
 change, revision or branch through the link:#rebase-input[RebaseInput] entity.
 
+Providing a `committer_email` through the link:#rebase-input[RebaseInput] entity is not supported
+when rebasing a chain.
+
 If the chain is outdated, i.e., there's a change that depends on an old revision of its parent, the
 result is the same as individually rebasing all outdated changes on top of their parent's latest
 revision before running the rebase chain action.
@@ -1906,10 +1947,15 @@
 
 Submits a change.
 
+If the submission results in a new patch set (due to a rebase or cherry-pick merge method), the
+committer email will remain the same as the one used in the previous commit, provided that one of
+the secondary emails associated with the user performing the operation was used as the committer
+email in the previous commit. Otherwise, the user's preferred email will be used.
+
 The request body only needs to include a link:#submit-input[
 SubmitInput] entity if submitting on behalf of another user.
 
-Submitting a change also removes all users from the link:#attention-set[attention set].
+Submitting a change also removes all users from the link:user-attention-set.html[attention set].
 
 .Request
 ----
@@ -2263,6 +2309,10 @@
 
 Creates a new patch set on a destination change from the provided patch.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the current patch set, the same email will be used as the committer email in the
+new patch set; otherwise, the user's preferred email will be used.
+
 The patch must be provided in the request body, inside a
 link:#applypatchpatchset-input[ApplyPatchPatchSetInput] entity.
 
@@ -2655,7 +2705,7 @@
 notifying *OWNER* instead of *ALL*.
 
 Marking a change work in progress also removes all users from the
-link:#attention-set[attention set].
+link:user-attention-set.html[attention set].
 
 .Request
 ----
@@ -2686,7 +2736,7 @@
 if no review comment is added.
 
 Marking a change ready for review also adds all of the reviewers of the change
-to the link:#attention-set[attention set].
+to the link:user-attention-set.html[attention set].
 
 .Request
 ----
@@ -2839,6 +2889,81 @@
   ]
 ----
 
+[[get-custom-keyed-values]]
+=== Get Custom Keyed Values
+--
+'GET /changes/link:#change-id[\{change-id\}]/custom_keyed_values'
+--
+
+Gets the custom keyed values associated with a change.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/custom_keyed_values HTTP/1.0
+----
+
+As response the change's custom keyed values are returned as a map of strings.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "key1": "value1",
+    "key2": "value2"
+  }
+----
+
+[[set-custom-keyed-values]]
+=== Set Custom Keyed Values
+--
+'POST /changes/link:#change-id[\{change-id\}]/custom_keyed_values'
+--
+
+Adds and/or removes custom keyed values from a change.
+
+The custom keyed values to add or remove must be provided in the request body
+inside a link:#custom-keyed-values-input[CustomKeyedValuesInput] entity.
+
+Note that custom keyed values are expected to be small in both key and value.
+A typical use-case would be storing the ID to some external system, in which
+case the key would be something unique to that system and the value would be
+the ID.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/custom_keyed_values HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "add" : {
+      "key1": "value1"
+    },
+    "remove" : [
+      "key2"
+    ]
+  }
+----
+
+As response the change's custom keyed values are returned as a map of strings to strings.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "key1": "value1",
+    "key3": "value3"
+  }
+----
+
+
 [[list-change-messages]]
 === List Change Messages
 --
@@ -3100,11 +3225,15 @@
 [[put-edit-file]]
 === Change file content in Change Edit
 --
-'PUT /changes/link:#change-id[\{change-id\}]/edit/path%2fto%2ffile
+'PUT /changes/link:#change-id[\{change-id\}]/edit/path%2fto%2ffile'
 --
 
 Put content of a file to a change edit.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the latest patch set, the same email will be used as the committer email in the
+new change edit commit; otherwise, the user's preferred email will be used.
+
 .Request
 ----
   PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit/foo HTTP/1.0
@@ -3151,7 +3280,7 @@
 [[post-edit]]
 === Restore file content or rename files in Change Edit
 --
-'POST /changes/link:#change-id[\{change-id\}]/edit
+'POST /changes/link:#change-id[\{change-id\}]/edit'
 --
 
 Creates empty change edit, restores file content or renames files in change
@@ -3159,6 +3288,10 @@
 link:#change-edit-input[ChangeEditInput] entity when a file within change
 edit should be restored or old and new file names to rename a file.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the latest patch set, the same email will be used as the committer email in the
+new change edit commit; otherwise, the user's preferred email will be used.
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit HTTP/1.0
@@ -3195,13 +3328,17 @@
 [[put-change-edit-message]]
 === Change commit message in Change Edit
 --
-'PUT /changes/link:#change-id[\{change-id\}]/edit:message
+'PUT /changes/link:#change-id[\{change-id\}]/edit:message'
 --
 
 Modify commit message. The request body needs to include a
 link:#change-edit-message-input[ChangeEditMessageInput]
 entity.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the latest patch set, the same email will be used as the committer email in the
+new change edit commit; otherwise, the user's preferred email will be used.
+
 .Request
 ----
   PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit:message HTTP/1.0
@@ -3230,6 +3367,10 @@
 completely. This is not the same as reverting or restoring a file to its
 previous contents.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the latest patch set, the same email will be used as the committer email in the
+new change edit commit; otherwise, the user's preferred email will be used.
+
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit/foo HTTP/1.0
@@ -3397,11 +3538,15 @@
 [[rebase-edit]]
 === Rebase Change Edit
 --
-'POST /changes/link:#change-id[\{change-id\}]/edit:rebase
+'POST /changes/link:#change-id[\{change-id\}]/edit:rebase'
 --
 
 Rebases change edit on top of latest patch set.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the latest patch set, the same email will be used as the committer email in the
+new change edit commit; otherwise, the user's preferred email will be used.
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit:rebase HTTP/1.0
@@ -4324,32 +4469,8 @@
 added as a reviewer, otherwise (if they only commented) they are added to
 the CC list.
 
-Some updates to the attention set occur here. If more than one update should
-occur, only the first update in the order of the below documentation occurs:
-
-If a user is part of remove_from_attention_set, the user will be explicitly
-removed from the attention set.
-
-If a user is part of add_to_attention_set, the user will be explicitly
-added to the attention set.
-
-If the boolean ignore_default_attention_set_rules is set to true, all
-other rules below will be ignored:
-
-The user who created the review is removed from the attention set.
-
-If the change is ready for review, the following also apply:
-
-When the uploader replies, the owner is added to the attention set.
-
-When the owner or uploader replies, all the reviewers are added to
-the attention set.
-
-When neither the owner nor the uploader replies, add the owner and the
-uploader to the attention set.
-
-Then, new reviewers are added to the attention set, and removed reviewers
-(by becoming CC) are removed from the attention set.
+Posting a review usually updates the link:user-attention-set.html[attention
+set].
 
 A review cannot be set on a change edit. Trying to post a review for a
 change edit fails with `409 Conflict`.
@@ -4677,7 +4798,7 @@
 --
 
 Submits a revision.
-Submitting a change also removes all users from the link:#attention-set[attention set].
+Submitting a change also removes all users from the link:user-attention-set.html[attention set].
 
 .Request
 ----
@@ -5511,6 +5632,10 @@
 exists and the fix refers to the current patch set, or the fix refers to the
 patch set on which the change edit is based.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the current patch set, the same email will be used as the committer email in the
+new change edit commit; otherwise, the user's preferred email will be used.
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/fixes/8f605a55_f6aa4ecc/apply HTTP/1.0
@@ -5577,6 +5702,10 @@
 application of the fixes creates a new change edit. `Apply Provided Fix` can only be applied on the current
 patchset.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the current patch set, the same email will be used as the committer email in the
+new change edit commit; otherwise, the user's preferred email will be used.
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/fix:apply HTTP/1.0
@@ -6232,6 +6361,10 @@
 
 Cherry picks a revision to a destination branch.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the original revision, the same email will be used as the committer email
+in the new patch set; otherwise, the user's preferred email will be used.
+
 To cherry pick a commit with no change-id associated with it, see
 link:rest-api-projects.html#cherry-pick-commit[CherryPickCommit].
 
@@ -6548,40 +6681,6 @@
   HTTP/1.1 204 No Content
 ----
 
-[[attention-set]]
-== Attention Set
-Attention Set is the set of users that should perform some action on the
-change. E.g, reviewers should review the change, owner/uploader should
-add a new patchset or respond to comments.
-
-Users are added to the attention set if one the following apply:
-
-* They are manually added in link:#review-input[ReviewInput] in
- add_to_attention_set.
-* They are added as reviewers.
-* The change is marked ready for review.
-* As an owner/uploader, when someone replies on your change.
-* As a reviewer, when the owner/uploader replies.
-* When the user's vote is deleted by another user.
-* The rules above (except manually adding to the attention set) don't apply
- for changes that are work in progress.
-
-Users are removed from the attention set if one the following apply:
-
-* They are manually removed in link:#review-input[ReviewInput] in
- remove_from_attention_set.
-* They are removed from reviewers.
-* The change is marked work in progress, abandoned, or submitted.
-* When the user replies on a change.
-
-If the ignore_default_attention_set_rules in link:#review-input[ReviewInput]
-is set to true, no other changes to the attention set will occur during the
-link:#set-review[set-review].
-Also, users specified in the list will occur instead of any of the implicit
-changes to the attention set. E.g, if a user is added by add_to_attention_set
-in link:#review-input[ReviewInput], but also the change is marked work in
-progress, the user will still be added.
-
 [[ids]]
 == IDs
 
@@ -6593,22 +6692,22 @@
 [[change-id]]
 === \{change-id\}
 Identifier that uniquely identifies one change. It contains the URL-encoded
-project name as well as the change number: "'$$<project>~<changeNumber>$$'"
+project name as well as the change number: "<project>~<changeNumber>"
 
 ==== Alternative identifiers
 Gerrit also supports an array of other change identifiers.
 
 [NOTE]
 Even though these identifiers will work in the majority of cases it is highly
-recommended to use "'$$<project>~<changeNumber>$$'" whenever possible.
+recommended to use "<project>~<changeNumber>" whenever possible.
 Since these identifiers require additional lookups from index and caches, to
-be translated to the "'$$<project>~<changeNumber>$$'" identifier, they
+be translated to the "<project>~<changeNumber>" identifier, they
 may result in both false-positives and false-negatives.
 Furthermore the additional lookup mean that they come with a performance penalty.
 
-* an ID of the change in the format "'$$<project>~<branch>~<Change-Id>$$'",
+* an ID of the change in the format "<project>\~<branch>~<Change-Id>",
   where for the branch the `refs/heads/` prefix can be omitted
-  ("$$myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940$$")
+  ("myProject\~master~I8473b95934b5732ac55d26311a706c9c2bde9940")
 * a Change-Id if it uniquely identifies one change
   ("I8473b95934b5732ac55d26311a706c9c2bde9940")
 * a change number if it uniquely identifies one change ("4247")
@@ -6800,6 +6899,10 @@
 caller.
 |`response_format_options`     |optional|
 List of link:#query-options[query options] to format the response.
+|`amend`              |optional|
+If true, the revision from the URL will be amended by the patch. This will use the tree of the
+revision, apply the patch and create a new commit whose tree is the resulting tree of the operation
+and whose parent(s) are the parent(s) of the revision. Cannot be used together with `base`.
 |=================================
 
 
@@ -6839,7 +6942,7 @@
 [[attention-set-info]]
 === AttentionSetInfo
 The `AttentionSetInfo` entity contains details of users that are in
-the link:#attention-set[attention set].
+the link:user-attention-set.html[attention set].
 
 [options="header",cols="1,^1,5"]
 |===========================
@@ -6858,7 +6961,7 @@
 [[attention-set-input]]
 === AttentionSetInput
 The `AttentionSetInput` entity contains details for adding users to the
-link:#attention-set[attention set] and removing them from it.
+link:user-attention-set.html[attention set] and removing them from it.
 
 [options="header",cols="1,^1,5"]
 |===========================
@@ -6926,6 +7029,9 @@
 |==================================
 |Field Name           ||Description
 |`id`                 ||
+The ID of the change. The format is "'<project>\~<_number>'".
+'project' and '_number' are URL encoded. The callers must not rely on the format.
+|`triplet_id`         ||
 The ID of the change in the format "'<project>\~<branch>~<Change-Id>'",
 where 'project' and 'branch' are URL encoded. For 'branch' the
 `refs/heads/` prefix is omitted.
@@ -6946,6 +7052,10 @@
  of the account from the attention set.
 |`hashtags`           |optional|
 List of hashtags that are set on the change.
+|`custom_keyed_values`       |optional|
+A map that maps custom keys to custom values that are tied to a specific change,
+both in the form of strings. Only set if link:#custom-keyed-values[custom keyed
+values] are requested.
 |`change_id`          ||The Change-Id of the change.
 |`subject`            ||
 The subject of the change (header line of the commit message).
@@ -6964,11 +7074,8 @@
 The user who submitted the change, as an
 link:rest-api-accounts.html#account-info[ AccountInfo] entity.
 |`starred`            |not set if `false`|
-Whether the calling user has starred this change with the default label.
+Whether the calling user has starred this change.
 Only set if link:#star[requested].
-|`stars`              |optional|
-A list of star labels that are applied by the calling user to this
-change. The labels are lexicographically sorted.
 |`reviewed`           |not set if `false`|
 Whether the change was reviewed by the calling user.
 Only set if link:#reviewed[reviewed] is requested.
@@ -7168,6 +7275,8 @@
 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.
+|`custom_keyed_values`|optional|Custom keyed values as a
+map from custom keys to values.
 |`merge`              |optional|
 The detail of a merge commit as a link:#merge-input[MergeInput] entity.
 If set, the target branch (see  `branch` field) must exist (it is not
@@ -7217,9 +7326,10 @@
 |`message`            ||
 The text left by the user or Gerrit system. Accounts are served as account IDs
 inlined in the text as `<GERRIT_ACCOUNT_18419>`.
-All accounts, used in message, can be found in `accountsInMessage`
+All accounts, used in message, can be found in `accounts_in_message`
 field.
-|`accountsInMessage`            ||Accounts, used in `message`.
+|`accounts_in_message` ||
+link:rest-api-accounts.html#account-info[AccountInfo] list, used in `message`.
 |`tag`                 |optional|
 Value of the `tag` field from link:#review-input[ReviewInput] set
 while posting the review. Votes/comments that contain `tag` with
@@ -7277,6 +7387,12 @@
 If `true`, the cherry-pick succeeds also if the created commit will be empty.
 If `false`, a cherry-pick that would create an empty commit fails without creating
 the commit.
+|`committer_email`|optional|
+Cherry-pick is committed using this email address. Only the registered emails
+of the calling user are considered valid. Defaults to source commit's committer
+email if it is a registered email of the calling user, else defaults to calling
+user's preferred email.
+
 |`validation_options`|optional|
 Map with key-value pairs that are forwarded as options to the commit validation
 listeners (e.g. can be used to skip certain validations). Which validation
@@ -7552,7 +7668,7 @@
 link:#notify-info[NotifyInfo] entity.
 |`ignore_automatic_attention_set_rules`|optional|
 If set to true, ignore all automatic attention set rules described in the
-link:#attention-set[attention set]. When not set, the default is false.
+link:user-attention-set.html[attention set]. When not set, the default is false.
 |`reason`         |optional|
 The reason why this vote is deleted. Will +
 go into the change message.
@@ -7688,6 +7804,19 @@
 === ApplyProvidedFixInput
 The `ApplyProvidedFixInput` entity contains the fixes to be applied on a review.
 
+[[custom-keyed-values-input]]
+=== CustomKeyedValuesInput
+
+The `CustomKeyedValuesInput` entity contains information about custom keyed values
+to add to, and/or remove from, a change.
+
+[options="header",cols="1,^1,5"]
+|=======================
+|Field Name||Description
+|`add`     |optional|The map of custom keyed values to be added to the change.
+|`remove`  |optional|The list of custom keys to be removed from the change.
+|=======================
+
 [options="header",cols="1,6"]
 |=======================
 |Field Name              |Description
@@ -8025,6 +8154,14 @@
 The caller needs "Forge Author" permission when using this field.
 This field does not affect the owner or the committer of the change, which will
 continue to use the identity of the caller.
+|`validation_options`   |optional|
+Map with key-value pairs that are forwarded as options to the commit validation
+listeners (e.g. can be used to skip certain validations). Which validation
+options are supported depends on the installed commit validation listeners.
+Gerrit core doesn't support any validation options, but commit 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.
 |==================================
 
 [[move-input]]
@@ -8061,6 +8198,34 @@
 identify the accounts that should be should be notified.
 |=======================
 
+[[parent-info]]
+=== ParentInfo
+The `ParentInfo` entity contains information about the parent commit of a
+patch-set.
+
+[options="header",cols="1,^1,5"]
+|=======================
+|Field Name||Description
+|`branch_name` |optional|Name of the target branch into which the parent commit
+is merged.
+|`commit_id` |optional|The commit SHA-1 of the parent commit, or null if the
+current commit is root.
+|`is_merged_in_target_branch` |optional, default to false| Set to true if the
+parent commit is merged into the target branch.
+|`change_id` |optional| If the parent commit is a patch-set of another gerrit
+change, this field will hold the change ID of the parent change. Otherwise,
+will be null.
+|`change_number` |optional|If the parent commit is a patch-set of another gerrit
+change, this field will hold the change number of the parent change. Otherwise,
+will be null.
+|`patch_set_number` |optional|If the parent commit is a patch-set of another gerrit
+change, this field will hold the patch-set number of the parent change. Otherwise,
+will be null.
+|`change_status` |optional|If the parent commit is a patch-set of another gerrit
+change, this field will hold the change status of the parent change. Otherwise,
+will be null.
+|=======================
+
 [[private-input]]
 === PrivateInput
 The `PrivateInput` entity contains information for changing the private
@@ -8161,6 +8326,9 @@
 patch set is inferred. +
 Empty string is used for rebasing directly on top of the target branch,
 which effectively breaks dependency towards a parent change.
+|`strategy`       |optional|
+The strategy of the merge, can be `recursive`, `resolve`,
+`simple-two-way-in-core`, `ours` or `theirs`, default will use project settings.
 |`allow_conflicts`      |optional, defaults to false|
 If `true`, the rebase also succeeds if there are conflicts. +
 If there are conflicts the file contents of the rebased patch set contain
@@ -8178,6 +8346,10 @@
 In addition, rebasing on behalf of the uploader is only supported for the
 current patch set of a change. +
 If the caller is the uploader this flag is ignored and a normal rebase is done.
+|`committer_email`|optional|
+Rebase is committed using this email address. Only the registered emails
+of the calling user or uploader (when `on_behalf_of_uploader` is `true`) are
+considered valid. This option is not supported when rebasing a chain.
 |`validation_options`   |optional|
 Map with key-value pairs that are forwarded as options to the commit validation
 listeners (e.g. can be used to skip certain validations). Which validation
@@ -8424,15 +8596,17 @@
 `ready` and `work_in_progress` to be true.
 |`add_to_attention_set`                |optional|
 list of link:#attention-set-input[AttentionSetInput] entities to add
-to the link:#attention-set[attention set]. Users that are not reviewers,
+to the link:user-attention-set.html[attention set]. Users that are not reviewers,
 ccs, owner, or uploader are silently ignored.
 |`remove_from_attention_set`           |optional|
 list of link:#attention-set-input[AttentionSetInput] entities to remove
-from the link:#attention-set[attention set].
+from the link:user-attention-set.html[attention set].
 |`ignore_automatic_attention_set_rules`|optional|
 If set to true, ignore all automatic attention set rules described in the
-link:#attention-set[attention set]. Updates in add_to_attention_set
+link:user-attention-set.html[attention set]. Updates in add_to_attention_set
 and remove_from_attention_set are not ignored.
+|`response_format_options`     |optional|
+List of link:#query-options[query options] to format the response.
 |============================
 
 [[review-result]]
@@ -8456,6 +8630,9 @@
 action. Not set if false.
 |`error`                  |optional|
 Error message for non-200 responses.
+|`change_info`            |optional|
+Post-update change information. Only set if `response_format_options` were
+set.
 |============================
 
 [[reviewer-info]]
@@ -8581,6 +8758,19 @@
 interface is installed.
 |`commit`      |optional|The commit of the patch set as
 link:#commit-info[CommitInfo] entity.
+|`parents_data`     |optional|
+The parent commits of this patch-set commit as a list of
+link:#parent-info[ParentInfo] entities. In each parent, we include the target
+branch name if the parent is a merged commit in the target branch. Otherwise,
+we include the change and patch-set numbers of the parent change. +
+Only set if the `PARENTS` option is set.
+|`branch`      | optional|The name of the target branch that this revision is
+set to be merged into. +
+Note that if the change is moved with the link:#move-change[Move Change]
+endpoint, this field can be different for different patchsets. For example,
+if the change is moved and a new patchset is uploaded afterwards, the
+link:#revision-info[RevisionInfo] of the previous patchset will contain the old
+`branch`, but the newer patchset will contain the new `branch`.
 |`files`       |optional|
 The files of the patch set as a map that maps the file names to
 link:#file-info[FileInfo] entities. Only set if
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index fe9b13c..ec1ac03 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -30,6 +30,32 @@
   "2.7"
 ----
 
+The `verbose` option can be used to provide a verbose version output as
+link:#version-info[VersionInfo].
+
+.Request
+----
+  GET /config/server/version?verbose HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "gerrit_version": "3.8.0",
+    "note_db_version": 185,
+    "change_index_version": 83,
+    "account_index_version": 13,
+    "project_index_version": 6,
+    "group_index_version": 10
+  }
+----
+
+
+
 [[get-info]]
 === Get Server Info
 --
@@ -695,11 +721,6 @@
 +
 Includes a JVM summary.
 
-* `gc`:
-+
-Requests a Java garbage collection before computing the information
-about the Java memory heap.
-
 .Request
 ----
   GET /config/server/summary?jvm HTTP/1.0
@@ -1845,6 +1866,8 @@
 link:config-gerrit.html#gerrit.reportBugUrl[URL to report bugs].
 |`instance_id`       |optional|
 link:config-gerrit.html#gerrit.instanceId[Short identifier for this Gerrit installation].
+|`default_branch`       |optional|
+link:config-gerrit.html#gerrit.defaultBranch[Name of the default branch to use on the project creation].
 |=================================
 
 [[index-config-info]]
@@ -1968,6 +1991,22 @@
 details.
 |=======================================
 
+[[version-info]]
+=== VersionInfo
+The `VersionInfo` entity contains information about the version of the
+Gerrit server.
+
+[options="header",cols="1,^1,5"]
+|=======================================
+|Field Name                ||Description
+|`gerrit_version`          ||Gerrit server version
+|`note_db_version`         ||NoteDb version
+|`change_index_version`    ||Change index version
+|`account_index_version`   ||Account index version
+|`project_index_version`   ||Project index version
+|`group_index_version`     ||Group index version
+|=======================================
+
 [[server-info]]
 === ServerInfo
 The `ServerInfo` entity contains information about the configuration of
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 675c054..fff9d0b 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -139,11 +139,16 @@
   )]}'
   {
     "some-project": {
-      "id": "some-project"
+      "id": "some-project",
+      _more_projects: true
     }
   }
 ----
 
+If the number of projects matching the query exceeds either the
+internal limit or a supplied `limit` query parameter, the last project
+object has a `_more_projects: true` JSON field set.
+
 
 [[suggest-projects]]
 Prefix(p)::
@@ -433,6 +438,10 @@
   GET /projects/?query=<query>&limit=25 HTTP/1.0
 ----
 
+If the number of projects matching the query exceeds either the
+internal limit or a supplied `limit` query parameter, the last project
+object has a `_more_projects: true` JSON field set.
+
 The `/projects/` URL also accepts a start integer in the `start`
 parameter. The results will skip `start` projects from project list.
 
@@ -1609,6 +1618,11 @@
 As result a list of link:#branch-info[BranchInfo] entries is
 returned.
 
+If the `limit` parameter was set and the number of branches is larger than the
+`limit`, the response header `X-GERRIT-NEXT-PAGE-TOKEN` will be set. Clients
+can pass this token with subsequent requests (using the `next-page-token`
+request parameter) for pagination to skip over previous results.
+
 .Request
 ----
   GET /projects/work%2Fmy-project/branches/ HTTP/1.0
@@ -1750,6 +1764,11 @@
   ]
 ----
 
+Next-page-token(t)::
+Skips over previous results. Cannot be set simultaneously with the `Skip`
+parameter, and also must be set to an exact token received by the server in a
+previous call, otherwise the request would fail with `400 Bad Request`.
+
 [[get-branch]]
 === Get Branch
 --
@@ -1895,6 +1914,69 @@
   Ly8gQ29weXJpZ2h0IChDKSAyMDEwIFRoZSBBbmRyb2lkIE9wZW4gU291cmNlIFByb2plY...
 ----
 
+[[suggest-reviewers]]
+=== Suggest Reviewers
+--
+'GET /projects/link:#project-name[\{project-name\}]/branches/link:#branch-id[\{branch-id\}]/suggest_reviewers?q=J&n=5'
+--
+
+Suggest the reviewers for a given query `q` and result limit `n`. If result
+limit is not passed, then the default 10 is used.
+
+This REST endpoint only suggests accounts that
+
+* are active
+* can see the branch
+* are visible to the calling user
+* are not service users (unless
+  link:config.html#suggest.skipServiceUsers[skipServiceUsers] is set to `false`)
+
+Groups can be excluded from the results by specifying the 'exclude-groups'
+request parameter:
+
+--
+'GET /changes/link:#project-name[\{project-name\}]/branches/link:#branch-id[\{branch-id\}]/suggest_reviewers?q=J&n=5&exclude-groups'
+--
+
+As result a list of link:#suggested-reviewer-info[SuggestedReviewerInfo] entries is returned.
+
+.Request
+----
+  GET /projects/myProject/branches/myBranch/suggest_reviewers?q=J HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "account": {
+        "_account_id": 1000097,
+        "name": "Jane Roe",
+        "email": "jane.roe@example.com"
+      },
+      "count": 1
+    },
+    {
+      "group": {
+        "id": "4fd581c0657268f2bdcc26699fbf9ddb76e3a279",
+        "name": "Joiner"
+      },
+      "count": 5
+    }
+  ]
+----
+
+To suggest CCs `reviewer-state=CC` can be specified as additional URL
+parameter.
+--
+'GET /changes/link:#project-name[\{project-name\}]/branches/link:#branch-id[\{branch-id\}]/suggest_reviewers?q=J&reviewer-state=CC'
+--
+
 
 [[get-mergeable-info]]
 === Get Mergeable Information
@@ -2671,6 +2753,10 @@
 
 Cherry-picks a commit of a project to a destination branch.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the original commit, the same email will be used as the committer email in the
+new patch set; otherwise, the user's preferred email will be used.
+
 To cherry pick a change revision, see link:rest-api-changes.html#cherry-pick[CherryPick].
 
 The destination branch must be provided in the request body inside a
@@ -4383,28 +4469,31 @@
 The `ProjectInfo` entity contains information about a project.
 
 [options="header",cols="1,^2,4"]
-|===========================
-|Field Name    ||Description
-|`id`          ||The URL encoded project name.
-|`name`        |
+|=============================
+|Field Name      ||Description
+|`id`            ||The URL encoded project name.
+|`name`          |
 not set if returned in a map where the project name is used as map key|
 The name of the project.
-|`parent`      |optional|
+|`parent`        |optional|
 The name of the parent project. +
 `?-<n>` if the parent project is not visible (`<n>` is a number which
 is increased for each non-visible project).
-|`description` |optional|The description of the project.
-|`state`       |optional|`ACTIVE`, `READ_ONLY` or `HIDDEN`.
-|`branches`    |optional|Map of branch names to HEAD revisions.
-|`labels`      |optional|
+|`description`   |optional|The description of the project.
+|`state`         |optional|`ACTIVE`, `READ_ONLY` or `HIDDEN`.
+|`branches`      |optional|Map of branch names to HEAD revisions.
+|`labels`        |optional|
 Map of label names to
 link:#label-type-info[LabelTypeInfo] entries.
 This field is filled for link:#create-project[Create Project] and
 link:#get-project[Get Project] calls.
-|`web_links`   |optional|
+|`web_links`     |optional|
 Links to the project in external sites as a list of
 link:rest-api-changes.html#web-link-info[WebLinkInfo] entries.
-|===========================
+|`_more_projects`|optional, not set if `false`|
+Whether the query would deliver more results if not limited. +
+Only set on the last project that is returned.
+|=============================
 
 [[project-input]]
 === ProjectInput
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
index 4fe5aae..9825478 100644
--- a/Documentation/user-attention-set.txt
+++ b/Documentation/user-attention-set.txt
@@ -97,21 +97,21 @@
 
 === Dashboard
 
-The default *dashboard* contains a new section at the top called "Your Turn". It
+The default *dashboard* contains a new section at the top called "Your turn". It
 lists all changes where the logged-in user is in the attention set. When you are
 a reviewer, the change is highlighted and is shown at the top of the section.
 The "Waiting" column indicates how long the owner has already been waiting for
 you to act.
 
-image::images/user-attention-set-dashboard.png["dashboard with Your Turn section", align="center"]
+image::images/user-attention-set-dashboard.png["dashboard with Your turn section", align="center"]
 
 As an active developer, one of your daily goals will be to iterate over this
 list and clear it.
 
-image::images/user-attention-set-dashboard-empty.png["dashboard with empty Your Turn section", align="center"]
+image::images/user-attention-set-dashboard-empty.png["dashboard with empty Your turn section", align="center"]
 
 Note that you can also navigate to other users' dashboards to check their
-"Your Turn" section.
+"Your turn" section.
 
 === Emails
 
@@ -188,3 +188,18 @@
 
 SEARCHBOX
 ---------
+
+=== Auto readd owner [[auto-readd-owner]]
+
+This job automatically readds the change owner to the attention-set for open non-WIP/private
+changes that have been inactive for a defined time. Gerrit administrators may configure
+link:config-gerrit.html#auto-readd[this]
+
+Readding the owner to the attention-set of an inactive change has the advantages:
+
+* It signals the change owner that the review is not progressing and that the owner
+may need to adjust the attention-set or indicate a need for a priority review.
+* It may prevent changes where no one is in the attention-set from getting forgotten.
+* It makes people set changes in WIP or private for changes that should not
+be actively reviewed.
+
diff --git a/Documentation/user-named-destinations.txt b/Documentation/user-named-destinations.txt
index a1ab258..cee562b 100644
--- a/Documentation/user-named-destinations.txt
+++ b/Documentation/user-named-destinations.txt
@@ -1,15 +1,19 @@
 = Gerrit Code Review - Named Destinations
 
-[[user-named-destinations]]
-== User Named Destinations
-It is possible to define named destination sets on a user level.
+[[user-or-group-named-destinations]]
+== User Or Group Named Destinations
+It is possible to define named destination sets on a user or group level.
 To do this, define the named destination sets in files named after
 each destination set in the `destinations` directory of the user's
-account ref in the `All-Users` project.  The user's account ref is
-based on the user's account id which is an integer.  The account
-refs are sharded by the last two digits (`+nn+`) in the refname,
-leading to refs of the format `+refs/users/nn/accountid+`.  The
-user's destination files are a 2 column tab delimited file.  Each
+or group's account ref in the `All-Users` project. The user's account ref is
+based on the user's account id which is an integer. The user account refs
+are sharded by the last two digits (`+nn+`) in the refname, leading to refs
+of the format `+refs/users/nn/accountid+`. Similarly, the group's ref is
+based on the group id which is a UUID. The group refs are sharded
+by the first 2 characters of the group UUID, leading to a refs of the
+format `+refs/groups/cc/groupid+`.
+
+The destination files are a 2 column tab delimited file.  Each
 row in a destination file represents a single destination in the
 named set.  The left column represents the ref of the destination,
 and the right column represents the project of the destination.
diff --git a/Documentation/user-named-queries.txt b/Documentation/user-named-queries.txt
index c01f790..938cd53 100644
--- a/Documentation/user-named-queries.txt
+++ b/Documentation/user-named-queries.txt
@@ -1,11 +1,12 @@
 = Gerrit Code Review - Named Queries
 
-[[user-named-queries]]
-== User Named Queries
-It is possible to define named queries on a user level. To do
+[[user-or-group-named-queries]]
+== User Or Group Named Queries
+It is possible to define named queries on a user or group level. To do
 this, define the named queries in the `queries` file under the
-link:intro-user.html#user-refs[user's ref] in the `All-Users` project.  The
-user's queries file is a 2 column tab delimited file.  The left
+link:intro-user.html#user-refs[user's ref] or
+link:config-groups.html#_storage_format[group's ref] in the `All-Users`
+project. The named queries file is a 2 column tab delimited file. The left
 column represents the name of the query, and the right column
 represents the query expression represented by the name. The named queries
 can be publicly accessible by other users.
diff --git a/Documentation/user-review-ui.txt b/Documentation/user-review-ui.txt
index 39929e1..899c7a7 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -280,9 +280,14 @@
 
 ** [[cherry-pick]]`Cherry-Pick`:
 +
-Allows to cherry-pick the change to another branch. The destination
-branch can be selected from a dialog. Cherry-picking a change creates a
-new open change on the selected destination branch.
+Allows to cherry-pick the change to another branch. The destination branch
+can be selected from a dialog. Cherry-picking a change creates a new open
+change on the selected destination branch. 'Cherry-pick committer email'
+drop-down is visible for single change cherry-picks when user has more than
+one email registered to their account. It is possible to select any of the
+registered emails to be used as the cherry-pick committer email. It defaults
+to source commit's committer email if it is a registered email of the calling
+user, else defaults to calling user's preferred email.
 +
 It is also possible to cherry-pick a change to the same branch. This is
 effectively the same as rebasing it to the current tip of the
@@ -655,6 +660,44 @@
 
 image::images/user-review-ui-side-by-side-diff-screen-rename.png[width=400, link="images/user-review-ui-side-by-side-diff-screen-rename.png"]
 
+[[normal-and-rebase-edits]]
+=== Normal and Rebase Edits
+
+In the diff view, Gerrit shows added and removed contents with green and red
+colors respectively.
+
+image::images/user-review-ui-side-by-side-diff-normal-edits.png[width=800, link="images/user-review-ui-side-by-side-diff-normal-edits.png"]
+
+When comparing two patch-sets against each other, and if both patch-sets have
+different bases (parents), Gerrit also identifies parts of the diff that were
+modified due to rebase. Those are called “rebase edits” and are highlighted with
+different colors.
+
+image::images/user-review-ui-side-by-side-diff-rebase-edits.png[width=800, link="images/user-review-ui-side-by-side-diff-rebase-edits.png"]
+
+Gerrit identifies rebase edits by also inspecting the diff between parents, and
+if it detects an edit between parents that’s also an edit between the patch-sets
+(after mapping/transforming the edit), then it marks it as a rebase edit. This
+first diffs both patch-sets to identify all edits, then potentially excludes
+some of them if they were identified as rebase edits.
+
+image::images/user-review-ui-side-by-side-diff-rebase.png[width=400, link="images/user-review-ui-side-by-side-diff-rebase.png"]
+
+If all edits in a file were due to rebase, the file is skipped and is not shown
+among the list of files in the ‘files tab’.
+
+[[hazardous-rebases]]
+=== Hazardous Rebases
+
+A rebase might be hazardous in some cases. One such example is when users have a
+stack of changes (e.g. two changes as in the below figure) then squash both
+changes and upload the resulting commit as patch-set 2. In this case, PS1 and
+PS2 are identical and Gerrit shows an empty diff, which is a correct diff
+but it's hiding the fact that new content got implicitly merged into this change
+from the parent change.
+
+image::images/user-review-ui-side-by-side-diff-squash.png[width=400, link="images/user-review-ui-side-by-side-diff-squash.png"]
+
 [[inline-comments]]
 === Inline Comments
 
diff --git a/Documentation/user-search-projects.txt b/Documentation/user-search-projects.txt
index 8ebbf3e..aabbd55 100644
--- a/Documentation/user-search-projects.txt
+++ b/Documentation/user-search-projects.txt
@@ -12,6 +12,17 @@
 +
 Matches projects that have exactly the name 'NAME'.
 
+[[prefix]]
+prefix:'PREFIX'::
++
+Matches projects that have a name that starts with 'PREFIX' (may be
+case-sensitive, depending on which index backend is used).
+
+[[substring]]
+substring:'SUBSTRING'::
++
+Matches projects that have a name that contains 'SUBSTRING' (case-insensitive).
+
 [[parent]]
 parent:'PARENT'::
 +
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 67b8d75..0744ded 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -128,11 +128,13 @@
 as a change number such as 15183, or a Change-Id from the Change-Id footer.
 
 [[destination]]
-destination:'[name=]NAME[,user=USER]'::
+destination:'[name=]NAME[,user=USER|,group=GROUP]'::
 +
-Changes which match the specified USER's destination named 'NAME'. If 'USER'
-is unspecified, the current user is used. The named destinations can be
-publicly accessible by other users.
+Changes which match the specified USER's or GROUP's destination named 'NAME'.
+If 'USER' is unspecified, the current user is used. The named destinations can
+be publicly accessible by other users.
+The value may be wrapped in double quotes to include spaces. For example,
+`destination:"myreviews,group=My Group"`
 (see link:user-named-destinations.html[Named Destinations]).
 
 [[owner]]
@@ -160,11 +162,13 @@
 'GROUP'.
 
 [[query]]
-query:'[name=]NAME[,user=USER]'::
+query:'[name=]NAME[,user=USER|,group=GROUP]'::
 +
-Changes which match the specified USER's query named 'NAME'. If 'USER'
-is unspecified, the current user is used. The named queries can be
-publicly accessible by other users.
+Changes which match the specified USER's or GROUP's query named 'NAME'.
+If neither 'USER' nor 'GROUP' is specified, the current user is used.
+The named queries can be publicly accessible by other users.
+The value may be wrapped in double quotes to include spaces. For example,
+`query:"myquery,group=My Group"`
 (see link:user-named-queries.html[Named Queries]).
 
 [[reviewer]]
@@ -341,7 +345,7 @@
 of the argument.
 
 [[message]]
-message:'MESSAGE'::
+message:'MESSAGE'::, m:'MESSAGE'::, description:'MESSAGE'::, d:'MESSAGE'::
 +
 Changes that match 'MESSAGE' arbitrary string in the commit message body.
 By default full text matching is used, but regular expressions can be
diff --git a/Documentation/user-suggest-edits.txt b/Documentation/user-suggest-edits.txt
index 99f17a4..9c67358 100644
--- a/Documentation/user-suggest-edits.txt
+++ b/Documentation/user-suggest-edits.txt
@@ -27,7 +27,14 @@
 == Author workflow
 
 You can apply one or more suggested fixes. When suggested fix is applied - it creates
-a change edit that you can modify. link:user-inline-edit.html#editing-change[More about editing mode.]
+a change edit that you can modify in gerrit. link:user-inline-edit.html#editing-change[More about editing mode.]
+
+FYI: Publishing a new patchset in gerrit will make gerrit change out of sync with
+your local git. You can checkout latest gerrit by using commands from download drop-down panel.
+link:user-review-ui.html#download[More about download drop-down panel]
+
+You can use copy to clipboard button to copy suggestion to clipboard and then you can paste it
+in your editor.
 
 GERRIT
 ------
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index c6fce2a5..625c2e9 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -479,6 +479,21 @@
 configured on the host, but not the link:config.html#receive.timeout[receive
 timeout].
 
+[[push_justification]]
+==== Provide a push justification
+
+When making a direct push (which directly modifies target branch, without creating a change), you
+can provide a justification for the push. To do this set `push-justification=justification` push
+option on the git push; the justification is an arbitrary text.
+
+----
+  git push -o push-justification=id/2345 ssh://john.doe@git.example.com:29418/kernel/common refs/heads/master
+----
+
+**NOTE** This options is used internally in google. The value is ignored in the upstream version
+of Gerrit.
+
+
 [[push_replace]]
 === Replace Changes
 
diff --git a/WORKSPACE b/WORKSPACE
index 047da6a..8ce21d5 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -33,10 +33,10 @@
 
 http_archive(
     name = "platforms",
-    sha256 = "379113459b0feaf6bfbb584a91874c065078aa673222846ac765f86661c27407",
+    sha256 = "3a561c99e7bdbe9173aa653fd579fe849f1d8d67395780ab4770b1f381431d51",
     urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/platforms/releases/download/0.0.5/platforms-0.0.5.tar.gz",
-        "https://github.com/bazelbuild/platforms/releases/download/0.0.5/platforms-0.0.5.tar.gz",
+        "https://mirror.bazel.build/github.com/bazelbuild/platforms/releases/download/0.0.7/platforms-0.0.7.tar.gz",
+        "https://github.com/bazelbuild/platforms/releases/download/0.0.7/platforms-0.0.7.tar.gz",
     ],
 )
 
@@ -65,8 +65,8 @@
 
 http_archive(
     name = "build_bazel_rules_nodejs",
-    sha256 = "c29944ba9b0b430aadcaf3bf2570fece6fc5ebfb76df145c6cdad40d65c20811",
-    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.7.0/rules_nodejs-5.7.0.tar.gz"],
+    sha256 = "94070eff79305be05b7699207fbac5d2608054dd53e6109f7d00d923919ff45a",
+    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.8.2/rules_nodejs-5.8.2.tar.gz"],
 )
 
 load("@build_bazel_rules_nodejs//:repositories.bzl", "build_bazel_rules_nodejs_dependencies")
@@ -103,6 +103,12 @@
 
 register_toolchains("//tools:error_prone_warnings_toolchain_java17_definition")
 
+# Java-Prettify external repository consumed from git submodule
+local_repository(
+    name = "java-prettify",
+    path = "modules/java-prettify",
+)
+
 # JGit external repository consumed from git submodule
 local_repository(
     name = "jgit",
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index f4e7cce..80582a4 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -285,7 +285,9 @@
   @Inject protected TestTicker testTicker;
 
   protected EventRecorder eventRecorder;
+
   protected GerritServer server;
+
   protected Project.NameKey project;
   protected RestSession adminRestSession;
   protected RestSession userRestSession;
@@ -317,6 +319,101 @@
   private String systemTimeZone;
   private SystemReader oldSystemReader;
 
+  /**
+   * The Getters and Setters below are needed for tests that run on custom {@link GerritServer}
+   * (that can be set up via {@link #initServer} and {@link #setUpDatabase} methods. Because tests
+   * inherit directly from {@link AbstractDaemonTest}, the set up has to be delegated to some other
+   * class that can share the set up logic across different test classes.
+   *
+   * <p>E.g, we need to be able to do something like:
+   *
+   * <pre>{@code
+   * public class AccountIT extends AbstractDaemonTest {...}
+   *
+   * public class AbstractDaemonTestAdapter {
+   *
+   *   protected void initServer() {...}
+   *
+   *   ...
+   *
+   * }
+   *
+   * public class CustomAccountIT extends AccountIT {
+   *
+   *   AbstractDaemonTestAdapter testAdapter;
+   *
+   *   {@literal @Override}
+   *   protected void initServer() {
+   *         testAdapter.initServer();
+   *   }
+   *   ...
+   * }
+   *
+   * public class CustomChangeIT extends ChangeIT {
+   *
+   *   AbstractDaemonTestAdapter testAdapter;
+   *
+   *   {@literal @Override}
+   *   protected void initServer() {
+   *         testAdapter.initServer();
+   *   }
+   *   ...
+   * }
+   *
+   * }</pre>
+   */
+  public String getResourcePrefix() {
+    return resourcePrefix;
+  }
+
+  public void setResourcePrefix(String resourcePrefix) {
+    this.resourcePrefix = resourcePrefix;
+  }
+
+  public Description getDescription() {
+    return description;
+  }
+
+  public TestRepository<InMemoryRepository> getTestRepo() {
+    return testRepo;
+  }
+
+  public void setTestRepo(TestRepository<InMemoryRepository> testRepo) {
+    this.testRepo = testRepo;
+  }
+
+  public TestAccount getUser() {
+    return user;
+  }
+
+  public void setUser(TestAccount user) {
+    this.user = user;
+  }
+
+  public TestAccount getAdmin() {
+    return admin;
+  }
+
+  public void setAdmin(TestAccount admin) {
+    this.admin = admin;
+  }
+
+  public Project.NameKey getProject() {
+    return project;
+  }
+
+  public void setProject(Project.NameKey project) {
+    this.project = project;
+  }
+
+  public GerritServer getServer() {
+    return server;
+  }
+
+  public void setServer(GerritServer server) {
+    this.server = server;
+  }
+
   @Before
   public void clearSender() {
     if (sender != null) {
@@ -408,7 +505,7 @@
     initSsh();
   }
 
-  protected void reindexAccount(Account.Id accountId) {
+  public void reindexAccount(Account.Id accountId) {
     accountIndexer.index(accountId);
   }
 
@@ -456,6 +553,52 @@
 
     baseConfig.setInt("index", null, "batchThreads", -1);
 
+    initServer(classDesc, methodDesc);
+
+    server.getTestInjector().injectMembers(this);
+    Transport.register(inProcessProtocol);
+    toClose = Collections.synchronizedList(new ArrayList<>());
+
+    setUpDatabase(classDesc);
+
+    // Set the clock step last, so that the test setup isn't consuming any timestamps after the
+    // clock has been set.
+    setTimeSettings(classDesc.useSystemTime(), classDesc.useClockStep(), classDesc.useTimezone());
+    setTimeSettings(
+        methodDesc.useSystemTime(), methodDesc.useClockStep(), methodDesc.useTimezone());
+  }
+
+  protected void setUpDatabase(GerritServer.Description classDesc) throws Exception {
+    admin = accountCreator.admin();
+    user = accountCreator.user1();
+
+    // Evict and reindex accounts in case tests modify them.
+    reindexAccount(admin.id());
+    reindexAccount(user.id());
+
+    adminRestSession = new RestSession(server, admin);
+    userRestSession = new RestSession(server, user);
+    anonymousRestSession = new RestSession(server, null);
+
+    initSsh();
+
+    String testMethodName = description.getMethodName();
+    resourcePrefix =
+        UNSAFE_PROJECT_NAME
+            .matcher(description.getClassName() + "_" + testMethodName + "_")
+            .replaceAll("");
+
+    setRequestScope(admin);
+    ProjectInput in = projectInput(description);
+    gApi.projects().create(in);
+    project = Project.nameKey(in.name);
+    if (!classDesc.skipProjectClone()) {
+      testRepo = cloneProject(project, getCloneAsAccount(description));
+    }
+  }
+
+  protected void initServer(GerritServer.Description classDesc, GerritServer.Description methodDesc)
+      throws Exception {
     Module module = createModule();
     Module auditModule = createAuditModule();
     Module sshModule = createSshModule();
@@ -471,43 +614,6 @@
           GerritServer.initAndStart(
               temporaryFolder, methodDesc, baseConfig, module, auditModule, sshModule);
     }
-
-    server.getTestInjector().injectMembers(this);
-    Transport.register(inProcessProtocol);
-    toClose = Collections.synchronizedList(new ArrayList<>());
-
-    admin = accountCreator.admin();
-    user = accountCreator.user1();
-
-    // Evict and reindex accounts in case tests modify them.
-    reindexAccount(admin.id());
-    reindexAccount(user.id());
-
-    adminRestSession = new RestSession(server, admin);
-    userRestSession = new RestSession(server, user);
-    anonymousRestSession = new RestSession(server, null);
-
-    initSsh();
-
-    resourcePrefix =
-        UNSAFE_PROJECT_NAME
-            .matcher(description.getClassName() + "_" + description.getMethodName() + "_")
-            .replaceAll("");
-
-    Context ctx = newRequestContext(admin);
-    atrScope.set(ctx);
-    ProjectInput in = projectInput(description);
-    gApi.projects().create(in);
-    project = Project.nameKey(in.name);
-    if (!classDesc.skipProjectClone()) {
-      testRepo = cloneProject(project, getCloneAsAccount(description));
-    }
-
-    // Set the clock step last, so that the test setup isn't consuming any timestamps after the
-    // clock has been set.
-    setTimeSettings(classDesc.useSystemTime(), classDesc.useClockStep(), classDesc.useTimezone());
-    setTimeSettings(
-        methodDesc.useSystemTime(), methodDesc.useClockStep(), methodDesc.useTimezone());
   }
 
   private static SystemReader setFakeSystemReader(File tempDir) {
@@ -589,13 +695,13 @@
     }
   }
 
-  private TestAccount getCloneAsAccount(Description description) {
+  protected TestAccount getCloneAsAccount(Description description) {
     TestProjectInput ann = description.getAnnotation(TestProjectInput.class);
     return accountCreator.get(ann != null ? ann.cloneAs() : "admin");
   }
 
   /** Generate default project properties based on test description */
-  private ProjectInput projectInput(Description description) {
+  public ProjectInput projectInput(Description description) {
     ProjectInput in = new ProjectInput();
     TestProjectInput ann = description.getAnnotation(TestProjectInput.class);
     in.name = name("project");
@@ -628,7 +734,7 @@
     // Default implementation does nothing.
   }
 
-  private static final Pattern UNSAFE_PROJECT_NAME = Pattern.compile("[^a-zA-Z0-9._/-]+");
+  public static final Pattern UNSAFE_PROJECT_NAME = Pattern.compile("[^a-zA-Z0-9._/-]+");
 
   protected Git git() {
     return testRepo.git();
@@ -944,10 +1050,11 @@
       String subject,
       String fileName,
       String content,
-      String topic)
+      @Nullable String topic)
       throws Exception {
     PushOneCommit push = pushFactory.create(admin.newIdent(), repo, subject, fileName, content);
-    return push.to("refs/for/" + branch + "%topic=" + name(topic));
+    return push.to(
+        "refs/for/" + branch + (Strings.isNullOrEmpty(topic) ? "" : "%topic=" + name(topic)));
   }
 
   protected BranchApi createBranch(BranchNameKey branch) throws Exception {
@@ -1051,7 +1158,13 @@
     return gApi.changes().query(q).get();
   }
 
-  private Context newRequestContext(TestAccount account) {
+  /** Sets up {@code account} as a caller in tests. */
+  public void setRequestScope(TestAccount account) {
+    Context ctx = newRequestContext(account);
+    atrScope.set(ctx);
+  }
+
+  protected Context newRequestContext(TestAccount account) {
     requestScopeOperations.setApiUser(account.id());
     return atrScope.get();
   }
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index ff5bc00..f3881f2 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.GroupCache;
@@ -32,7 +33,6 @@
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -166,6 +166,10 @@
         accounts.get(username), () -> String.format("No TestAccount created for %s ", username));
   }
 
+  public void evict(Account.Id id) {
+    evict(ImmutableSet.of(id));
+  }
+
   public void evict(Collection<Account.Id> ids) {
     accounts.values().removeIf(a -> ids.contains(a.id()));
   }
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index 3d90bf0..f3527f0 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -45,6 +45,7 @@
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.change.ChangeETagComputation;
+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.PluginPushOption;
@@ -108,6 +109,7 @@
 
   private final DynamicMap<ChangeHasOperandFactory> hasOperands;
   private final DynamicMap<ChangeIsOperandFactory> isOperands;
+  private final DynamicMap<ReviewerSuggestion> reviewerSuggestions;
 
   @Inject
   ExtensionRegistry(
@@ -150,7 +152,8 @@
       DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners,
       DynamicMap<ChangeHasOperandFactory> hasOperands,
       DynamicMap<ChangeIsOperandFactory> isOperands,
-      DynamicSet<AttentionSetListener> attentionSetListeners) {
+      DynamicSet<AttentionSetListener> attentionSetListeners,
+      DynamicMap<ReviewerSuggestion> reviewerSuggestions) {
     this.accountIndexedListeners = accountIndexedListeners;
     this.changeIndexedListeners = changeIndexedListeners;
     this.groupIndexedListeners = groupIndexedListeners;
@@ -191,6 +194,7 @@
     this.hasOperands = hasOperands;
     this.isOperands = isOperands;
     this.attentionSetListeners = attentionSetListeners;
+    this.reviewerSuggestions = reviewerSuggestions;
   }
 
   public Registration newRegistration() {
@@ -367,6 +371,10 @@
       return add(reviewerDeletedListeners, reviewerDeletedListener);
     }
 
+    public Registration add(ReviewerSuggestion reviewerSuggestion, String exportName) {
+      return add(reviewerSuggestions, reviewerSuggestion, exportName);
+    }
+
     private <T> Registration add(DynamicSet<T> dynamicSet, T extension) {
       return add(dynamicSet, extension, "gerrit");
     }
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 1199bf9..73631e9 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -94,6 +94,7 @@
 import java.util.HashMap;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Optional;
 import java.util.concurrent.BrokenBarrierException;
 import java.util.concurrent.CyclicBarrier;
 import java.util.concurrent.ExecutorService;
@@ -220,7 +221,7 @@
 
     abstract boolean sandboxed();
 
-    abstract boolean skipProjectClone();
+    public abstract boolean skipProjectClone();
 
     abstract boolean useSshAnnotation();
 
@@ -468,10 +469,11 @@
             bind(TestTicker.class).toInstance(testTicker);
           }
         });
-    daemon.setEnableHttpd(desc.httpd());
-    // Assure that SSHD is enabled if HTTPD is not required, otherwise the Gerrit server would not
-    // even start.
-    daemon.setEnableSshd(!desc.httpd() || desc.useSsh());
+    // Assure that HTTPD is enabled if SSHD is not required. If both are disabled the Gerrit server
+    // does not start. Alternatively we could assure that SSHD is enabled if HTTPD is not required,
+    // but this would break the tests at Google, because they don't have support for SSHD.
+    daemon.setEnableHttpd(desc.httpd() || !desc.useSsh());
+    daemon.setEnableSshd(desc.useSsh());
     daemon.setReplica(
         ReplicaUtil.isReplica(baseConfig) || ReplicaUtil.isReplica(desc.buildConfig(baseConfig)));
 
@@ -529,7 +531,7 @@
     daemon.addAdditionalSysModuleForTesting(
         new ReindexProjectsAtStartupModule(), new ReindexGroupsAtStartupModule());
     daemon.start();
-    return new GerritServer(desc, null, createTestInjector(daemon), daemon, null);
+    return new GerritServer(desc, null, createTestInjector(daemon), Optional.of(daemon), null);
   }
 
   private static AbstractIndexModule createIndexModule(
@@ -579,7 +581,8 @@
     }
     System.out.println("Gerrit Server Started");
 
-    return new GerritServer(desc, site, createTestInjector(daemon), daemon, daemonService);
+    return new GerritServer(
+        desc, site, createTestInjector(daemon), Optional.of(daemon), daemonService);
   }
 
   private static void mergeTestConfig(Config cfg) {
@@ -600,6 +603,7 @@
     cfg.setString("gerrit", null, "basePath", "git");
     cfg.setBoolean("sendemail", null, "enable", true);
     cfg.setInt("sendemail", null, "threadPoolSize", 0);
+    cfg.setInt("execution", null, "fanOutThreadPoolSize", 0);
     cfg.setInt("plugins", null, "checkFrequency", 0);
 
     cfg.setInt("sshd", null, "threads", 1);
@@ -628,7 +632,7 @@
             factory(PerCommentOperationsImpl.Factory.class);
             factory(PerDraftCommentOperationsImpl.Factory.class);
             factory(PerRobotCommentOperationsImpl.Factory.class);
-            factory(PushOneCommit.Factory.class);
+            install(new PushOneCommit.Module());
             install(InProcessProtocol.module());
             install(new NoSshModule());
             install(new AsyncReceiveCommitsModule());
@@ -667,17 +671,17 @@
   private final Description desc;
   private final Path sitePath;
 
-  private Daemon daemon;
-  private ExecutorService daemonService;
-  private Injector testInjector;
-  private String url;
-  private InetSocketAddress httpAddress;
+  private final Optional<Daemon> daemon;
+  private final ExecutorService daemonService;
+  protected final Injector testInjector;
+  private final String url;
+  private final Optional<InetSocketAddress> httpAddress;
 
-  private GerritServer(
+  protected GerritServer(
       Description desc,
       @Nullable Path sitePath,
       Injector testInjector,
-      Daemon daemon,
+      Optional<Daemon> daemon,
       @Nullable ExecutorService daemonService) {
     this.desc = requireNonNull(desc);
     this.sitePath = sitePath;
@@ -687,15 +691,20 @@
 
     Config cfg = testInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
     url = cfg.getString("gerrit", null, "canonicalWebUrl");
-    URI uri = URI.create(url);
-    httpAddress = new InetSocketAddress(uri.getHost(), uri.getPort());
+
+    if (daemon.isPresent()) {
+      URI uri = URI.create(url);
+      httpAddress = Optional.of(new InetSocketAddress(uri.getHost(), uri.getPort()));
+    } else {
+      httpAddress = Optional.empty();
+    }
   }
 
   public String getUrl() {
     return url;
   }
 
-  InetSocketAddress getHttpAddress() {
+  Optional<InetSocketAddress> getHttpAddress() {
     return httpAddress;
   }
 
@@ -703,8 +712,8 @@
     return testInjector;
   }
 
-  public Injector getHttpdInjector() {
-    return daemon.getHttpdInjector();
+  public Optional<Injector> getHttpdInjector() {
+    return daemon.map(Daemon::getHttpdInjector);
   }
 
   Description getDescription() {
@@ -725,7 +734,7 @@
     }
 
     server.close();
-    server.daemon.stop();
+    server.daemon.ifPresent(Daemon::stop);
     return start(server.desc, cfg, site, null, null, null, inMemoryRepoManager);
   }
 
@@ -742,7 +751,7 @@
     }
 
     server.close();
-    server.daemon.stop();
+    server.daemon.ifPresent(Daemon::stop);
     return start(server.desc, cfg, site, testSysModule, null, testSshModule, inMemoryRepoManager);
   }
 
@@ -752,7 +761,7 @@
 
   @Override
   public void close() throws Exception {
-    daemon.getLifecycleManager().stop();
+    daemon.ifPresent(d -> d.getLifecycleManager().stop());
     if (daemonService != null) {
       System.out.println("Gerrit Server Shutdown");
       daemonService.shutdownNow();
@@ -771,6 +780,6 @@
   }
 
   public boolean isReplica() {
-    return daemon.isReplica();
+    return daemon.map(Daemon::isReplica).orElse(false);
   }
 }
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index 9f38fcb..a61fa46 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -81,6 +82,15 @@
           + "\n"
           + PATCH_FILE_ONLY;
 
+  public static class Module extends FactoryModule {
+    @Override
+    protected void configure() {
+      factory(PushOneCommit.Factory.class);
+
+      factory(PushOneCommit.Result.Factory.class);
+    }
+  }
+
   public interface Factory {
     PushOneCommit create(PersonIdent i, TestRepository<?> testRepo);
 
@@ -148,11 +158,10 @@
     return String.format("%040x", CHANGE_ID_COUNTER.incrementAndGet());
   }
 
-  private final ChangeNotes.Factory notesFactory;
-  private final ApprovalsUtil approvalsUtil;
-  private final Provider<InternalChangeQuery> queryProvider;
   private final TestRepository<?> testRepo;
 
+  private final Result.Factory pushResultFactory;
+
   private final String subject;
   private final Map<String, String> files;
   private String changeId;
@@ -164,68 +173,49 @@
 
   @AssistedInject
   PushOneCommit(
-      ChangeNotes.Factory notesFactory,
-      ApprovalsUtil approvalsUtil,
-      Provider<InternalChangeQuery> queryProvider,
+      Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo)
       throws Exception {
-    this(notesFactory, approvalsUtil, queryProvider, i, testRepo, SUBJECT, FILE_NAME, FILE_CONTENT);
+    this(pushResultFactory, i, testRepo, SUBJECT, FILE_NAME, FILE_CONTENT);
   }
 
   @AssistedInject
   PushOneCommit(
-      ChangeNotes.Factory notesFactory,
-      ApprovalsUtil approvalsUtil,
-      Provider<InternalChangeQuery> queryProvider,
+      Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
       @Assisted("changeId") String changeId)
       throws Exception {
-    this(
-        notesFactory,
-        approvalsUtil,
-        queryProvider,
-        i,
-        testRepo,
-        SUBJECT,
-        FILE_NAME,
-        FILE_CONTENT,
-        changeId);
+    this(pushResultFactory, i, testRepo, SUBJECT, FILE_NAME, FILE_CONTENT, changeId);
   }
 
   @AssistedInject
   PushOneCommit(
-      ChangeNotes.Factory notesFactory,
-      ApprovalsUtil approvalsUtil,
-      Provider<InternalChangeQuery> queryProvider,
+      Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
       @Assisted("subject") String subject,
       @Assisted("fileName") String fileName,
       @Assisted("content") String content)
       throws Exception {
-    this(notesFactory, approvalsUtil, queryProvider, i, testRepo, subject, fileName, content, null);
+    this(pushResultFactory, i, testRepo, subject, fileName, content, null);
   }
 
   @AssistedInject
   PushOneCommit(
-      ChangeNotes.Factory notesFactory,
-      ApprovalsUtil approvalsUtil,
-      Provider<InternalChangeQuery> queryProvider,
+      Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
       @Assisted String subject,
       @Assisted Map<String, String> files)
       throws Exception {
-    this(notesFactory, approvalsUtil, queryProvider, i, testRepo, subject, files, null);
+    this(pushResultFactory, i, testRepo, subject, files, null);
   }
 
   @AssistedInject
   PushOneCommit(
-      ChangeNotes.Factory notesFactory,
-      ApprovalsUtil approvalsUtil,
-      Provider<InternalChangeQuery> queryProvider,
+      Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
       @Assisted("subject") String subject,
@@ -233,22 +223,12 @@
       @Assisted("content") String content,
       @Nullable @Assisted("changeId") String changeId)
       throws Exception {
-    this(
-        notesFactory,
-        approvalsUtil,
-        queryProvider,
-        i,
-        testRepo,
-        subject,
-        ImmutableMap.of(fileName, content),
-        changeId);
+    this(pushResultFactory, i, testRepo, subject, ImmutableMap.of(fileName, content), changeId);
   }
 
   @AssistedInject
   PushOneCommit(
-      ChangeNotes.Factory notesFactory,
-      ApprovalsUtil approvalsUtil,
-      Provider<InternalChangeQuery> queryProvider,
+      Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
       @Assisted("subject") String subject,
@@ -256,12 +236,10 @@
       @Nullable @Assisted("changeId") String changeId)
       throws Exception {
     this.testRepo = testRepo;
-    this.notesFactory = notesFactory;
-    this.approvalsUtil = approvalsUtil;
-    this.queryProvider = queryProvider;
     this.subject = subject;
     this.files = files;
     this.changeId = changeId;
+    this.pushResultFactory = pushResultFactory;
     if (changeId != null) {
       commitBuilder = testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
     } else {
@@ -283,7 +261,7 @@
   }
 
   @CanIgnoreReturnValue
-  public PushOneCommit setTopLevelTreeId(ObjectId treeId) throws Exception {
+  public PushOneCommit setTopLevelTreeId(ObjectId treeId) {
     commitBuilder.setTopLevelTree(treeId);
     return this;
   }
@@ -294,7 +272,7 @@
     return this;
   }
 
-  public PushOneCommit noParent() throws Exception {
+  public PushOneCommit noParent() {
     commitBuilder.noParents();
     return this;
   }
@@ -374,7 +352,13 @@
       }
       tagCommand.call();
     }
-    return new Result(ref, pushHead(testRepo, ref, tag != null, force, pushOptions), c, subject);
+    return pushResultFactory.create(
+        ref,
+        subject,
+        changeId,
+        pushHead(testRepo, ref, tag != null, force, pushOptions),
+        c,
+        pushOptions);
   }
 
   public void setTag(Tag tag) {
@@ -397,17 +381,51 @@
     commitBuilder.noParents();
   }
 
-  public class Result {
+  public static class Result {
+
+    public interface Factory {
+      Result create(
+          @Assisted("ref") String ref,
+          @Assisted("subject") String subject,
+          @Assisted("changeId") String changeId,
+          @Nullable PushResult resSubj,
+          @Nullable RevCommit commit,
+          @Nullable List<String> pushOptions);
+    }
+
     private final String ref;
     private final PushResult result;
     private final RevCommit commit;
     private final String resSubj;
 
-    private Result(String ref, PushResult resSubj, RevCommit commit, String subject) {
+    private final String changeId;
+
+    private final ChangeNotes.Factory notesFactory;
+    private final ApprovalsUtil approvalsUtil;
+    private final Provider<InternalChangeQuery> queryProvider;
+
+    private final List<String> pushOptions;
+
+    @AssistedInject
+    public Result(
+        ChangeNotes.Factory notesFactory,
+        ApprovalsUtil approvalsUtil,
+        Provider<InternalChangeQuery> queryProvider,
+        @Assisted("ref") String ref,
+        @Assisted("subject") String subject,
+        @Assisted("changeId") String changeId,
+        @Assisted @Nullable PushResult resSubj,
+        @Assisted @Nullable RevCommit commit,
+        @Assisted @Nullable List<String> pushOptions) {
       this.ref = ref;
       this.result = resSubj;
       this.commit = commit;
       this.resSubj = subject;
+      this.changeId = changeId;
+      this.notesFactory = notesFactory;
+      this.approvalsUtil = approvalsUtil;
+      this.queryProvider = queryProvider;
+      this.pushOptions = pushOptions;
     }
 
     public ChangeData getChange() {
@@ -431,7 +449,7 @@
     }
 
     public void assertPushOptions(List<String> pushOptions) {
-      assertEquals(pushOptions, getPushOptions());
+      assertEquals(pushOptions, this.pushOptions);
     }
 
     public void assertChange(
diff --git a/java/com/google/gerrit/acceptance/TestAccount.java b/java/com/google/gerrit/acceptance/TestAccount.java
index d5908f4..67d8a05 100644
--- a/java/com/google/gerrit/acceptance/TestAccount.java
+++ b/java/com/google/gerrit/acceptance/TestAccount.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance;
 
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.auto.value.AutoValue;
@@ -42,7 +43,7 @@
     return names(Arrays.asList(accounts));
   }
 
-  static TestAccount create(
+  public static TestAccount create(
       Account.Id id,
       @Nullable String username,
       @Nullable String email,
@@ -78,7 +79,8 @@
   }
 
   public String getHttpUrl(GerritServer server) {
-    InetSocketAddress addr = server.getHttpAddress();
+    checkState(server.getHttpAddress().isPresent(), "GerritServer must have httpAddress");
+    InetSocketAddress addr = server.getHttpAddress().get();
     return new URIBuilder()
         .setScheme("http")
         .setUserInfo(username(), httpPassword())
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
index c6457a4..edbb1ee 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountDelta;
 import com.google.gerrit.server.account.AccountState;
@@ -28,7 +29,6 @@
 import com.google.gerrit.server.account.AccountsUpdate.ConfigureDeltaFromState;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.Optional;
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
index 5efcfc6..dbcfceb 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeInserter;
@@ -41,7 +42,6 @@
 import com.google.gerrit.server.edit.tree.TreeModification;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerDraftCommentOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/PerDraftCommentOperationsImpl.java
index db264c5..3d53816 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/PerDraftCommentOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerDraftCommentOperationsImpl.java
@@ -18,7 +18,7 @@
 import static com.google.gerrit.acceptance.testsuite.change.PerCommentOperationsImpl.toTestComment;
 
 import com.google.gerrit.entities.HumanComment;
-import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -30,10 +30,9 @@
  * the separation between interface and implementation to enhance clarity.
  */
 public class PerDraftCommentOperationsImpl implements PerDraftCommentOperations {
-  private final CommentsUtil commentsUtil;
-
   private final ChangeNotes changeNotes;
   private final String commentUuid;
+  private final DraftCommentsReader draftCommentsReader;
 
   public interface Factory {
     PerDraftCommentOperationsImpl create(ChangeNotes changeNotes, String commentUuid);
@@ -41,16 +40,18 @@
 
   @Inject
   public PerDraftCommentOperationsImpl(
-      CommentsUtil commentsUtil, @Assisted ChangeNotes changeNotes, @Assisted String commentUuid) {
-    this.commentsUtil = commentsUtil;
+      DraftCommentsReader draftCommentsReader,
+      @Assisted ChangeNotes changeNotes,
+      @Assisted String commentUuid) {
     this.changeNotes = changeNotes;
     this.commentUuid = commentUuid;
+    this.draftCommentsReader = draftCommentsReader;
   }
 
   @Override
   public TestHumanComment get() {
     HumanComment comment =
-        commentsUtil.draftByChange(changeNotes).stream()
+        draftCommentsReader.getDraftsByChangeForAllAuthors(changeNotes).stream()
             .filter(foundComment -> foundComment.key.uuid.equals(commentUuid))
             .collect(onlyElement());
     return toTestComment(comment);
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
index dcf1158..0a22688 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
@@ -21,13 +21,13 @@
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.GroupUuid;
 import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupCreation;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.Optional;
diff --git a/java/com/google/gerrit/common/IoUtil.java b/java/com/google/gerrit/common/IoUtil.java
index e691025..e51a6e5 100644
--- a/java/com/google/gerrit/common/IoUtil.java
+++ b/java/com/google/gerrit/common/IoUtil.java
@@ -14,19 +14,9 @@
 
 package com.google.gerrit.common;
 
-import static com.google.gerrit.launcher.GerritLauncher.GerritClassLoader;
-
-import com.google.common.collect.Sets;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.nio.file.Path;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Set;
 
 public final class IoUtil {
   public static void copyWithThread(InputStream src, OutputStream dst) {
@@ -63,40 +53,5 @@
     }.start();
   }
 
-  public static void loadJARs(Collection<Path> jars) {
-    if (jars.isEmpty()) {
-      return;
-    }
-
-    ClassLoader cl = IoUtil.class.getClassLoader();
-    if (!(cl instanceof GerritClassLoader)) {
-      throw noAddURL("Not loaded by GerritClassLoader", null);
-    }
-
-    @SuppressWarnings("resource") // Leave open so classes can be loaded.
-    GerritClassLoader gerritClassLoader = (GerritClassLoader) cl;
-
-    Set<URL> have = Sets.newHashSet(Arrays.asList(gerritClassLoader.getURLs()));
-    for (Path path : jars) {
-      try {
-        URL url = path.toUri().toURL();
-        if (have.add(url)) {
-          gerritClassLoader.addURL(url);
-        }
-      } catch (MalformedURLException | IllegalArgumentException e) {
-        throw noAddURL("addURL " + path + " failed", e);
-      }
-    }
-  }
-
-  public static void loadJARs(Path jar) {
-    loadJARs(Collections.singleton(jar));
-  }
-
-  private static UnsupportedOperationException noAddURL(String m, Throwable why) {
-    String prefix = "Cannot extend classpath: ";
-    return new UnsupportedOperationException(prefix + m, why);
-  }
-
   private IoUtil() {}
 }
diff --git a/java/com/google/gerrit/common/JarUtil.java b/java/com/google/gerrit/common/JarUtil.java
new file mode 100644
index 0000000..b88a7ab
--- /dev/null
+++ b/java/com/google/gerrit/common/JarUtil.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2023 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.common;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.launcher.GerritLauncher.GerritClassLoader;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Set;
+
+/** Provides util methods for dynamic loading jars */
+public final class JarUtil {
+  public static void loadJars(Collection<Path> jars) {
+    if (jars.isEmpty()) {
+      return;
+    }
+
+    ClassLoader cl = JarUtil.class.getClassLoader();
+    if (!(cl instanceof GerritClassLoader)) {
+      throw noAddURL("Not loaded by GerritClassLoader", null);
+    }
+
+    @SuppressWarnings("resource") // Leave open so classes can be loaded.
+    GerritClassLoader gerritClassLoader = (GerritClassLoader) cl;
+
+    Set<URL> have = Sets.newHashSet(Arrays.asList(gerritClassLoader.getURLs()));
+    for (Path path : jars) {
+      try {
+        URL url = path.toUri().toURL();
+        if (have.add(url)) {
+          gerritClassLoader.addURL(url);
+        }
+      } catch (MalformedURLException | IllegalArgumentException e) {
+        throw noAddURL("addURL " + path + " failed", e);
+      }
+    }
+  }
+
+  public static void loadJars(Path jar) {
+    loadJars(Collections.singleton(jar));
+  }
+
+  private static UnsupportedOperationException noAddURL(String m, Throwable why) {
+    String prefix = "Cannot extend classpath: ";
+    return new UnsupportedOperationException(prefix + m, why);
+  }
+
+  private JarUtil() {}
+}
diff --git a/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java b/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java
index fa9b139..95df5be 100644
--- a/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java
+++ b/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java
@@ -35,7 +35,7 @@
   public static void loadSiteLib(Path libdir) {
     try {
       List<Path> jars = listJars(libdir);
-      IoUtil.loadJARs(jars);
+      JarUtil.loadJars(jars);
       logger.atFine().log("Loaded site libraries: %s", lazy(() -> jarList(jars)));
     } catch (IOException e) {
       logger.atSevere().withCause(e).log("Error scanning lib directory %s", libdir);
diff --git a/java/com/google/gerrit/common/UsedAt.java b/java/com/google/gerrit/common/UsedAt.java
index 46b43c6..5ea5177 100644
--- a/java/com/google/gerrit/common/UsedAt.java
+++ b/java/com/google/gerrit/common/UsedAt.java
@@ -43,8 +43,8 @@
     PLUGIN_DELETE_PROJECT,
     PLUGIN_HIGH_AVAILABILITY,
     PLUGIN_MULTI_SITE,
-    PLUGIN_SERVICEUSER,
     PLUGIN_PULL_REPLICATION,
+    PLUGIN_SERVICEUSER,
     PLUGIN_WEBSESSION_FLATFILE,
     MODULE_GIT_REFS_FILTER,
     MODULE_VIRTUALHOST
diff --git a/java/com/google/gerrit/common/data/FilenameComparator.java b/java/com/google/gerrit/common/data/FilenameComparator.java
index 0b188df..e1c763c0 100644
--- a/java/com/google/gerrit/common/data/FilenameComparator.java
+++ b/java/com/google/gerrit/common/data/FilenameComparator.java
@@ -30,6 +30,13 @@
 
   @Override
   public int compare(String path1, String path2) {
+    if (Patch.PATCHSET_LEVEL.equals(path1) && Patch.PATCHSET_LEVEL.equals(path2)) {
+      return 0;
+    } else if (Patch.PATCHSET_LEVEL.equals(path1)) {
+      return -1;
+    } else if (Patch.PATCHSET_LEVEL.equals(path2)) {
+      return 1;
+    }
     if (Patch.COMMIT_MSG.equals(path1) && Patch.COMMIT_MSG.equals(path2)) {
       return 0;
     } else if (Patch.COMMIT_MSG.equals(path1)) {
diff --git a/java/com/google/gerrit/common/data/GlobalCapability.java b/java/com/google/gerrit/common/data/GlobalCapability.java
index 23151c2..c957986 100644
--- a/java/com/google/gerrit/common/data/GlobalCapability.java
+++ b/java/com/google/gerrit/common/data/GlobalCapability.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.common.data;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.PermissionRange;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
@@ -172,8 +172,8 @@
   }
 
   /** Returns all valid capability names. */
-  public static Collection<String> getAllNames() {
-    return Collections.unmodifiableList(NAMES_ALL);
+  public static ImmutableList<String> getAllNames() {
+    return ImmutableList.copyOf(NAMES_ALL);
   }
 
   /** Returns true if the name is recognized as a capability name. */
diff --git a/java/com/google/gerrit/entities/Account.java b/java/com/google/gerrit/entities/Account.java
index 699acc0..52ad0a9 100644
--- a/java/com/google/gerrit/entities/Account.java
+++ b/java/com/google/gerrit/entities/Account.java
@@ -162,7 +162,7 @@
   /**
    * Create a new account.
    *
-   * @param newId unique id, see {@link com.google.gerrit.server.notedb.Sequences#nextAccountId()}.
+   * @param newId unique id, see Sequences#nextAccountId().
    * @param registeredOn when the account was registered.
    */
   public static Account.Builder builder(Account.Id newId, Instant registeredOn) {
diff --git a/java/com/google/gerrit/entities/ChangeMessage.java b/java/com/google/gerrit/entities/ChangeMessage.java
index 609b54c..dea070f 100644
--- a/java/com/google/gerrit/entities/ChangeMessage.java
+++ b/java/com/google/gerrit/entities/ChangeMessage.java
@@ -95,6 +95,7 @@
   }
 
   /** If null, the message was written 'by the Gerrit system'. */
+  @Nullable
   public Account.Id getAuthor() {
     return author;
   }
diff --git a/java/com/google/gerrit/entities/ParentCommitData.java b/java/com/google/gerrit/entities/ParentCommitData.java
new file mode 100644
index 0000000..e1fce30
--- /dev/null
+++ b/java/com/google/gerrit/entities/ParentCommitData.java
@@ -0,0 +1,94 @@
+// Copyright (C) 2023 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.entities;
+
+import com.google.auto.value.AutoValue;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Information about the parent of a revision patch-set. The parent can either be a merged commit of
+ * the target branch, or a patch-set of another gerrit change.
+ */
+@AutoValue
+public abstract class ParentCommitData {
+
+  /**
+   * The name of the target branch into which the current commit should be merged. Set if the change
+   * is based on a merged commit in the target branch.
+   *
+   * <p>This field is {@link Optional#empty()} if this information is not available for the current
+   * commit, or if the parent commit belongs to a patch-set of another Gerrit change.
+   */
+  public abstract Optional<String> branchName();
+
+  /**
+   * The commit SHA-1 of the parent commit, or {@link Optional#empty} if there is no parent (i.e.
+   * current commit is a root commit).
+   */
+  public abstract Optional<ObjectId> commitId();
+
+  /** Whether the parent commit is merged in the target branch {@link #branchName()}. */
+  public abstract Boolean isMergedInTargetBranch();
+
+  /**
+   * Change key of the parent commit. Only set if the parent commit is a patch-set of another gerrit
+   * change.
+   */
+  public abstract Optional<Change.Key> changeKey();
+
+  /**
+   * Change number of the parent commit. Only set if the parent commit is a patch-set of another
+   * gerrit change.
+   */
+  public abstract Optional<Integer> changeNumber();
+
+  /**
+   * patch-set number of the parent commit. Only set if the parent commit is a patch-set of another
+   * gerrit change.
+   */
+  public abstract Optional<Integer> patchSetNumber();
+
+  /**
+   * Change status of the parent commit. Only set if the parent commit is a patch-set of another
+   * gerrit change.
+   */
+  public abstract Optional<Change.Status> changeStatus();
+
+  public static Builder builder() {
+    return new AutoValue_ParentCommitData.Builder().isMergedInTargetBranch(false);
+  }
+
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder branchName(Optional<String> branchName);
+
+    public abstract Builder commitId(Optional<ObjectId> commitId);
+
+    public abstract Builder isMergedInTargetBranch(Boolean isMerged);
+
+    public abstract Builder changeKey(Optional<Change.Key> changeKey);
+
+    public abstract Builder changeNumber(Optional<Integer> changeNumber);
+
+    public abstract Builder patchSetNumber(Optional<Integer> patchSetNumber);
+
+    public abstract Builder changeStatus(Optional<Change.Status> changeStatus);
+
+    public abstract ParentCommitData autoBuild();
+  }
+}
diff --git a/java/com/google/gerrit/entities/PatchSet.java b/java/com/google/gerrit/entities/PatchSet.java
index 8784437..e8759fa 100644
--- a/java/com/google/gerrit/entities/PatchSet.java
+++ b/java/com/google/gerrit/entities/PatchSet.java
@@ -168,6 +168,10 @@
 
     public abstract Optional<ObjectId> commitId();
 
+    public abstract Builder branch(Optional<String> branch);
+
+    public abstract Builder branch(String branch);
+
     public abstract Builder uploader(Account.Id uploader);
 
     public abstract Builder realUploader(Account.Id realUploader);
@@ -204,6 +208,12 @@
   public abstract ObjectId commitId();
 
   /**
+   * Name of the target branch where this patch-set should be merged into. If the change is moved,
+   * different patch-sets will have different target branches.
+   */
+  public abstract Optional<String> branch();
+
+  /**
    * Account that uploaded the patch set.
    *
    * <p>If the upload was done on behalf of another user, the impersonated user on whom's behalf the
diff --git a/java/com/google/gerrit/entities/Permission.java b/java/com/google/gerrit/entities/Permission.java
index 2a34579..0e959e7 100644
--- a/java/com/google/gerrit/entities/Permission.java
+++ b/java/com/google/gerrit/entities/Permission.java
@@ -36,6 +36,7 @@
   public static final String DELETE = "delete";
   public static final String DELETE_CHANGES = "deleteChanges";
   public static final String DELETE_OWN_CHANGES = "deleteOwnChanges";
+  public static final String EDIT_CUSTOM_KEYED_VALUES = "editCustomKeyedValues";
   public static final String EDIT_HASHTAGS = "editHashtags";
   public static final String EDIT_TOPIC_NAME = "editTopicName";
   public static final String FORGE_AUTHOR = "forgeAuthor";
@@ -73,6 +74,7 @@
     NAMES_LC.add(DELETE.toLowerCase(Locale.US));
     NAMES_LC.add(DELETE_CHANGES.toLowerCase(Locale.US));
     NAMES_LC.add(DELETE_OWN_CHANGES.toLowerCase(Locale.US));
+    NAMES_LC.add(EDIT_CUSTOM_KEYED_VALUES.toLowerCase(Locale.US));
     NAMES_LC.add(EDIT_HASHTAGS.toLowerCase(Locale.US));
     NAMES_LC.add(EDIT_TOPIC_NAME.toLowerCase(Locale.US));
     NAMES_LC.add(FORGE_AUTHOR.toLowerCase(Locale.US));
diff --git a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
index b32f09a..a3b4abf 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
@@ -44,6 +44,7 @@
             .setUploaderAccountId(accountIdConverter.toProto(patchSet.uploader()))
             .setRealUploaderAccountId(accountIdConverter.toProto(patchSet.realUploader()))
             .setCreatedOn(patchSet.createdOn().toEpochMilli());
+    patchSet.branch().ifPresent(builder::setBranch);
     List<String> groups = patchSet.groups();
     if (!groups.isEmpty()) {
       builder.setGroups(PatchSet.joinGroups(groups));
@@ -66,6 +67,9 @@
     if (proto.hasDescription()) {
       builder.description(proto.getDescription());
     }
+    if (proto.hasBranch()) {
+      builder.branch(proto.getBranch());
+    }
 
     // The following fields used to theoretically be nullable in PatchSet, but in practice no
     // production codepath should have ever serialized an instance that was missing one of these
diff --git a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
index 9c9c282..0d019aa 100644
--- a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
+++ b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
@@ -124,6 +124,8 @@
    */
   String setHttpPassword(String httpPassword) throws RestApiException;
 
+  void delete() throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -327,5 +329,10 @@
     public String setHttpPassword(String httpPassword) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void delete() throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java b/java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java
index cf114df..b843789 100644
--- a/java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java
@@ -44,4 +44,11 @@
   @Nullable public AccountInput author;
 
   @Nullable public List<ListChangesOption> responseFormatOptions;
+
+  /**
+   * If {@code true}, the revision will be amended by the patch. This will use the tree of the
+   * revision, apply the patch and create a new commit whose tree is the resulting tree of the
+   * operation and whose parent(s) are the parent(s) of the revision.
+   */
+  @Nullable public Boolean amend;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index ef61b68..d8fd727 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.api.changes;
 
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -337,6 +338,16 @@
    */
   Set<String> getHashtags() throws RestApiException;
 
+  /** Set custom keyed values on a change */
+  void setCustomKeyedValues(CustomKeyedValuesInput input) throws RestApiException;
+
+  /**
+   * Gets the custom keyed values on a change.
+   *
+   * @return customKeyedValues
+   */
+  ImmutableMap<String, String> getCustomKeyedValues() throws RestApiException;
+
   /**
    * Manage the attention set.
    *
@@ -720,6 +731,16 @@
     }
 
     @Override
+    public void setCustomKeyedValues(CustomKeyedValuesInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ImmutableMap<String, String> getCustomKeyedValues() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public AttentionSetApi attention(String id) throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/Changes.java b/java/com/google/gerrit/extensions/api/changes/Changes.java
index d8741f5..ea2a158 100644
--- a/java/com/google/gerrit/extensions/api/changes/Changes.java
+++ b/java/com/google/gerrit/extensions/api/changes/Changes.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
@@ -33,12 +34,16 @@
    * <p><strong>Note:</strong> This method eagerly reads the change. Methods that mutate the change
    * do not necessarily re-read the change. Therefore, calling a getter method on an instance after
    * calling a mutation method on that same instance is not guaranteed to reflect the mutation. It
-   * is not recommended to store references to {@code ChangeApi} instances.
+   * is not recommended to store references to {@code ChangeApi} instances. Also note that the
+   * change numeric id without a project name parameter may fail to identify a unique change
+   * element, because the potential conflict with other changes imported from Gerrit instances with
+   * a different Server-Id.
    *
    * @param id change number.
    * @return API for accessing the change.
    * @throws RestApiException if an error occurred.
    */
+  @Deprecated(since = "3.9")
   ChangeApi id(int id) throws RestApiException;
 
   /**
@@ -81,6 +86,7 @@
     private int limit;
     private int start;
     private boolean isNoLimit;
+    private boolean allowIncompleteResults;
     private Set<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
     private ListMultimap<String, String> pluginOptions = ArrayListMultimap.create();
 
@@ -106,6 +112,12 @@
       return this;
     }
 
+    @UsedAt(UsedAt.Project.GOOGLE)
+    public QueryRequest withAllowIncompleteResults(boolean allow) {
+      this.allowIncompleteResults = allow;
+      return this;
+    }
+
     /** Set an option on the request, appending to existing options. */
     public QueryRequest withOption(ListChangesOption options) {
       this.options.add(options);
@@ -152,6 +164,11 @@
       return start;
     }
 
+    @UsedAt(UsedAt.Project.GOOGLE)
+    public boolean getAllowIncompleteResults() {
+      return allowIncompleteResults;
+    }
+
     public Set<ListChangesOption> getOptions() {
       return options;
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
index 232b2b5..646d551 100644
--- a/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
@@ -32,4 +32,5 @@
   public String topic;
   public boolean allowEmpty;
   public Map<String, String> validationOptions;
+  public String committerEmail;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/CustomKeyedValuesInput.java b/java/com/google/gerrit/extensions/api/changes/CustomKeyedValuesInput.java
new file mode 100644
index 0000000..740df21
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/CustomKeyedValuesInput.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2023 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.extensions.api.changes;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+import java.util.Map;
+import java.util.Set;
+
+public class CustomKeyedValuesInput {
+  @DefaultInput public Map<String, String> add;
+  public Set<String> remove;
+
+  public CustomKeyedValuesInput() {}
+
+  public CustomKeyedValuesInput(Map<String, String> add) {
+    this.add = add;
+  }
+
+  public CustomKeyedValuesInput(Map<String, String> add, Set<String> remove) {
+    this(add);
+    this.remove = remove;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
index a85bc73..42dea8d 100644
--- a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
@@ -20,6 +20,13 @@
   public String base;
 
   /**
+   * {@code strategy} name of the merge strategy.
+   *
+   * @see org.eclipse.jgit.merge.MergeStrategy
+   */
+  public String strategy;
+
+  /**
    * Whether the rebase should succeed if there are conflicts.
    *
    * <p>If there are conflicts the file contents of the rebased change contain git conflict markers
@@ -44,4 +51,12 @@
   public boolean onBehalfOfUploader;
 
   public Map<String, String> validationOptions;
+
+  /**
+   * Rebase will be committed using this email address. Only the registered emails of the calling
+   * user or uploader (when onBehalfOfUploader is true) are considered valid.
+   *
+   * <p>This option is not supported when rebasing a chain.
+   */
+  public String committerEmail;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
index 8bfe468..98807cb 100644
--- a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
@@ -18,7 +18,9 @@
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.restapi.DefaultInput;
@@ -96,6 +98,8 @@
    */
   public boolean ignoreAutomaticAttentionSetRules;
 
+  @Nullable public List<ListChangesOption> responseFormatOptions;
+
   public enum DraftHandling {
     /** Leave pending drafts alone. */
     KEEP,
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewResult.java b/java/com/google/gerrit/extensions/api/changes/ReviewResult.java
index 95bea5b..bd22ca8 100644
--- a/java/com/google/gerrit/extensions/api/changes/ReviewResult.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewResult.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.api.changes;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import java.util.Map;
 
 /** Result object representing the outcome of a review request. */
@@ -38,4 +39,7 @@
 
   /** Error message for non-200 responses. */
   @Nullable public String error;
+
+  /** Change after applying the update. */
+  @Nullable public ChangeInfo changeInfo;
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/BranchApi.java b/java/com/google/gerrit/extensions/api/projects/BranchApi.java
index a1f7327..90f1f3f 100644
--- a/java/com/google/gerrit/extensions/api/projects/BranchApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/BranchApi.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.gerrit.extensions.api.changes.ChangeApi.SuggestedReviewersRequest;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -31,6 +32,16 @@
 
   List<ReflogEntryInfo> reflog() throws RestApiException;
 
+  SuggestedReviewersRequest suggestReviewers() throws RestApiException;
+
+  default SuggestedReviewersRequest suggestReviewers(String query) throws RestApiException {
+    return suggestReviewers().withQuery(query);
+  }
+
+  default SuggestedReviewersRequest suggestCcs(String query) throws RestApiException {
+    return suggestReviewers().forCc().withQuery(query);
+  }
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -42,6 +53,16 @@
     }
 
     @Override
+    public SuggestedReviewersRequest suggestReviewers() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public SuggestedReviewersRequest suggestReviewers(String query) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public BranchInfo get() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index f6408b6..370068e 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -71,6 +71,7 @@
     protected int start;
     protected String substring;
     protected String regex;
+    protected String nextPageToken;
 
     public abstract List<T> get() throws RestApiException;
 
@@ -84,6 +85,11 @@
       return this;
     }
 
+    public ListRefsRequest<T> withNextPageToken(String token) {
+      this.nextPageToken = token;
+      return this;
+    }
+
     public ListRefsRequest<T> withSubstring(String substring) {
       this.substring = substring;
       return this;
@@ -102,6 +108,10 @@
       return start;
     }
 
+    public String getNextPageToken() {
+      return nextPageToken;
+    }
+
     public String getSubstring() {
       return substring;
     }
diff --git a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
index 6d52a93..109afd6 100644
--- a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.extensions.client;
 
+import com.google.common.base.MoreObjects;
+import java.util.Objects;
+
 public class DiffPreferencesInfo {
 
   /** Default number of lines of context. */
@@ -60,6 +63,97 @@
   public Boolean skipUnchanged;
   public Boolean skipUncommented;
 
+  @Override
+  public boolean equals(Object obj) {
+    if (!(obj instanceof DiffPreferencesInfo)) {
+      return false;
+    }
+    DiffPreferencesInfo other = (DiffPreferencesInfo) obj;
+    return Objects.equals(this.context, other.context)
+        && Objects.equals(this.tabSize, other.tabSize)
+        && Objects.equals(this.fontSize, other.fontSize)
+        && Objects.equals(this.lineLength, other.lineLength)
+        && Objects.equals(this.cursorBlinkRate, other.cursorBlinkRate)
+        && Objects.equals(this.expandAllComments, other.expandAllComments)
+        && Objects.equals(this.intralineDifference, other.intralineDifference)
+        && Objects.equals(this.manualReview, other.manualReview)
+        && Objects.equals(this.showLineEndings, other.showLineEndings)
+        && Objects.equals(this.showTabs, other.showTabs)
+        && Objects.equals(this.showWhitespaceErrors, other.showWhitespaceErrors)
+        && Objects.equals(this.syntaxHighlighting, other.syntaxHighlighting)
+        && Objects.equals(this.hideTopMenu, other.hideTopMenu)
+        && Objects.equals(this.autoHideDiffTableHeader, other.autoHideDiffTableHeader)
+        && Objects.equals(this.hideLineNumbers, other.hideLineNumbers)
+        && Objects.equals(this.renderEntireFile, other.renderEntireFile)
+        && Objects.equals(this.hideEmptyPane, other.hideEmptyPane)
+        && Objects.equals(this.matchBrackets, other.matchBrackets)
+        && Objects.equals(this.lineWrapping, other.lineWrapping)
+        && Objects.equals(this.ignoreWhitespace, other.ignoreWhitespace)
+        && Objects.equals(this.retainHeader, other.retainHeader)
+        && Objects.equals(this.skipDeleted, other.skipDeleted)
+        && Objects.equals(this.skipUnchanged, other.skipUnchanged)
+        && Objects.equals(this.skipUncommented, other.skipUncommented);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        context,
+        tabSize,
+        fontSize,
+        lineLength,
+        cursorBlinkRate,
+        expandAllComments,
+        intralineDifference,
+        manualReview,
+        showLineEndings,
+        showTabs,
+        showWhitespaceErrors,
+        syntaxHighlighting,
+        hideTopMenu,
+        autoHideDiffTableHeader,
+        hideLineNumbers,
+        renderEntireFile,
+        hideEmptyPane,
+        matchBrackets,
+        lineWrapping,
+        ignoreWhitespace,
+        retainHeader,
+        skipDeleted,
+        skipUnchanged,
+        skipUncommented);
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper("DiffPreferencesInfo")
+        .add("context", context)
+        .add("tabSize", tabSize)
+        .add("fontSize", fontSize)
+        .add("lineLength", lineLength)
+        .add("cursorBlinkRate", cursorBlinkRate)
+        .add("expandAllComments", expandAllComments)
+        .add("intralineDifference", intralineDifference)
+        .add("manualReview", manualReview)
+        .add("showLineEndings", showLineEndings)
+        .add("showTabs", showTabs)
+        .add("showWhitespaceErrors", showWhitespaceErrors)
+        .add("syntaxHighlighting", syntaxHighlighting)
+        .add("hideTopMenu", hideTopMenu)
+        .add("autoHideDiffTableHeader", autoHideDiffTableHeader)
+        .add("hideLineNumbers", hideLineNumbers)
+        .add("renderEntireFile", renderEntireFile)
+        .add("hideEmptyPane", hideEmptyPane)
+        .add("matchBrackets", matchBrackets)
+        .add("lineWrapping", lineWrapping)
+        .add("ignoreWhitespace", ignoreWhitespace)
+        .add("retainHeader", retainHeader)
+        .add("skipDeleted", skipDeleted)
+        .add("skipUnchanged", skipUnchanged)
+        .add("skipUncommented", skipUncommented)
+        .toString();
+  }
+
   public static DiffPreferencesInfo defaults() {
     DiffPreferencesInfo i = new DiffPreferencesInfo();
     i.context = DEFAULT_CONTEXT;
diff --git a/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java b/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
index 6672cb1..0a3ec0a 100644
--- a/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.extensions.client;
 
+import com.google.common.base.MoreObjects;
+import java.util.Objects;
+
 /* This class is stored in Git config file. */
 public class EditPreferencesInfo {
   public Integer tabSize;
@@ -31,6 +34,67 @@
   public Boolean autoCloseBrackets;
   public Boolean showBase;
 
+  @Override
+  public boolean equals(Object obj) {
+    if (!(obj instanceof EditPreferencesInfo)) {
+      return false;
+    }
+    EditPreferencesInfo other = (EditPreferencesInfo) obj;
+    return Objects.equals(this.tabSize, other.tabSize)
+        && Objects.equals(this.lineLength, other.lineLength)
+        && Objects.equals(this.indentUnit, other.indentUnit)
+        && Objects.equals(this.cursorBlinkRate, other.cursorBlinkRate)
+        && Objects.equals(this.hideTopMenu, other.hideTopMenu)
+        && Objects.equals(this.showTabs, other.showTabs)
+        && Objects.equals(this.showWhitespaceErrors, other.showWhitespaceErrors)
+        && Objects.equals(this.syntaxHighlighting, other.syntaxHighlighting)
+        && Objects.equals(this.hideLineNumbers, other.hideLineNumbers)
+        && Objects.equals(this.matchBrackets, other.matchBrackets)
+        && Objects.equals(this.lineWrapping, other.lineWrapping)
+        && Objects.equals(this.indentWithTabs, other.indentWithTabs)
+        && Objects.equals(this.autoCloseBrackets, other.autoCloseBrackets)
+        && Objects.equals(this.showBase, other.showBase);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        tabSize,
+        lineLength,
+        indentUnit,
+        cursorBlinkRate,
+        hideTopMenu,
+        showTabs,
+        showWhitespaceErrors,
+        syntaxHighlighting,
+        hideLineNumbers,
+        matchBrackets,
+        lineWrapping,
+        indentWithTabs,
+        autoCloseBrackets,
+        showBase);
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper("EditPreferencesInfo")
+        .add("tabSize", tabSize)
+        .add("lineLength", lineLength)
+        .add("indentUnit", indentUnit)
+        .add("cursorBlinkRate", cursorBlinkRate)
+        .add("hideTopMenu", hideTopMenu)
+        .add("showTabs", showTabs)
+        .add("showWhitespaceErrors", showWhitespaceErrors)
+        .add("syntaxHighlighting", syntaxHighlighting)
+        .add("hideLineNumbers", hideLineNumbers)
+        .add("matchBrackets", matchBrackets)
+        .add("lineWrapping", lineWrapping)
+        .add("indentWithTabs", indentWithTabs)
+        .add("autoCloseBrackets", autoCloseBrackets)
+        .add("showBase", showBase)
+        .toString();
+  }
+
   public static EditPreferencesInfo defaults() {
     EditPreferencesInfo i = new EditPreferencesInfo();
     i.tabSize = 8;
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 020351b..5c48aaf 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.extensions.client;
 
+import com.google.common.base.MoreObjects;
 import java.util.List;
+import java.util.Objects;
 
 /** Preferences about a single user. */
 public class GeneralPreferencesInfo {
@@ -22,16 +24,6 @@
   /** Default number of items to display per page. */
   public static final int DEFAULT_PAGESIZE = 25;
 
-  /** Preferred method to download a change. */
-  public enum DownloadCommand {
-    PULL,
-    CHECKOUT,
-    CHERRY_PICK,
-    FORMAT_PATCH,
-    BRANCH,
-    RESET,
-  }
-
   public enum DateFormat {
     /** US style dates: Apr 27, Feb 14, 2010 */
     STD("MMM d", "MMM d, yyyy"),
@@ -150,6 +142,13 @@
   public List<MenuItem> my;
   public List<String> changeTable;
   public Boolean allowBrowserNotifications;
+  /**
+   * The sidebar section that the user prefers to have open on the diff page, or "NONE" if all
+   * sidebars should be closed.
+   *
+   * <p>Sidebars supplied by plugins are prefixed with "plugin-".
+   */
+  public String diffPageSidebar;
 
   public DateFormat getDateFormat() {
     if (dateFormat == null) {
@@ -186,6 +185,94 @@
     return emailFormat;
   }
 
+  @Override
+  public boolean equals(Object obj) {
+    if (!(obj instanceof GeneralPreferencesInfo)) {
+      return false;
+    }
+    GeneralPreferencesInfo other = (GeneralPreferencesInfo) obj;
+    return Objects.equals(this.changesPerPage, other.changesPerPage)
+        && Objects.equals(this.downloadScheme, other.downloadScheme)
+        && Objects.equals(this.theme, other.theme)
+        && Objects.equals(this.dateFormat, other.dateFormat)
+        && Objects.equals(this.timeFormat, other.timeFormat)
+        && Objects.equals(this.expandInlineDiffs, other.expandInlineDiffs)
+        && Objects.equals(this.relativeDateInChangeTable, other.relativeDateInChangeTable)
+        && Objects.equals(this.diffView, other.diffView)
+        && Objects.equals(this.sizeBarInChangeTable, other.sizeBarInChangeTable)
+        && Objects.equals(this.legacycidInChangeTable, other.legacycidInChangeTable)
+        && Objects.equals(this.muteCommonPathPrefixes, other.muteCommonPathPrefixes)
+        && Objects.equals(this.signedOffBy, other.signedOffBy)
+        && Objects.equals(this.emailStrategy, other.emailStrategy)
+        && Objects.equals(this.emailFormat, other.emailFormat)
+        && Objects.equals(this.defaultBaseForMerges, other.defaultBaseForMerges)
+        && Objects.equals(this.publishCommentsOnPush, other.publishCommentsOnPush)
+        && Objects.equals(this.disableKeyboardShortcuts, other.disableKeyboardShortcuts)
+        && Objects.equals(this.disableTokenHighlighting, other.disableTokenHighlighting)
+        && Objects.equals(this.workInProgressByDefault, other.workInProgressByDefault)
+        && Objects.equals(this.my, other.my)
+        && Objects.equals(this.changeTable, other.changeTable)
+        && Objects.equals(this.allowBrowserNotifications, other.allowBrowserNotifications)
+        && Objects.equals(this.diffPageSidebar, other.diffPageSidebar);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        changesPerPage,
+        downloadScheme,
+        theme,
+        dateFormat,
+        timeFormat,
+        expandInlineDiffs,
+        relativeDateInChangeTable,
+        diffView,
+        sizeBarInChangeTable,
+        legacycidInChangeTable,
+        muteCommonPathPrefixes,
+        signedOffBy,
+        emailStrategy,
+        emailFormat,
+        defaultBaseForMerges,
+        publishCommentsOnPush,
+        disableKeyboardShortcuts,
+        disableTokenHighlighting,
+        workInProgressByDefault,
+        my,
+        changeTable,
+        allowBrowserNotifications,
+        diffPageSidebar);
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper("GeneralPreferencesInfo")
+        .add("changesPerPage", changesPerPage)
+        .add("downloadScheme", downloadScheme)
+        .add("theme", theme)
+        .add("dateFormat", dateFormat)
+        .add("timeFormat", timeFormat)
+        .add("expandInlineDiffs", expandInlineDiffs)
+        .add("relativeDateInChangeTable", relativeDateInChangeTable)
+        .add("diffView", diffView)
+        .add("sizeBarInChangeTable", sizeBarInChangeTable)
+        .add("legacycidInChangeTable", legacycidInChangeTable)
+        .add("muteCommonPathPrefixes", muteCommonPathPrefixes)
+        .add("signedOffBy", signedOffBy)
+        .add("emailStrategy", emailStrategy)
+        .add("emailFormat", emailFormat)
+        .add("defaultBaseForMerges", defaultBaseForMerges)
+        .add("publishCommentsOnPush", publishCommentsOnPush)
+        .add("disableKeyboardShortcuts", disableKeyboardShortcuts)
+        .add("disableTokenHighlighting", disableTokenHighlighting)
+        .add("workInProgressByDefault", workInProgressByDefault)
+        .add("my", my)
+        .add("changeTable", changeTable)
+        .add("allowBrowserNotifications", allowBrowserNotifications)
+        .add("diffPageSidebar", diffPageSidebar)
+        .toString();
+  }
+
   public static GeneralPreferencesInfo defaults() {
     GeneralPreferencesInfo p = new GeneralPreferencesInfo();
     p.changesPerPage = DEFAULT_PAGESIZE;
@@ -208,6 +295,7 @@
     p.disableTokenHighlighting = false;
     p.workInProgressByDefault = false;
     p.allowBrowserNotifications = true;
+    p.diffPageSidebar = "NONE";
     return p;
   }
 }
diff --git a/java/com/google/gerrit/extensions/client/ListChangesOption.java b/java/com/google/gerrit/extensions/client/ListChangesOption.java
index 48a3502..4cf7b0a 100644
--- a/java/com/google/gerrit/extensions/client/ListChangesOption.java
+++ b/java/com/google/gerrit/extensions/client/ListChangesOption.java
@@ -90,8 +90,17 @@
   /** Include the evaluated submit requirements for the caller. */
   SUBMIT_REQUIREMENTS(24),
 
+  /** Include custom keyed values. */
+  CUSTOM_KEYED_VALUES(25),
+
   /** Include the 'starred' field, that is if the change is starred by the current user . */
-  STAR(25);
+  STAR(26),
+
+  /**
+   * Include the `parents_data` field in each revision, e.g. if it's merged in the target branch and
+   * whether it points to a patch-set of another change.
+   */
+  PARENTS(27);
 
   private final int value;
 
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index dc9bc32..a2e2e8f 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -37,6 +37,8 @@
   // protected by any ListChangesOption.
 
   public String id;
+  public String tripletId;
+
   public String project;
   public String branch;
   public String topic;
@@ -49,6 +51,8 @@
 
   public Map<Integer, AttentionSetInfo> removedFromAttentionSet;
 
+  public Map<String, String> customKeyedValues;
+
   public Collection<String> hashtags;
   public String changeId;
   public String subject;
diff --git a/java/com/google/gerrit/extensions/common/ChangeInput.java b/java/com/google/gerrit/extensions/common/ChangeInput.java
index 6f9cff7..2e2b9ca 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInput.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInput.java
@@ -38,6 +38,7 @@
   public String baseCommit;
   public Boolean newBranch;
   public Map<String, String> validationOptions;
+  public Map<String, String> customKeyedValues;
   public MergeInput merge;
   public ApplyPatchInput patch;
 
diff --git a/java/com/google/gerrit/extensions/common/EmailInfo.java b/java/com/google/gerrit/extensions/common/EmailInfo.java
index 184a89f..96e8adf 100644
--- a/java/com/google/gerrit/extensions/common/EmailInfo.java
+++ b/java/com/google/gerrit/extensions/common/EmailInfo.java
@@ -20,6 +20,6 @@
   public Boolean pendingConfirmation;
 
   public void preferred(String e) {
-    this.preferred = e != null && e.equals(email) ? true : null;
+    this.preferred = (e != null && e.equals(email)) ? true : null;
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/GerritInfo.java b/java/com/google/gerrit/extensions/common/GerritInfo.java
index 3265a00..547e606 100644
--- a/java/com/google/gerrit/extensions/common/GerritInfo.java
+++ b/java/com/google/gerrit/extensions/common/GerritInfo.java
@@ -24,4 +24,5 @@
   public String reportBugUrl;
   public String primaryWeblinkName;
   public String instanceId;
+  public String defaultBranch;
 }
diff --git a/java/com/google/gerrit/extensions/common/MergePatchSetInput.java b/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
index 734d7e9..4a769dd 100644
--- a/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
+++ b/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import com.google.gerrit.extensions.api.accounts.AccountInput;
+import java.util.Map;
 
 public class MergePatchSetInput {
   public String subject;
@@ -22,4 +23,5 @@
   public String baseChange;
   public MergeInput merge;
   public AccountInput author;
+  public Map<String, String> validationOptions;
 }
diff --git a/java/com/google/gerrit/extensions/common/ProjectInfo.java b/java/com/google/gerrit/extensions/common/ProjectInfo.java
index 46b2599..2b00710 100644
--- a/java/com/google/gerrit/extensions/common/ProjectInfo.java
+++ b/java/com/google/gerrit/extensions/common/ProjectInfo.java
@@ -27,4 +27,10 @@
   public Map<String, String> branches;
   public List<WebLinkInfo> webLinks;
   public Map<String, LabelTypeInfo> labels;
+
+  /**
+   * Whether the query would deliver more results if not limited. Only set on the last project that
+   * is returned as a query result.
+   */
+  public Boolean _moreProjects;
 }
diff --git a/java/com/google/gerrit/extensions/common/RevisionInfo.java b/java/com/google/gerrit/extensions/common/RevisionInfo.java
index 941dffe..7b74a06 100644
--- a/java/com/google/gerrit/extensions/common/RevisionInfo.java
+++ b/java/com/google/gerrit/extensions/common/RevisionInfo.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.client.ChangeKind;
 import java.sql.Timestamp;
 import java.time.Instant;
+import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 
@@ -36,6 +37,8 @@
   public String ref;
   public Map<String, FetchInfo> fetch;
   public CommitInfo commit;
+  public List<ParentInfo> parentsData;
+  public String branch;
   public Map<String, FileInfo> files;
   public Map<String, ActionInfo> actions;
   public String commitWithFooters;
@@ -77,6 +80,8 @@
           && Objects.equals(ref, revisionInfo.ref)
           && Objects.equals(fetch, revisionInfo.fetch)
           && Objects.equals(commit, revisionInfo.commit)
+          && Objects.equals(parentsData, revisionInfo.parentsData)
+          && Objects.equals(branch, revisionInfo.branch)
           && Objects.equals(files, revisionInfo.files)
           && Objects.equals(actions, revisionInfo.actions)
           && Objects.equals(commitWithFooters, revisionInfo.commitWithFooters)
@@ -98,10 +103,74 @@
         ref,
         fetch,
         commit,
+        parentsData,
+        branch,
         files,
         actions,
         commitWithFooters,
         pushCertificate,
         description);
   }
+
+  public static class ParentInfo {
+    /** The name of the target branch where the patch-set commit is set to be merged into. */
+    public String branchName;
+
+    /** The commit SHA-1 of the parent commit. */
+    public String commitId;
+
+    /** Whether the parent commit is merged in the target branch. */
+    public Boolean isMergedInTargetBranch;
+
+    /**
+     * If the parent commit is a patch-set of another gerrit change, this field will hold the change
+     * ID of the parent change. Otherwise, will be null.
+     */
+    public String changeId;
+
+    /**
+     * If the parent commit is a patch-set of another gerrit change, this field will hold the change
+     * number of the parent change. Otherwise, will be null.
+     */
+    public Integer changeNumber;
+
+    /**
+     * If the parent commit is a patch-set of another gerrit change, this field will hold the
+     * patch-set number of the parent change. Otherwise, will be null.
+     */
+    public Integer patchSetNumber;
+
+    /**
+     * If the parent commit is a patch-set of another gerrit change, this field will hold the change
+     * status of the parent change. Otherwise, will be null.
+     */
+    public String changeStatus;
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof ParentInfo) {
+        ParentInfo parentInfo = (ParentInfo) o;
+        return Objects.equals(branchName, parentInfo.branchName)
+            && Objects.equals(commitId, parentInfo.commitId)
+            && Objects.equals(isMergedInTargetBranch, parentInfo.isMergedInTargetBranch)
+            && Objects.equals(changeId, parentInfo.changeId)
+            && Objects.equals(changeNumber, parentInfo.changeNumber)
+            && Objects.equals(patchSetNumber, parentInfo.patchSetNumber)
+            && Objects.equals(changeStatus, parentInfo.changeStatus);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(
+          branchName,
+          commitId,
+          isMergedInTargetBranch,
+          changeId,
+          changeNumber,
+          patchSetNumber,
+          changeStatus);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/VersionInfo.java b/java/com/google/gerrit/extensions/common/VersionInfo.java
new file mode 100644
index 0000000..f18e1cc
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/VersionInfo.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2023 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.extensions.common;
+
+public class VersionInfo {
+  public String gerritVersion;
+  public int noteDbVersion;
+  public int changeIndexVersion;
+  public int accountIndexVersion;
+  public int projectIndexVersion;
+  public int groupIndexVersion;
+
+  public String compact() {
+    return "gerrit version " + gerritVersion + "\n";
+  }
+
+  public String verbose() {
+    StringBuilder s = new StringBuilder();
+    s.append("gerrit version " + gerritVersion).append("\n");
+    s.append("NoteDb version " + noteDbVersion).append("\n");
+    s.append("Index versions\n");
+    String format = "  %-8s %3d\n";
+    s.append(String.format(format, "changes", changeIndexVersion));
+    s.append(String.format(format, "accounts", accountIndexVersion));
+    s.append(String.format(format, "projects", projectIndexVersion));
+    s.append(String.format(format, "groups", groupIndexVersion));
+    return s.toString();
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/BUILD b/java/com/google/gerrit/extensions/common/testing/BUILD
index c976de0..33cbb99 100644
--- a/java/com/google/gerrit/extensions/common/testing/BUILD
+++ b/java/com/google/gerrit/extensions/common/testing/BUILD
@@ -6,10 +6,13 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/truth",
         "//lib:guava",
         "//lib:jgit",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
         "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/extensions/common/testing/ChangeInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/ChangeInfoSubject.java
new file mode 100644
index 0000000..f9850dc
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/testing/ChangeInfoSubject.java
@@ -0,0 +1,112 @@
+// Copyright (C) 2023 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.extensions.common.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.LabelInfo;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+
+/** A Truth subject for {@link ChangeInfo} instances. */
+public class ChangeInfoSubject extends Subject {
+  private final ChangeInfo changeInfo;
+
+  public static ChangeInfoSubject assertThat(ChangeInfo changeInfo) {
+    return assertAbout(changes()).that(changeInfo);
+  }
+
+  public static Factory<ChangeInfoSubject, ChangeInfo> changes() {
+    return ChangeInfoSubject::new;
+  }
+
+  private ChangeInfoSubject(FailureMetadata metadata, ChangeInfo changeInfo) {
+    super(metadata, changeInfo);
+    this.changeInfo = changeInfo;
+  }
+
+  private ChangeInfo changeInfo() {
+    isNotNull();
+    return changeInfo;
+  }
+
+  /**
+   * Asserts that the ChangeInfo has exactly the provided votes or fails.
+   *
+   * <p>The 0-value votes and non-existing votes are treated as equal votes. In other word, if
+   * expectedVote has value zero, then the actual vote can be either 0 or not present at all and
+   * vice-verse.
+   */
+  public void hasExactlyVotes(Vote... expectedVotes) {
+    assertWithMessage("ChangeInfo.labels is null").that(changeInfo().labels).isNotNull();
+    Set<Vote> actualVotes = getAllNonZeroVotes(changeInfo().labels);
+    Arrays.stream(expectedVotes)
+        .filter(v -> v.value() == 0 && !actualVotes.contains(v))
+        .forEach(actualVotes::add);
+    assertWithMessage("Votes are different.")
+        .that(actualVotes)
+        .containsExactlyElementsIn(expectedVotes);
+  }
+
+  /** Assers that the ChangeInfo has no votes or fails. */
+  public void hasNoVotes() {
+    hasExactlyVotes();
+  }
+
+  private static Set<Vote> getAllNonZeroVotes(Map<String, LabelInfo> labels) {
+    Set<Vote> votes = new HashSet<>();
+    for (Entry<String, LabelInfo> entry : labels.entrySet()) {
+      List<ApprovalInfo> allApprovals = entry.getValue().all;
+      if (allApprovals == null) {
+        continue;
+      }
+      allApprovals.stream()
+          .filter(approvalInfo -> !approvalInfo.value.equals(0))
+          .map(
+              apprvoalInfo ->
+                  vote(entry.getKey(), Account.id(apprvoalInfo._accountId), apprvoalInfo.value))
+          .forEach(votes::add);
+    }
+    return votes;
+  }
+
+  public static Vote vote(String labelId, Account.Id accountId, int value) {
+    return Vote.create(labelId, accountId, value);
+  }
+
+  @AutoValue
+  public abstract static class Vote {
+    static Vote create(String labelId, Account.Id accountId, int value) {
+      return new AutoValue_ChangeInfoSubject_Vote(labelId, accountId, value);
+    }
+
+    public abstract String labelId();
+
+    public abstract Account.Id accountId();
+
+    public abstract int value();
+  }
+}
diff --git a/java/com/google/gerrit/extensions/events/CommentAddedListener.java b/java/com/google/gerrit/extensions/events/CommentAddedListener.java
index 071dac1..7e0b623 100644
--- a/java/com/google/gerrit/extensions/events/CommentAddedListener.java
+++ b/java/com/google/gerrit/extensions/events/CommentAddedListener.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.events;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import java.util.Map;
@@ -22,6 +23,7 @@
 @ExtensionPoint
 public interface CommentAddedListener {
   interface Event extends RevisionEvent {
+    @Nullable
     String getComment();
 
     Map<String, ApprovalInfo> getApprovals();
diff --git a/java/com/google/gerrit/extensions/events/CustomKeyedValuesEditedListener.java b/java/com/google/gerrit/extensions/events/CustomKeyedValuesEditedListener.java
new file mode 100644
index 0000000..d008675
--- /dev/null
+++ b/java/com/google/gerrit/extensions/events/CustomKeyedValuesEditedListener.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2023 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.extensions.events;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/** Notified whenever a Change's Custom Keyed Values are edited. */
+@ExtensionPoint
+public interface CustomKeyedValuesEditedListener {
+  interface Event extends ChangeEvent {
+    ImmutableMap<String, String> getCustomKeyedValues();
+
+    ImmutableMap<String, String> getAddedCustomKeyedValues();
+
+    ImmutableSet<String> getRemovedCustomKeys();
+  }
+
+  void onCustomKeyedValuesEdited(Event event);
+}
diff --git a/java/com/google/gerrit/extensions/restapi/Response.java b/java/com/google/gerrit/extensions/restapi/Response.java
index bf363d8..3ebae8d 100644
--- a/java/com/google/gerrit/extensions/restapi/Response.java
+++ b/java/com/google/gerrit/extensions/restapi/Response.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.restapi;
 
+import com.google.common.collect.ImmutableMultimap;
 import java.util.concurrent.TimeUnit;
 
 /** Special return value to mean specific HTTP status codes in a REST API. */
@@ -23,7 +24,11 @@
 
   /** HTTP 200 OK: pointless wrapper for type safety. */
   public static <T> Response<T> ok(T value) {
-    return new Impl<>(200, value);
+    return ok(value, ImmutableMultimap.of());
+  }
+
+  public static <T> Response<T> ok(T value, ImmutableMultimap<String, String> headers) {
+    return new Impl<>(200, value, headers);
   }
 
   /** HTTP 200 OK: with empty value. */
@@ -81,6 +86,8 @@
 
   public abstract T value();
 
+  public abstract ImmutableMultimap<String, String> headers();
+
   public abstract CacheControl caching();
 
   public abstract Response<T> caching(CacheControl c);
@@ -91,11 +98,17 @@
   private static final class Impl<T> extends Response<T> {
     private final int statusCode;
     private final T value;
+    private final ImmutableMultimap<String, String> headers;
     private CacheControl caching = CacheControl.NONE;
 
     private Impl(int sc, T val) {
+      this(sc, val, ImmutableMultimap.of());
+    }
+
+    private Impl(int sc, T val, ImmutableMultimap<String, String> hs) {
       statusCode = sc;
       value = val;
+      headers = hs;
     }
 
     @Override
@@ -114,6 +127,11 @@
     }
 
     @Override
+    public ImmutableMultimap<String, String> headers() {
+      return headers;
+    }
+
+    @Override
     public CacheControl caching() {
       return caching;
     }
@@ -149,6 +167,11 @@
     }
 
     @Override
+    public ImmutableMultimap<String, String> headers() {
+      return ImmutableMultimap.of();
+    }
+
+    @Override
     public CacheControl caching() {
       return CacheControl.NONE;
     }
@@ -188,6 +211,11 @@
     }
 
     @Override
+    public ImmutableMultimap<String, String> headers() {
+      return ImmutableMultimap.of();
+    }
+
+    @Override
     public CacheControl caching() {
       return CacheControl.NONE;
     }
@@ -241,6 +269,11 @@
     }
 
     @Override
+    public ImmutableMultimap<String, String> headers() {
+      return ImmutableMultimap.of();
+    }
+
+    @Override
     public CacheControl caching() {
       return CacheControl.NONE;
     }
diff --git a/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java b/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
index 74bccbd..4f8fa10 100644
--- a/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
+++ b/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
@@ -59,6 +59,7 @@
    * @return WebLinkInfo that links to patch set in external service, null if there should be no
    *     link.
    */
+  @Deprecated
   default WebLinkInfo getPatchSetWebLink(
       String projectName,
       String commit,
@@ -67,4 +68,33 @@
       String changeKey) {
     return getPatchSetWebLink(projectName, commit, commitMessage, branchName);
   }
+
+  /**
+   * {@link com.google.gerrit.extensions.common.WebLinkInfo} describing a link from a patch set to
+   * an external service.
+   *
+   * <p>In order for the web link to be visible {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#url} and {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#name} must be set.
+   *
+   * <p>
+   *
+   * @param projectName name of the project
+   * @param commit commit of the patch set
+   * @param commitMessage the commit message of the change
+   * @param branchName target branch of the change
+   * @param changeKey the changeID for this change
+   * @param numericChangeId the numeric changeID for this change
+   * @return WebLinkInfo that links to patch set in external service, null if there should be no
+   *     link.
+   */
+  default WebLinkInfo getPatchSetWebLink(
+      String projectName,
+      String commit,
+      String commitMessage,
+      String branchName,
+      String changeKey,
+      int numericChangeId) {
+    return getPatchSetWebLink(projectName, commit, commitMessage, branchName, changeKey);
+  }
 }
diff --git a/java/com/google/gerrit/git/BUILD b/java/com/google/gerrit/git/BUILD
index 0574716..98dacfa 100644
--- a/java/com/google/gerrit/git/BUILD
+++ b/java/com/google/gerrit/git/BUILD
@@ -10,5 +10,6 @@
         "//lib:jgit",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
     ],
 )
diff --git a/java/com/google/gerrit/git/RefUpdateUtil.java b/java/com/google/gerrit/git/RefUpdateUtil.java
index bd88962..c2db073 100644
--- a/java/com/google/gerrit/git/RefUpdateUtil.java
+++ b/java/com/google/gerrit/git/RefUpdateUtil.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.git;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import java.io.IOException;
 import org.eclipse.jgit.internal.JGitText;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -147,18 +148,19 @@
    *     occurs.
    * @throws IOException if an error occurred.
    */
-  public static void deleteChecked(Repository repo, String refName) throws IOException {
+  @CanIgnoreReturnValue
+  public static RefUpdate deleteChecked(Repository repo, String refName) throws IOException {
     RefUpdate ru = repo.updateRef(refName);
     ru.setForceUpdate(true);
     ru.setCheckConflicting(false);
     switch (ru.delete()) {
       case FORCED:
         // Ref was deleted.
-        return;
+        return ru;
 
       case NEW:
         // Ref didn't exist (yes, really).
-        return;
+        return ru;
 
       case LOCK_FAILURE:
         throw new LockFailureException("Failed to delete " + refName + ": " + ru.getResult(), ru);
diff --git a/java/com/google/gerrit/gpg/BUILD b/java/com/google/gerrit/gpg/BUILD
index b2173c4..3958821 100644
--- a/java/com/google/gerrit/gpg/BUILD
+++ b/java/com/google/gerrit/gpg/BUILD
@@ -4,6 +4,9 @@
     name = "gpg",
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
+    runtime_deps = [
+        "//lib/bouncycastle:bcpg",
+    ],
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
@@ -11,7 +14,6 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/api",
         "//lib:guava",
         "//lib:jgit",
         "//lib/auto:auto-factory",
diff --git a/java/com/google/gerrit/gpg/PublicKeyChecker.java b/java/com/google/gerrit/gpg/PublicKeyChecker.java
index 5347398..946fee3 100644
--- a/java/com/google/gerrit/gpg/PublicKeyChecker.java
+++ b/java/com/google/gerrit/gpg/PublicKeyChecker.java
@@ -237,7 +237,6 @@
       List<PGPSignature> revocations,
       Map<Long, RevocationKey> revokers)
       throws PGPException {
-    @SuppressWarnings("unchecked")
     Iterator<PGPSignature> allSigs = key.getSignatures();
     while (allSigs.hasNext()) {
       PGPSignature sig = allSigs.next();
diff --git a/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java b/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java
new file mode 100644
index 0000000..7040f2d
--- /dev/null
+++ b/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java
@@ -0,0 +1,123 @@
+// Copyright (C) 2023 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.gpg;
+
+import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.io.BaseEncoding;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.bouncycastle.openpgp.PGPException;
+import org.bouncycastle.openpgp.PGPPublicKey;
+import org.bouncycastle.openpgp.PGPPublicKeyRing;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.util.NB;
+
+public class PublicKeyStoreUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final ExternalIds externalIds;
+  private final Provider<PublicKeyStore> storeProvider;
+
+  @Inject
+  PublicKeyStoreUtil(ExternalIds externalIds, Provider<PublicKeyStore> storeProvider) {
+    this.externalIds = externalIds;
+    this.storeProvider = storeProvider;
+  }
+
+  public static byte[] parseFingerprint(ExternalId gpgKeyExtId) {
+    return BaseEncoding.base16().decode(gpgKeyExtId.key().id());
+  }
+
+  public static long keyIdFromFingerprint(byte[] fp) {
+    return NB.decodeInt64(fp, fp.length - 8);
+  }
+
+  public boolean hasInitializedPublicKeyStore() {
+    try {
+      return storeProvider.get() != null;
+    } catch (Exception e) {
+      return false;
+    }
+  }
+
+  public List<PGPPublicKey> listGpgKeysForUser(Account.Id id) throws PGPException, IOException {
+    List<PGPPublicKey> keys = new ArrayList<>();
+    try (PublicKeyStore store = storeProvider.get()) {
+      for (ExternalId extId : getGpgExtIds(id)) {
+        byte[] fp = parseFingerprint(extId);
+        boolean found = false;
+        for (PGPPublicKeyRing keyRing : store.get(keyIdFromFingerprint(fp))) {
+          if (Arrays.equals(keyRing.getPublicKey().getFingerprint(), fp)) {
+            found = true;
+            keys.add(keyRing.getPublicKey());
+            break;
+          }
+        }
+        if (!found) {
+          logger.atWarning().log(
+              "No public key stored for fingerprint %s", Fingerprint.toString(fp));
+        }
+      }
+    }
+    return keys;
+  }
+
+  public Iterable<ExternalId> getGpgExtIds(Account.Id id) throws IOException {
+    return externalIds.byAccount(id, SCHEME_GPGKEY);
+  }
+
+  public RefUpdate.Result deletePgpKey(PGPPublicKey key, PersonIdent committer, PersonIdent author)
+      throws PGPException, IOException {
+    return deletePgpKeys(ImmutableList.of(key), committer, author).get(0);
+  }
+
+  public List<RefUpdate.Result> deletePgpKeys(
+      List<PGPPublicKey> keys, PersonIdent committer, PersonIdent author)
+      throws IOException, PGPException {
+    List<RefUpdate.Result> res = new ArrayList<>();
+    try (PublicKeyStore store = storeProvider.get()) {
+      for (PGPPublicKey key : keys) {
+        store.remove(key.getFingerprint());
+
+        CommitBuilder cb = new CommitBuilder();
+        cb.setAuthor(author);
+        cb.setCommitter(committer);
+        cb.setMessage("Delete public key " + keyIdToString(key.getKeyID()));
+
+        RefUpdate.Result saveResult = store.save(cb);
+        res.add(saveResult);
+      }
+    }
+    return res;
+  }
+
+  public List<RefUpdate.Result> deleteAllPgpKeysForUser(
+      Account.Id id, PersonIdent committer, PersonIdent author) throws PGPException, IOException {
+    return deletePgpKeys(listGpgKeysForUser(id), committer, author);
+  }
+}
diff --git a/java/com/google/gerrit/gpg/SignedPushModule.java b/java/com/google/gerrit/gpg/SignedPushModule.java
index f4fb9f9..98487ca 100644
--- a/java/com/google/gerrit/gpg/SignedPushModule.java
+++ b/java/com/google/gerrit/gpg/SignedPushModule.java
@@ -33,6 +33,7 @@
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
+import com.google.inject.multibindings.OptionalBinder;
 import java.io.IOException;
 import java.security.NoSuchAlgorithmException;
 import java.security.SecureRandom;
@@ -54,7 +55,12 @@
     if (!BouncyCastleUtil.havePGP()) {
       throw new ProvisionException("Bouncy Castle PGP not installed");
     }
-    bind(PublicKeyStore.class).toProvider(StoreProvider.class);
+    // This binding is optional as some modules might bind
+    // {@code UnimplementedPublicKeyStoreProvider} as default binding.
+    OptionalBinder.newOptionalBinder(binder(), PublicKeyStore.class)
+        .setBinding()
+        .toProvider(StoreProvider.class);
+
     DynamicSet.bind(binder(), ReceivePackInitializer.class).to(Initializer.class);
   }
 
diff --git a/java/com/google/gerrit/gpg/UnimplementedPublicKeyStoreProvider.java b/java/com/google/gerrit/gpg/UnimplementedPublicKeyStoreProvider.java
new file mode 100644
index 0000000..12e8edb
--- /dev/null
+++ b/java/com/google/gerrit/gpg/UnimplementedPublicKeyStoreProvider.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2023 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.gpg;
+
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class UnimplementedPublicKeyStoreProvider implements Provider<PublicKeyStore> {
+  @Override
+  public PublicKeyStore get() {
+    throw new NotImplementedException("UnimplementedPublicKeyStoreProvider was bound.");
+  }
+}
diff --git a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
index 6ae0334..57fda5b 100644
--- a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
+++ b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.gpg.api;
 
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
 import com.google.gerrit.extensions.api.accounts.GpgKeysInput;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
@@ -70,8 +68,10 @@
       return gpgKeys.get().list().apply(account).value();
     } catch (PGPException | IOException e) {
       throw new GpgException(e);
+    } catch (RestApiException e) {
+      throw e;
     } catch (Exception e) {
-      throw asRestApiException("Cannot list GPG keys", e);
+      throw RestApiException.wrap("Cannot list GPG keys", e);
     }
   }
 
@@ -86,8 +86,10 @@
       return postGpgKeys.get().apply(account, in).value();
     } catch (PGPException | IOException | ConfigInvalidException e) {
       throw new GpgException(e);
+    } catch (RestApiException e) {
+      throw e;
     } catch (Exception e) {
-      throw asRestApiException("Cannot put GPG keys", e);
+      throw RestApiException.wrap("Cannot put GPG keys", e);
     }
   }
 
diff --git a/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java b/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
index 0ff12e8..2a05f35 100644
--- a/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
+++ b/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.gpg.api;
 
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.Input;
@@ -50,7 +48,7 @@
     try {
       return get.apply(rsrc).value();
     } catch (Exception e) {
-      throw asRestApiException("Cannot get GPG key", e);
+      throw RestApiException.wrap("Cannot get GPG key", e);
     }
   }
 
@@ -58,8 +56,10 @@
   public void delete() throws RestApiException {
     try {
       delete.apply(rsrc, new Input());
+    } catch (RestApiException e) {
+      throw e;
     } catch (PGPException | IOException | ConfigInvalidException e) {
-      throw asRestApiException("Cannot delete GPG key", e);
+      throw RestApiException.wrap("Cannot delete GPG key", e);
     }
   }
 }
diff --git a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
index bcc8631..7f057e8 100644
--- a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
+++ b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.gpg.server;
 
-import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.mail.EmailFactories.KEY_DELETED;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
@@ -28,13 +28,14 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.gpg.PublicKeyStore;
+import com.google.gerrit.gpg.PublicKeyStoreUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.mail.send.DeleteKeySender;
+import com.google.gerrit.server.mail.EmailFactories;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -42,7 +43,6 @@
 import org.bouncycastle.openpgp.PGPException;
 import org.bouncycastle.openpgp.PGPPublicKey;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
 
@@ -50,25 +50,25 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final Provider<PersonIdent> serverIdent;
-  private final Provider<PublicKeyStore> storeProvider;
+  private final PublicKeyStoreUtil publicKeyStoreUtil;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
   private final ExternalIds externalIds;
-  private final DeleteKeySender.Factory deleteKeySenderFactory;
+  private final EmailFactories emailFactories;
   private final ExternalIdKeyFactory externalIdKeyFactory;
 
   @Inject
   DeleteGpgKey(
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
-      Provider<PublicKeyStore> storeProvider,
+      PublicKeyStoreUtil publicKeyStoreUtil,
       @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
       ExternalIds externalIds,
-      DeleteKeySender.Factory deleteKeySenderFactory,
+      EmailFactories emailFactories,
       ExternalIdKeyFactory externalIdKeyFactory) {
     this.serverIdent = serverIdent;
-    this.storeProvider = storeProvider;
+    this.publicKeyStoreUtil = publicKeyStoreUtil;
     this.accountsUpdateProvider = accountsUpdateProvider;
     this.externalIds = externalIds;
-    this.deleteKeySenderFactory = deleteKeySenderFactory;
+    this.emailFactories = emailFactories;
     this.externalIdKeyFactory = externalIdKeyFactory;
   }
 
@@ -90,42 +90,38 @@
             rsrc.getUser().getAccountId(),
             u -> u.deleteExternalId(extId.get()));
 
-    try (PublicKeyStore store = storeProvider.get()) {
-      store.remove(rsrc.getKeyRing().getPublicKey().getFingerprint());
+    PersonIdent committer = serverIdent.get();
+    PersonIdent author = rsrc.getUser().newCommitterIdent(committer);
 
-      CommitBuilder cb = new CommitBuilder();
-      PersonIdent committer = serverIdent.get();
-      cb.setAuthor(rsrc.getUser().newCommitterIdent(committer));
-      cb.setCommitter(committer);
-      cb.setMessage("Delete public key " + keyIdToString(key.getKeyID()));
-
-      RefUpdate.Result saveResult = store.save(cb);
-      switch (saveResult) {
-        case NO_CHANGE:
-        case FAST_FORWARD:
-          try {
-            deleteKeySenderFactory
-                .create(rsrc.getUser(), ImmutableList.of(PublicKeyStore.keyToString(key)))
-                .send();
-          } catch (EmailException e) {
-            logger.atSevere().withCause(e).log(
-                "Cannot send GPG key deletion message to %s",
-                rsrc.getUser().getAccount().preferredEmail());
-          }
-          break;
-        case LOCK_FAILURE:
-        case FORCED:
-        case IO_FAILURE:
-        case NEW:
-        case NOT_ATTEMPTED:
-        case REJECTED:
-        case REJECTED_CURRENT_BRANCH:
-        case RENAMED:
-        case REJECTED_MISSING_OBJECT:
-        case REJECTED_OTHER_REASON:
-        default:
-          throw new StorageException(String.format("Failed to delete public key: %s", saveResult));
-      }
+    RefUpdate.Result saveResult = publicKeyStoreUtil.deletePgpKey(key, committer, author);
+    switch (saveResult) {
+      case NO_CHANGE:
+      case FAST_FORWARD:
+        try {
+          emailFactories
+              .createOutgoingEmail(
+                  KEY_DELETED,
+                  emailFactories.createDeleteKeyEmail(
+                      rsrc.getUser(), ImmutableList.of(PublicKeyStore.keyToString(key))))
+              .send();
+        } catch (EmailException e) {
+          logger.atSevere().withCause(e).log(
+              "Cannot send GPG key deletion message to %s",
+              rsrc.getUser().getAccount().preferredEmail());
+        }
+        break;
+      case LOCK_FAILURE:
+      case FORCED:
+      case IO_FAILURE:
+      case NEW:
+      case NOT_ATTEMPTED:
+      case REJECTED:
+      case REJECTED_CURRENT_BRANCH:
+      case RENAMED:
+      case REJECTED_MISSING_OBJECT:
+      case REJECTED_OTHER_REASON:
+      default:
+        throw new StorageException(String.format("Failed to delete public key: %s", saveResult));
     }
     return Response.none();
   }
diff --git a/java/com/google/gerrit/gpg/server/GpgKeys.java b/java/com/google/gerrit/gpg/server/GpgKeys.java
index 00a0f57..9fb8286 100644
--- a/java/com/google/gerrit/gpg/server/GpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/GpgKeys.java
@@ -14,13 +14,10 @@
 
 package com.google.gerrit.gpg.server;
 
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableList;
-import com.google.common.flogger.FluentLogger;
-import com.google.common.io.BaseEncoding;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -36,10 +33,10 @@
 import com.google.gerrit.gpg.GerritPublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyStore;
+import com.google.gerrit.gpg.PublicKeyStoreUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -48,36 +45,35 @@
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.Iterator;
+import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import org.bouncycastle.bcpg.ArmoredOutputStream;
 import org.bouncycastle.openpgp.PGPException;
 import org.bouncycastle.openpgp.PGPPublicKey;
 import org.bouncycastle.openpgp.PGPPublicKeyRing;
-import org.eclipse.jgit.util.NB;
 
 @Singleton
 public class GpgKeys implements ChildCollection<AccountResource, GpgKey> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final DynamicMap<RestView<GpgKey>> views;
   private final Provider<CurrentUser> self;
+  private final PublicKeyStoreUtil publicKeyStoreUtil;
   private final Provider<PublicKeyStore> storeProvider;
   private final GerritPublicKeyChecker.Factory checkerFactory;
-  private final ExternalIds externalIds;
 
   @Inject
   GpgKeys(
       DynamicMap<RestView<GpgKey>> views,
       Provider<CurrentUser> self,
+      PublicKeyStoreUtil publicKeyStoreUtil,
       Provider<PublicKeyStore> storeProvider,
-      GerritPublicKeyChecker.Factory checkerFactory,
-      ExternalIds externalIds) {
+      GerritPublicKeyChecker.Factory checkerFactory) {
     this.views = views;
     this.self = self;
+    this.publicKeyStoreUtil = publicKeyStoreUtil;
     this.storeProvider = storeProvider;
     this.checkerFactory = checkerFactory;
-    this.externalIds = externalIds;
   }
 
   @Override
@@ -90,10 +86,11 @@
       throws ResourceNotFoundException, PGPException, IOException {
     checkVisible(self, parent);
 
-    ExternalId gpgKeyExtId = findGpgKey(id.get(), getGpgExtIds(parent));
-    byte[] fp = parseFingerprint(gpgKeyExtId);
+    ExternalId gpgKeyExtId =
+        findGpgKey(id.get(), publicKeyStoreUtil.getGpgExtIds(parent.getUser().getAccountId()));
+    byte[] fp = PublicKeyStoreUtil.parseFingerprint(gpgKeyExtId);
     try (PublicKeyStore store = storeProvider.get()) {
-      long keyId = keyId(fp);
+      long keyId = PublicKeyStoreUtil.keyIdFromFingerprint(fp);
       for (PGPPublicKeyRing keyRing : store.get(keyId)) {
         PGPPublicKey key = keyRing.getPublicKey();
         if (Arrays.equals(key.getFingerprint(), fp)) {
@@ -131,10 +128,6 @@
     return gpgKeyExtId;
   }
 
-  static byte[] parseFingerprint(ExternalId gpgKeyExtId) {
-    return BaseEncoding.base16().decode(gpgKeyExtId.key().id());
-  }
-
   @Override
   public DynamicMap<RestView<GpgKey>> views() {
     return views;
@@ -145,29 +138,17 @@
     public Response<Map<String, GpgKeyInfo>> apply(AccountResource rsrc)
         throws PGPException, IOException, ResourceNotFoundException {
       checkVisible(self, rsrc);
-      Map<String, GpgKeyInfo> keys = new HashMap<>();
+      List<PGPPublicKey> keys =
+          publicKeyStoreUtil.listGpgKeysForUser(rsrc.getUser().getAccountId());
+      Map<String, GpgKeyInfo> res = new HashMap<>();
       try (PublicKeyStore store = storeProvider.get()) {
-        for (ExternalId extId : getGpgExtIds(rsrc)) {
-          byte[] fp = parseFingerprint(extId);
-          boolean found = false;
-          for (PGPPublicKeyRing keyRing : store.get(keyId(fp))) {
-            if (Arrays.equals(keyRing.getPublicKey().getFingerprint(), fp)) {
-              found = true;
-              GpgKeyInfo info =
-                  toJson(
-                      keyRing.getPublicKey(), checkerFactory.create(rsrc.getUser(), store), store);
-              keys.put(info.id, info);
-              info.id = null;
-              break;
-            }
-          }
-          if (!found) {
-            logger.atWarning().log(
-                "No public key stored for fingerprint %s", Fingerprint.toString(fp));
-          }
+        for (PGPPublicKey key : keys) {
+          GpgKeyInfo info = toJson(key, checkerFactory.create(rsrc.getUser(), store), store);
+          res.put(info.id, info);
+          info.id = null;
         }
       }
-      return Response.ok(keys);
+      return Response.ok(res);
     }
   }
 
@@ -194,14 +175,6 @@
     }
   }
 
-  private Iterable<ExternalId> getGpgExtIds(AccountResource rsrc) throws IOException {
-    return externalIds.byAccount(rsrc.getUser().getAccountId(), SCHEME_GPGKEY);
-  }
-
-  private static long keyId(byte[] fp) {
-    return NB.decodeInt64(fp, fp.length - 8);
-  }
-
   static void checkVisible(Provider<CurrentUser> self, AccountResource rsrc)
       throws ResourceNotFoundException {
     if (!BouncyCastleUtil.havePGP()) {
diff --git a/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index d51ee6a..886e4dd 100644
--- a/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -17,6 +17,8 @@
 import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
 import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.mail.EmailFactories.KEY_ADDED;
+import static com.google.gerrit.server.mail.EmailFactories.KEY_DELETED;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
@@ -46,6 +48,7 @@
 import com.google.gerrit.gpg.GerritPublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyStore;
+import com.google.gerrit.gpg.PublicKeyStoreUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -57,8 +60,7 @@
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.mail.send.AddKeySender;
-import com.google.gerrit.server.mail.send.DeleteKeySender;
+import com.google.gerrit.server.mail.EmailFactories;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.inject.Inject;
@@ -90,8 +92,7 @@
   private final Provider<CurrentUser> self;
   private final Provider<PublicKeyStore> storeProvider;
   private final GerritPublicKeyChecker.Factory checkerFactory;
-  private final AddKeySender.Factory addKeySenderFactory;
-  private final DeleteKeySender.Factory deleteKeySenderFactory;
+  private final EmailFactories emailFactories;
   private final Provider<InternalAccountQuery> accountQueryProvider;
   private final ExternalIds externalIds;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
@@ -105,8 +106,7 @@
       Provider<CurrentUser> self,
       Provider<PublicKeyStore> storeProvider,
       GerritPublicKeyChecker.Factory checkerFactory,
-      AddKeySender.Factory addKeySenderFactory,
-      DeleteKeySender.Factory deleteKeySenderFactory,
+      EmailFactories emailFactories,
       Provider<InternalAccountQuery> accountQueryProvider,
       ExternalIds externalIds,
       @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
@@ -117,8 +117,7 @@
     this.self = self;
     this.storeProvider = storeProvider;
     this.checkerFactory = checkerFactory;
-    this.addKeySenderFactory = addKeySenderFactory;
-    this.deleteKeySenderFactory = deleteKeySenderFactory;
+    this.emailFactories = emailFactories;
     this.accountQueryProvider = accountQueryProvider;
     this.externalIds = externalIds;
     this.accountsUpdateProvider = accountsUpdateProvider;
@@ -175,7 +174,8 @@
     for (String id : input.delete) {
       try {
         ExternalId gpgKeyExtId = GpgKeys.findGpgKey(id, existingExtIds);
-        fingerprints.put(gpgKeyExtId, new Fingerprint(GpgKeys.parseFingerprint(gpgKeyExtId)));
+        fingerprints.put(
+            gpgKeyExtId, new Fingerprint(PublicKeyStoreUtil.parseFingerprint(gpgKeyExtId)));
       } catch (ResourceNotFoundException e) {
         // Skip removal.
       }
@@ -261,7 +261,9 @@
         case FORCED:
           if (!addedKeys.isEmpty()) {
             try {
-              addKeySenderFactory.create(user, addedKeys).send();
+              emailFactories
+                  .createOutgoingEmail(KEY_ADDED, emailFactories.createAddKeyEmail(user, addedKeys))
+                  .send();
             } catch (EmailException e) {
               logger.atSevere().withCause(e).log(
                   "Cannot send GPG key added message to %s",
@@ -270,8 +272,11 @@
           }
           if (!toRemove.isEmpty()) {
             try {
-              deleteKeySenderFactory
-                  .create(user, toRemove.stream().map(Fingerprint::toString).collect(toList()))
+              emailFactories
+                  .createOutgoingEmail(
+                      KEY_DELETED,
+                      emailFactories.createDeleteKeyEmail(
+                          user, toRemove.stream().map(Fingerprint::toString).collect(toList())))
                   .send();
             } catch (EmailException e) {
               logger.atSevere().withCause(e).log(
diff --git a/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java b/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
index 77c5381..d9f1c09 100644
--- a/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
+++ b/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
@@ -29,7 +29,17 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
-/** Redirects {@code domain.tld/123} to {@code domain.tld/c/project/+/123}. */
+/**
+ * Redirects:
+ *
+ * <ul>
+ *   <li>{@code domain.tld/123} to {@code domain.tld/c/project/+/123}
+ *   <li/>
+ *   <li>{@code domain.tld/123/comment/bc630c55_3e265b44} to {@code
+ *       domain.tld/c/project/+/123/comment/bc630c55_3e265b44/}
+ *   <li/>
+ * </ul>
+ */
 @Singleton
 public class NumericChangeIdRedirectServlet extends HttpServlet {
   private static final long serialVersionUID = 1L;
@@ -43,7 +53,11 @@
 
   @Override
   protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
-    String idString = req.getPathInfo();
+    String uriPath = req.getPathInfo();
+    // Check if we are processing a comment url, like "/c/1/comment/ff3303fd_8341647b/".
+    int commentIdx = uriPath.indexOf("/comment");
+    String idString = commentIdx == -1 ? uriPath : uriPath.substring(0, commentIdx);
+
     if (idString.endsWith("/")) {
       idString = idString.substring(0, idString.length() - 1);
     }
@@ -64,6 +78,10 @@
     }
     String path =
         PageLinks.toChange(changeResource.getProject(), changeResource.getChange().getId());
+    if (commentIdx > -1) {
+      // path already contain a trailing /, hence we start from "commentIdx + 1"
+      path = path + uriPath.substring(commentIdx + 1);
+    }
     UrlModule.toGerrit(path, req, rsp);
   }
 }
diff --git a/java/com/google/gerrit/httpd/RemoteUserUtil.java b/java/com/google/gerrit/httpd/RemoteUserUtil.java
index 6f3e9c4..9ec10e2 100644
--- a/java/com/google/gerrit/httpd/RemoteUserUtil.java
+++ b/java/com/google/gerrit/httpd/RemoteUserUtil.java
@@ -36,6 +36,7 @@
    * @param loginHeader name of header which is used for extracting username.
    * @return the extracted username or null.
    */
+  @Nullable
   public static String getRemoteUser(HttpServletRequest req, String loginHeader) {
     if (AUTHORIZATION.equals(loginHeader)) {
       String user = emptyToNull(req.getRemoteUser());
diff --git a/java/com/google/gerrit/httpd/UrlModule.java b/java/com/google/gerrit/httpd/UrlModule.java
index 69adf82..aad6b57 100644
--- a/java/com/google/gerrit/httpd/UrlModule.java
+++ b/java/com/google/gerrit/httpd/UrlModule.java
@@ -72,7 +72,8 @@
     serveRegex("^/settings/?$").with(screen(PageLinks.SETTINGS));
     serveRegex("^/register$").with(registerScreen(false));
     serveRegex("^/register/(.+)$").with(registerScreen(true));
-    serveRegex("^/([1-9][0-9]*)/?$").with(NumericChangeIdRedirectServlet.class);
+    serveRegex("^(?:/c)?/([1-9][0-9]*)/?$").with(NumericChangeIdRedirectServlet.class);
+    serveRegex("^(?:/c)?/([1-9][0-9]*)/comment/\\w+/?$").with(NumericChangeIdRedirectServlet.class);
     serveRegex("^/p/(.*)$").with(queryProjectNew());
     serveRegex("^/r/(.+)/?$").with(DirectChangeByCommit.class);
 
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index e3cc0a5..e1abcb1 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -51,15 +51,17 @@
 import com.google.gerrit.server.LibModuleType;
 import com.google.gerrit.server.ModuleOverloader;
 import com.google.gerrit.server.StartupChecks.StartupChecksModule;
+import com.google.gerrit.server.account.AccountCacheImpl;
 import com.google.gerrit.server.account.AccountDeactivator.AccountDeactivatorModule;
 import com.google.gerrit.server.account.InternalAccountDirectory.InternalAccountDirectoryModule;
-import com.google.gerrit.server.account.externalids.ExternalIdCaseSensitivityMigrator;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdCaseSensitivityMigrator;
 import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.api.PluginApiModule;
 import com.google.gerrit.server.api.projects.ProjectQueryBuilderModule;
 import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
+import com.google.gerrit.server.change.AttentionSetOwnerAdder.AttentionSetOwnerAdderModule;
 import com.google.gerrit.server.change.ChangeCleanupRunner.ChangeCleanupRunnerModule;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.AuthConfigModule;
@@ -85,6 +87,7 @@
 import com.google.gerrit.server.index.OnlineUpgrader.OnlineUpgraderModule;
 import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.server.index.options.AutoFlush;
+import com.google.gerrit.server.mail.EmailModule;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier.SignedTokenEmailTokenVerifierModule;
 import com.google.gerrit.server.mail.receive.MailReceiver.MailReceiverModule;
 import com.google.gerrit.server.mail.send.SmtpEmailSender.SmtpEmailSenderModule;
@@ -304,10 +307,11 @@
     modules.add(new EventBrokerModule());
     modules.add(new JdbcAccountPatchReviewStoreModule(config));
     modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
-    modules.add(new StreamEventsApiListenerModule());
+    modules.add(new StreamEventsApiListenerModule(config));
     modules.add(new SysExecutorModule());
     modules.add(new DiffExecutorModule());
     modules.add(new MimeUtil2Module());
+    modules.add(cfgInjector.getInstance(AccountCacheImpl.AccountCacheModule.class));
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new GerritApiModule());
     modules.add(new ProjectQueryBuilderModule());
@@ -319,6 +323,7 @@
     modules.add(new DefaultMemoryCacheModule());
     modules.add(new H2CacheModule());
     modules.add(cfgInjector.getInstance(MailReceiverModule.class));
+    modules.add(new EmailModule());
     modules.add(new SmtpEmailSenderModule());
     modules.add(new SignedTokenEmailTokenVerifierModule());
     modules.add(new LocalMergeSuperSetComputationModule());
@@ -360,6 +365,7 @@
           }
         });
     modules.add(new GarbageCollectionModule());
+    modules.add(new AttentionSetOwnerAdderModule());
     modules.add(new ChangeCleanupRunnerModule());
     modules.add(new AccountDeactivatorModule());
     modules.add(new DefaultProjectNameLockManagerModule());
diff --git a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
index 36fa61b..4c42e79 100644
--- a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
@@ -95,13 +95,15 @@
           ListChangesOption.ALL_COMMITS,
           ListChangesOption.ALL_REVISIONS,
           ListChangesOption.CHANGE_ACTIONS,
+          ListChangesOption.DETAILED_ACCOUNTS,
           ListChangesOption.DETAILED_LABELS,
           ListChangesOption.DOWNLOAD_COMMANDS,
           ListChangesOption.MESSAGES,
           ListChangesOption.SUBMITTABLE,
           ListChangesOption.WEB_LINKS,
           ListChangesOption.SKIP_DIFFSTAT,
-          ListChangesOption.SUBMIT_REQUIREMENTS);
+          ListChangesOption.SUBMIT_REQUIREMENTS,
+          ListChangesOption.PARENTS);
 
   @Nullable
   public static String getPath(@Nullable String requestedURL) throws URISyntaxException {
diff --git a/java/com/google/gerrit/httpd/raw/SiteStaticDirectoryServlet.java b/java/com/google/gerrit/httpd/raw/SiteStaticDirectoryServlet.java
index 594415a..bdc4f65 100644
--- a/java/com/google/gerrit/httpd/raw/SiteStaticDirectoryServlet.java
+++ b/java/com/google/gerrit/httpd/raw/SiteStaticDirectoryServlet.java
@@ -35,7 +35,7 @@
   SiteStaticDirectoryServlet(
       SitePaths site,
       @GerritServerConfig Config cfg,
-      @Named(StaticModule.CACHE) Cache<Path, Resource> cache) {
+      @Named(StaticModuleConstants.CACHE) Cache<Path, Resource> cache) {
     super(cache, cfg.getBoolean("site", "refreshHeaderFooter", true));
     Path p;
     try {
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 8319d9d..587d82a 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.httpd.raw;
 
+import static com.google.gerrit.httpd.raw.StaticModuleConstants.CACHE;
+import static com.google.gerrit.httpd.raw.StaticModuleConstants.POLYGERRIT_INDEX_PATHS;
 import static java.nio.file.Files.exists;
 import static java.nio.file.Files.isReadable;
 
@@ -44,6 +46,7 @@
 import java.io.IOException;
 import java.nio.file.FileSystem;
 import java.nio.file.Path;
+import java.util.regex.Pattern;
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -59,28 +62,8 @@
 
 public class StaticModule extends ServletModule {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public static final String CACHE = "static_content";
-
-  /**
-   * Paths at which we should serve the main PolyGerrit application {@code index.html}.
-   *
-   * <p>Supports {@code "/*"} as a trailing wildcard.
-   */
-  public static final ImmutableList<String> POLYGERRIT_INDEX_PATHS =
-      ImmutableList.of(
-          "/",
-          "/c/*",
-          "/id/*",
-          "/p/*",
-          "/q/*",
-          "/x/*",
-          "/admin/*",
-          "/dashboard/*",
-          "/profile/*",
-          "/groups/self",
-          "/settings/*",
-          "/Documentation/q/*");
+  public static final String CHANGE_NUMBER_URI_REGEX = "^(?:/c)?/([1-9][0-9]*)/?$";
+  private static final Pattern CHANGE_NUMBER_URI_PATTERN = Pattern.compile(CHANGE_NUMBER_URI_REGEX);
 
   /**
    * Paths that should be treated as static assets when serving PolyGerrit.
@@ -423,7 +406,11 @@
     }
 
     private static boolean isPolyGerritIndex(String path) {
-      return matchPath(POLYGERRIT_INDEX_PATHS, path);
+      return !isChangeNumberUri(path) && matchPath(POLYGERRIT_INDEX_PATHS, path);
+    }
+
+    private static boolean isChangeNumberUri(String path) {
+      return CHANGE_NUMBER_URI_PATTERN.matcher(path).matches();
     }
 
     private static boolean matchPath(Iterable<String> paths, String path) {
diff --git a/java/com/google/gerrit/httpd/raw/StaticModuleConstants.java b/java/com/google/gerrit/httpd/raw/StaticModuleConstants.java
new file mode 100644
index 0000000..f6ac544
--- /dev/null
+++ b/java/com/google/gerrit/httpd/raw/StaticModuleConstants.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2023 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.httpd.raw;
+
+import com.google.common.collect.ImmutableList;
+
+/**
+ * Various constants related to {@link StaticModule}
+ *
+ * <p>Methods of the {@link StaticModule} are not used internally in google, so moving public
+ * constants into the {@link StaticModuleConstants} allows to exclude {@link StaticModule} from the
+ * google-hosted gerrit hosts.
+ */
+public final class StaticModuleConstants {
+  public static final String CACHE = "static_content";
+
+  /**
+   * Paths at which we should serve the main PolyGerrit application {@code index.html}.
+   *
+   * <p>Supports {@code "/*"} as a trailing wildcard.
+   */
+  public static final ImmutableList<String> POLYGERRIT_INDEX_PATHS =
+      ImmutableList.of(
+          "/",
+          "/c/*",
+          "/id/*",
+          "/p/*",
+          "/q/*",
+          "/x/*",
+          "/admin/*",
+          "/dashboard/*",
+          "/profile/*",
+          "/groups/self",
+          "/settings/*",
+          "/Documentation/q/*");
+
+  private StaticModuleConstants() {}
+}
diff --git a/java/com/google/gerrit/httpd/restapi/CorsResponder.java b/java/com/google/gerrit/httpd/restapi/CorsResponder.java
new file mode 100644
index 0000000..60dce61
--- /dev/null
+++ b/java/com/google/gerrit/httpd/restapi/CorsResponder.java
@@ -0,0 +1,175 @@
+// Copyright (C) 2023 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.httpd.restapi;
+
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_MAX_AGE;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD;
+import static com.google.common.net.HttpHeaders.AUTHORIZATION;
+import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
+import static com.google.common.net.HttpHeaders.ORIGIN;
+import static com.google.common.net.HttpHeaders.VARY;
+import static javax.servlet.http.HttpServletResponse.SC_OK;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.util.http.CacheHeaders;
+import java.util.Locale;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.lib.Config;
+
+/** Provides methods for processing CORS requests. */
+public class CorsResponder {
+  private static final String PLAIN_TEXT = "text/plain";
+  private static final String X_GERRIT_AUTH = "X-Gerrit-Auth";
+  private static final String X_REQUESTED_WITH = "X-Requested-With";
+
+  static final ImmutableSet<String> ALLOWED_CORS_METHODS =
+      ImmutableSet.of("GET", "HEAD", "POST", "PUT", "DELETE");
+  private static final ImmutableSet<String> ALLOWED_CORS_REQUEST_HEADERS =
+      Stream.of(AUTHORIZATION, CONTENT_TYPE, X_GERRIT_AUTH, X_REQUESTED_WITH)
+          .map(s -> s.toLowerCase(Locale.US))
+          .collect(ImmutableSet.toImmutableSet());
+
+  private static boolean isCorsPreflight(HttpServletRequest req) {
+    return "OPTIONS".equals(req.getMethod())
+        && !Strings.isNullOrEmpty(req.getHeader(ORIGIN))
+        && !Strings.isNullOrEmpty(req.getHeader(ACCESS_CONTROL_REQUEST_METHOD));
+  }
+
+  @Nullable
+  public static Pattern makeAllowOrigin(Config cfg) {
+    String[] allow = cfg.getStringList("site", null, "allowOriginRegex");
+    if (allow.length > 0) {
+      return Pattern.compile(Joiner.on('|').join(allow));
+    }
+    return null;
+  }
+
+  @Nullable private final Pattern allowOrigin;
+
+  public CorsResponder(@Nullable Pattern allowOrigin) {
+    this.allowOrigin = allowOrigin;
+  }
+
+  /**
+   * Responses to a CORS preflight request.
+   *
+   * <p>If the request is a CORS preflight request, the method writes a correct preflight response
+   * and returns true. A further processing of the request is not required. Otherwise, the method
+   * returns false without adding anything to the response.
+   */
+  public boolean filterCorsPreflight(HttpServletRequest req, HttpServletResponse res)
+      throws BadRequestException {
+    if (!isCorsPreflight(req)) {
+      return false;
+    }
+    doCorsPreflight(req, res);
+    return true;
+  }
+
+  /**
+   * Processes CORS request and add required headers to the response.
+   *
+   * <p>The method checks if the incoming request is a CORS request and if so validates the
+   * request's origin.
+   */
+  public void checkCors(HttpServletRequest req, HttpServletResponse res, boolean isXd)
+      throws BadRequestException {
+    String origin = req.getHeader(ORIGIN);
+    if (isXd) {
+      // Cross-domain, non-preflighted requests must come from an approved origin.
+      if (Strings.isNullOrEmpty(origin) || !isOriginAllowed(origin)) {
+        throw new BadRequestException("origin not allowed");
+      }
+      res.addHeader(VARY, ORIGIN);
+      res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
+      res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
+    } else if (!Strings.isNullOrEmpty(origin)) {
+      // All other requests must be processed, but conditionally set CORS headers.
+      if (allowOrigin != null) {
+        res.addHeader(VARY, ORIGIN);
+      }
+      if (isOriginAllowed(origin)) {
+        setCorsHeaders(res, origin);
+      }
+    }
+  }
+
+  private void doCorsPreflight(HttpServletRequest req, HttpServletResponse res)
+      throws BadRequestException {
+    CacheHeaders.setNotCacheable(res);
+    setHeaderList(
+        res,
+        VARY,
+        ImmutableList.of(ORIGIN, ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS));
+
+    String origin = req.getHeader(ORIGIN);
+    if (Strings.isNullOrEmpty(origin) || !isOriginAllowed(origin)) {
+      throw new BadRequestException("CORS not allowed");
+    }
+
+    String method = req.getHeader(ACCESS_CONTROL_REQUEST_METHOD);
+    if (!ALLOWED_CORS_METHODS.contains(method)) {
+      throw new BadRequestException(method + " not allowed in CORS");
+    }
+
+    String headers = req.getHeader(ACCESS_CONTROL_REQUEST_HEADERS);
+    if (headers != null) {
+      for (String reqHdr : Splitter.on(',').trimResults().split(headers)) {
+        if (!ALLOWED_CORS_REQUEST_HEADERS.contains(reqHdr.toLowerCase(Locale.US))) {
+          throw new BadRequestException(reqHdr + " not allowed in CORS");
+        }
+      }
+    }
+
+    res.setStatus(SC_OK);
+    setCorsHeaders(res, origin);
+    res.setContentType(PLAIN_TEXT);
+    res.setContentLength(0);
+  }
+
+  private static void setCorsHeaders(HttpServletResponse res, String origin) {
+    res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
+    res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
+    res.setHeader(ACCESS_CONTROL_MAX_AGE, "600");
+    setHeaderList(
+        res,
+        ACCESS_CONTROL_ALLOW_METHODS,
+        Iterables.concat(ALLOWED_CORS_METHODS, ImmutableList.of("OPTIONS")));
+    setHeaderList(res, ACCESS_CONTROL_ALLOW_HEADERS, ALLOWED_CORS_REQUEST_HEADERS);
+  }
+
+  private static void setHeaderList(HttpServletResponse res, String name, Iterable<String> values) {
+    res.setHeader(name, Joiner.on(", ").join(values));
+  }
+
+  private boolean isOriginAllowed(String origin) {
+    return allowOrigin != null && allowOrigin.matcher(origin).matches();
+  }
+}
diff --git a/java/com/google/gerrit/httpd/restapi/ParameterParser.java b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
index 315c9c8..de53e64 100644
--- a/java/com/google/gerrit/httpd/restapi/ParameterParser.java
+++ b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.httpd.restapi;
 
-import static com.google.gerrit.httpd.restapi.RestApiServlet.ALLOWED_CORS_METHODS;
+import static com.google.gerrit.httpd.restapi.CorsResponder.ALLOWED_CORS_METHODS;
 import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_AUTHORIZATION;
 import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_CONTENT_TYPE;
 import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_METHOD;
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 44e7854..9b53c17 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -19,17 +19,7 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.flogger.LazyArgs.lazy;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_MAX_AGE;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD;
-import static com.google.common.net.HttpHeaders.AUTHORIZATION;
 import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
-import static com.google.common.net.HttpHeaders.ORIGIN;
-import static com.google.common.net.HttpHeaders.VARY;
 import static java.math.RoundingMode.CEILING;
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -48,13 +38,11 @@
 import static javax.servlet.http.HttpServletResponse.SC_REQUEST_TIMEOUT;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
@@ -176,10 +164,10 @@
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Enumeration;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
-import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
@@ -187,7 +175,6 @@
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.regex.Pattern;
-import java.util.stream.Stream;
 import java.util.zip.GZIPOutputStream;
 import javax.servlet.ServletException;
 import javax.servlet.http.HttpServlet;
@@ -216,15 +203,6 @@
   @VisibleForTesting
   public static final String X_GERRIT_UPDATED_REF_ENABLED = "X-Gerrit-UpdatedRef-Enabled";
 
-  private static final String X_REQUESTED_WITH = "X-Requested-With";
-  private static final String X_GERRIT_AUTH = "X-Gerrit-Auth";
-  static final ImmutableSet<String> ALLOWED_CORS_METHODS =
-      ImmutableSet.of("GET", "HEAD", "POST", "PUT", "DELETE");
-  private static final ImmutableSet<String> ALLOWED_CORS_REQUEST_HEADERS =
-      Stream.of(AUTHORIZATION, CONTENT_TYPE, X_GERRIT_AUTH, X_REQUESTED_WITH)
-          .map(s -> s.toLowerCase(Locale.US))
-          .collect(ImmutableSet.toImmutableSet());
-
   public static final String XD_AUTHORIZATION = "access_token";
   public static final String XD_CONTENT_TYPE = "$ct";
   public static final String XD_METHOD = "$m";
@@ -302,25 +280,17 @@
       this.changeFinder = changeFinder;
       this.retryHelper = retryHelper;
       this.exceptionHooks = exceptionHooks;
-      allowOrigin = makeAllowOrigin(config);
+      allowOrigin = CorsResponder.makeAllowOrigin(config);
       this.injector = injector;
       this.dynamicBeans = dynamicBeans;
       this.deadlineCheckerFactory = deadlineCheckerFactory;
       this.cancellationMetrics = cancellationMetrics;
     }
-
-    @Nullable
-    private static Pattern makeAllowOrigin(Config cfg) {
-      String[] allow = cfg.getStringList("site", null, "allowOriginRegex");
-      if (allow.length > 0) {
-        return Pattern.compile(Joiner.on('|').join(allow));
-      }
-      return null;
-    }
   }
 
   private final Globals globals;
   private final Provider<RestCollection<RestResource, RestResource>> members;
+  private final CorsResponder corsResponder;
 
   public RestApiServlet(
       Globals globals, RestCollection<? extends RestResource, ? extends RestResource> members) {
@@ -335,6 +305,7 @@
         (Provider<RestCollection<RestResource, RestResource>>) requireNonNull((Object) members);
     this.globals = globals;
     this.members = n;
+    this.corsResponder = new CorsResponder(globals.allowOrigin);
   }
 
   @Override
@@ -358,7 +329,7 @@
 
       try (PerThreadCache ignored = PerThreadCache.create()) {
         List<IdString> path = splitPath(req);
-        RequestInfo requestInfo = createRequestInfo(traceContext, requestUri(req), path);
+        RequestInfo requestInfo = createRequestInfo(traceContext, req, requestUri, path);
         globals.requestListeners.runEach(l -> l.onRequest(requestInfo));
 
         // It's important that the PerformanceLogContext is closed before the response is sent to
@@ -375,13 +346,12 @@
                 new PerformanceLogContext(globals.config, globals.performanceLoggers)) {
           traceRequestData(req);
 
-          if (isCorsPreflight(req)) {
-            doCorsPreflight(req, res);
+          if (corsResponder.filterCorsPreflight(req, res)) {
             return;
           }
 
           qp = ParameterParser.getQueryParams(req);
-          checkCors(req, res, qp.hasXdOverride());
+          corsResponder.checkCors(req, res, qp.hasXdOverride());
           if (qp.hasXdOverride()) {
             req = applyXdOverrides(req, qp);
           }
@@ -550,7 +520,6 @@
                       (RestReadView<RestResource>) viewData.view,
                       rsrc);
             } else if (viewData.view instanceof RestModifyView<?, ?>) {
-              @SuppressWarnings("unchecked")
               RestModifyView<RestResource, Object> m =
                   (RestModifyView<RestResource, Object>) viewData.view;
 
@@ -566,7 +535,6 @@
                 }
               }
             } else if (viewData.view instanceof RestCollectionCreateView<?, ?, ?>) {
-              @SuppressWarnings("unchecked")
               RestCollectionCreateView<RestResource, RestResource, Object> m =
                   (RestCollectionCreateView<RestResource, RestResource, Object>) viewData.view;
 
@@ -581,7 +549,6 @@
                 }
               }
             } else if (viewData.view instanceof RestCollectionDeleteMissingView<?, ?, ?>) {
-              @SuppressWarnings("unchecked")
               RestCollectionDeleteMissingView<RestResource, RestResource, Object> m =
                   (RestCollectionDeleteMissingView<RestResource, RestResource, Object>)
                       viewData.view;
@@ -597,7 +564,6 @@
                 }
               }
             } else if (viewData.view instanceof RestCollectionModifyView<?, ?, ?>) {
-              @SuppressWarnings("unchecked")
               RestCollectionModifyView<RestResource, RestResource, Object> m =
                   (RestCollectionModifyView<RestResource, RestResource, Object>) viewData.view;
 
@@ -635,6 +601,7 @@
             }
 
             statusCode = response.statusCode();
+            response.headers().forEach((k, v) -> res.setHeader(k, v));
             configureCaching(req, res, traceContext, rsrc, viewData, response.caching());
             res.setStatus(statusCode);
             logger.atFinest().log("REST call succeeded: %d", statusCode);
@@ -768,7 +735,8 @@
             if (status.isPresent()) {
               responseBytes = reply(req, res, e, status.get(), getUserMessages(e));
             } else {
-              responseBytes = replyInternalServerError(req, res, e, getUserMessages(e));
+              responseBytes =
+                  replyInternalServerError(req, res, e, getViewName(viewData), getUserMessages(e));
             }
           }
         }
@@ -1032,86 +1000,6 @@
     };
   }
 
-  private void checkCors(HttpServletRequest req, HttpServletResponse res, boolean isXd)
-      throws BadRequestException {
-    String origin = req.getHeader(ORIGIN);
-    if (isXd) {
-      // Cross-domain, non-preflighted requests must come from an approved origin.
-      if (Strings.isNullOrEmpty(origin) || !isOriginAllowed(origin)) {
-        throw new BadRequestException("origin not allowed");
-      }
-      res.addHeader(VARY, ORIGIN);
-      res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
-      res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
-    } else if (!Strings.isNullOrEmpty(origin)) {
-      // All other requests must be processed, but conditionally set CORS headers.
-      if (globals.allowOrigin != null) {
-        res.addHeader(VARY, ORIGIN);
-      }
-      if (isOriginAllowed(origin)) {
-        setCorsHeaders(res, origin);
-      }
-    }
-  }
-
-  private static boolean isCorsPreflight(HttpServletRequest req) {
-    return "OPTIONS".equals(req.getMethod())
-        && !Strings.isNullOrEmpty(req.getHeader(ORIGIN))
-        && !Strings.isNullOrEmpty(req.getHeader(ACCESS_CONTROL_REQUEST_METHOD));
-  }
-
-  private void doCorsPreflight(HttpServletRequest req, HttpServletResponse res)
-      throws BadRequestException {
-    CacheHeaders.setNotCacheable(res);
-    setHeaderList(
-        res,
-        VARY,
-        ImmutableList.of(ORIGIN, ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS));
-
-    String origin = req.getHeader(ORIGIN);
-    if (Strings.isNullOrEmpty(origin) || !isOriginAllowed(origin)) {
-      throw new BadRequestException("CORS not allowed");
-    }
-
-    String method = req.getHeader(ACCESS_CONTROL_REQUEST_METHOD);
-    if (!ALLOWED_CORS_METHODS.contains(method)) {
-      throw new BadRequestException(method + " not allowed in CORS");
-    }
-
-    String headers = req.getHeader(ACCESS_CONTROL_REQUEST_HEADERS);
-    if (headers != null) {
-      for (String reqHdr : Splitter.on(',').trimResults().split(headers)) {
-        if (!ALLOWED_CORS_REQUEST_HEADERS.contains(reqHdr.toLowerCase(Locale.US))) {
-          throw new BadRequestException(reqHdr + " not allowed in CORS");
-        }
-      }
-    }
-
-    res.setStatus(SC_OK);
-    setCorsHeaders(res, origin);
-    res.setContentType(PLAIN_TEXT);
-    res.setContentLength(0);
-  }
-
-  private static void setCorsHeaders(HttpServletResponse res, String origin) {
-    res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
-    res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
-    res.setHeader(ACCESS_CONTROL_MAX_AGE, "600");
-    setHeaderList(
-        res,
-        ACCESS_CONTROL_ALLOW_METHODS,
-        Iterables.concat(ALLOWED_CORS_METHODS, ImmutableList.of("OPTIONS")));
-    setHeaderList(res, ACCESS_CONTROL_ALLOW_HEADERS, ALLOWED_CORS_REQUEST_HEADERS);
-  }
-
-  private static void setHeaderList(HttpServletResponse res, String name, Iterable<String> values) {
-    res.setHeader(name, Joiner.on(", ").join(values));
-  }
-
-  private boolean isOriginAllowed(String origin) {
-    return globals.allowOrigin != null && globals.allowOrigin.matcher(origin).matches();
-  }
-
   private static String messageOr(Throwable t, String defaultMessage) {
     if (!Strings.isNullOrEmpty(t.getMessage())) {
       return t.getMessage();
@@ -1176,7 +1064,7 @@
     }
   }
 
-  private static <R extends RestResource> void setCacheHeaders(
+  private static void setCacheHeaders(
       HttpServletRequest req, HttpServletResponse res, CacheControl cacheControl) {
     if (isRead(req)) {
       switch (cacheControl.getType()) {
@@ -1726,7 +1614,9 @@
   private void checkUserSession(HttpServletRequest req) throws AuthException {
     CurrentUser user = globals.currentUser.get();
     if (isRead(req)) {
-      user.setAccessPath(AccessPath.REST_API);
+      if (user.getAccessPath().equals(AccessPath.UNKNOWN)) {
+        user.setAccessPath(AccessPath.REST_API);
+      }
     } else if (user instanceof AnonymousUser) {
       throw new AuthException("Authentication required");
     } else if (!globals.webSession.get().isAccessPathOk(AccessPath.REST_API)) {
@@ -1780,11 +1670,25 @@
   }
 
   private RequestInfo createRequestInfo(
-      TraceContext traceContext, String requestUri, List<IdString> path) {
+      TraceContext traceContext, HttpServletRequest req, String requestUri, List<IdString> path) {
     RequestInfo.Builder requestInfo =
         RequestInfo.builder(RequestInfo.RequestType.REST, globals.currentUser.get(), traceContext)
             .requestUri(requestUri);
 
+    if (req.getQueryString() != null) {
+      requestInfo.requestQueryString(req.getQueryString());
+    }
+
+    Enumeration<String> headerNames = req.getHeaderNames();
+    while (headerNames.hasMoreElements()) {
+      String headerName = headerNames.nextElement();
+      Enumeration<String> headerValues = req.getHeaders(headerName);
+      while (headerValues.hasMoreElements()) {
+        String headerValue = headerValues.nextElement();
+        requestInfo.addHeader(headerName, headerValue);
+      }
+    }
+
     if (path.size() < 1) {
       return requestInfo.build();
     }
@@ -1900,11 +1804,12 @@
       HttpServletRequest req,
       HttpServletResponse res,
       Throwable err,
+      String viewName,
       ImmutableList<String> userMessages)
       throws IOException {
     logger.atSevere().withCause(err).log(
-        "Error in %s %s: %s",
-        req.getMethod(), uriForLogging(req), globals.retryHelper.formatCause(err));
+        "Error in %s %s (view: %s): %s",
+        req.getMethod(), uriForLogging(req), viewName, globals.retryHelper.formatCause(err));
 
     StringBuilder msg = new StringBuilder("Internal server error");
     if (!userMessages.isEmpty()) {
@@ -1978,8 +1883,9 @@
       case CLIENT_CLOSED_REQUEST:
         return SC_CLIENT_CLOSED_REQUEST;
       case CLIENT_PROVIDED_DEADLINE_EXCEEDED:
-      case SERVER_DEADLINE_EXCEEDED:
         return SC_REQUEST_TIMEOUT;
+      case SERVER_DEADLINE_EXCEEDED:
+        return SC_INTERNAL_SERVER_ERROR;
     }
     logger.atSevere().log("Unexpected cancellation reason: %s", cancellationReason);
     return SC_INTERNAL_SERVER_ERROR;
diff --git a/java/com/google/gerrit/index/BUILD b/java/com/google/gerrit/index/BUILD
index ba1c8bd..8b48fc0 100644
--- a/java/com/google/gerrit/index/BUILD
+++ b/java/com/google/gerrit/index/BUILD
@@ -36,6 +36,7 @@
         "//lib/antlr:java-runtime",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
     ],
 )
diff --git a/java/com/google/gerrit/index/Index.java b/java/com/google/gerrit/index/Index.java
index 870d827..3ed76ba 100644
--- a/java/com/google/gerrit/index/Index.java
+++ b/java/com/google/gerrit/index/Index.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.IndexPredicate;
+import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import java.util.Optional;
@@ -158,12 +159,14 @@
   }
 
   /**
-   * Rewriter that should be invoked on queries to this index.
+   * An Optional filter that is invoked right after the results are returned from the index, but
+   * before any post-filter predicates.
    *
-   * <p>The default implementation does not do anything. Should be overridden by implementation, if
-   * needed.
+   * <p>The filter is invoked before any other index predicates. If the filter returns 'true', then
+   * other index predicates are evaluated. Otherwise, the result from the index is not returned to
+   * the DataSource.
    */
-  default IndexRewriter<V> getIndexRewriter() {
-    return (in, opts) -> in;
+  default Optional<Matchable<V>> getIndexFilter() {
+    return Optional.empty();
   }
 }
diff --git a/java/com/google/gerrit/index/IndexConfig.java b/java/com/google/gerrit/index/IndexConfig.java
index c21f32e..2141bf2 100644
--- a/java/com/google/gerrit/index/IndexConfig.java
+++ b/java/com/google/gerrit/index/IndexConfig.java
@@ -38,6 +38,7 @@
 
   public static Builder fromConfig(Config cfg) {
     Builder b = builder();
+    setIfPresent(cfg, "defaultLimit", b::defaultLimit);
     setIfPresent(cfg, "maxLimit", b::maxLimit);
     setIfPresent(cfg, "maxPages", b::maxPages);
     setIfPresent(cfg, "maxTerms", b::maxTerms);
@@ -67,6 +68,7 @@
 
   public static Builder builder() {
     return new AutoValue_IndexConfig.Builder()
+        .defaultLimit(Integer.MAX_VALUE)
         .maxLimit(Integer.MAX_VALUE)
         .maxPages(Integer.MAX_VALUE)
         .maxTerms(DEFAULT_MAX_TERMS)
@@ -79,6 +81,10 @@
 
   @AutoValue.Builder
   public abstract static class Builder {
+    public abstract Builder defaultLimit(int defaultLimit);
+
+    public abstract int defaultLimit();
+
     public abstract Builder maxLimit(int maxLimit);
 
     public abstract int maxLimit();
@@ -107,6 +113,7 @@
 
     public IndexConfig build() {
       IndexConfig cfg = autoBuild();
+      checkLimit(cfg.defaultLimit(), "defaultLimit");
       checkLimit(cfg.maxLimit(), "maxLimit");
       checkLimit(cfg.maxPages(), "maxPages");
       checkLimit(cfg.maxTerms(), "maxTerms");
@@ -121,6 +128,12 @@
   }
 
   /**
+   * Returns default limit for index queries, if the user does not provide one. If this is not set,
+   * then the max permitted limit for each user is used, which might be much higher than intended.
+   */
+  public abstract int defaultLimit();
+
+  /**
    * Returns maximum limit supported by the underlying index, or limited for performance reasons.
    */
   public abstract int maxLimit();
diff --git a/java/com/google/gerrit/index/QueryOptions.java b/java/com/google/gerrit/index/QueryOptions.java
index 29ab6d0..bee8fa1 100644
--- a/java/com/google/gerrit/index/QueryOptions.java
+++ b/java/com/google/gerrit/index/QueryOptions.java
@@ -52,6 +52,38 @@
       int pageSizeMultiplier,
       int limit,
       Set<String> fields) {
+    return create(
+        config,
+        start,
+        searchAfter,
+        pageSize,
+        pageSizeMultiplier,
+        limit,
+        /* allowIncompleteResults= */ false,
+        fields);
+  }
+
+  public static QueryOptions create(
+      IndexConfig config,
+      int start,
+      int pageSize,
+      int pageSizeMultiplier,
+      int limit,
+      boolean allowIncompleteResults,
+      Set<String> fields) {
+    return create(
+        config, start, null, pageSize, pageSizeMultiplier, limit, allowIncompleteResults, fields);
+  }
+
+  public static QueryOptions create(
+      IndexConfig config,
+      int start,
+      Object searchAfter,
+      int pageSize,
+      int pageSizeMultiplier,
+      int limit,
+      boolean allowIncompleteResults,
+      Set<String> fields) {
     checkArgument(start >= 0, "start must be nonnegative: %s", start);
     checkArgument(limit > 0, "limit must be positive: %s", limit);
     if (searchAfter != null) {
@@ -64,6 +96,7 @@
         pageSize,
         pageSizeMultiplier,
         limit,
+        allowIncompleteResults,
         ImmutableSet.copyOf(fields));
   }
 
@@ -77,7 +110,15 @@
         Math.min(
             Math.min(Ints.saturatedCast((long) pageSize() + start()), config().maxPageSize()),
             backendLimit);
-    return create(config(), 0, null, pageSize, pageSizeMultiplier(), limit, fields());
+    return create(
+        config(),
+        0,
+        null,
+        pageSize,
+        pageSizeMultiplier(),
+        limit,
+        allowIncompleteResults(),
+        fields());
   }
 
   public abstract IndexConfig config();
@@ -93,28 +134,62 @@
 
   public abstract int limit();
 
+  /**
+   * When set to true, entities that fail to get parsed from the index are replaced with a canonical
+   * erroneous record. If false, parsing would throw an exception.
+   */
+  public abstract boolean allowIncompleteResults();
+
   public abstract ImmutableSet<String> fields();
 
   public QueryOptions withPageSize(int pageSize) {
     return create(
-        config(), start(), searchAfter(), pageSize, pageSizeMultiplier(), limit(), fields());
+        config(),
+        start(),
+        searchAfter(),
+        pageSize,
+        pageSizeMultiplier(),
+        limit(),
+        allowIncompleteResults(),
+        fields());
   }
 
   public QueryOptions withLimit(int newLimit) {
     return create(
-        config(), start(), searchAfter(), pageSize(), pageSizeMultiplier(), newLimit, fields());
+        config(),
+        start(),
+        searchAfter(),
+        pageSize(),
+        pageSizeMultiplier(),
+        newLimit,
+        allowIncompleteResults(),
+        fields());
   }
 
   public QueryOptions withStart(int newStart) {
     return create(
-        config(), newStart, searchAfter(), pageSize(), pageSizeMultiplier(), limit(), fields());
+        config(),
+        newStart,
+        searchAfter(),
+        pageSize(),
+        pageSizeMultiplier(),
+        limit(),
+        allowIncompleteResults(),
+        fields());
   }
 
   public QueryOptions withSearchAfter(Object newSearchAfter) {
     // Index search-after APIs don't use 'start', so set it to 0 to be safe. ElasticSearch for
     // example, expects it to be 0 when using search-after APIs.
     return create(
-            config(), start(), newSearchAfter, pageSize(), pageSizeMultiplier(), limit(), fields())
+            config(),
+            start(),
+            newSearchAfter,
+            pageSize(),
+            pageSizeMultiplier(),
+            limit(),
+            allowIncompleteResults(),
+            fields())
         .withStart(0);
   }
 
@@ -126,6 +201,7 @@
         pageSize(),
         pageSizeMultiplier(),
         limit(),
+        allowIncompleteResults(),
         filter.apply(this));
   }
 }
diff --git a/java/com/google/gerrit/index/Schema.java b/java/com/google/gerrit/index/Schema.java
index 974bb74..ab10d9e 100644
--- a/java/com/google/gerrit/index/Schema.java
+++ b/java/com/google/gerrit/index/Schema.java
@@ -291,7 +291,7 @@
    * @param skipFields set of field names to skip when indexing the document
    * @return all non-null field values from the object.
    */
-  public final Iterable<Values<T>> buildFields(T obj, ImmutableSet<String> skipFields) {
+  public final ImmutableList<Values<T>> buildFields(T obj, ImmutableSet<String> skipFields) {
     return schemaFields.values().stream()
         .map(f -> fieldValues(obj, f, skipFields))
         .filter(Objects::nonNull)
diff --git a/java/com/google/gerrit/index/project/ProjectField.java b/java/com/google/gerrit/index/project/ProjectField.java
index ff55546..29c920b 100644
--- a/java/com/google/gerrit/index/project/ProjectField.java
+++ b/java/com/google/gerrit/index/project/ProjectField.java
@@ -44,6 +44,9 @@
   public static final IndexedField<ProjectData, String>.SearchSpec NAME_SPEC =
       NAME_FIELD.exact("name");
 
+  public static final IndexedField<ProjectData, String>.SearchSpec PREFIX_NAME_SPEC =
+      NAME_FIELD.prefix("nameprefix");
+
   public static final IndexedField<ProjectData, String> DESCRIPTION_FIELD =
       IndexedField.<ProjectData>stringBuilder("Description")
           .stored()
@@ -59,6 +62,13 @@
   public static final IndexedField<ProjectData, String>.SearchSpec PARENT_NAME_SPEC =
       PARENT_NAME_FIELD.exact("parent_name");
 
+  public static final IndexedField<ProjectData, String> PARENT_NAME_2_FIELD =
+      IndexedField.<ProjectData>stringBuilder("ParentName2")
+          .build(p -> p.getParent().map(parent -> parent.getProject().getName()).orElse(null));
+
+  public static final IndexedField<ProjectData, String>.SearchSpec PARENT_NAME_2_SPEC =
+      PARENT_NAME_2_FIELD.exact("parent_name2");
+
   public static final IndexedField<ProjectData, Iterable<String>> NAME_PART_FIELD =
       IndexedField.<ProjectData>iterableStringBuilder("NamePart")
           .size(200)
diff --git a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
index 3ac594e..6cd43db 100644
--- a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
+++ b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
@@ -62,7 +62,24 @@
   @Deprecated static final Schema<ProjectData> V4 = schema(V3);
 
   // Upgrade Lucene to 7.x requires reindexing.
-  static final Schema<ProjectData> V5 = schema(V4);
+  @Deprecated static final Schema<ProjectData> V5 = schema(V4);
+
+  // Upgrade Lucene to 8.x requires reindexing.
+  @Deprecated static final Schema<ProjectData> V6 = schema(V5);
+
+  @Deprecated
+  static final Schema<ProjectData> V7 =
+      new Schema.Builder<ProjectData>()
+          .add(V6)
+          .addIndexedFields(ProjectField.PARENT_NAME_2_FIELD)
+          .addSearchSpecs(ProjectField.PARENT_NAME_2_SPEC)
+          .build();
+
+  static final Schema<ProjectData> V8 =
+      new Schema.Builder<ProjectData>()
+          .add(V7)
+          .addSearchSpecs(ProjectField.PREFIX_NAME_SPEC)
+          .build();
 
   /**
    * Name of the project index to be used when contacting index backends or loading configurations.
diff --git a/java/com/google/gerrit/index/project/ProjectSubstringPredicate.java b/java/com/google/gerrit/index/project/ProjectSubstringPredicate.java
new file mode 100644
index 0000000..fb6e5ae
--- /dev/null
+++ b/java/com/google/gerrit/index/project/ProjectSubstringPredicate.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2023 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.index.project;
+
+import com.google.gerrit.index.query.PostFilterPredicate;
+import java.util.Locale;
+
+/** Predicate to match projects by a substring in the project name. */
+public class ProjectSubstringPredicate extends PostFilterPredicate<ProjectData> {
+
+  public ProjectSubstringPredicate(String fieldName, String value) {
+    super(fieldName, value);
+  }
+
+  @Override
+  public boolean match(ProjectData projectData) {
+    return projectData
+        .getProject()
+        .getName()
+        .toLowerCase(Locale.US)
+        .contains(getValue().toLowerCase(Locale.US));
+  }
+
+  @Override
+  public int getCost() {
+    return 2;
+  }
+}
diff --git a/java/com/google/gerrit/index/query/AndSource.java b/java/com/google/gerrit/index/query/AndSource.java
index 3adf881..6de0712 100644
--- a/java/com/google/gerrit/index/query/AndSource.java
+++ b/java/com/google/gerrit/index/query/AndSource.java
@@ -17,11 +17,12 @@
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.PaginationType;
 import java.util.Collection;
 import java.util.List;
 
 public class AndSource<T> extends AndPredicate<T> implements DataSource<T> {
-  protected final DataSource<T> source;
+  protected final FilteredSource<T> filteredSource;
 
   private final int start;
   private final int cardinality;
@@ -58,18 +59,18 @@
     if (selectedSource == null) {
       throw new IllegalArgumentException("No DataSource Found");
     }
-    this.source = toPaginatingSource(selectedSource);
+    this.filteredSource = toDataSource(selectedSource);
     this.cardinality = c;
   }
 
   @Override
   public ResultSet<T> read() {
-    return source.read();
+    return filteredSource.read();
   }
 
   @Override
   public ResultSet<FieldBundle> readRaw() {
-    return source.readRaw();
+    return filteredSource.readRaw();
   }
 
   @Override
@@ -91,17 +92,44 @@
   }
 
   @SuppressWarnings("unchecked")
-  private PaginatingSource<T> toPaginatingSource(Predicate<T> pred) {
-    return new PaginatingSource<>((DataSource<T>) pred, start, indexConfig) {
-      @Override
-      protected boolean match(T object) {
-        return AndSource.this.match(object);
-      }
+  private FilteredSource<T> toDataSource(Predicate<T> pred) {
+    if (indexConfig.paginationType().equals(PaginationType.NONE)) {
+      return new DatasourceWithoutPagination((DataSource<T>) pred, start, indexConfig);
+    }
+    return new DatasourceWithPagination((DataSource<T>) pred, start, indexConfig);
+  }
 
-      @Override
-      protected boolean isMatchable() {
-        return AndSource.this.isMatchable();
-      }
-    };
+  private class DatasourceWithoutPagination extends FilteredSource<T> {
+
+    public DatasourceWithoutPagination(DataSource<T> source, int start, IndexConfig indexConfig) {
+      super(source, start, indexConfig);
+    }
+
+    @Override
+    protected boolean match(T object) {
+      return AndSource.this.match(object);
+    }
+
+    @Override
+    protected boolean isMatchable() {
+      return AndSource.this.isMatchable();
+    }
+  }
+
+  private class DatasourceWithPagination extends PaginatingSource<T> {
+
+    public DatasourceWithPagination(DataSource<T> source, int start, IndexConfig indexConfig) {
+      super(source, start, indexConfig);
+    }
+
+    @Override
+    protected boolean match(T object) {
+      return AndSource.this.match(object);
+    }
+
+    @Override
+    protected boolean isMatchable() {
+      return AndSource.this.isMatchable();
+    }
   }
 }
diff --git a/java/com/google/gerrit/index/query/FilteredSource.java b/java/com/google/gerrit/index/query/FilteredSource.java
new file mode 100644
index 0000000..fb31eb6
--- /dev/null
+++ b/java/com/google/gerrit/index/query/FilteredSource.java
@@ -0,0 +1,94 @@
+// Copyright (C) 2023 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.index.query;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.IndexConfig;
+import java.util.ArrayList;
+import java.util.List;
+
+public class FilteredSource<T> implements DataSource<T> {
+
+  protected final DataSource<T> source;
+  protected final int start;
+  protected final int cardinality;
+  protected final IndexConfig indexConfig;
+  private static final int PARTITION_SIZE = 50;
+
+  public FilteredSource(DataSource<T> source, int start, IndexConfig indexConfig) {
+    checkArgument(start >= 0, "negative start: %s", start);
+    this.source = source;
+    this.start = start;
+    this.cardinality = source.getCardinality();
+    this.indexConfig = indexConfig;
+  }
+
+  @Override
+  public ResultSet<T> read() {
+    if (source == null) {
+      throw new StorageException("No DataSource defined.");
+    }
+    // ResultSets are lazy. Calling #read here first and then dealing with ResultSets only when
+    // requested allows the index to run asynchronous queries.
+    ResultSet<T> resultSet = source.read();
+    return new LazyResultSet<>(
+        () -> {
+          List<T> r = new ArrayList<>();
+          for (T data : buffer(resultSet)) {
+            if (!isMatchable() || match(data)) {
+              r.add(data);
+            }
+          }
+          if (start >= r.size()) {
+            return ImmutableList.of();
+          } else if (start > 0) {
+            return ImmutableList.copyOf(r.subList(start, r.size()));
+          }
+          return ImmutableList.copyOf(r);
+        });
+  }
+
+  @Override
+  public ResultSet<FieldBundle> readRaw() {
+    return source.readRaw();
+  }
+
+  protected Iterable<T> buffer(ResultSet<T> scanner) {
+    return FluentIterable.from(Iterables.partition(scanner, PARTITION_SIZE))
+        .transformAndConcat(this::transformBuffer);
+  }
+
+  protected List<T> transformBuffer(List<T> buffer) {
+    return buffer;
+  }
+
+  @Override
+  public int getCardinality() {
+    return cardinality;
+  }
+
+  protected boolean match(T object) {
+    return true;
+  }
+
+  protected boolean isMatchable() {
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/index/query/IndexPredicate.java b/java/com/google/gerrit/index/query/IndexPredicate.java
index 0bde640..e41742b 100644
--- a/java/com/google/gerrit/index/query/IndexPredicate.java
+++ b/java/com/google/gerrit/index/query/IndexPredicate.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import java.util.Locale;
 import java.util.Objects;
-import java.util.Set;
 import java.util.stream.StreamSupport;
 
 /** Predicate that is mapped to a field in the index. */
@@ -103,8 +102,8 @@
     } else if (fieldTypeName.equals(FieldType.PREFIX.getName())) {
       return String.valueOf(fieldValueFromObject).startsWith(value);
     } else if (fieldTypeName.equals(FieldType.FULL_TEXT.getName())) {
-      Set<String> tokenizedField = tokenizeString(String.valueOf(fieldValueFromObject));
-      Set<String> tokenizedValue = tokenizeString(value);
+      ImmutableSet<String> tokenizedField = tokenizeString(String.valueOf(fieldValueFromObject));
+      ImmutableSet<String> tokenizedValue = tokenizeString(value);
       return !tokenizedValue.isEmpty() && tokenizedField.containsAll(tokenizedValue);
     } else if (fieldTypeName.equals(FieldType.STORED_ONLY.getName())) {
       throw new IllegalStateException("can't filter for storedOnly field " + getField().getName());
diff --git a/java/com/google/gerrit/index/query/InternalQuery.java b/java/com/google/gerrit/index/query/InternalQuery.java
index b6418a9..d610dbf 100644
--- a/java/com/google/gerrit/index/query/InternalQuery.java
+++ b/java/com/google/gerrit/index/query/InternalQuery.java
@@ -89,7 +89,7 @@
     return self();
   }
 
-  public final List<T> query(Predicate<T> p) {
+  public final ImmutableList<T> query(Predicate<T> p) {
     return queryResults(p).entities();
   }
 
diff --git a/java/com/google/gerrit/index/query/PaginatingSource.java b/java/com/google/gerrit/index/query/PaginatingSource.java
index 98a0ed3..be789ee 100644
--- a/java/com/google/gerrit/index/query/PaginatingSource.java
+++ b/java/com/google/gerrit/index/query/PaginatingSource.java
@@ -14,11 +14,7 @@
 
 package com.google.gerrit.index.query;
 
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.IndexConfig;
@@ -27,18 +23,10 @@
 import java.util.ArrayList;
 import java.util.List;
 
-public class PaginatingSource<T> implements DataSource<T> {
-  protected final DataSource<T> source;
-  private final int start;
-  private final int cardinality;
-  private final IndexConfig indexConfig;
+public class PaginatingSource<T> extends FilteredSource<T> {
 
   public PaginatingSource(DataSource<T> source, int start, IndexConfig indexConfig) {
-    checkArgument(start >= 0, "negative start: %s", start);
-    this.source = source;
-    this.start = start;
-    this.cardinality = source.getCardinality();
-    this.indexConfig = indexConfig;
+    super(source, start, indexConfig);
   }
 
   @Override
@@ -63,13 +51,7 @@
             pageResultSize++;
           }
 
-          if (last != null
-              && source instanceof Paginated
-              // TODO: this fix is only for the stable branches and the real refactoring would be to
-              // restore the logic
-              // for the filtering in AndSource (L58 - 64) as per
-              // https://gerrit-review.googlesource.com/c/gerrit/+/345634/9
-              && !indexConfig.paginationType().equals(PaginationType.NONE)) {
+          if (last != null && source instanceof Paginated) {
             // Restart source and continue if we have not filled the
             // full limit the caller wants.
             //
@@ -117,34 +99,6 @@
     throw new UnsupportedOperationException("not implemented");
   }
 
-  private Iterable<T> buffer(ResultSet<T> scanner) {
-    return FluentIterable.from(Iterables.partition(scanner, 50))
-        .transformAndConcat(this::transformBuffer);
-  }
-
-  /**
-   * Checks whether the given object matches.
-   *
-   * @param object the object to be matched
-   * @return whether the given object matches
-   */
-  protected boolean match(T object) {
-    return true;
-  }
-
-  protected boolean isMatchable() {
-    return true;
-  }
-
-  protected List<T> transformBuffer(List<T> buffer) {
-    return buffer;
-  }
-
-  @Override
-  public int getCardinality() {
-    return cardinality;
-  }
-
   private int getNextPageSize(int pageSize, int pageSizeMultiplier) {
     List<Integer> possiblePageSizes = new ArrayList<>(3);
     try {
diff --git a/java/com/google/gerrit/index/query/Predicate.java b/java/com/google/gerrit/index/query/Predicate.java
index e251b00..fc9bc00 100644
--- a/java/com/google/gerrit/index/query/Predicate.java
+++ b/java/com/google/gerrit/index/query/Predicate.java
@@ -194,7 +194,7 @@
   @Override
   public abstract boolean equals(Object other);
 
-  private static class Any<T> extends Predicate<T> implements Matchable<T> {
+  public static class Any<T> extends Predicate<T> implements Matchable<T> {
     private static final Any<Object> INSTANCE = new Any<>();
 
     private Any() {}
diff --git a/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
index 1f8266a..f49cecb 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -26,12 +26,14 @@
 import com.google.common.collect.Ordering;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexCollection;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.IndexRewriter;
+import com.google.gerrit.index.PaginationType;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.metrics.Description;
@@ -92,6 +94,7 @@
   private boolean enforceVisibility = true;
   private int userProvidedLimit;
   private boolean isNoLimit;
+  private boolean allowIncompleteResults;
   private Set<String> requestedFields;
 
   protected QueryProcessor(
@@ -163,6 +166,12 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
+  public QueryProcessor<T> setAllowIncompleteResults(boolean allowIncompleteResults) {
+    this.allowIncompleteResults = allowIncompleteResults;
+    return this;
+  }
+
   public QueryProcessor<T> setRequestedFields(Set<String> fields) {
     requestedFields = fields;
     return this;
@@ -270,12 +279,12 @@
                 // max for this user. The only way to see if there are more entities is to
                 // ask for one more result from the query.
                 // NOTE: This is consistent to the behaviour before the introduction of pagination.`
-                Ints.saturatedCast((long) limit + 1),
+                limit == getBackendSupportedLimit() ? limit : Ints.saturatedCast((long) limit + 1),
+                allowIncompleteResults,
                 getRequestedFields());
         logger.atFine().log("Query options: %s", opts);
         // Apply index-specific rewrite first
-        Predicate<T> pred = indexes.getSearchIndex().getIndexRewriter().rewrite(q, opts);
-        pred = rewriter.rewrite(pred, opts);
+        Predicate<T> pred = rewriter.rewrite(q, opts);
         if (enforceVisibility) {
           pred = enforceVisibility(pred);
         }
@@ -288,7 +297,9 @@
 
         @SuppressWarnings("unchecked")
         DataSource<T> s = (DataSource<T>) pred;
-        if (initialPageSize < limit && !(pred instanceof AndSource)) {
+        if (!indexConfig.paginationType().equals(PaginationType.NONE)
+            && initialPageSize < limit
+            && !(pred instanceof AndSource)) {
           s = new PaginatingSource<>(s, start, indexConfig);
         }
         sources.add(s);
@@ -302,16 +313,20 @@
 
       out = new ArrayList<>(cnt);
       for (int i = 0; i < cnt; i++) {
+        String queryString = queryStrings != null ? queryStrings.get(i) : null;
         ImmutableList<T> matchesList = matches.get(i).toList();
+        int matchCount = matchesList.size();
+        int limit = limits.get(i);
         logger.atFine().log(
             "Matches[%d]:\n%s",
             i, lazy(() -> matchesList.stream().map(this::formatForLogging).collect(toList())));
-        out.add(
-            QueryResult.create(
-                queryStrings != null ? queryStrings.get(i) : null,
-                predicates.get(i),
-                limits.get(i),
-                matchesList));
+        // TODO(brohlfs): Remove this extra logging by end of Q3 2023.
+        if (limit > 500 && userProvidedLimit <= 0 && matchCount > 100 && enforceVisibility) {
+          logger.atWarning().log(
+              "%s index query without provided limit. effective limit: %d, result count: %d, query: %s",
+              schemaDef.getName(), getPermittedLimit(), matchCount, queryString);
+        }
+        out.add(QueryResult.create(queryString, predicates.get(i), limit, matchesList));
       }
 
       // Only measure successful queries that actually touched the index.
@@ -355,9 +370,16 @@
       int pageSize,
       int pageSizeMultiplier,
       int limit,
+      boolean allowIncompleteResults,
       Set<String> requestedFields) {
     return QueryOptions.create(
-        indexConfig, start, pageSize, pageSizeMultiplier, limit, requestedFields);
+        indexConfig,
+        start,
+        pageSize,
+        pageSizeMultiplier,
+        limit,
+        allowIncompleteResults,
+        requestedFields);
   }
 
   /**
@@ -411,6 +433,8 @@
     possibleLimits.add(getPermittedLimit());
     if (userProvidedLimit > 0) {
       possibleLimits.add(userProvidedLimit);
+    } else if (indexConfig.defaultLimit() > 0) {
+      possibleLimits.add(indexConfig.defaultLimit());
     }
     if (limitField != null) {
       Integer limitFromPredicate = LimitPredicate.getLimit(limitField, p);
diff --git a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
index e4c6745..86d062b2 100644
--- a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
+++ b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
@@ -85,7 +85,7 @@
     this.indexName = indexName;
     this.indexedDocuments = new HashMap<>();
     this.queryCount = 0;
-    this.resultsSizes = new ArrayList<Integer>();
+    this.resultsSizes = new ArrayList<>();
   }
 
   @Override
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index 2fd1c45..938cd67 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -552,8 +552,7 @@
                 (long) getLimitBasedOnPaginationType(opts, opts.pageSize()) + opts.start());
         TopFieldDocs docs =
             opts.searchAfter() != null
-                ? searcher.searchAfter(
-                    (ScoreDoc) opts.searchAfter(), query, realLimit, sort, false, false)
+                ? searcher.searchAfter((ScoreDoc) opts.searchAfter(), query, realLimit, sort, false)
                 : searcher.search(query, realLimit, sort);
         ImmutableList.Builder<T> b = ImmutableList.builderWithExpectedSize(docs.scoreDocs.length);
         for (int i = opts.start(); i < docs.scoreDocs.length; i++) {
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 4c05f70..6718e36 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -416,12 +416,7 @@
             if (maxRemainingHits > 0) {
               TopFieldDocs subIndexHits =
                   searchers[i].searchAfter(
-                      searchAfter,
-                      query,
-                      maxRemainingHits,
-                      sort,
-                      /* doDocScores= */ false,
-                      /* doMaxScore= */ false);
+                      searchAfter, query, maxRemainingHits, sort, /* doDocScores= */ false);
               searchAfterHitsCount += subIndexHits.scoreDocs.length;
               hits.add(subIndexHits);
               searchAfterBySubIndex.put(
@@ -560,7 +555,7 @@
     }
 
     for (SchemaField<ChangeData, ?> field : getSchema().getSchemaFields().values()) {
-      if (fields.contains(field.getName()) && doc.get(field.getName()) != null) {
+      if (fields.contains(field.getName())) {
         field.setIfPossible(cd, new LuceneStoredValue(doc.get(field.getName())));
       }
     }
diff --git a/java/com/google/gerrit/lucene/QueryBuilder.java b/java/com/google/gerrit/lucene/QueryBuilder.java
index 14ad528..4ff41a1 100644
--- a/java/com/google/gerrit/lucene/QueryBuilder.java
+++ b/java/com/google/gerrit/lucene/QueryBuilder.java
@@ -79,6 +79,8 @@
       return or(p);
     } else if (p instanceof NotPredicate) {
       return not(p);
+    } else if (p instanceof Predicate.Any) {
+      return new MatchAllDocsQuery();
     } else if (p instanceof IndexPredicate) {
       return fieldQuery((IndexPredicate<V>) p);
     } else if (p instanceof PostFilterPredicate) {
diff --git a/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java b/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
index ca750cd..998d838 100644
--- a/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
+++ b/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
@@ -21,6 +21,9 @@
 
   ThreadMXBeanSun(java.lang.management.ThreadMXBean sys) {
     this.sys = (ThreadMXBean) sys;
+    if (this.sys.isThreadAllocatedMemorySupported()) {
+      this.sys.setThreadAllocatedMemoryEnabled(true);
+    }
   }
 
   @Override
@@ -40,7 +43,7 @@
 
   @Override
   public boolean supportsAllocatedBytes() {
-    return true;
+    return sys.isThreadAllocatedMemorySupported();
   }
 
   @Override
diff --git a/java/com/google/gerrit/pgm/BUILD b/java/com/google/gerrit/pgm/BUILD
index df64bc7..8523e8a 100644
--- a/java/com/google/gerrit/pgm/BUILD
+++ b/java/com/google/gerrit/pgm/BUILD
@@ -20,6 +20,7 @@
         "//java/com/google/gerrit/httpd/auth/restapi",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index/project",
+        "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/launcher",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/lucene",
@@ -39,6 +40,7 @@
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/server/version",
         "//java/com/google/gerrit/sshd",
         "//lib:args4j",
         "//lib:guava",
@@ -56,5 +58,6 @@
         "//lib/prolog:cafeteria",
         "//lib/prolog:compiler",
         "//lib/prolog:runtime",
+        "@gson//jar",
     ],
 )
diff --git a/java/com/google/gerrit/pgm/ChangeExternalIdCaseSensitivity.java b/java/com/google/gerrit/pgm/ChangeExternalIdCaseSensitivity.java
index 2b7f23e..00e8fa4 100644
--- a/java/com/google/gerrit/pgm/ChangeExternalIdCaseSensitivity.java
+++ b/java/com/google/gerrit/pgm/ChangeExternalIdCaseSensitivity.java
@@ -16,15 +16,15 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.util.SiteProgram;
-import com.google.gerrit.server.account.externalids.DisabledExternalIdCache;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdCaseSensitivityMigrator;
-import com.google.gerrit.server.account.externalids.ExternalIdUpsertPreprocessor;
 import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.storage.notedb.DisabledExternalIdCache;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdCaseSensitivityMigrator;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNoteDbReadStorageModule;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNoteDbWriteStorageModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -84,7 +84,8 @@
                     new FactoryModuleBuilder()
                         .build(ExternalIdCaseSensitivityMigrator.Factory.class));
                 factory(MetaDataUpdate.InternalFactory.class);
-                DynamicMap.mapOf(binder(), ExternalIdUpsertPreprocessor.class);
+                install(new ExternalIdNoteDbReadStorageModule());
+                install(new ExternalIdNoteDbWriteStorageModule());
 
                 // The ChangeExternalIdCaseSensitivity program needs to access all external IDs only
                 // once to update them. After the update they are not accessed again. Hence the
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 72c465d..6230136 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -60,15 +60,19 @@
 import com.google.gerrit.server.LibModuleType;
 import com.google.gerrit.server.ModuleOverloader;
 import com.google.gerrit.server.StartupChecks.StartupChecksModule;
+import com.google.gerrit.server.account.AccountCacheImpl;
 import com.google.gerrit.server.account.AccountDeactivator.AccountDeactivatorModule;
 import com.google.gerrit.server.account.InternalAccountDirectory.InternalAccountDirectoryModule;
-import com.google.gerrit.server.account.externalids.ExternalIdCaseSensitivityMigrator;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdCaseSensitivityMigrator;
+import com.google.gerrit.server.account.storage.notedb.AccountNoteDbReadStorageModule;
+import com.google.gerrit.server.account.storage.notedb.AccountNoteDbWriteStorageModule;
 import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.api.PluginApiModule;
 import com.google.gerrit.server.api.projects.ProjectQueryBuilderModule;
 import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
+import com.google.gerrit.server.change.AttentionSetOwnerAdder.AttentionSetOwnerAdderModule;
 import com.google.gerrit.server.change.ChangeCleanupRunner.ChangeCleanupRunnerModule;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.AuthConfigModule;
@@ -94,10 +98,12 @@
 import com.google.gerrit.server.index.OnlineUpgrader.OnlineUpgraderModule;
 import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.server.index.options.AutoFlush;
+import com.google.gerrit.server.mail.EmailModule;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier.SignedTokenEmailTokenVerifierModule;
 import com.google.gerrit.server.mail.receive.MailReceiver.MailReceiverModule;
 import com.google.gerrit.server.mail.send.SmtpEmailSender.SmtpEmailSenderModule;
 import com.google.gerrit.server.mime.MimeUtil2Module;
+import com.google.gerrit.server.notedb.RepoSequence.RepoSequenceModule;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.permissions.DefaultPermissionBackendModule;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
@@ -450,7 +456,7 @@
     modules.add(new SubscriptionGraphModule());
     modules.add(new SuperprojectUpdateSubmissionListenerModule());
     modules.add(new WorkQueueModule());
-    modules.add(new StreamEventsApiListenerModule());
+    modules.add(new StreamEventsApiListenerModule(config));
     modules.add(new EventBrokerModule());
     if (accountPatchReviewStoreModule != null) {
       modules.add(accountPatchReviewStoreModule);
@@ -460,6 +466,11 @@
     modules.add(new SysExecutorModule());
     modules.add(new DiffExecutorModule());
     modules.add(new MimeUtil2Module());
+    modules.add(cfgInjector.getInstance(AccountCacheImpl.AccountCacheModule.class));
+
+    modules.add(new AccountNoteDbWriteStorageModule());
+    modules.add(new AccountNoteDbReadStorageModule());
+    modules.add(new RepoSequenceModule());
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new GerritApiModule());
     modules.add(new ProjectQueryBuilderModule());
@@ -475,6 +486,7 @@
     modules.add(new DefaultMemoryCacheModule());
     modules.add(new H2CacheModule());
     modules.add(cfgInjector.getInstance(MailReceiverModule.class));
+    modules.add(new EmailModule());
     if (emailModule != null) {
       modules.add(emailModule);
     } else {
@@ -538,6 +550,7 @@
       modules.add(new PeriodicGroupIndexerModule());
     } else {
       modules.add(new AccountDeactivatorModule());
+      modules.add(new AttentionSetOwnerAdderModule());
       modules.add(new ChangeCleanupRunnerModule());
     }
     modules.add(new LocalMergeSuperSetComputationModule());
diff --git a/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java b/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
index b7ff1f7..6967fb1 100644
--- a/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
+++ b/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
@@ -20,11 +20,11 @@
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.pgm.util.SiteProgram;
-import com.google.gerrit.server.account.externalids.DisabledExternalIdCache;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
-import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.storage.notedb.DisabledExternalIdCache;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNotes;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index b4344d7..a2e780d 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -32,6 +32,8 @@
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.server.LibModuleLoader;
 import com.google.gerrit.server.ModuleOverloader;
+import com.google.gerrit.server.account.storage.notedb.AccountNoteDbReadStorageModule;
+import com.google.gerrit.server.account.storage.notedb.AccountNoteDbWriteStorageModule;
 import com.google.gerrit.server.cache.CacheDisplay;
 import com.google.gerrit.server.cache.CacheInfo;
 import com.google.gerrit.server.change.ChangeResource;
@@ -42,6 +44,7 @@
 import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.gerrit.server.index.options.BuildBloomFilter;
 import com.google.gerrit.server.index.options.IsFirstInsertForEntry;
+import com.google.gerrit.server.notedb.RepoSequence.RepoSequenceModule;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.gerrit.server.util.ReplicaUtil;
 import com.google.inject.AbstractModule;
@@ -224,6 +227,9 @@
             factory(ChangeResource.Factory.class);
           }
         });
+    modules.add(new AccountNoteDbWriteStorageModule());
+    modules.add(new AccountNoteDbReadStorageModule());
+    modules.add(new RepoSequenceModule());
 
     return dbInjector.createChildInjector(
         ModuleOverloader.override(
diff --git a/java/com/google/gerrit/pgm/SwitchSecureStore.java b/java/com/google/gerrit/pgm/SwitchSecureStore.java
index 6dec2d8..063fcdb 100644
--- a/java/com/google/gerrit/pgm/SwitchSecureStore.java
+++ b/java/com/google/gerrit/pgm/SwitchSecureStore.java
@@ -18,7 +18,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.IoUtil;
+import com.google.gerrit.common.JarUtil;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.SiteLibraryLoaderUtil;
 import com.google.gerrit.pgm.util.SiteProgram;
@@ -79,7 +79,7 @@
       return -1;
     }
 
-    IoUtil.loadJARs(newSecureStorePath);
+    JarUtil.loadJars(newSecureStorePath);
     SiteLibraryLoaderUtil.loadSiteLib(sitePaths.lib_dir);
 
     logger.atInfo().log(
diff --git a/java/com/google/gerrit/pgm/Version.java b/java/com/google/gerrit/pgm/Version.java
index 2392be5..27c52d3 100644
--- a/java/com/google/gerrit/pgm/Version.java
+++ b/java/com/google/gerrit/pgm/Version.java
@@ -14,18 +14,40 @@
 
 package com.google.gerrit.pgm;
 
+import com.google.gerrit.extensions.common.VersionInfo;
+import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.pgm.util.AbstractProgram;
+import com.google.gerrit.server.version.VersionInfoModule;
+import org.kohsuke.args4j.Option;
 
 /** Display the version of Gerrit. */
 public class Version extends AbstractProgram {
+
+  @Option(
+      name = "--verbose",
+      aliases = {"-v"},
+      usage = "verbose version info")
+  private boolean verbose;
+
+  @Option(name = "--json", usage = "json output format, assumes verbose output")
+  private boolean json;
+
   @Override
   public int run() throws Exception {
-    final String v = com.google.gerrit.common.Version.getVersion();
-    if (v == null) {
+    VersionInfo versionInfo = new VersionInfoModule().createVersionInfo();
+    if (versionInfo.gerritVersion == null) {
       System.err.println("fatal: version unavailable");
       return 1;
     }
-    System.out.println("gerrit version " + v);
+
+    if (json) {
+      System.out.println(OutputFormat.JSON.newGson().toJson(versionInfo));
+    } else if (verbose) {
+      System.out.print(versionInfo.verbose());
+    } else {
+      System.out.print(versionInfo.compact());
+    }
+
     return 0;
   }
 }
diff --git a/java/com/google/gerrit/pgm/init/AccountsOnInit.java b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
index be5fe1a..1a0e5d8 100644
--- a/java/com/google/gerrit/pgm/init/AccountsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
@@ -14,124 +14,12 @@
 
 package com.google.gerrit.pgm.init;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.Objects.requireNonNull;
-
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
-import com.google.gerrit.pgm.init.api.InitFlags;
-import com.google.gerrit.server.GerritPersonIdentProvider;
-import com.google.gerrit.server.account.AccountDelta;
-import com.google.gerrit.server.account.AccountProperties;
-import com.google.gerrit.server.account.Accounts;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.inject.Inject;
-import java.io.File;
 import java.io.IOException;
-import java.nio.file.Path;
-import org.eclipse.jgit.dircache.DirCache;
-import org.eclipse.jgit.dircache.DirCacheEditor;
-import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
-import org.eclipse.jgit.dircache.DirCacheEntry;
-import org.eclipse.jgit.internal.storage.file.FileRepository;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.FileMode;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.RefUpdate.Result;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.RepositoryCache.FileKey;
-import org.eclipse.jgit.util.FS;
 
-public class AccountsOnInit {
-  private final InitFlags flags;
-  private final SitePaths site;
-  private final String allUsers;
+public interface AccountsOnInit {
 
-  @Inject
-  public AccountsOnInit(InitFlags flags, SitePaths site, AllUsersNameOnInitProvider allUsers) {
-    this.flags = flags;
-    this.site = site;
-    this.allUsers = allUsers.get();
-  }
+  Account insert(Account.Builder account) throws IOException;
 
-  public Account insert(Account.Builder account) throws IOException {
-    File path = getPath();
-    try (Repository repo = new FileRepository(path);
-        ObjectInserter oi = repo.newObjectInserter()) {
-      PersonIdent ident =
-          new PersonIdent(new GerritPersonIdentProvider(flags.cfg).get(), account.registeredOn());
-
-      Config accountConfig = new Config();
-      AccountProperties.writeToAccountConfig(
-          AccountDelta.builder()
-              .setActive(!account.inactive())
-              .setFullName(account.fullName())
-              .setPreferredEmail(account.preferredEmail())
-              .setStatus(account.status())
-              .build(),
-          accountConfig);
-
-      DirCache newTree = DirCache.newInCore();
-      DirCacheEditor editor = newTree.editor();
-      final ObjectId blobId = oi.insert(Constants.OBJ_BLOB, accountConfig.toText().getBytes(UTF_8));
-      editor.add(
-          new PathEdit(AccountProperties.ACCOUNT_CONFIG) {
-            @Override
-            public void apply(DirCacheEntry ent) {
-              ent.setFileMode(FileMode.REGULAR_FILE);
-              ent.setObjectId(blobId);
-            }
-          });
-      editor.finish();
-
-      ObjectId treeId = newTree.writeTree(oi);
-
-      CommitBuilder cb = new CommitBuilder();
-      cb.setTreeId(treeId);
-      cb.setCommitter(ident);
-      cb.setAuthor(ident);
-      cb.setMessage("Create Account");
-      ObjectId id = oi.insert(cb);
-      oi.flush();
-
-      String refName = RefNames.refsUsers(account.id());
-      RefUpdate ru = repo.updateRef(refName);
-      ru.setExpectedOldObjectId(ObjectId.zeroId());
-      ru.setNewObjectId(id);
-      ru.setRefLogIdent(ident);
-      ru.setRefLogMessage("Create Account", false);
-      Result result = ru.update();
-      if (result != Result.NEW) {
-        throw new IOException(String.format("Failed to update ref %s: %s", refName, result.name()));
-      }
-      account.setMetaId(id.name());
-    }
-    return account.build();
-  }
-
-  public boolean hasAnyAccount() throws IOException {
-    File path = getPath();
-    if (path == null) {
-      return false;
-    }
-
-    try (Repository repo = new FileRepository(path)) {
-      return Accounts.hasAnyAccount(repo);
-    }
-  }
-
-  private File getPath() {
-    Path basePath = site.resolve(flags.cfg.getString("gerrit", null, "basePath"));
-    requireNonNull(basePath, "gerrit.basePath must be configured");
-    File file = basePath.resolve(allUsers).toFile();
-    File resolvedFile = FileKey.resolve(file, FS.DETECTED);
-    requireNonNull(resolvedFile, () -> String.format("%s does not exist", file.getAbsolutePath()));
-    return resolvedFile;
-  }
+  boolean hasAnyAccount() throws IOException;
 }
diff --git a/java/com/google/gerrit/pgm/init/AccountsOnInitNoteDbImpl.java b/java/com/google/gerrit/pgm/init/AccountsOnInitNoteDbImpl.java
new file mode 100644
index 0000000..e3e485f
--- /dev/null
+++ b/java/com/google/gerrit/pgm/init/AccountsOnInitNoteDbImpl.java
@@ -0,0 +1,137 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
+import com.google.gerrit.pgm.init.api.InitFlags;
+import com.google.gerrit.server.GerritPersonIdentProvider;
+import com.google.gerrit.server.account.AccountDelta;
+import com.google.gerrit.server.account.AccountProperties;
+import com.google.gerrit.server.account.storage.notedb.AccountsNoteDbRepoReader;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.dircache.DirCacheEntry;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.RepositoryCache;
+import org.eclipse.jgit.util.FS;
+
+public class AccountsOnInitNoteDbImpl implements AccountsOnInit {
+  private final InitFlags flags;
+  private final SitePaths site;
+  private final String allUsers;
+
+  @Inject
+  AccountsOnInitNoteDbImpl(InitFlags flags, SitePaths site, AllUsersNameOnInitProvider allUsers) {
+    this.flags = flags;
+    this.site = site;
+    this.allUsers = allUsers.get();
+  }
+
+  @Override
+  public Account insert(Account.Builder account) throws IOException {
+    File path = getPath();
+    try (Repository repo = new FileRepository(path);
+        ObjectInserter oi = repo.newObjectInserter()) {
+      PersonIdent ident =
+          new PersonIdent(new GerritPersonIdentProvider(flags.cfg).get(), account.registeredOn());
+
+      Config accountConfig = new Config();
+      AccountProperties.writeToAccountConfig(
+          AccountDelta.builder()
+              .setActive(!account.inactive())
+              .setFullName(account.fullName())
+              .setPreferredEmail(account.preferredEmail())
+              .setStatus(account.status())
+              .build(),
+          accountConfig);
+
+      DirCache newTree = DirCache.newInCore();
+      DirCacheEditor editor = newTree.editor();
+      final ObjectId blobId = oi.insert(Constants.OBJ_BLOB, accountConfig.toText().getBytes(UTF_8));
+      editor.add(
+          new DirCacheEditor.PathEdit(AccountProperties.ACCOUNT_CONFIG) {
+            @Override
+            public void apply(DirCacheEntry ent) {
+              ent.setFileMode(FileMode.REGULAR_FILE);
+              ent.setObjectId(blobId);
+            }
+          });
+      editor.finish();
+
+      ObjectId treeId = newTree.writeTree(oi);
+
+      CommitBuilder cb = new CommitBuilder();
+      cb.setTreeId(treeId);
+      cb.setCommitter(ident);
+      cb.setAuthor(ident);
+      cb.setMessage("Create Account");
+      ObjectId id = oi.insert(cb);
+      oi.flush();
+
+      String refName = RefNames.refsUsers(account.id());
+      RefUpdate ru = repo.updateRef(refName);
+      ru.setExpectedOldObjectId(ObjectId.zeroId());
+      ru.setNewObjectId(id);
+      ru.setRefLogIdent(ident);
+      ru.setRefLogMessage("Create Account", false);
+      RefUpdate.Result result = ru.update();
+      if (result != RefUpdate.Result.NEW) {
+        throw new IOException(String.format("Failed to update ref %s: %s", refName, result.name()));
+      }
+      account.setMetaId(id.name());
+    }
+    return account.build();
+  }
+
+  @Override
+  public boolean hasAnyAccount() throws IOException {
+    File path = getPath();
+    if (path == null) {
+      return false;
+    }
+
+    try (Repository repo = new FileRepository(path)) {
+      return AccountsNoteDbRepoReader.hasAnyAccount(repo);
+    }
+  }
+
+  private File getPath() {
+    Path basePath = site.resolve(flags.cfg.getString("gerrit", null, "basePath"));
+    requireNonNull(basePath, "gerrit.basePath must be configured");
+    File file = basePath.resolve(allUsers).toFile();
+    File resolvedFile = RepositoryCache.FileKey.resolve(file, FS.DETECTED);
+    requireNonNull(resolvedFile, () -> String.format("%s does not exist", file.getAbsolutePath()));
+    return resolvedFile;
+  }
+}
diff --git a/java/com/google/gerrit/pgm/init/BaseInit.java b/java/com/google/gerrit/pgm/init/BaseInit.java
index b59b924..abaefb2 100644
--- a/java/com/google/gerrit/pgm/init/BaseInit.java
+++ b/java/com/google/gerrit/pgm/init/BaseInit.java
@@ -21,7 +21,7 @@
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Die;
-import com.google.gerrit.common.IoUtil;
+import com.google.gerrit.common.JarUtil;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.IndexType;
@@ -331,7 +331,7 @@
                 "%s has more that one implementation of %s interface",
                 secureStore, SecureStore.class.getName()));
       }
-      IoUtil.loadJARs(secureStoreLib);
+      JarUtil.loadJars(secureStoreLib);
       return new SecureStoreInitData(secureStoreLib, secureStores.get(0));
     } catch (IOException e) {
       throw new InvalidSecureStoreException(String.format("%s is not a valid jar", secureStore), e);
diff --git a/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java b/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
index 35892f2..a056a08 100644
--- a/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
@@ -18,8 +18,8 @@
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdFactory;
-import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdFactoryNoteDbImpl;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNotes;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.SitePaths;
@@ -41,7 +41,7 @@
   private final InitFlags flags;
   private final SitePaths site;
   private final AllUsersName allUsers;
-  private final ExternalIdFactory externalIdFactory;
+  private final ExternalIdFactoryNoteDbImpl externalIdFactory;
   private final AuthConfig authConfig;
 
   @Inject
@@ -49,7 +49,7 @@
       InitFlags flags,
       SitePaths site,
       AllUsersNameOnInitProvider allUsers,
-      ExternalIdFactory externalIdFactory,
+      ExternalIdFactoryNoteDbImpl externalIdFactory,
       AuthConfig authConfig) {
     this.flags = flags;
     this.site = site;
diff --git a/java/com/google/gerrit/pgm/init/InitModule.java b/java/com/google/gerrit/pgm/init/InitModule.java
index 32c6697..f36ec3d 100644
--- a/java/com/google/gerrit/pgm/init/InitModule.java
+++ b/java/com/google/gerrit/pgm/init/InitModule.java
@@ -14,10 +14,20 @@
 
 package com.google.gerrit.pgm.init;
 
+import static com.google.gerrit.server.Sequence.LightweightAccounts;
+import static com.google.inject.Scopes.SINGLETON;
+
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.Section;
+import com.google.gerrit.pgm.init.api.SequencesOnInit.DisabledGitRefUpdatedRepoAccountsSequenceProvider;
+import com.google.gerrit.server.Sequence;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdFactoryNoteDbImpl;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Singleton;
 import com.google.inject.binder.LinkedBindingBuilder;
 import com.google.inject.internal.UniqueAnnotations;
 import java.lang.annotation.Annotation;
@@ -34,6 +44,10 @@
   @Override
   protected void configure() {
     bind(SitePaths.class);
+    bind(AllUsersName.class).toProvider(AllUsersNameProvider.class).in(SINGLETON);
+    bind(Sequence.class)
+        .annotatedWith(LightweightAccounts.class)
+        .toProvider(DisabledGitRefUpdatedRepoAccountsSequenceProvider.class);
     factory(Section.Factory.class);
     factory(VersionedAuthorizedKeysOnInit.Factory.class);
 
@@ -55,6 +69,9 @@
     step().to(InitCache.class);
     step().to(InitPlugins.class);
     step().to(InitDev.class);
+
+    bind(AccountsOnInit.class).to(AccountsOnInitNoteDbImpl.class);
+    bind(ExternalIdFactory.class).to(ExternalIdFactoryNoteDbImpl.class).in(Singleton.class);
   }
 
   protected LinkedBindingBuilder<InitStep> step() {
diff --git a/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
index a057e66..d0d03b5 100644
--- a/java/com/google/gerrit/pgm/init/SitePathInitializer.java
+++ b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
@@ -122,6 +122,8 @@
     extractMailExample("DeleteReviewerHtml.soy");
     extractMailExample("DeleteVote.soy");
     extractMailExample("DeleteVoteHtml.soy");
+    extractMailExample("Email.soy");
+    extractMailExample("EmailHtml.soy");
     extractMailExample("Footer.soy");
     extractMailExample("FooterHtml.soy");
     extractMailExample("ChangeHeader.soy");
diff --git a/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java b/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
index c11230c..68b1de7 100644
--- a/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
+++ b/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
@@ -14,34 +14,66 @@
 
 package com.google.gerrit.pgm.init.api;
 
-import com.google.gerrit.entities.Project;
+import static com.google.gerrit.server.Sequence.LightweightAccounts;
+
+import com.google.gerrit.server.Sequence;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.RepoSequence;
-import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.notedb.RepoSequence.RepoSequenceModule;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class SequencesOnInit {
-  private final GitRepositoryManager repoManager;
-  private final AllUsersNameOnInitProvider allUsersName;
+  private final Sequence accountsSequence;
 
   @Inject
-  SequencesOnInit(GitRepositoryManagerOnInit repoManager, AllUsersNameOnInitProvider allUsersName) {
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
+  SequencesOnInit(@LightweightAccounts Sequence accountsSequence) {
+    this.accountsSequence = accountsSequence;
   }
 
   public int nextAccountId() {
-    RepoSequence accountSeq =
-        new RepoSequence(
-            repoManager,
-            GitReferenceUpdated.DISABLED,
-            Project.nameKey(allUsersName.get()),
-            Sequences.NAME_ACCOUNTS,
-            () -> Sequences.FIRST_ACCOUNT_ID,
-            1);
-    return accountSeq.next();
+    return accountsSequence.next();
+  }
+
+  /** A accounts sequence provider that does not fire git reference updates. */
+  public static class DisabledGitRefUpdatedRepoAccountsSequenceProvider
+      implements Provider<Sequence> {
+    private final GitRepositoryManager repoManager;
+    private final AllUsersName allUsers;
+    private final Config cfg;
+
+    @Inject
+    DisabledGitRefUpdatedRepoAccountsSequenceProvider(
+        @GerritServerConfig Config cfg,
+        GitRepositoryManagerOnInit repoManager,
+        AllUsersName allUsersName) {
+      this.repoManager = repoManager;
+      this.allUsers = allUsersName;
+      this.cfg = cfg;
+    }
+
+    @Override
+    public Sequence get() {
+      int accountBatchSize =
+          cfg.getInt(
+              RepoSequenceModule.SECTION_NOTE_DB,
+              Sequence.NAME_ACCOUNTS,
+              RepoSequenceModule.KEY_SEQUENCE_BATCH_SIZE,
+              RepoSequenceModule.DEFAULT_ACCOUNTS_SEQUENCE_BATCH_SIZE);
+      return new RepoSequence(
+          repoManager,
+          GitReferenceUpdated.DISABLED,
+          allUsers,
+          Sequence.NAME_ACCOUNTS,
+          () -> Sequences.FIRST_ACCOUNT_ID,
+          accountBatchSize);
+    }
   }
 }
diff --git a/java/com/google/gerrit/pgm/util/BUILD b/java/com/google/gerrit/pgm/util/BUILD
index f7c2b75..5b01c9c 100644
--- a/java/com/google/gerrit/pgm/util/BUILD
+++ b/java/com/google/gerrit/pgm/util/BUILD
@@ -15,6 +15,7 @@
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/cache/mem",
         "//java/com/google/gerrit/server/restapi",
+        "//java/com/google/gerrit/server/rules/prolog",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/util/cli",
         "//java/com/google/gerrit/util/logging",
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 1e41cbc..21ae8e1 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.ChangeDraftUpdate;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.DefaultRefLogIdentityProvider;
 import com.google.gerrit.server.IdentifiedUser;
@@ -42,7 +43,7 @@
 import com.google.gerrit.server.account.GroupIncludeCacheImpl;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.account.ServiceUserClassifierImpl;
-import com.google.gerrit.server.account.externalids.ExternalIdCacheModule;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdCacheModule;
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
@@ -72,6 +73,7 @@
 import com.google.gerrit.server.git.ChangesByProjectCache;
 import com.google.gerrit.server.git.PureRevertCache;
 import com.google.gerrit.server.git.TagCache;
+import com.google.gerrit.server.notedb.ChangeDraftNotesUpdate;
 import com.google.gerrit.server.notedb.NoteDbModule;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.patch.DiffOperationsImpl;
@@ -93,8 +95,8 @@
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule.DefaultSubmitRuleModule;
 import com.google.gerrit.server.rules.IgnoreSelfApprovalRule.IgnoreSelfApprovalRuleModule;
-import com.google.gerrit.server.rules.PrologModule;
 import com.google.gerrit.server.rules.SubmitRule;
+import com.google.gerrit.server.rules.prolog.PrologModule;
 import com.google.gerrit.server.submitrequirement.predicate.DistinctVotersPredicate;
 import com.google.gerrit.server.submitrequirement.predicate.FileEditsPredicate;
 import com.google.gerrit.server.submitrequirement.predicate.HasSubmoduleUpdatePredicate;
@@ -202,6 +204,7 @@
     factory(DistinctVotersPredicate.Factory.class);
     factory(HasSubmoduleUpdatePredicate.Factory.class);
     factory(ProjectState.Factory.class);
+    bind(ChangeDraftUpdate.ChangeDraftUpdateFactory.class).to(ChangeDraftNotesUpdate.Factory.class);
 
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class);
diff --git a/java/com/google/gerrit/proto/testing/BUILD b/java/com/google/gerrit/proto/testing/BUILD
index 0e5f887..069bb46 100644
--- a/java/com/google/gerrit/proto/testing/BUILD
+++ b/java/com/google/gerrit/proto/testing/BUILD
@@ -9,6 +9,7 @@
     deps = [
         "//lib:guava",
         "//lib/commons:lang3",
+        "//lib/guice",
         "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/proto/testing/SerializedClassSubject.java b/java/com/google/gerrit/proto/testing/SerializedClassSubject.java
index 79affc6..1264478 100644
--- a/java/com/google/gerrit/proto/testing/SerializedClassSubject.java
+++ b/java/com/google/gerrit/proto/testing/SerializedClassSubject.java
@@ -20,6 +20,7 @@
 
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.Subject;
+import com.google.inject.TypeLiteral;
 import java.lang.reflect.Field;
 import java.lang.reflect.Method;
 import java.lang.reflect.Modifier;
@@ -61,10 +62,12 @@
   }
 
   private final Class<?> clazz;
+  private final TypeLiteral<?> clazzTl;
 
   private SerializedClassSubject(FailureMetadata metadata, Class<?> clazz) {
     super(metadata, clazz);
     this.clazz = clazz;
+    this.clazzTl = TypeLiteral.get(clazz);
   }
 
   public void isAbstract() {
@@ -87,7 +90,7 @@
         .that(
             FieldUtils.getAllFieldsList(clazz).stream()
                 .filter(f -> !Modifier.isStatic(f.getModifiers()))
-                .collect(toImmutableMap(Field::getName, Field::getGenericType)))
+                .collect(toImmutableMap(Field::getName, f -> clazzTl.getFieldType(f).getType())))
         .containsExactlyEntriesIn(expectedFields);
   }
 
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 2be3383..000f095 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -14,6 +14,8 @@
     "account/externalids/testing/ExternalIdTestUtil.java",
 ]
 
+PROLOG_SRC = ["rules/prolog/*.java"]
+
 java_library(
     name = "constants",
     srcs = CONSTANTS_SRC,
@@ -30,7 +32,8 @@
     name = "server",
     srcs = glob(
         ["**/*.java"],
-        exclude = CONSTANTS_SRC + GERRIT_GLOBAL_MODULE_SRC + TESTING_SRC,
+        exclude = CONSTANTS_SRC + GERRIT_GLOBAL_MODULE_SRC + TESTING_SRC +
+                  PROLOG_SRC,
     ),
     resource_strip_prefix = "resources",
     resources = ["//resources/com/google/gerrit/server"],
@@ -68,31 +71,7 @@
         "//lib:autolink",
         "//lib:automaton",
         "//lib:blame-cache",
-        "//lib:flexmark",
-        "//lib:flexmark-ext-abbreviation",
-        "//lib:flexmark-ext-anchorlink",
-        "//lib:flexmark-ext-autolink",
-        "//lib:flexmark-ext-definition",
-        "//lib:flexmark-ext-emoji",
-        "//lib:flexmark-ext-escaped-character",
-        "//lib:flexmark-ext-footnotes",
-        "//lib:flexmark-ext-gfm-issues",
-        "//lib:flexmark-ext-gfm-strikethrough",
-        "//lib:flexmark-ext-gfm-tables",
-        "//lib:flexmark-ext-gfm-tasklist",
-        "//lib:flexmark-ext-gfm-users",
-        "//lib:flexmark-ext-ins",
-        "//lib:flexmark-ext-jekyll-front-matter",
-        "//lib:flexmark-ext-superscript",
-        "//lib:flexmark-ext-tables",
-        "//lib:flexmark-ext-toc",
-        "//lib:flexmark-ext-typographic",
-        "//lib:flexmark-ext-wikilink",
-        "//lib:flexmark-ext-yaml-front-matter",
-        "//lib:flexmark-formatter",
-        "//lib:flexmark-html-parser",
-        "//lib:flexmark-profile-pegdown",
-        "//lib:flexmark-util",
+        "//lib:flexmark-all-lib",
         "//lib:gson",
         "//lib:guava",
         "//lib:guava-retrying",
@@ -101,6 +80,7 @@
         "//lib:juniversalchardet",
         "//lib:mime-util",
         "//lib:protobuf",
+        "//lib:roaringbitmap",
         "//lib:servlet-api",
         "//lib:soy",
         "//lib:tukaani-xz",
@@ -151,6 +131,8 @@
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
+        "//java/com/google/gerrit/server/rules/prolog",
+        "//java/com/google/gerrit/server/version",
         "//lib:blame-cache",
         "//lib:guava",
         "//lib:jgit",
diff --git a/java/com/google/gerrit/server/ChangeDraftUpdate.java b/java/com/google/gerrit/server/ChangeDraftUpdate.java
new file mode 100644
index 0000000..eb33fb5
--- /dev/null
+++ b/java/com/google/gerrit/server/ChangeDraftUpdate.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2014 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;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import java.time.Instant;
+import java.util.List;
+import org.eclipse.jgit.lib.PersonIdent;
+
+/** An interface for updating draft comments. */
+public interface ChangeDraftUpdate {
+
+  interface ChangeDraftUpdateFactory {
+    ChangeDraftUpdate create(
+        ChangeNotes notes,
+        Account.Id accountId,
+        Account.Id realAccountId,
+        PersonIdent authorIdent,
+        Instant when);
+
+    ChangeDraftUpdate create(
+        Change change,
+        Account.Id accountId,
+        Account.Id realAccountId,
+        PersonIdent authorIdent,
+        Instant when);
+  }
+
+  /** Creates a draft comment. */
+  void putDraftComment(HumanComment c);
+
+  /**
+   * Marks a comment for deletion. Called when the comment is deleted because the user published it.
+   *
+   * <p>NOTE for implementers: The actual deletion of a published draft should only happen after the
+   * published comment is successfully updated. For more context, see {@link
+   * com.google.gerrit.server.notedb.NoteDbUpdateManager#execute(boolean)}.
+   *
+   * <p>TODO(nitzan) - add generalized support for the above sync issue. The implementation should
+   * support deletion of published drafts from multiple ChangeDraftUpdateFactory instances.
+   */
+  void markDraftCommentAsPublished(HumanComment c);
+
+  /**
+   * Marks a comment for deletion. Called when the comment is deleted because the user removed it.
+   */
+  void addDraftCommentForDeletion(HumanComment c);
+
+  /**
+   * Marks all comments for deletion. Called when there are inconsistencies between the published
+   * comments storage and the drafts one.
+   */
+  void addAllDraftCommentsForDeletion(List<Comment> comments);
+}
diff --git a/java/com/google/gerrit/server/ChangeUtil.java b/java/com/google/gerrit/server/ChangeUtil.java
index 2265055..dd86f88 100644
--- a/java/com/google/gerrit/server/ChangeUtil.java
+++ b/java/com/google/gerrit/server/ChangeUtil.java
@@ -22,10 +22,13 @@
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.util.CommitMessageUtil;
+import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.security.SecureRandom;
@@ -38,6 +41,7 @@
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Stream;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -54,6 +58,16 @@
   public static final Ordering<PatchSet> PS_ID_ORDER =
       Ordering.from(comparingInt(PatchSet::number));
 
+  private final DynamicItem<UrlFormatter> urlFormatter;
+  private final boolean enableLinkChangeIdFooters;
+
+  @Inject
+  ChangeUtil(DynamicItem<UrlFormatter> urlFormatter, @GerritServerConfig Config config) {
+    this.urlFormatter = urlFormatter;
+    this.enableLinkChangeIdFooters =
+        config.getBoolean("receive", "enableChangeIdLinkFooters", true);
+  }
+
   /** Returns a new unique identifier for change message entities. */
   public static String messageUuid() {
     byte[] buf = new byte[8];
@@ -124,11 +138,8 @@
    * @throws ResourceConflictException if the new commit message has a missing or invalid Change-Id
    * @throws BadRequestException if the new commit message is null or empty
    */
-  public static void ensureChangeIdIsCorrect(
-      boolean requireChangeId,
-      String currentChangeId,
-      String newCommitMessage,
-      UrlFormatter urlFormatter)
+  public void ensureChangeIdIsCorrect(
+      boolean requireChangeId, String currentChangeId, String newCommitMessage)
       throws ResourceConflictException, BadRequestException {
     RevCommit revCommit =
         RevCommit.parse(
@@ -137,7 +148,7 @@
     // Check that the commit message without footers is not empty
     CommitMessageUtil.checkAndSanitizeCommitMessage(revCommit.getShortMessage());
 
-    List<String> changeIdFooters = getChangeIdsFromFooter(revCommit, urlFormatter);
+    List<String> changeIdFooters = getChangeIdsFromFooter(revCommit);
     if (requireChangeId && changeIdFooters.isEmpty()) {
       throw new ResourceConflictException("missing Change-Id footer");
     }
@@ -155,9 +166,13 @@
 
   private static final Pattern LINK_CHANGE_ID_PATTERN = Pattern.compile("I[0-9a-f]{40}");
 
-  public static List<String> getChangeIdsFromFooter(RevCommit c, UrlFormatter urlFormatter) {
+  public List<String> getChangeIdsFromFooter(RevCommit c) {
     List<String> changeIds = c.getFooterLines(FooterConstants.CHANGE_ID);
-    Optional<String> webUrl = urlFormatter.getWebUrl();
+    if (!enableLinkChangeIdFooters) {
+      return changeIds;
+    }
+
+    Optional<String> webUrl = urlFormatter.get().getWebUrl();
     if (!webUrl.isPresent()) {
       return changeIds;
     }
@@ -176,6 +191,4 @@
 
     return changeIds;
   }
-
-  private ChangeUtil() {}
 }
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index 285657e..9d5d46a 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -24,7 +24,6 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
@@ -32,12 +31,10 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -53,13 +50,11 @@
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
-import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 
@@ -116,18 +111,15 @@
 
   private final DiffOperations diffOperations;
   private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsers;
   private final String serverId;
 
   @Inject
   CommentsUtil(
       DiffOperations diffOperations,
       GitRepositoryManager repoManager,
-      AllUsersName allUsers,
       @GerritServerId String serverId) {
     this.diffOperations = diffOperations;
     this.repoManager = repoManager;
-    this.allUsers = allUsers;
     this.serverId = serverId;
   }
 
@@ -203,12 +195,6 @@
         .findFirst();
   }
 
-  public Optional<HumanComment> getDraft(ChangeNotes notes, IdentifiedUser user, Comment.Key key) {
-    return draftByChangeAuthor(notes, user.getAccountId()).stream()
-        .filter(c -> key.equals(c.key))
-        .findFirst();
-  }
-
   public List<HumanComment> publishedHumanCommentsByChange(ChangeNotes notes) {
     notes.load();
     return sort(Lists.newArrayList(notes.getHumanComments().values()));
@@ -223,30 +209,6 @@
     return robotCommentsByChange(notes).stream().filter(c -> c.key.uuid.equals(uuid)).findFirst();
   }
 
-  public List<HumanComment> draftByChange(ChangeNotes notes) {
-    List<HumanComment> comments = new ArrayList<>();
-    for (Ref ref : getDraftRefs(notes.getChangeId())) {
-      Account.Id account = Account.Id.fromRefSuffix(ref.getName());
-      if (account != null) {
-        comments.addAll(draftByChangeAuthor(notes, account));
-      }
-    }
-    return sort(comments);
-  }
-
-  public List<HumanComment> byPatchSet(ChangeNotes notes, PatchSet.Id psId) {
-    List<HumanComment> comments = new ArrayList<>();
-    comments.addAll(publishedByPatchSet(notes, psId));
-
-    for (Ref ref : getDraftRefs(notes.getChangeId())) {
-      Account.Id account = Account.Id.fromRefSuffix(ref.getName());
-      if (account != null) {
-        comments.addAll(draftByPatchSetAuthor(psId, account, notes));
-      }
-    }
-    return sort(comments);
-  }
-
   public List<HumanComment> publishedByChangeFile(ChangeNotes notes, String file) {
     return commentsOnFile(notes.load().getHumanComments().values(), file);
   }
@@ -273,28 +235,50 @@
       List<? extends CommentInfo> comments,
       List<ChangeMessage> changeMessages,
       boolean skipAutoGeneratedMessages) {
+
+    // First sort by timestamp, then by authorId so that we could move on to the next change message
+    // in case multiple accounts left comments at the same timestamp.
     ArrayList<ChangeMessage> sortedChangeMessages =
         changeMessages.stream()
-            .sorted(comparing(ChangeMessage::getWrittenOn))
+            .sorted(
+                comparing(ChangeMessage::getWrittenOn)
+                    .thenComparingInt(c -> c.getAuthor() == null ? 0 : c.getAuthor().get()))
             .collect(toCollection(ArrayList::new));
 
     ArrayList<CommentInfo> sortedCommentInfos =
-        comments.stream().sorted(comparing(c -> c.updated)).collect(toCollection(ArrayList::new));
+        comments.stream()
+            .sorted(
+                comparing(CommentInfo::getUpdated)
+                    .thenComparingInt(c -> c.author == null ? 0 : c.author._accountId))
+            .collect(toCollection(ArrayList::new));
 
     int cmItr = 0;
+    int lastMatch = 0;
     for (CommentInfo comment : sortedCommentInfos) {
       // Keep advancing the change message pointer until we associate the comment to the next change
       // message in timestamp
       while (cmItr < sortedChangeMessages.size()) {
         ChangeMessage cm = sortedChangeMessages.get(cmItr);
-        if (isAfter(comment, cm) || (skipAutoGeneratedMessages && isAutoGenerated(cm))) {
+        if (isAfter(comment, cm)
+            || !haveSameAuthor(cm, comment)
+            || (skipAutoGeneratedMessages && isAutoGenerated(cm))) {
           cmItr += 1;
         } else {
+          lastMatch = cmItr;
           break;
         }
       }
       if (cmItr < changeMessages.size()) {
         comment.changeMessageId = sortedChangeMessages.get(cmItr).getKey().uuid();
+      } else {
+        // In case of no match "cmItr" will never be less than "changeMessages" size, hence the
+        // changeMessageId won't be set for any comment.
+        //
+        // Reset the search to the last succesful match, since we can't assume there will always be
+        // a match between change messages and comments. This could be the case of imported changes.
+        //
+        // More details here: https://issues.gerritcodereview.com/issues/318079520
+        cmItr = lastMatch;
       }
     }
   }
@@ -309,6 +293,11 @@
     return c.getUpdated().isAfter(cm.getWrittenOn());
   }
 
+  private static boolean haveSameAuthor(ChangeMessage cm, CommentInfo comment) {
+    return Objects.equals(
+        Optional.ofNullable(cm.getAuthor()).map(a -> a.get()),
+        Optional.ofNullable(comment.author).map(a -> a._accountId));
+  }
   /**
    * For the commit message the A side in a diff view is always empty when a comparison against an
    * ancestor is done, so there can't be any comments on this ancestor. However earlier we showed
@@ -322,22 +311,6 @@
         .collect(toList());
   }
 
-  public List<HumanComment> draftByPatchSetAuthor(
-      PatchSet.Id psId, Account.Id author, ChangeNotes notes) {
-    return commentsOnPatchSet(notes.load().getDraftComments(author).values(), psId);
-  }
-
-  public List<HumanComment> draftByChangeFileAuthor(
-      ChangeNotes notes, String file, Account.Id author) {
-    return commentsOnFile(notes.load().getDraftComments(author).values(), file);
-  }
-
-  public List<HumanComment> draftByChangeAuthor(ChangeNotes notes, Account.Id author) {
-    List<HumanComment> comments = new ArrayList<>();
-    comments.addAll(notes.getDraftComments(author).values());
-    return sort(comments);
-  }
-
   public void putHumanComments(
       ChangeUpdate update, Comment.Status status, Iterable<HumanComment> comments) {
     for (HumanComment c : comments) {
@@ -386,12 +359,13 @@
   }
 
   public void setCommentCommitId(Comment c, Change change, PatchSet ps) {
-    checkArgument(
-        c.key.patchSetId == ps.id().get(),
-        "cannot set commit ID for patch set %s on comment %s",
-        ps.id(),
-        c);
     if (c.getCommitId() == null) {
+      checkArgument(
+          c.key.patchSetId == ps.id().get(),
+          "cannot set commit ID for patch set %s on comment %s",
+          ps.id(),
+          c);
+
       // This code is very much down into our stack and shouldn't be used for validation. Hence,
       // don't throw an exception here if we can't find a commit for the indicated side but
       // simply use the all-null ObjectId.
@@ -451,54 +425,7 @@
     }
   }
 
-  /**
-   * Get NoteDb draft refs for a change.
-   *
-   * <p>This is just a simple ref scan, so the results may potentially include refs for zombie draft
-   * comments. A zombie draft is one which has been published but the write to delete the draft ref
-   * from All-Users failed.
-   *
-   * @param changeId change ID.
-   * @return raw refs from All-Users repo.
-   */
-  public Collection<Ref> getDraftRefs(Change.Id changeId) {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      return getDraftRefs(repo, changeId);
-    } catch (IOException e) {
-      throw new StorageException(e);
-    }
-  }
-
-  /** returns all changes that contain draft comments of {@code accountId}. */
-  public Collection<Change.Id> getChangesWithDrafts(Account.Id accountId) {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      return getChangesWithDrafts(repo, accountId);
-    } catch (IOException e) {
-      throw new StorageException(e);
-    }
-  }
-
-  private Collection<Ref> getDraftRefs(Repository repo, Change.Id changeId) throws IOException {
-    return repo.getRefDatabase().getRefsByPrefix(RefNames.refsDraftCommentsPrefix(changeId));
-  }
-
-  private Collection<Change.Id> getChangesWithDrafts(Repository repo, Account.Id accountId)
-      throws IOException {
-    Set<Change.Id> changes = new HashSet<>();
-    for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_DRAFT_COMMENTS)) {
-      Integer accountIdFromRef = RefNames.parseRefSuffix(ref.getName());
-      if (accountIdFromRef != null && accountIdFromRef == accountId.get()) {
-        Change.Id changeId = Change.Id.fromAllUsersRef(ref.getName());
-        if (changeId == null) {
-          continue;
-        }
-        changes.add(changeId);
-      }
-    }
-    return changes;
-  }
-
-  private static <T extends Comment> List<T> sort(List<T> comments) {
+  public static <T extends Comment> List<T> sort(List<T> comments) {
     comments.sort(COMMENT_ORDER);
     return comments;
   }
diff --git a/java/com/google/gerrit/server/DeleteZombieComments.java b/java/com/google/gerrit/server/DeleteZombieComments.java
new file mode 100644
index 0000000..4532b04
--- /dev/null
+++ b/java/com/google/gerrit/server/DeleteZombieComments.java
@@ -0,0 +1,304 @@
+// Copyright (C) 2023 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;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Consumer;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * This class can be used to clean zombie draft comments. More context in <a
+ * href="https://gerrit-review.googlesource.com/c/gerrit/+/246233">
+ * https://gerrit-review.googlesource.com/c/gerrit/+/246233 </a>
+ *
+ * <p>The implementation has two cases for detecting zombie drafts:
+ *
+ * <ul>
+ *   <li>An earlier bug in the deletion of draft comments caused some draft refs to remain empty but
+ *       not get deleted.
+ *   <li>Inspecting all draft-comments. Check for each draft if there exists a published comment
+ *       with the same UUID. These comments are called zombie drafts. If the program is run in
+ *       {@link DeleteZombieComments#dryRun} mode, the zombie draft IDs will only be logged for
+ *       tracking, otherwise they will also be deleted.
+ * </uL>
+ */
+public abstract class DeleteZombieComments<KeyT> implements AutoCloseable {
+  @AutoValue
+  abstract static class ChangeUserIDsPair {
+    abstract Change.Id changeId();
+
+    abstract Account.Id accountId();
+
+    static ChangeUserIDsPair create(Change.Id changeId, Account.Id accountId) {
+      return new AutoValue_DeleteZombieComments_ChangeUserIDsPair(changeId, accountId);
+    }
+  }
+
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final int cleanupPercentage;
+  protected final boolean dryRun;
+  @Nullable private final Consumer<String> uiConsumer;
+  @Nullable private final GitRepositoryManager repoManager;
+  @Nullable private final DraftCommentsReader draftCommentsReader;
+  @Nullable private final ChangeNotes.Factory changeNotesFactory;
+  @Nullable private final CommentsUtil commentsUtil;
+
+  private Map<Change.Id, Project.NameKey> changeProjectMap = new HashMap<>();
+  private Map<Change.Id, ChangeNotes> changeNotes = new HashMap<>();
+
+  protected DeleteZombieComments(
+      Integer cleanupPercentage,
+      boolean dryRun,
+      Consumer<String> uiConsumer,
+      GitRepositoryManager repoManager,
+      DraftCommentsReader draftCommentsReader,
+      ChangeNotes.Factory changeNotesFactory,
+      CommentsUtil commentsUtil) {
+    this.cleanupPercentage = cleanupPercentage == null ? 100 : cleanupPercentage;
+    this.dryRun = dryRun;
+    this.uiConsumer = uiConsumer;
+    this.repoManager = repoManager;
+    this.draftCommentsReader = draftCommentsReader;
+    this.changeNotesFactory = changeNotesFactory;
+    this.commentsUtil = commentsUtil;
+  }
+
+  /** Deletes all draft comments. Returns the number of zombie draft comments that were deleted. */
+  @CanIgnoreReturnValue
+  public int execute() throws IOException {
+    setup();
+    ListMultimap<KeyT, HumanComment> alreadyPublished = listDraftCommentsThatAreAlsoPublished();
+    if (!dryRun) {
+      deleteZombieDrafts(alreadyPublished);
+    }
+
+    List<KeyT> emptyDrafts = filterByCleanupPercentage(listEmptyDrafts(), "empty");
+    if (!dryRun) {
+      deleteEmptyDraftsByKey(emptyDrafts);
+    } else {
+      logInfo(
+          String.format(
+              "Running in dry run mode. Skipping deletion."
+                  + "\nStats (with %d cleanup-percentage):"
+                  + "\nEmpty drafts = %d"
+                  + "\nAlready published drafts (zombies) = %d",
+              cleanupPercentage, emptyDrafts.size(), alreadyPublished.size()));
+    }
+    return emptyDrafts.size() + alreadyPublished.size();
+  }
+
+  @VisibleForTesting
+  public abstract void setup() throws IOException;
+
+  @Override
+  public abstract void close() throws IOException;
+
+  protected abstract List<KeyT> listAllDrafts() throws IOException;
+
+  protected abstract List<KeyT> listEmptyDrafts() throws IOException;
+
+  protected abstract void deleteEmptyDraftsByKey(Collection<KeyT> keys) throws IOException;
+
+  protected abstract void deleteZombieDrafts(ListMultimap<KeyT, HumanComment> drafts)
+      throws IOException;
+
+  protected abstract Change.Id getChangeId(KeyT key);
+
+  protected abstract Account.Id getAccountId(KeyT key);
+
+  protected abstract String loggable(KeyT key);
+
+  protected ChangeNotes getChangeNotes(Change.Id changeId) {
+    if (changeNotes.containsKey(changeId)) {
+      return changeNotes.get(changeId);
+    }
+    checkState(
+        changeProjectMap.containsKey(changeId),
+        "Cannot get a project associated with change ID " + changeId);
+    ChangeNotes notes = changeNotesFactory.createChecked(changeProjectMap.get(changeId), changeId);
+    changeNotes.put(changeId, notes);
+    return notes;
+  }
+
+  private List<KeyT> filterByCleanupPercentage(List<KeyT> drafts, String reason) {
+    if (cleanupPercentage >= 100) {
+      logInfo(
+          String.format(
+              "Cleanup percentage = %d" + "\nNumber of drafts to be cleaned for %s = %d",
+              cleanupPercentage, reason, drafts.size()));
+      return drafts;
+    }
+    ImmutableList<KeyT> res =
+        drafts.stream()
+            .filter(key -> getChangeId(key).get() % 100 < cleanupPercentage)
+            .collect(toImmutableList());
+    logInfo(
+        String.format(
+            "Cleanup percentage = %d"
+                + "\nOriginal number of drafts for %s = %d"
+                + "\nNumber of drafts to be processed for %s = %d",
+            cleanupPercentage, reason, drafts.size(), reason, res.size()));
+    return res;
+  }
+
+  @VisibleForTesting
+  public ListMultimap<KeyT, HumanComment> listDraftCommentsThatAreAlsoPublished()
+      throws IOException {
+    List<KeyT> draftKeys = filterByCleanupPercentage(listAllDrafts(), "all-drafts");
+    changeProjectMap.putAll(mapChangesWithDraftsToProjects(draftKeys));
+
+    ListMultimap<KeyT, HumanComment> zombieDrafts = ArrayListMultimap.create();
+    Set<ChangeUserIDsPair> visitedSet = new HashSet<>();
+    for (KeyT key : draftKeys) {
+      try {
+        Change.Id changeId = getChangeId(key);
+        Account.Id accountId = getAccountId(key);
+        ChangeUserIDsPair changeUserIDsPair = ChangeUserIDsPair.create(changeId, accountId);
+        if (!visitedSet.add(changeUserIDsPair)) {
+          continue;
+        }
+        if (!changeProjectMap.containsKey(changeId)) {
+          logger.atWarning().log(
+              "Could not find a project associated with change ID %s. Skipping draft [%s]",
+              changeId, loggable(key));
+          continue;
+        }
+        List<HumanComment> drafts =
+            draftCommentsReader.getDraftsByChangeAndDraftAuthor(changeId, accountId);
+        ChangeNotes notes = getChangeNotes(changeId);
+        List<HumanComment> published = commentsUtil.publishedHumanCommentsByChange(notes);
+        Set<String> publishedIds = toUuid(published);
+        ImmutableList<HumanComment> zombieDraftsForChangeAndAuthor =
+            drafts.stream()
+                .filter(draft -> publishedIds.contains(draft.key.uuid))
+                .collect(toImmutableList());
+        zombieDraftsForChangeAndAuthor.forEach(
+            zombieDraft ->
+                logger.atWarning().log(
+                    "Draft comment with uuid '%s' of change %s, account %s, written on %s,"
+                        + " is a zombie draft that is already published.",
+                    zombieDraft.key.uuid, changeId, accountId, zombieDraft.writtenOn));
+        zombieDrafts.putAll(key, zombieDraftsForChangeAndAuthor);
+      } catch (RuntimeException e) {
+        logger.atWarning().withCause(e).log("Failed to process draft [%s]", loggable(key));
+      }
+    }
+
+    if (!zombieDrafts.isEmpty()) {
+      Timestamp earliestZombieTs = null;
+      Timestamp latestZombieTs = null;
+      for (HumanComment zombieDraft : zombieDrafts.values()) {
+        earliestZombieTs = getEarlierTs(earliestZombieTs, zombieDraft.writtenOn);
+        latestZombieTs = getLaterTs(latestZombieTs, zombieDraft.writtenOn);
+      }
+      logger.atWarning().log(
+          "Detected %d zombie drafts that were already published (earliest at %s, latest at %s).",
+          zombieDrafts.size(), earliestZombieTs, latestZombieTs);
+    }
+    return zombieDrafts;
+  }
+
+  /**
+   * Map each change ID to its associated project.
+   *
+   * <p>When doing a ref scan of draft refs
+   * "refs/draft-comments/$change_id_short/$change_id/$user_id" we don't know which project this
+   * draft comment is associated with. The project name is needed to load published comments for the
+   * change, hence we map each change ID to its project here by scanning through the change meta ref
+   * of the change ID in all projects.
+   */
+  private Map<Change.Id, Project.NameKey> mapChangesWithDraftsToProjects(List<KeyT> drafts) {
+    ImmutableSet<Change.Id> changeIds =
+        drafts.stream().map(key -> getChangeId(key)).collect(ImmutableSet.toImmutableSet());
+    Map<Change.Id, Project.NameKey> result = new HashMap<>();
+    for (Project.NameKey project : repoManager.list()) {
+      try (Repository repo = repoManager.openRepository(project)) {
+        Sets.SetView<Change.Id> unmappedChangeIds = Sets.difference(changeIds, result.keySet());
+        for (Change.Id changeId : unmappedChangeIds) {
+          Ref ref = repo.getRefDatabase().exactRef(RefNames.changeMetaRef(changeId));
+          if (ref != null) {
+            result.put(changeId, project);
+          }
+        }
+      } catch (Exception e) {
+        logger.atWarning().withCause(e).log("Failed to open repository for project '%s'.", project);
+      }
+      if (changeIds.size() == result.size()) {
+        // We do not need to scan the remaining repositories
+        break;
+      }
+    }
+    if (result.size() != changeIds.size()) {
+      logger.atWarning().log(
+          "Failed to associate the following change Ids to a project: %s",
+          Sets.difference(changeIds, result.keySet()));
+    }
+    return result;
+  }
+
+  protected void logInfo(String message) {
+    logger.atInfo().log("%s", message);
+    uiConsumer.accept(message);
+  }
+
+  /** Map the list of input comments to their UUIDs. */
+  private Set<String> toUuid(List<HumanComment> in) {
+    return in.stream().map(c -> c.key.uuid).collect(toImmutableSet());
+  }
+
+  private Timestamp getEarlierTs(@Nullable Timestamp t1, Timestamp t2) {
+    if (t1 == null) {
+      return t2;
+    }
+    return t1.before(t2) ? t1 : t2;
+  }
+
+  private Timestamp getLaterTs(@Nullable Timestamp t1, Timestamp t2) {
+    if (t1 == null) {
+      return t2;
+    }
+    return t1.after(t2) ? t1 : t2;
+  }
+}
diff --git a/java/com/google/gerrit/server/DraftCommentsReader.java b/java/com/google/gerrit/server/DraftCommentsReader.java
new file mode 100644
index 0000000..1eea228
--- /dev/null
+++ b/java/com/google/gerrit/server/DraftCommentsReader.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2023 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;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+public interface DraftCommentsReader {
+  /**
+   * Returns a single draft of the provided change, that was written by {@code author} and has the
+   * given {@code key}, or {@code Optional::empty} if there is no such comment.
+   */
+  Optional<HumanComment> getDraftComment(ChangeNotes notes, IdentifiedUser author, Comment.Key key);
+
+  /**
+   * Returns all drafts of the provided change, that were written by {@code author}. The comments
+   * are sorted by {@link CommentsUtil#COMMENT_ORDER}.
+   */
+  List<HumanComment> getDraftsByChangeAndDraftAuthor(ChangeNotes notes, Account.Id author);
+
+  /**
+   * Returns all drafts of the provided change, that were written by {@code author}. The comments
+   * are sorted by {@link CommentsUtil#COMMENT_ORDER}.
+   *
+   * <p>If you already have a ChangeNotes instance, consider using {@link
+   * #getDraftsByChangeAndDraftAuthor(ChangeNotes, Account.Id)} instead.
+   */
+  List<HumanComment> getDraftsByChangeAndDraftAuthor(Change.Id changeId, Account.Id author);
+
+  /**
+   * Returns all drafts of the provided patch set, that were written by {@code author}. The comments
+   * are sorted by {@link CommentsUtil#COMMENT_ORDER}.
+   */
+  List<HumanComment> getDraftsByPatchSetAndDraftAuthor(
+      ChangeNotes notes, PatchSet.Id psId, Account.Id author);
+
+  /**
+   * Returns all drafts of the provided change, regardless of the author. The comments are sorted by
+   * {@link CommentsUtil#COMMENT_ORDER}.
+   */
+  List<HumanComment> getDraftsByChangeForAllAuthors(ChangeNotes notes);
+
+  /** Returns all users that have any draft comments on the provided change. */
+  Set<Account.Id> getUsersWithDrafts(ChangeNotes changeNotes);
+
+  /** Returns all changes that contain draft comments of {@code author}. */
+  Set<Change.Id> getChangesWithDrafts(Account.Id author);
+}
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index 36d7888..d45d329 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -468,9 +468,7 @@
 
   public PersonIdent newCommitterIdent(Instant when, ZoneId zoneId) {
     final Account ua = getAccount();
-    String name = ua.fullName();
     String email = ua.preferredEmail();
-
     if (email == null || email.isEmpty()) {
       // No preferred email is configured. Use a generic identity so we
       // don't leak an address the user may have given us, but doesn't
@@ -491,19 +489,18 @@
 
       email = user + "@" + host;
     }
-
-    if (name == null || name.isEmpty()) {
-      final int at = email.indexOf('@');
-      if (0 < at) {
-        name = email.substring(0, at);
-      } else {
-        name = anonymousCowardName;
-      }
-    }
-
+    String name = getCommitterName(ua, email);
     return new PersonIdent(name, email, when, zoneId);
   }
 
+  public Optional<PersonIdent> newCommitterIdent(String email, Instant when, ZoneId zoneId) {
+    if (!hasEmailAddress(email)) {
+      return Optional.empty();
+    }
+    String name = getCommitterName(getAccount(), email);
+    return Optional.of(new PersonIdent(name, email, when, zoneId));
+  }
+
   @Override
   public String toString() {
     return "IdentifiedUser[account " + getAccountId() + "]";
@@ -551,4 +548,17 @@
   public boolean hasSameAccountId(CurrentUser other) {
     return getAccountId().get() == other.getAccountId().get();
   }
+
+  protected String getCommitterName(Account ua, String email) {
+    String name = ua.fullName();
+    if (name == null || name.isEmpty()) {
+      final int at = email.indexOf('@');
+      if (0 < at) {
+        name = email.substring(0, at);
+      } else {
+        name = anonymousCowardName;
+      }
+    }
+    return name;
+  }
 }
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index 68d2314..81d8f8f 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelFunction;
@@ -53,6 +54,8 @@
 /** Utilities for manipulating patch sets. */
 @Singleton
 public class PatchSetUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final Provider<ApprovalsUtil> approvalsUtilProvider;
   private final ProjectCache projectCache;
   private final GitRepositoryManager repoManager;
@@ -76,7 +79,22 @@
   }
 
   public ImmutableCollection<PatchSet> byChange(ChangeNotes notes) {
-    return notes.load().getPatchSets().values();
+    ChangeNotes reloadedNotes = notes.load();
+
+    if (!reloadedNotes
+        .getPatchSets()
+        .keySet()
+        .contains(reloadedNotes.getChange().currentPatchSetId())) {
+      logger.atSevere().log(
+          "Current patch set %s missing in ChangeNotes of change %s (available patch sets: %s,"
+              + " meta revision: %s)",
+          reloadedNotes.getChange().currentPatchSetId().get(),
+          reloadedNotes.getChange().getId().get(),
+          reloadedNotes.getPatchSets().keySet(),
+          reloadedNotes.getRevision().name());
+    }
+
+    return reloadedNotes.getPatchSets().values();
   }
 
   public ImmutableMap<PatchSet.Id, PatchSet> byChangeAsMap(ChangeNotes notes) {
diff --git a/java/com/google/gerrit/server/PerformanceMetrics.java b/java/com/google/gerrit/server/PerformanceMetrics.java
deleted file mode 100644
index 845ed80..0000000
--- a/java/com/google/gerrit/server/PerformanceMetrics.java
+++ /dev/null
@@ -1,91 +0,0 @@
-// Copyright (C) 2021 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.metrics.Counter3;
-import com.google.gerrit.metrics.Description;
-import com.google.gerrit.metrics.Field;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.metrics.Timer3;
-import com.google.gerrit.server.logging.Metadata;
-import com.google.gerrit.server.logging.PerformanceLogger;
-import com.google.gerrit.server.logging.TraceContext;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.concurrent.TimeUnit;
-
-/** Performance logger that records the execution times as a metric. */
-@Singleton
-public class PerformanceMetrics implements PerformanceLogger {
-  private static final String OPERATION_LATENCY_METRIC_NAME = "performance/operations";
-  private static final String OPERATION_COUNT_METRIC_NAME = "performance/operations_count";
-
-  public final Timer3<String, String, String> operationsLatency;
-  public final Counter3<String, String, String> operationsCounter;
-
-  @Inject
-  PerformanceMetrics(MetricMaker metricMaker) {
-    Field<String> operationNameField =
-        Field.ofString(
-                "operation_name",
-                (metadataBuilder, fieldValue) -> metadataBuilder.operationName(fieldValue))
-            .description("The operation that was performed.")
-            .build();
-    Field<String> requestField =
-        Field.ofString("request", (metadataBuilder, fieldValue) -> {})
-            .description(
-                "The request for which the operation was performed"
-                    + " (format = '<request-type> <redacted-request-uri>').")
-            .build();
-    Field<String> pluginField =
-        Field.ofString(
-                "plugin", (metadataBuilder, fieldValue) -> metadataBuilder.pluginName(fieldValue))
-            .description("The name of the plugin that performed the operation.")
-            .build();
-
-    this.operationsLatency =
-        metricMaker
-            .newTimer(
-                OPERATION_LATENCY_METRIC_NAME,
-                new Description("Latency of performing operations")
-                    .setCumulative()
-                    .setUnit(Description.Units.MILLISECONDS),
-                operationNameField,
-                requestField,
-                pluginField)
-            .suppressLogging();
-    this.operationsCounter =
-        metricMaker.newCounter(
-            OPERATION_COUNT_METRIC_NAME,
-            new Description("Number of performed operations").setRate(),
-            operationNameField,
-            requestField,
-            pluginField);
-  }
-
-  @Override
-  public void log(String operation, long durationMs) {
-    log(operation, durationMs, /* metadata= */ null);
-  }
-
-  @Override
-  public void log(String operation, long durationMs, @Nullable Metadata metadata) {
-    String requestTag = TraceContext.getTag(TraceRequestListener.TAG_REQUEST).orElse("");
-    String pluginTag = TraceContext.getPluginTag().orElse("");
-    operationsLatency.record(operation, requestTag, pluginTag, durationMs, TimeUnit.MILLISECONDS);
-    operationsCounter.increment(operation, requestTag, pluginTag);
-  }
-}
diff --git a/java/com/google/gerrit/server/PublishCommentsOp.java b/java/com/google/gerrit/server/PublishCommentsOp.java
index 830928a..bfafcb6 100644
--- a/java/com/google/gerrit/server/PublishCommentsOp.java
+++ b/java/com/google/gerrit/server/PublishCommentsOp.java
@@ -48,7 +48,7 @@
   private final PatchSetUtil psUtil;
   private final ChangeNotes.Factory changeNotesFactory;
   private final CommentAdded commentAdded;
-  private final CommentsUtil commentsUtil;
+  private final DraftCommentsReader draftCommentsReader;
   private final EmailReviewComments.Factory email;
   private final Project.NameKey projectNameKey;
   private final PatchSet.Id psId;
@@ -67,7 +67,7 @@
   public PublishCommentsOp(
       ChangeNotes.Factory changeNotesFactory,
       CommentAdded commentAdded,
-      CommentsUtil commentsUtil,
+      DraftCommentsReader draftCommentsReader,
       EmailReviewComments.Factory email,
       PatchSetUtil psUtil,
       PublishCommentUtil publishCommentUtil,
@@ -76,7 +76,7 @@
       @Assisted Project.NameKey projectNameKey) {
     this.changeNotesFactory = changeNotesFactory;
     this.commentAdded = commentAdded;
-    this.commentsUtil = commentsUtil;
+    this.draftCommentsReader = draftCommentsReader;
     this.email = email;
     this.psId = psId;
     this.publishCommentUtil = publishCommentUtil;
@@ -90,7 +90,9 @@
       throws ResourceConflictException, UnprocessableEntityException, IOException,
           PatchListNotAvailableException, CommentsRejectedException {
     preUpdateMetaId = ctx.getNotes().getMetaId();
-    comments = commentsUtil.draftByChangeAuthor(ctx.getNotes(), ctx.getUser().getAccountId());
+    comments =
+        draftCommentsReader.getDraftsByChangeAndDraftAuthor(
+            ctx.getNotes(), ctx.getUser().getAccountId());
 
     // PublishCommentsOp should update a separate ChangeUpdate Object than the one used by other ops
     // For example, with the "publish comments on PS upload" workflow,
diff --git a/java/com/google/gerrit/server/RequestConfig.java b/java/com/google/gerrit/server/RequestConfig.java
index 83cea5b..f942c5e 100644
--- a/java/com/google/gerrit/server/RequestConfig.java
+++ b/java/com/google/gerrit/server/RequestConfig.java
@@ -42,6 +42,8 @@
         requestConfig.requestTypes(parseRequestTypes(cfg, section, id));
         requestConfig.requestUriPatterns(parseRequestUriPatterns(cfg, section, id));
         requestConfig.excludedRequestUriPatterns(parseExcludedRequestUriPatterns(cfg, section, id));
+        requestConfig.requestQueryStringPatterns(parseRequestQueryStringPatterns(cfg, section, id));
+        requestConfig.headerPatterns(parseHeaderPatterns(cfg, section, id));
         requestConfig.accountIds(parseAccounts(cfg, section, id));
         requestConfig.projectPatterns(parseProjectPatterns(cfg, section, id));
         requestConfigs.add(requestConfig.build());
@@ -67,6 +69,16 @@
     return parsePatterns(cfg, section, id, "excludedRequestUriPattern");
   }
 
+  private static ImmutableSet<Pattern> parseRequestQueryStringPatterns(
+      Config cfg, String section, String id) throws ConfigInvalidException {
+    return parsePatterns(cfg, section, id, "requestQueryStringPattern");
+  }
+
+  private static ImmutableSet<Pattern> parseHeaderPatterns(Config cfg, String section, String id)
+      throws ConfigInvalidException {
+    return parsePatterns(cfg, section, id, "headerPattern");
+  }
+
   private static ImmutableSet<Account.Id> parseAccounts(Config cfg, String section, String id)
       throws ConfigInvalidException {
     ImmutableSet.Builder<Account.Id> accountIds = ImmutableSet.builder();
@@ -124,6 +136,12 @@
   /** pattern matching request URIs to be excluded */
   abstract ImmutableSet<Pattern> excludedRequestUriPatterns();
 
+  /** pattern matching request query strings */
+  abstract ImmutableSet<Pattern> requestQueryStringPatterns();
+
+  /** pattern matching headers */
+  abstract ImmutableSet<Pattern> headerPatterns();
+
   /** accounts IDs matching calling user */
   abstract ImmutableSet<Account.Id> accountIds();
 
@@ -170,6 +188,37 @@
       return false;
     }
 
+    // If in the request config request query string patterns are set and none of them matches,
+    // then the request is not matched.
+    if (!requestQueryStringPatterns().isEmpty()) {
+      if (!requestInfo.requestQueryString().isPresent()) {
+        // The request has no request query string, hence it cannot match a request query string
+        // pattern.
+        return false;
+      }
+
+      if (requestQueryStringPatterns().stream()
+          .noneMatch(p -> p.matcher(requestInfo.requestQueryString().get()).matches())) {
+        return false;
+      }
+    }
+
+    // If in the request config header patterns are set and none of them matches, then the request
+    // is not matched.
+    if (!headerPatterns().isEmpty()) {
+      if (requestInfo.headers().isEmpty()) {
+        // The request has no headers, hence it cannot match a header pattern.
+        return false;
+      }
+
+      if (headerPatterns().stream()
+          .noneMatch(
+              p ->
+                  requestInfo.headers().stream().anyMatch(header -> p.matcher(header).matches()))) {
+        return false;
+      }
+    }
+
     // If in the request config accounts are set and none of them matches, then the request is not
     // matched.
     if (!accountIds().isEmpty()) {
@@ -198,9 +247,9 @@
       }
     }
 
-    // For any match criteria (request type, request URI pattern, account, project pattern) that
-    // was specified in the request config, at least one of the configured value matched the
-    // request.
+    // For any match criteria (request type, request URI pattern, request query string pattern,
+    // header, account, project pattern) that was specified in the request config, at least one of
+    // the configured value matched the request.
     return true;
   }
 
@@ -218,6 +267,10 @@
 
     abstract Builder excludedRequestUriPatterns(ImmutableSet<Pattern> excludedRequestUriPatterns);
 
+    abstract Builder requestQueryStringPatterns(ImmutableSet<Pattern> requestQueryStringPatterns);
+
+    abstract Builder headerPatterns(ImmutableSet<Pattern> headerPatterns);
+
     abstract Builder accountIds(ImmutableSet<Account.Id> accountIds);
 
     abstract Builder projectPatterns(ImmutableSet<Pattern> projectPatterns);
diff --git a/java/com/google/gerrit/server/RequestInfo.java b/java/com/google/gerrit/server/RequestInfo.java
index 791e228..927985d8 100644
--- a/java/com/google/gerrit/server/RequestInfo.java
+++ b/java/com/google/gerrit/server/RequestInfo.java
@@ -20,6 +20,8 @@
 import com.google.auto.value.AutoValue;
 import com.google.auto.value.extension.memoized.Memoized;
 import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.logging.TraceContext;
@@ -57,10 +59,23 @@
    * <p>Only set if request type is {@link RequestType#REST}.
    *
    * <p>Never includes the "/a" prefix.
+   *
+   * <p>Doesn't include the query string with the request parameters (see {@link
+   * #requestQueryString()}.
    */
   public abstract Optional<String> requestUri();
 
   /**
+   * Request query string that contains the request parameters.
+   *
+   * <p>Only set if request type is {@link RequestType#REST}.
+   */
+  public abstract Optional<String> requestQueryString();
+
+  /** Request headers in the form '{@code <header-name>:<header-value>}'. */
+  public abstract ImmutableList<String> headers();
+
+  /**
    * Redacted request URI.
    *
    * <p>Request URI where resource IDs are replaced by '*'.
@@ -164,6 +179,18 @@
 
     public abstract Builder requestUri(String requestUri);
 
+    public abstract Builder requestQueryString(String requestQueryString);
+
+    /** Gets a builder for adding reasons for this status. */
+    abstract ImmutableList.Builder<String> headersBuilder();
+
+    /** Adds a header. */
+    @CanIgnoreReturnValue
+    public Builder addHeader(String headerName, String headerValue) {
+      headersBuilder().add(headerName + "=" + headerValue);
+      return this;
+    }
+
     public abstract Builder callingUser(CurrentUser callingUser);
 
     public abstract Builder traceContext(TraceContext traceContext);
diff --git a/java/com/google/gerrit/server/Sequence.java b/java/com/google/gerrit/server/Sequence.java
new file mode 100644
index 0000000..844b583
--- /dev/null
+++ b/java/com/google/gerrit/server/Sequence.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2023 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;
+
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.common.collect.ImmutableList;
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * An incrementing sequence that's used to assign new unique numbers for change, account and group
+ * IDs.
+ */
+public interface Sequence {
+  String NAME_ACCOUNTS = "accounts";
+  String NAME_GROUPS = "groups";
+  String NAME_CHANGES = "changes";
+
+  /**
+   * Some callers cannot get the normal {@link #NAME_ACCOUNTS} sequence injected because some
+   * injected fields are not available at injection time. Allow for providing a light-weight
+   * injected instance.
+   */
+  @BindingAnnotation
+  @Target({FIELD, PARAMETER, METHOD})
+  @Retention(RUNTIME)
+  @interface LightweightAccounts {}
+
+  /**
+   * Some callers cannot get the normal {@link #NAME_GROUPS} sequence injected because some injected
+   * fields are not available at injection time. Allow for providing a light-weight injected
+   * instance.
+   */
+  @BindingAnnotation
+  @Target({FIELD, PARAMETER, METHOD})
+  @Retention(RUNTIME)
+  @interface LightweightGroups {}
+
+  enum SequenceType {
+    ACCOUNTS,
+    CHANGES,
+    GROUPS;
+  }
+
+  /** Returns the next available sequence value and increments the sequence for the next call. */
+  int next();
+
+  /** Similar to {@link #next()} but returns a {@code count} of next available values. */
+  ImmutableList<Integer> next(int count);
+
+  /** Returns the next available sequence value. */
+  int current();
+
+  /** Returns the last sequence number that was assigned. */
+  int last();
+
+  /**
+   * Stores a new {@code value} to be returned on the next calls for {@link #next()} or {@link
+   * #current()}.
+   */
+  void storeNew(int value);
+
+  /** Returns the batch size that was used to initialize the sequence. */
+  int getBatchSize();
+}
diff --git a/java/com/google/gerrit/server/Sequences.java b/java/com/google/gerrit/server/Sequences.java
new file mode 100644
index 0000000..431a1b2
--- /dev/null
+++ b/java/com/google/gerrit/server/Sequences.java
@@ -0,0 +1,143 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static com.google.gerrit.server.Sequence.NAME_ACCOUNTS;
+import static com.google.gerrit.server.Sequence.NAME_CHANGES;
+import static com.google.gerrit.server.Sequence.NAME_GROUPS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer2;
+import com.google.gerrit.server.Sequence.SequenceType;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+
+@Singleton
+public class Sequences {
+  public static final int FIRST_CHANGE_ID = 1;
+  public static final int FIRST_GROUP_ID = 1;
+  public static final int FIRST_ACCOUNT_ID = 1000000;
+
+  private final Sequence accountSeq;
+  private final Sequence changeSeq;
+  private final Sequence groupSeq;
+  private final Timer2<SequenceType, Boolean> nextIdLatency;
+
+  @Inject
+  public Sequences(
+      MetricMaker metrics,
+      @Named(NAME_ACCOUNTS) Sequence accountsSeq,
+      @Named(NAME_GROUPS) Sequence groupsSeq,
+      @Named(NAME_CHANGES) Sequence changesSeq) {
+    this.accountSeq = accountsSeq;
+    this.groupSeq = groupsSeq;
+    this.changeSeq = changesSeq;
+
+    nextIdLatency =
+        metrics.newTimer(
+            "sequence/next_id_latency",
+            new Description("Latency of requesting IDs from repo sequences")
+                .setCumulative()
+                .setUnit(Units.MILLISECONDS),
+            Field.ofEnum(SequenceType.class, "sequence", Metadata.Builder::noteDbSequenceType)
+                .description("The sequence from which IDs were retrieved.")
+                .build(),
+            Field.ofBoolean("multiple", Metadata.Builder::multiple)
+                .description("Whether more than one ID was retrieved.")
+                .build());
+  }
+
+  public int nextAccountId() {
+    try (Timer2.Context<SequenceType, Boolean> timer =
+        nextIdLatency.start(Sequence.SequenceType.ACCOUNTS, false)) {
+      return accountSeq.next();
+    }
+  }
+
+  public int nextChangeId() {
+    try (Timer2.Context<SequenceType, Boolean> timer =
+        nextIdLatency.start(Sequence.SequenceType.CHANGES, false)) {
+      return changeSeq.next();
+    }
+  }
+
+  public ImmutableList<Integer> nextChangeIds(int count) {
+    try (Timer2.Context<SequenceType, Boolean> timer =
+        nextIdLatency.start(Sequence.SequenceType.CHANGES, count > 1)) {
+      return changeSeq.next(count);
+    }
+  }
+
+  public int nextGroupId() {
+    try (Timer2.Context<SequenceType, Boolean> timer =
+        nextIdLatency.start(Sequence.SequenceType.GROUPS, false)) {
+      return groupSeq.next();
+    }
+  }
+
+  public int changeBatchSize() {
+    return changeSeq.getBatchSize();
+  }
+
+  public int groupBatchSize() {
+    return groupSeq.getBatchSize();
+  }
+
+  public int accountBatchSize() {
+    return accountSeq.getBatchSize();
+  }
+
+  public int currentChangeId() {
+    return changeSeq.current();
+  }
+
+  public int currentAccountId() {
+    return accountSeq.current();
+  }
+
+  public int currentGroupId() {
+    return groupSeq.current();
+  }
+
+  public int lastChangeId() {
+    return changeSeq.last();
+  }
+
+  public int lastGroupId() {
+    return groupSeq.last();
+  }
+
+  public int lastAccountId() {
+    return accountSeq.last();
+  }
+
+  public void setChangeIdValue(int value) {
+    changeSeq.storeNew(value);
+  }
+
+  public void setAccountIdValue(int value) {
+    accountSeq.storeNew(value);
+  }
+
+  public void setGroupIdValue(int value) {
+    groupSeq.storeNew(value);
+  }
+}
diff --git a/java/com/google/gerrit/server/StarredChangesReader.java b/java/com/google/gerrit/server/StarredChangesReader.java
new file mode 100644
index 0000000..ddf0cd3
--- /dev/null
+++ b/java/com/google/gerrit/server/StarredChangesReader.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2023 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;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+public interface StarredChangesReader {
+  boolean isStarred(Account.Id accountId, Change.Id changeId);
+
+  /**
+   * Returns a subset of change IDs among the input {@code changeIds} list that are starred by the
+   * {@code caller} user.
+   */
+  Set<Change.Id> areStarred(Repository allUsersRepo, List<Change.Id> changeIds, Account.Id caller);
+
+  ImmutableMap<Account.Id, Ref> byChange(Change.Id changeId);
+
+  ImmutableSet<Change.Id> byAccountId(Account.Id accountId);
+
+  ImmutableSet<Change.Id> byAccountId(Account.Id accountId, boolean skipInvalidChanges);
+}
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index cf04029..0709b86 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -14,471 +14,6 @@
 
 package com.google.gerrit.server;
 
-import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.Objects.requireNonNull;
-import static java.util.stream.Collectors.joining;
-import static java.util.stream.Collectors.toSet;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.base.CharMatcher;
-import com.google.common.base.Joiner;
-import com.google.common.base.Splitter;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ImmutableSortedSet;
-import com.google.common.flogger.FluentLogger;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.git.GitUpdateFailureException;
-import com.google.gerrit.git.LockFailureException;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.logging.Metadata;
-import com.google.gerrit.server.logging.TraceContext;
-import com.google.gerrit.server.logging.TraceContext.TraceTimer;
-import com.google.gerrit.server.update.context.RefUpdateContext;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.NavigableSet;
-import java.util.Objects;
-import java.util.Set;
-import java.util.TreeSet;
-import java.util.stream.Collectors;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectLoader;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
-@Singleton
-public class StarredChangesUtil {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  @AutoValue
-  public abstract static class StarField {
-    private static final String SEPARATOR = ":";
-
-    @Nullable
-    public static StarField parse(String s) {
-      int p = s.indexOf(SEPARATOR);
-      if (p >= 0) {
-        Integer id = Ints.tryParse(s.substring(0, p));
-        if (id == null) {
-          return null;
-        }
-        Account.Id accountId = Account.id(id);
-        String label = s.substring(p + 1);
-        return create(accountId, label);
-      }
-      return null;
-    }
-
-    public static StarField create(Account.Id accountId, String label) {
-      return new AutoValue_StarredChangesUtil_StarField(accountId, label);
-    }
-
-    public abstract Account.Id accountId();
-
-    public abstract String label();
-
-    @Override
-    public final String toString() {
-      return accountId() + SEPARATOR + label();
-    }
-  }
-
-  public enum Operation {
-    ADD,
-    REMOVE
-  }
-
-  @AutoValue
-  public abstract static class StarRef {
-    private static final StarRef MISSING =
-        new AutoValue_StarredChangesUtil_StarRef(null, Collections.emptyNavigableSet());
-
-    private static StarRef create(Ref ref, Iterable<String> labels) {
-      return new AutoValue_StarredChangesUtil_StarRef(
-          requireNonNull(ref), ImmutableSortedSet.copyOf(labels));
-    }
-
-    @Nullable
-    public abstract Ref ref();
-
-    public abstract NavigableSet<String> labels();
-
-    public ObjectId objectId() {
-      return ref() != null ? ref().getObjectId() : ObjectId.zeroId();
-    }
-  }
-
-  public static class IllegalLabelException extends Exception {
-    private static final long serialVersionUID = 1L;
-
-    IllegalLabelException(String message) {
-      super(message);
-    }
-  }
-
-  public static class InvalidLabelsException extends IllegalLabelException {
-    private static final long serialVersionUID = 1L;
-
-    InvalidLabelsException(Set<String> invalidLabels) {
-      super(String.format("invalid labels: %s", Joiner.on(", ").join(invalidLabels)));
-    }
-  }
-
-  public static class MutuallyExclusiveLabelsException extends IllegalLabelException {
-    private static final long serialVersionUID = 1L;
-
-    MutuallyExclusiveLabelsException(String label1, String label2) {
-      super(
-          String.format(
-              "The labels %s and %s are mutually exclusive. Only one of them can be set.",
-              label1, label2));
-    }
-  }
-
-  public static final String DEFAULT_LABEL = "star";
-
-  private final GitRepositoryManager repoManager;
-  private final GitReferenceUpdated gitRefUpdated;
-  private final AllUsersName allUsers;
-  private final Provider<PersonIdent> serverIdent;
-
-  @Inject
-  StarredChangesUtil(
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated gitRefUpdated,
-      AllUsersName allUsers,
-      @GerritPersonIdent Provider<PersonIdent> serverIdent) {
-    this.repoManager = repoManager;
-    this.gitRefUpdated = gitRefUpdated;
-    this.allUsers = allUsers;
-    this.serverIdent = serverIdent;
-  }
-
-  public NavigableSet<String> getLabels(Account.Id accountId, Change.Id changeId) {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      return readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)).labels();
-    } catch (IOException e) {
-      throw new StorageException(
-          String.format(
-              "Reading stars from change %d for account %d failed",
-              changeId.get(), accountId.get()),
-          e);
-    }
-  }
-
-  public void star(Account.Id accountId, Change.Id changeId, Operation op)
-      throws IllegalLabelException {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      String refName = RefNames.refsStarredChanges(changeId, accountId);
-      StarRef old = readLabels(repo, refName);
-
-      NavigableSet<String> labels = new TreeSet<>(old.labels());
-      switch (op) {
-        case ADD:
-          labels.add(DEFAULT_LABEL);
-          break;
-        case REMOVE:
-          labels.remove(DEFAULT_LABEL);
-          break;
-      }
-
-      if (labels.isEmpty()) {
-        deleteRef(repo, refName, old.objectId());
-      } else {
-        updateLabels(repo, refName, old.objectId(), labels);
-      }
-    } catch (IOException e) {
-      throw new StorageException(
-          String.format("Star change %d for account %d failed", changeId.get(), accountId.get()),
-          e);
-    }
-  }
-
-  /**
-   * Returns a subset of change IDs among the input {@code changeIds} list that are starred by the
-   * {@code caller} user.
-   */
-  public Set<Change.Id> areStarred(
-      Repository allUsersRepo, List<Change.Id> changeIds, Account.Id caller) {
-    List<String> starRefs =
-        changeIds.stream()
-            .map(c -> RefNames.refsStarredChanges(c, caller))
-            .collect(Collectors.toList());
-    try {
-      return allUsersRepo.getRefDatabase().exactRef(starRefs.toArray(new String[0])).keySet()
-          .stream()
-          .map(r -> Change.Id.fromAllUsersRef(r))
-          .collect(Collectors.toSet());
-    } catch (IOException e) {
-      logger.atWarning().withCause(e).log(
-          "Failed getting starred changes for account %d within changes: %s",
-          caller.get(), Joiner.on(", ").join(changeIds));
-      return ImmutableSet.of();
-    }
-  }
-
-  /**
-   * Unstar the given change for all users.
-   *
-   * <p>Intended for use only when we're about to delete a change. For that reason, the change is
-   * not reindexed.
-   *
-   * @param changeId change ID.
-   * @throws IOException if an error occurred.
-   */
-  public void unstarAllForChangeDeletion(Change.Id changeId) throws IOException {
-    try (Repository repo = repoManager.openRepository(allUsers);
-        RevWalk rw = new RevWalk(repo)) {
-      BatchRefUpdate batchUpdate = repo.getRefDatabase().newBatchUpdate();
-      batchUpdate.setAllowNonFastForwards(true);
-      batchUpdate.setRefLogIdent(serverIdent.get());
-      batchUpdate.setRefLogMessage("Unstar change " + changeId.get(), true);
-      for (Account.Id accountId : getStars(repo, changeId)) {
-        String refName = RefNames.refsStarredChanges(changeId, accountId);
-        Ref ref = repo.getRefDatabase().exactRef(refName);
-        if (ref != null) {
-          batchUpdate.addCommand(new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), refName));
-        }
-      }
-      batchUpdate.execute(rw, NullProgressMonitor.INSTANCE);
-      for (ReceiveCommand command : batchUpdate.getCommands()) {
-        if (command.getResult() != ReceiveCommand.Result.OK) {
-          String message =
-              String.format(
-                  "Unstar change %d failed, ref %s could not be deleted: %s",
-                  changeId.get(), command.getRefName(), command.getResult());
-          if (command.getResult() == ReceiveCommand.Result.LOCK_FAILURE) {
-            throw new LockFailureException(message, batchUpdate);
-          }
-          throw new GitUpdateFailureException(message, batchUpdate);
-        }
-      }
-    }
-  }
-
-  public ImmutableMap<Account.Id, StarRef> byChange(Change.Id changeId) {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      ImmutableMap.Builder<Account.Id, StarRef> builder = ImmutableMap.builder();
-      for (Account.Id accountId : getStars(repo, changeId)) {
-        builder.put(accountId, readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)));
-      }
-      return builder.build();
-    } catch (IOException e) {
-      throw new StorageException(
-          String.format("Get accounts that starred change %d failed", changeId.get()), e);
-    }
-  }
-
-  public ImmutableSet<Change.Id> byAccountId(Account.Id accountId) {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      ImmutableSet.Builder<Change.Id> builder = ImmutableSet.builder();
-      for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_STARRED_CHANGES)) {
-        Account.Id currentAccountId = Account.Id.fromRef(ref.getName());
-        // Skip all refs that don't correspond with accountId.
-        if (currentAccountId == null || !currentAccountId.equals(accountId)) {
-          continue;
-        }
-        // Skip all refs that don't contain the required label.
-        StarRef starRef = readLabels(repo, ref.getName());
-        if (!starRef.labels().contains(DEFAULT_LABEL)) {
-          continue;
-        }
-
-        // Skip invalid change ids.
-        Change.Id changeId = Change.Id.fromAllUsersRef(ref.getName());
-        if (changeId == null) {
-          continue;
-        }
-        builder.add(changeId);
-      }
-      return builder.build();
-    } catch (IOException e) {
-      throw new StorageException(
-          String.format("Get starred changes for account %d failed", accountId.get()), e);
-    }
-  }
-
-  private static Set<Account.Id> getStars(Repository allUsers, Change.Id changeId)
-      throws IOException {
-    String prefix = RefNames.refsStarredChangesPrefix(changeId);
-    RefDatabase refDb = allUsers.getRefDatabase();
-    return refDb.getRefsByPrefix(prefix).stream()
-        .map(r -> r.getName().substring(prefix.length()))
-        .map(refPart -> Ints.tryParse(refPart))
-        .filter(Objects::nonNull)
-        .map(id -> Account.id(id))
-        .collect(toSet());
-  }
-
-  public ObjectId getObjectId(Account.Id accountId, Change.Id changeId) {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      Ref ref = repo.exactRef(RefNames.refsStarredChanges(changeId, accountId));
-      return ref != null ? ref.getObjectId() : ObjectId.zeroId();
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log(
-          "Getting star object ID for account %d on change %d failed",
-          accountId.get(), changeId.get());
-      return ObjectId.zeroId();
-    }
-  }
-
-  public static StarRef readLabels(Repository repo, String refName) throws IOException {
-    try (TraceTimer traceTimer =
-        TraceContext.newTimer(
-            "Read star labels", Metadata.builder().noteDbRefName(refName).build())) {
-      Ref ref = repo.exactRef(refName);
-      return readLabels(repo, ref);
-    }
-  }
-
-  public static StarRef readLabels(Repository repo, Ref ref) throws IOException {
-    if (ref == null) {
-      return StarRef.MISSING;
-    }
-    try (TraceTimer traceTimer =
-            TraceContext.newTimer(
-                String.format("Read star labels from %s (without ref lookup)", ref.getName()));
-        ObjectReader reader = repo.newObjectReader()) {
-      ObjectLoader obj = reader.open(ref.getObjectId(), Constants.OBJ_BLOB);
-      return StarRef.create(
-          ref,
-          Splitter.on(CharMatcher.whitespace())
-              .omitEmptyStrings()
-              .split(new String(obj.getCachedBytes(Integer.MAX_VALUE), UTF_8)));
-    }
-  }
-
-  public static ObjectId writeLabels(Repository repo, Collection<String> labels)
-      throws IOException, InvalidLabelsException {
-    validateLabels(labels);
-    try (ObjectInserter oi = repo.newObjectInserter()) {
-      ObjectId id =
-          oi.insert(
-              Constants.OBJ_BLOB,
-              labels.stream().sorted().distinct().collect(joining("\n")).getBytes(UTF_8));
-      oi.flush();
-      return id;
-    }
-  }
-
-  private static void validateLabels(Collection<String> labels) throws InvalidLabelsException {
-    if (labels == null) {
-      return;
-    }
-
-    NavigableSet<String> invalidLabels = new TreeSet<>();
-    for (String label : labels) {
-      if (CharMatcher.whitespace().matchesAnyOf(label)) {
-        invalidLabels.add(label);
-      }
-    }
-    if (!invalidLabels.isEmpty()) {
-      throw new InvalidLabelsException(invalidLabels);
-    }
-  }
-
-  private void updateLabels(
-      Repository repo, String refName, ObjectId oldObjectId, Collection<String> labels)
-      throws IOException, InvalidLabelsException {
-    try (TraceTimer traceTimer =
-            TraceContext.newTimer(
-                "Update star labels",
-                Metadata.builder().noteDbRefName(refName).resourceCount(labels.size()).build());
-        RevWalk rw = new RevWalk(repo)) {
-      RefUpdate u = repo.updateRef(refName);
-      u.setExpectedOldObjectId(oldObjectId);
-      u.setForceUpdate(true);
-      u.setNewObjectId(writeLabels(repo, labels));
-      u.setRefLogIdent(serverIdent.get());
-      u.setRefLogMessage("Update star labels", true);
-      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
-        RefUpdate.Result result = u.update(rw);
-        switch (result) {
-          case NEW:
-          case FORCED:
-          case NO_CHANGE:
-          case FAST_FORWARD:
-            gitRefUpdated.fire(allUsers, u, null);
-            return;
-          case LOCK_FAILURE:
-            throw new LockFailureException(
-                String.format("Update star labels on ref %s failed", refName), u);
-          case IO_FAILURE:
-          case NOT_ATTEMPTED:
-          case REJECTED:
-          case REJECTED_CURRENT_BRANCH:
-          case RENAMED:
-          case REJECTED_MISSING_OBJECT:
-          case REJECTED_OTHER_REASON:
-          default:
-            throw new StorageException(
-                String.format("Update star labels on ref %s failed: %s", refName, result.name()));
-        }
-      }
-    }
-  }
-
-  private void deleteRef(Repository repo, String refName, ObjectId oldObjectId) throws IOException {
-    if (ObjectId.zeroId().equals(oldObjectId)) {
-      // ref doesn't exist
-      return;
-    }
-
-    try (TraceTimer traceTimer =
-        TraceContext.newTimer(
-            "Delete star labels", Metadata.builder().noteDbRefName(refName).build())) {
-      RefUpdate u = repo.updateRef(refName);
-      u.setForceUpdate(true);
-      u.setExpectedOldObjectId(oldObjectId);
-      u.setRefLogIdent(serverIdent.get());
-      u.setRefLogMessage("Unstar change", true);
-      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
-        RefUpdate.Result result = u.delete();
-        switch (result) {
-          case FORCED:
-            gitRefUpdated.fire(allUsers, u, null);
-            return;
-          case LOCK_FAILURE:
-            throw new LockFailureException(String.format("Delete star ref %s failed", refName), u);
-          case NEW:
-          case NO_CHANGE:
-          case FAST_FORWARD:
-          case IO_FAILURE:
-          case NOT_ATTEMPTED:
-          case REJECTED:
-          case REJECTED_CURRENT_BRANCH:
-          case RENAMED:
-          case REJECTED_MISSING_OBJECT:
-          case REJECTED_OTHER_REASON:
-          default:
-            throw new StorageException(
-                String.format("Delete star ref %s failed: %s", refName, result.name()));
-        }
-      }
-    }
-  }
-}
+// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
+// used by a plugin.
+public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
diff --git a/java/com/google/gerrit/server/StarredChangesWriter.java b/java/com/google/gerrit/server/StarredChangesWriter.java
new file mode 100644
index 0000000..6c14cc9
--- /dev/null
+++ b/java/com/google/gerrit/server/StarredChangesWriter.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2023 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;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import java.io.IOException;
+
+public interface StarredChangesWriter {
+  void star(Account.Id accountId, Change.Id changeId);
+
+  void unstar(Account.Id accountId, Change.Id changeId);
+
+  /**
+   * Unstar the given change for all users.
+   *
+   * <p>Intended for use only when we're about to delete a change. For that reason, the change is
+   * not reindexed.
+   *
+   * @param changeId change ID.
+   * @throws IOException if an error occurred.
+   */
+  void unstarAllForChangeDeletion(Change.Id changeId) throws IOException;
+}
diff --git a/java/com/google/gerrit/server/WebLinks.java b/java/com/google/gerrit/server/WebLinks.java
index 58396f5..4676be3 100644
--- a/java/com/google/gerrit/server/WebLinks.java
+++ b/java/com/google/gerrit/server/WebLinks.java
@@ -87,18 +87,20 @@
    * @param commitMessage the commit message of the commit.
    * @param branchName branch of the commit.
    * @param changeKey change Identifier for this change
+   * @param changeId the numeric changeID for this change
    */
   public ImmutableList<WebLinkInfo> getPatchSetLinks(
       Project.NameKey project,
       String commit,
       String commitMessage,
       String branchName,
-      String changeKey) {
+      String changeKey,
+      int changeId) {
     return filterLinks(
         patchSetLinks,
         webLink ->
             webLink.getPatchSetWebLink(
-                project.get(), commit, commitMessage, branchName, changeKey));
+                project.get(), commit, commitMessage, branchName, changeKey, changeId));
   }
 
   /**
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 66a36f6..65a80a2 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account;
 
+import static com.google.gerrit.server.account.AccountCacheImpl.AccountCacheModule.ACCOUNT_CACHE_MODULE;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.cache.CacheLoader;
@@ -24,6 +25,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.ModuleImpl;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.cache.CacheModule;
@@ -52,24 +54,29 @@
 /** Caches important (but small) account state to avoid database hits. */
 @Singleton
 public class AccountCacheImpl implements AccountCache {
+  @ModuleImpl(name = ACCOUNT_CACHE_MODULE)
+  public static class AccountCacheModule extends CacheModule {
+    public static final String ACCOUNT_CACHE_MODULE = "account-cache-module";
+
+    @Override
+    protected void configure() {
+      persist(BYID_AND_REV_NAME, CachedAccountDetails.Key.class, CachedAccountDetails.class)
+          .version(2)
+          .keySerializer(CachedAccountDetails.Key.Serializer.INSTANCE)
+          .valueSerializer(CachedAccountDetails.Serializer.INSTANCE)
+          .loader(Loader.class);
+
+      bind(AccountCacheImpl.class);
+      bind(AccountCache.class).to(AccountCacheImpl.class);
+    }
+  }
+
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private static final String BYID_AND_REV_NAME = "accounts";
 
   public static Module module() {
-    return new CacheModule() {
-      @Override
-      protected void configure() {
-        persist(BYID_AND_REV_NAME, CachedAccountDetails.Key.class, CachedAccountDetails.class)
-            .version(1)
-            .keySerializer(CachedAccountDetails.Key.Serializer.INSTANCE)
-            .valueSerializer(CachedAccountDetails.Serializer.INSTANCE)
-            .loader(Loader.class);
-
-        bind(AccountCacheImpl.class);
-        bind(AccountCache.class).to(AccountCacheImpl.class);
-      }
-    };
+    return new AccountCacheModule();
   }
 
   private final ExternalIds externalIds;
@@ -122,13 +129,6 @@
   public Map<Account.Id, AccountState> get(Set<Account.Id> accountIds) {
     try {
       try (Repository allUsers = repoManager.openRepository(allUsersName)) {
-        // Get the default preferences for this Gerrit host
-        Ref ref = allUsers.exactRef(RefNames.REFS_USERS_DEFAULT);
-        CachedPreferences defaultPreferences =
-            ref != null
-                ? defaultPreferenceCache.get(ref.getObjectId())
-                : DefaultPreferencesCache.EMPTY;
-
         Set<CachedAccountDetails.Key> keys =
             Sets.newLinkedHashSetWithExpectedSize(accountIds.size());
         for (Account.Id id : accountIds) {
@@ -138,6 +138,7 @@
           }
           keys.add(CachedAccountDetails.Key.create(id, userRef.getObjectId()));
         }
+        CachedPreferences defaultPreferences = defaultPreferenceCache.get();
         ImmutableMap.Builder<Account.Id, AccountState> result = ImmutableMap.builder();
         for (Map.Entry<CachedAccountDetails.Key, CachedAccountDetails> account :
             accountDetailsCache.getAll(keys).entrySet()) {
diff --git a/java/com/google/gerrit/server/account/AccountConfig.java b/java/com/google/gerrit/server/account/AccountConfig.java
index 4143f77..2b0ba3f 100644
--- a/java/com/google/gerrit/server/account/AccountConfig.java
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -26,13 +26,11 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
-import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.CachedPreferences;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
-import com.google.gerrit.server.util.time.TimeUtil;
 import java.io.IOException;
 import java.time.Instant;
 import java.util.ArrayList;
@@ -95,7 +93,7 @@
   }
 
   @Override
-  protected String getRefName() {
+  public String getRefName() {
     return ref;
   }
 
@@ -125,7 +123,8 @@
    * Returns the revision of the {@code refs/meta/external-ids} branch.
    *
    * <p>This revision can be used to load the external IDs of the loaded account lazily via {@link
-   * ExternalIds#byAccount(com.google.gerrit.entities.Account.Id, ObjectId)}.
+   * com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdsNoteDbImpl#byAccount(com.google.gerrit.entities.Account.Id,
+   * ObjectId)}.
    *
    * @return revision of the {@code refs/meta/external-ids} branch, {@link Optional#empty()} if no
    *     {@code refs/meta/external-ids} branch exists
@@ -176,17 +175,7 @@
    * @return the new account
    * @throws DuplicateKeyException if the user branch already exists
    */
-  public Account getNewAccount() throws DuplicateKeyException {
-    return getNewAccount(TimeUtil.now());
-  }
-
-  /**
-   * Creates a new account.
-   *
-   * @return the new account
-   * @throws DuplicateKeyException if the user branch already exists
-   */
-  Account getNewAccount(Instant registeredOn) throws DuplicateKeyException {
+  public Account getNewAccount(Instant registeredOn) throws DuplicateKeyException {
     checkLoaded();
     if (revision != null) {
       throw new DuplicateKeyException(String.format("account %s already exists", accountId));
@@ -205,9 +194,9 @@
    * Returns the content of the {@code preferences.config} file wrapped as {@link
    * CachedPreferences}.
    */
-  CachedPreferences asCachedPreferences() {
+  public CachedPreferences asCachedPreferences() {
     checkLoaded();
-    return CachedPreferences.fromConfig(preferences.getRaw());
+    return CachedPreferences.fromLegacyConfig(preferences.getRaw());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/account/AccountDelta.java b/java/com/google/gerrit/server/account/AccountDelta.java
index 8f285b5..f074522 100644
--- a/java/com/google/gerrit/server/account/AccountDelta.java
+++ b/java/com/google/gerrit/server/account/AccountDelta.java
@@ -18,6 +18,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
@@ -161,6 +162,15 @@
    */
   public abstract Optional<EditPreferencesInfo> getEditPreferences();
 
+  /**
+   * Returns whether the delta for this account is deleting the account.
+   *
+   * <p>If set to true, deletion takes precedence on any other change in this delta.
+   *
+   * @return whether the account should be deleted.
+   */
+  public abstract Optional<Boolean> getShouldDeleteAccount();
+
   public boolean hasExternalIdUpdates() {
     return !this.getCreatedExternalIds().isEmpty()
         || !this.getDeletedExternalIds().isEmpty()
@@ -182,6 +192,7 @@
      *
      * @param fullName the new full name, if {@code null} or empty string the full name is unset
      */
+    @CanIgnoreReturnValue
     public abstract Builder setFullName(@Nullable String fullName);
 
     /**
@@ -190,6 +201,7 @@
      * @param displayName the new display name, if {@code null} or empty string the display name is
      *     unset
      */
+    @CanIgnoreReturnValue
     public abstract Builder setDisplayName(@Nullable String displayName);
 
     /**
@@ -198,6 +210,7 @@
      * @param preferredEmail the new preferred email, if {@code null} or empty string the preferred
      *     email is unset
      */
+    @CanIgnoreReturnValue
     public abstract Builder setPreferredEmail(@Nullable String preferredEmail);
 
     /**
@@ -206,6 +219,7 @@
      * @param active {@code true} if the account should be set to active, {@code false} if the
      *     account should be set to inactive
      */
+    @CanIgnoreReturnValue
     public abstract Builder setActive(boolean active);
 
     /**
@@ -213,6 +227,7 @@
      *
      * @param status the new status, if {@code null} or empty string the status is unset
      */
+    @CanIgnoreReturnValue
     public abstract Builder setStatus(@Nullable String status);
 
     /**
@@ -234,6 +249,7 @@
      * @param extId external ID that should be added
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public Builder addExternalId(ExternalId extId) {
       return addExternalIds(ImmutableSet.of(extId));
     }
@@ -250,6 +266,7 @@
      * @param extIds external IDs that should be added
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public Builder addExternalIds(Collection<ExternalId> extIds) {
       createdExternalIdsBuilder().addAll(extIds);
       return this;
@@ -273,6 +290,7 @@
      * @param extId external ID that should be updated
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public Builder updateExternalId(ExternalId extId) {
       return updateExternalIds(ImmutableSet.of(extId));
     }
@@ -289,6 +307,7 @@
      * @param extIds external IDs that should be updated
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public Builder updateExternalIds(Collection<ExternalId> extIds) {
       updatedExternalIdsBuilder().addAll(extIds);
       return this;
@@ -312,6 +331,7 @@
      * @param extId external ID that should be deleted
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public Builder deleteExternalId(ExternalId extId) {
       return deleteExternalIds(ImmutableSet.of(extId));
     }
@@ -327,6 +347,7 @@
      * @param extIds external IDs that should be deleted
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public Builder deleteExternalIds(Collection<ExternalId> extIds) {
       deletedExternalIdsBuilder().addAll(extIds);
       return this;
@@ -339,6 +360,7 @@
      * @param extIdToAdd external ID that should be added
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public Builder replaceExternalId(ExternalId extIdToDelete, ExternalId extIdToAdd) {
       return replaceExternalIds(ImmutableSet.of(extIdToDelete), ImmutableSet.of(extIdToAdd));
     }
@@ -350,6 +372,7 @@
      * @param extIdsToAdd external IDs that should be added
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public Builder replaceExternalIds(
         Collection<ExternalId> extIdsToDelete, Collection<ExternalId> extIdsToAdd) {
       return deleteExternalIds(extIdsToDelete).addExternalIds(extIdsToAdd);
@@ -371,6 +394,7 @@
      * @param notifyTypes the notify types that should be set for the project watch
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public Builder updateProjectWatch(
         ProjectWatchKey projectWatchKey, Set<NotifyType> notifyTypes) {
       return updateProjectWatches(ImmutableMap.of(projectWatchKey, notifyTypes));
@@ -385,6 +409,7 @@
      * @param projectWatches project watches that should be updated
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public Builder updateProjectWatches(Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
       updatedProjectWatchesBuilder().putAll(projectWatches);
       return this;
@@ -405,6 +430,7 @@
      * @param projectWatch project watch that should be deleted
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public Builder deleteProjectWatch(ProjectWatchKey projectWatch) {
       return deleteProjectWatches(ImmutableSet.of(projectWatch));
     }
@@ -417,6 +443,7 @@
      * @param projectWatches project watches that should be deleted
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public Builder deleteProjectWatches(Collection<ProjectWatchKey> projectWatches) {
       deletedProjectWatchesBuilder().addAll(projectWatches);
       return this;
@@ -430,6 +457,7 @@
      * @param generalPreferences the general preferences that should be set
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public abstract Builder setGeneralPreferences(GeneralPreferencesInfo generalPreferences);
 
     /**
@@ -440,6 +468,7 @@
      * @param diffPreferences the diff preferences that should be set
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public abstract Builder setDiffPreferences(DiffPreferencesInfo diffPreferences);
 
     /**
@@ -450,8 +479,25 @@
      * @param editPreferences the edit preferences that should be set
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public abstract Builder setEditPreferences(EditPreferencesInfo editPreferences);
 
+    @CanIgnoreReturnValue
+    public abstract Builder setShouldDeleteAccount(boolean shouldDelete);
+
+    /**
+     * Builds an AccountDelta that deletes all data.
+     *
+     * @param extIdsToDelete external IDs that should be deleted
+     * @return the builder
+     */
+    @CanIgnoreReturnValue
+    public Builder deleteAccount(Collection<ExternalId> extIdsToDelete) {
+      deleteExternalIds(extIdsToDelete);
+      setShouldDeleteAccount(true);
+      return this;
+    }
+
     /** Builds the instance. */
     public abstract AccountDelta build();
 
@@ -602,6 +648,12 @@
         delegate.setEditPreferences(editPreferences);
         return this;
       }
+
+      @Override
+      public Builder setShouldDeleteAccount(boolean shouldDelete) {
+        delegate.setShouldDeleteAccount(shouldDelete);
+        return this;
+      }
     }
   }
 }
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index edec52c..e32a0eb 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -45,7 +46,6 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.inject.Inject;
@@ -54,6 +54,7 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
@@ -505,28 +506,30 @@
    */
   public AuthResult updateLink(Account.Id to, AuthRequest who)
       throws AccountException, IOException, ConfigInvalidException {
+    Optional<ExternalId> optionalExtId = externalIds.get(who.getExternalIdKey());
+    if (optionalExtId.filter(extId -> !extId.accountId().equals(to)).isPresent()) {
+      throw new AccountException(
+          "Identity '" + optionalExtId.get().key().get() + "' in use by another account");
+    }
+
     accountsUpdateProvider
         .get()
         .update(
-            "Delete External IDs on Update Link",
+            "Update External IDs on Update Link",
             to,
             (a, u) -> {
               Set<ExternalId> filteredExtIdsByScheme =
                   a.externalIds().stream()
                       .filter(e -> e.key().isScheme(who.getExternalIdKey().scheme()))
                       .collect(toImmutableSet());
-              if (filteredExtIdsByScheme.isEmpty()) {
-                return;
-              }
+              ExternalId newExtId =
+                  externalIdFactory.createWithEmail(
+                      who.getExternalIdKey(), to, who.getEmailAddress());
 
-              if (filteredExtIdsByScheme.size() > 1
-                  || filteredExtIdsByScheme.stream()
-                      .noneMatch(e -> e.key().equals(who.getExternalIdKey()))) {
-                u.deleteExternalIds(filteredExtIdsByScheme);
-              }
+              u.replaceExternalIds(filteredExtIdsByScheme, Collections.singletonList(newExtId));
             });
 
-    return link(to, who);
+    return new AuthResult(to, who.getExternalIdKey(), false);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/account/AccountProperties.java b/java/com/google/gerrit/server/account/AccountProperties.java
index 928d851..5f56aa3 100644
--- a/java/com/google/gerrit/server/account/AccountProperties.java
+++ b/java/com/google/gerrit/server/account/AccountProperties.java
@@ -124,6 +124,7 @@
    * @param key the key
    * @return the value, {@code null} if key was not set or key was set to empty string
    */
+  @Nullable
   private static String get(Config cfg, String key) {
     return Strings.emptyToNull(cfg.getString(ACCOUNT, null, key));
   }
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 2020d2f..65e9d9d 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -26,7 +26,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Streams;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.index.Schema;
@@ -299,7 +300,7 @@
   private abstract class AccountIdSearcher implements Searcher<Account.Id> {
     @Override
     public final Stream<AccountState> search(Account.Id input) {
-      return Streams.stream(accountCache.get(input));
+      return accountCache.get(input).stream();
     }
   }
 
@@ -373,7 +374,7 @@
 
     @Override
     public Stream<AccountState> search(String input) {
-      return Streams.stream(accountCache.getByUsername(input));
+      return accountCache.getByUsername(input).stream();
     }
 
     @Override
@@ -585,6 +586,13 @@
           .addAll(nameOrEmailSearchers)
           .build();
 
+  private final ImmutableList<Searcher<?>> exactSearchers =
+      ImmutableList.<Searcher<?>>builder()
+          .add(new BySelf())
+          .add(new ByExactAccountId())
+          .add(new ByEmail())
+          .build();
+
   private final AccountCache accountCache;
   private final AccountControl.Factory accountControlFactory;
   private final Emails emails;
@@ -650,6 +658,17 @@
         input, searchers, self.get(), this::currentUserCanSeePredicate, AccountResolver::isActive);
   }
 
+  /** Resolves accounts using exact searchers. Similar to the previous method. */
+  @UsedAt(Project.GOOGLE)
+  public Result resolveExact(String input) throws ConfigInvalidException, IOException {
+    return searchImpl(
+        input,
+        exactSearchers,
+        self.get(),
+        this::currentUserCanSeePredicate,
+        AccountResolver::isActive);
+  }
+
   public Result resolve(String input, Predicate<AccountState> accountActivityPredicate)
       throws ConfigInvalidException, IOException {
     return searchImpl(
diff --git a/java/com/google/gerrit/server/account/AccountState.java b/java/com/google/gerrit/server/account/AccountState.java
index b7a54f4..8f2d66d 100644
--- a/java/com/google/gerrit/server/account/AccountState.java
+++ b/java/com/google/gerrit/server/account/AccountState.java
@@ -18,7 +18,6 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
@@ -26,13 +25,11 @@
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.config.CachedPreferences;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Optional;
-import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * Superset of all information related to an Account. This includes external IDs, project watches,
@@ -43,72 +40,6 @@
  */
 @AutoValue
 public abstract class AccountState {
-  /**
-   * Creates an AccountState from the given account config.
-   *
-   * @param externalIds class to access external IDs
-   * @param accountConfig the account config, must already be loaded
-   * @param defaultPreferences the default preferences for this Gerrit installation
-   * @return the account state, {@link Optional#empty()} if the account doesn't exist
-   * @throws IOException if accessing the external IDs fails
-   */
-  public static Optional<AccountState> fromAccountConfig(
-      ExternalIds externalIds, AccountConfig accountConfig, CachedPreferences defaultPreferences)
-      throws IOException {
-    return fromAccountConfig(externalIds, accountConfig, null, defaultPreferences);
-  }
-
-  /**
-   * Creates an AccountState from the given account config.
-   *
-   * <p>If external ID notes are provided the revision of the external IDs branch from which the
-   * external IDs for the account should be loaded is taken from the external ID notes. If external
-   * ID notes are not given the revision of the external IDs branch is taken from the account
-   * config. Updating external IDs is done via {@link ExternalIdNotes} and if external IDs were
-   * updated the revision of the external IDs branch in account config is outdated. Hence after
-   * updating external IDs the external ID notes must be provided.
-   *
-   * @param externalIds class to access external IDs
-   * @param accountConfig the account config, must already be loaded
-   * @param extIdNotes external ID notes, must already be loaded, may be {@code null}
-   * @param defaultPreferences the default preferences for this Gerrit installation
-   * @return the account state, {@link Optional#empty()} if the account doesn't exist
-   * @throws IOException if accessing the external IDs fails
-   */
-  public static Optional<AccountState> fromAccountConfig(
-      ExternalIds externalIds,
-      AccountConfig accountConfig,
-      @Nullable ExternalIdNotes extIdNotes,
-      CachedPreferences defaultPreferences)
-      throws IOException {
-    if (!accountConfig.getLoadedAccount().isPresent()) {
-      return Optional.empty();
-    }
-    Account account = accountConfig.getLoadedAccount().get();
-
-    Optional<ObjectId> extIdsRev =
-        extIdNotes != null
-            ? Optional.ofNullable(extIdNotes.getRevision())
-            : accountConfig.getExternalIdsRev();
-    ImmutableSet<ExternalId> extIds =
-        extIdsRev.isPresent()
-            ? externalIds.byAccount(account.id(), extIdsRev.get())
-            : ImmutableSet.of();
-
-    // Don't leak references to AccountConfig into the AccountState, since it holds a reference to
-    // an open Repository instance.
-    ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> projectWatches =
-        accountConfig.getProjectWatches();
-
-    return Optional.of(
-        new AutoValue_AccountState(
-            account,
-            extIds,
-            ExternalId.getUserName(extIds),
-            projectWatches,
-            Optional.of(defaultPreferences),
-            Optional.of(accountConfig.asCachedPreferences())));
-  }
 
   /**
    * Creates an AccountState for a given account with no external IDs, no project watches and
@@ -157,6 +88,18 @@
         Optional.empty());
   }
 
+  /** Creates an AccountState instance containing the given data. */
+  public static AccountState withState(
+      Account account,
+      ImmutableSet<ExternalId> externalIds,
+      Optional<String> userName,
+      ImmutableMap<ProjectWatches.ProjectWatchKey, ImmutableSet<NotifyType>> projectWatches,
+      Optional<CachedPreferences> defaultPreferences,
+      Optional<CachedPreferences> userPreferences) {
+    return new AutoValue_AccountState(
+        account, externalIds, userName, projectWatches, defaultPreferences, userPreferences);
+  }
+
   /** Get the cached account metadata. */
   public abstract Account account();
   /** The external identities that identify the account holder. */
@@ -200,8 +143,8 @@
   }
 
   /** Gerrit's default preferences as stored in {@code preferences.config}. */
-  protected abstract Optional<CachedPreferences> defaultPreferences();
+  public abstract Optional<CachedPreferences> defaultPreferences();
 
   /** User preferences as stored in {@code preferences.config}. */
-  protected abstract Optional<CachedPreferences> userPreferences();
+  public abstract Optional<CachedPreferences> userPreferences();
 }
diff --git a/java/com/google/gerrit/server/account/Accounts.java b/java/com/google/gerrit/server/account/Accounts.java
index 976a7d89..55192e9 100644
--- a/java/com/google/gerrit/server/account/Accounts.java
+++ b/java/com/google/gerrit/server/account/Accounts.java
@@ -14,107 +14,44 @@
 
 package com.google.gerrit.server.account;
 
-import static java.util.Comparator.comparing;
-import static java.util.stream.Collectors.toList;
-import static java.util.stream.Collectors.toSet;
-
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.metrics.Description;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.metrics.Timer0;
-import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.CachedPreferences;
-import com.google.gerrit.server.config.VersionedDefaultPreferences;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
-import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
-import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Repository;
 
 /** Class to access accounts. */
-@Singleton
-public class Accounts {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+public interface Accounts {
+  /**
+   * Gets the account state for the given ID.
+   *
+   * @return the account state if found, {@code Optional.empty} otherwise.
+   */
+  Optional<AccountState> get(Account.Id accountId) throws IOException, ConfigInvalidException;
 
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-  private final ExternalIds externalIds;
-  private final Timer0 readSingleLatency;
-
-  @Inject
-  Accounts(
-      GitRepositoryManager repoManager,
-      AllUsersName allUsersName,
-      ExternalIds externalIds,
-      MetricMaker metricMaker) {
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-    this.externalIds = externalIds;
-    this.readSingleLatency =
-        metricMaker.newTimer(
-            "notedb/read_single_account_config_latency",
-            new Description("Latency for reading a single account config.")
-                .setCumulative()
-                .setUnit(Description.Units.MILLISECONDS));
-  }
-
-  public Optional<AccountState> get(Account.Id accountId)
-      throws IOException, ConfigInvalidException {
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return read(repo, accountId);
-    }
-  }
-
-  public List<AccountState> get(Collection<Account.Id> accountIds)
-      throws IOException, ConfigInvalidException {
-    List<AccountState> accounts = new ArrayList<>(accountIds.size());
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      for (Account.Id accountId : accountIds) {
-        read(repo, accountId).ifPresent(accounts::add);
-      }
-    }
-    return accounts;
-  }
+  /**
+   * Gets the account states for all the given IDs.
+   *
+   * @return the account states.
+   */
+  List<AccountState> get(Collection<Account.Id> accountIds)
+      throws IOException, ConfigInvalidException;
 
   /**
    * Returns all accounts.
    *
    * @return all accounts
    */
-  public List<AccountState> all() throws IOException {
-    Set<Account.Id> accountIds = allIds();
-    List<AccountState> accounts = new ArrayList<>(accountIds.size());
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      for (Account.Id accountId : accountIds) {
-        try {
-          read(repo, accountId).ifPresent(accounts::add);
-        } catch (Exception e) {
-          logger.atSevere().withCause(e).log("Ignoring invalid account %s", accountId);
-        }
-      }
-    }
-    return accounts;
-  }
+  List<AccountState> all() throws IOException;
 
   /**
    * Returns all account IDs.
    *
    * @return all account IDs
    */
-  public Set<Account.Id> allIds() throws IOException {
-    return readUserRefs().collect(toSet());
-  }
+  Set<Account.Id> allIds() throws IOException;
 
   /**
    * Returns the first n account IDs.
@@ -122,48 +59,12 @@
    * @param n the number of account IDs that should be returned
    * @return first n account IDs
    */
-  public List<Account.Id> firstNIds(int n) throws IOException {
-    return readUserRefs().sorted(comparing(Account.Id::get)).limit(n).collect(toList());
-  }
+  List<Account.Id> firstNIds(int n) throws IOException;
 
   /**
    * Checks if any account exists.
    *
-   * @return {@code true} if at least one account exists, otherwise {@code false}
+   * @return {@code true} if at least one account exists, otherwise {@code false}.
    */
-  public boolean hasAnyAccount() throws IOException {
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return hasAnyAccount(repo);
-    }
-  }
-
-  public static boolean hasAnyAccount(Repository repo) throws IOException {
-    return readUserRefs(repo).findAny().isPresent();
-  }
-
-  private Stream<Account.Id> readUserRefs() throws IOException {
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return readUserRefs(repo);
-    }
-  }
-
-  private Optional<AccountState> read(Repository allUsersRepository, Account.Id accountId)
-      throws IOException, ConfigInvalidException {
-    AccountConfig cfg;
-    CachedPreferences defaultPreferences;
-    try (Timer0.Context ignored = readSingleLatency.start()) {
-      cfg = new AccountConfig(accountId, allUsersName, allUsersRepository).load();
-      defaultPreferences =
-          CachedPreferences.fromConfig(
-              VersionedDefaultPreferences.get(allUsersRepository, allUsersName));
-    }
-
-    return AccountState.fromAccountConfig(externalIds, cfg, defaultPreferences);
-  }
-
-  public static Stream<Account.Id> readUserRefs(Repository repo) throws IOException {
-    return repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_USERS).stream()
-        .map(r -> Account.Id.fromRef(r.getName()))
-        .filter(Objects::nonNull);
-  }
+  boolean hasAnyAccount() throws IOException;
 }
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index b706bca..24e8ba5 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -15,116 +15,41 @@
 package com.google.gerrit.server.account;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.ACCOUNTS_UPDATE;
-import static java.util.Objects.requireNonNull;
-import static java.util.stream.Collectors.toList;
-import static java.util.stream.Collectors.toSet;
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Strings;
-import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.common.Nullable;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.DuplicateKeyException;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.git.LockFailureException;
-import com.google.gerrit.git.RefUpdateUtil;
-import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.externalids.ExternalIdNotes;
-import com.google.gerrit.server.account.externalids.ExternalIdNotes.ExternalIdNotesLoader;
-import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.CachedPreferences;
-import com.google.gerrit.server.config.VersionedDefaultPreferences;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.index.change.ReindexAfterRefUpdate;
-import com.google.gerrit.server.notedb.Sequences;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryableAction.Action;
-import com.google.gerrit.server.update.context.RefUpdateContext;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
+import com.google.gerrit.server.Sequences;
+import com.google.inject.BindingAnnotation;
 import java.io.IOException;
-import java.util.ArrayList;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
 import java.util.List;
-import java.util.Objects;
 import java.util.Optional;
-import java.util.Set;
 import java.util.function.Consumer;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
  * Creates and updates accounts.
  *
- * <p>This class should be used for all account updates. See {@link AccountDelta} for what can be
- * updated.
- *
- * <p>Batch updates of multiple different accounts can be performed atomically, see {@link
- * #updateBatch(List)}. Batch creation is not supported.
- *
- * <p>For any account update the caller must provide a commit message, the account ID and an {@link
- * ConfigureDeltaFromState}. The account updater reads the current {@link AccountState} and prepares
- * updates to the account by calling setters on the provided {@link AccountDelta.Builder}. If the
- * current account state is of no interest the caller may also provide a {@link Consumer} for {@link
- * AccountDelta.Builder} instead of the account updater.
- *
- * <p>The provided commit message is used for the update of the user branch. Using a precise and
- * unique commit message allows to identify the code from which an update was made when looking at a
- * commit in the user branch, and thus help debugging.
+ * <p>This interface should be used for all account updates. See {@link AccountDelta} for what can
+ * be updated.
  *
  * <p>For creating a new account a new account ID can be retrieved from {@link
  * Sequences#nextAccountId()}.
  *
- * <p>The account updates are written to NoteDb. In NoteDb accounts are represented as user branches
- * in the {@code All-Users} repository. Optionally a user branch can contain a 'account.config' file
- * that stores account properties, such as full name, display name, preferred email, status and the
- * active flag. The timestamp of the first commit on a user branch denotes the registration date.
- * The initial commit on the user branch may be empty (since having an 'account.config' is
- * optional). See {@link AccountConfig} for details of the 'account.config' file format. In addition
- * the user branch can contain a 'preferences.config' config file to store preferences (see {@link
- * StoredPreferences}) and a 'watch.config' config file to store project watches (see {@link
- * ProjectWatches}). External IDs are stored separately in the {@code refs/meta/external-ids} notes
- * branch (see {@link ExternalIdNotes}).
- *
- * <p>On updating an account the account is evicted from the account cache and reindexed. The
- * eviction from the account cache and the reindexing is done by the {@link ReindexAfterRefUpdate}
- * class which receives the event about updating the user branch that is triggered by this class.
- *
- * <p>If external IDs are updated, the ExternalIdCache is automatically updated by {@link
- * ExternalIdNotes}. In addition {@link ExternalIdNotes} takes care about evicting and reindexing
- * corresponding accounts. This is needed because external ID updates don't touch the user branches.
- * Hence in this case the accounts are not evicted and reindexed via {@link ReindexAfterRefUpdate}.
- *
- * <p>Reindexing and flushing accounts from the account cache can be disabled by
- *
- * <ul>
- *   <li>binding {@link GitReferenceUpdated#DISABLED} and
- *   <li>passing an {@link
- *       com.google.gerrit.server.account.externalids.ExternalIdNotes.FactoryNoReindex} factory as
- *       parameter of {@link AccountsUpdate.Factory#create(IdentifiedUser,
- *       ExternalIdNotes.ExternalIdNotesLoader)}
- * </ul>
- *
- * <p>If there are concurrent account updates updating the user branch in NoteDb may fail with
- * {@link LockFailureException}. In this case the account update is automatically retried and the
- * account updater is invoked once more with the updated account state. This means the whole
- * read-modify-write sequence is atomic. Retrying is limited by a timeout. If the timeout is
- * exceeded the account update can still fail with {@link LockFailureException}.
+ * <p>See the implementing classes for more information.
  */
-public class AccountsUpdate {
-  public interface Factory {
+public abstract class AccountsUpdate {
+  public interface AccountsUpdateLoader {
     /**
      * Creates an {@code AccountsUpdate} which uses the identity of the specified user as author for
      * all commits related to accounts. The server identity will be used as committer.
@@ -134,9 +59,8 @@
      * AccountsUpdate} instead.
      *
      * @param currentUser the user to which modifications should be attributed
-     * @param externalIdNotesLoader the loader that should be used to load external ID notes
      */
-    AccountsUpdate create(IdentifiedUser currentUser, ExternalIdNotesLoader externalIdNotesLoader);
+    AccountsUpdate create(IdentifiedUser currentUser);
 
     /**
      * Creates an {@code AccountsUpdate} which uses the server identity as author and committer for
@@ -145,10 +69,34 @@
      * <p><strong>Note</strong>: Please use this method with care and consider using the {@link
      * com.google.gerrit.server.ServerInitiated} annotation on the provider of an {@code
      * AccountsUpdate} instead.
-     *
-     * @param externalIdNotesLoader the loader that should be used to load external ID notes
      */
-    AccountsUpdate createWithServerIdent(ExternalIdNotesLoader externalIdNotesLoader);
+    AccountsUpdate createWithServerIdent();
+
+    @BindingAnnotation
+    @Target({FIELD, PARAMETER, METHOD})
+    @Retention(RUNTIME)
+    @interface WithReindex {}
+
+    @BindingAnnotation
+    @Target({FIELD, PARAMETER, METHOD})
+    @Retention(RUNTIME)
+    @interface NoReindex {}
+  }
+
+  /** Data holder for the set of arguments required to update an account. Used for batch updates. */
+  public static class UpdateArguments {
+    public final String message;
+    public final Account.Id accountId;
+    public final AccountsUpdate.ConfigureDeltaFromState configureDeltaFromState;
+
+    public UpdateArguments(
+        String message,
+        Account.Id accountId,
+        AccountsUpdate.ConfigureDeltaFromState configureDeltaFromState) {
+      this.message = message;
+      this.accountId = accountId;
+      this.configureDeltaFromState = configureDeltaFromState;
+    }
   }
 
   /**
@@ -156,13 +104,13 @@
    * delta to be applied to it in a later step. This is done by implementing this interface.
    *
    * <p>If the current account state is not needed, use a {@link Consumer} of {@link
-   * AccountDelta.Builder} instead.
+   * com.google.gerrit.server.account.AccountDelta.Builder} instead.
    */
   @FunctionalInterface
   public interface ConfigureDeltaFromState {
     /**
      * Receives the current {@link AccountState} (which is immutable) and configures an {@link
-     * AccountDelta.Builder} with changes to the account.
+     * com.google.gerrit.server.account.AccountDelta.Builder} with changes to the account.
      *
      * @param accountState the state of the account that is being updated
      * @param delta the changes to be applied
@@ -170,133 +118,25 @@
     void configure(AccountState accountState, AccountDelta.Builder delta) throws IOException;
   }
 
-  /** Data holder for the set of arguments required to update an account. Used for batch updates. */
-  public static class UpdateArguments {
-    private final String message;
-    private final Account.Id accountId;
-    private final ConfigureDeltaFromState configureDeltaFromState;
-
-    public UpdateArguments(
-        String message, Account.Id accountId, ConfigureDeltaFromState configureDeltaFromState) {
-      this.message = message;
-      this.accountId = accountId;
-      this.configureDeltaFromState = configureDeltaFromState;
-    }
-  }
-
-  private final GitRepositoryManager repoManager;
-  private final GitReferenceUpdated gitRefUpdated;
-  private final Optional<IdentifiedUser> currentUser;
-  private final AllUsersName allUsersName;
-  private final ExternalIds externalIds;
-  private final Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
-  private final RetryHelper retryHelper;
-  private final ExternalIdNotesLoader extIdNotesLoader;
-  private final PersonIdent committerIdent;
-  private final PersonIdent authorIdent;
-
-  /** Invoked after reading the account config. */
-  private final Runnable afterReadRevision;
-
-  /** Invoked after updating the account but before committing the changes. */
-  private final Runnable beforeCommit;
-
-  /** Single instance that accumulates updates from the batch. */
-  @Nullable private ExternalIdNotes externalIdNotes;
-
-  @AssistedInject
-  AccountsUpdate(
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated gitRefUpdated,
-      AllUsersName allUsersName,
-      ExternalIds externalIds,
-      Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
-      RetryHelper retryHelper,
-      @GerritPersonIdent PersonIdent serverIdent,
-      @Assisted ExternalIdNotesLoader extIdNotesLoader) {
-    this(
-        repoManager,
-        gitRefUpdated,
-        Optional.empty(),
-        allUsersName,
-        externalIds,
-        metaDataUpdateInternalFactory,
-        retryHelper,
-        extIdNotesLoader,
-        serverIdent,
-        createPersonIdent(serverIdent, Optional.empty()),
-        AccountsUpdate::doNothing,
-        AccountsUpdate::doNothing);
-  }
-
-  @AssistedInject
-  AccountsUpdate(
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated gitRefUpdated,
-      AllUsersName allUsersName,
-      ExternalIds externalIds,
-      Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
-      RetryHelper retryHelper,
-      @GerritPersonIdent PersonIdent serverIdent,
-      @Assisted IdentifiedUser currentUser,
-      @Assisted ExternalIdNotesLoader extIdNotesLoader) {
-    this(
-        repoManager,
-        gitRefUpdated,
-        Optional.of(currentUser),
-        allUsersName,
-        externalIds,
-        metaDataUpdateInternalFactory,
-        retryHelper,
-        extIdNotesLoader,
-        serverIdent,
-        createPersonIdent(serverIdent, Optional.of(currentUser)),
-        AccountsUpdate::doNothing,
-        AccountsUpdate::doNothing);
-  }
-
-  @VisibleForTesting
-  public AccountsUpdate(
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated gitRefUpdated,
-      Optional<IdentifiedUser> currentUser,
-      AllUsersName allUsersName,
-      ExternalIds externalIds,
-      Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
-      RetryHelper retryHelper,
-      ExternalIdNotesLoader extIdNotesLoader,
-      PersonIdent committerIdent,
-      PersonIdent authorIdent,
-      Runnable afterReadRevision,
-      Runnable beforeCommit) {
-    this.repoManager = requireNonNull(repoManager, "repoManager");
-    this.gitRefUpdated = requireNonNull(gitRefUpdated, "gitRefUpdated");
-    this.currentUser = currentUser;
-    this.allUsersName = requireNonNull(allUsersName, "allUsersName");
-    this.externalIds = requireNonNull(externalIds, "externalIds");
-    this.metaDataUpdateInternalFactory =
-        requireNonNull(metaDataUpdateInternalFactory, "metaDataUpdateInternalFactory");
-    this.retryHelper = requireNonNull(retryHelper, "retryHelper");
-    this.extIdNotesLoader = requireNonNull(extIdNotesLoader, "extIdNotesLoader");
-    this.committerIdent = requireNonNull(committerIdent, "committerIdent");
-    this.authorIdent = requireNonNull(authorIdent, "authorIdent");
-    this.afterReadRevision = requireNonNull(afterReadRevision, "afterReadRevision");
-    this.beforeCommit = requireNonNull(beforeCommit, "beforeCommit");
-  }
-
   /** Returns an instance that runs all specified consumers. */
   public static ConfigureDeltaFromState joinConsumers(
       List<Consumer<AccountDelta.Builder>> consumers) {
     return (accountStateIgnored, update) -> consumers.forEach(c -> c.accept(update));
   }
 
-  private static ConfigureDeltaFromState fromConsumer(Consumer<AccountDelta.Builder> consumer) {
+  static ConfigureDeltaFromState fromConsumer(Consumer<AccountDelta.Builder> consumer) {
     return (a, u) -> consumer.accept(u);
   }
 
-  private static PersonIdent createPersonIdent(
-      PersonIdent serverIdent, Optional<IdentifiedUser> user) {
-    return user.isPresent() ? user.get().newCommitterIdent(serverIdent) : serverIdent;
+  protected final PersonIdent committerIdent;
+  protected final PersonIdent authorIdent;
+
+  protected final Optional<IdentifiedUser> currentUser;
+
+  protected AccountsUpdate(PersonIdent serverIdent, Optional<IdentifiedUser> user) {
+    this.currentUser = user;
+    this.committerIdent = serverIdent;
+    this.authorIdent = createPersonIdent(serverIdent, user);
   }
 
   /**
@@ -307,7 +147,7 @@
   public AccountState insert(
       String message, Account.Id accountId, Consumer<AccountDelta.Builder> init)
       throws IOException, ConfigInvalidException {
-    return insert(message, accountId, fromConsumer(init));
+    return insert(message, accountId, AccountsUpdate.fromConsumer(init));
   }
 
   /**
@@ -321,40 +161,19 @@
    * @throws IOException if creating the user branch fails due to an IO error
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
-  public AccountState insert(String message, Account.Id accountId, ConfigureDeltaFromState init)
-      throws IOException, ConfigInvalidException {
-    return execute(
-            ImmutableList.of(
-                repo -> {
-                  AccountConfig accountConfig = read(repo, accountId);
-                  Account account = accountConfig.getNewAccount(committerIdent.getWhenAsInstant());
-                  AccountState accountState = AccountState.forAccount(account);
-                  AccountDelta.Builder deltaBuilder = AccountDelta.builder();
-                  init.configure(accountState, deltaBuilder);
-
-                  AccountDelta accountDelta = deltaBuilder.build();
-                  accountConfig.setAccountDelta(accountDelta);
-                  externalIdNotes =
-                      createExternalIdNotes(
-                          repo, accountConfig.getExternalIdsRev(), accountId, accountDelta);
-                  CachedPreferences defaultPreferences =
-                      CachedPreferences.fromConfig(
-                          VersionedDefaultPreferences.get(repo, allUsersName));
-
-                  return new UpdatedAccount(message, accountConfig, defaultPreferences, true);
-                }))
-        .get(0)
-        .get();
-  }
+  public abstract AccountState insert(
+      String message, Account.Id accountId, ConfigureDeltaFromState init)
+      throws IOException, ConfigInvalidException;
 
   /**
    * Like {@link #update(String, Account.Id, ConfigureDeltaFromState)}, but using a {@link Consumer}
    * instead, i.e. the update does not depend on the current account state.
    */
+  @CanIgnoreReturnValue
   public Optional<AccountState> update(
       String message, Account.Id accountId, Consumer<AccountDelta.Builder> update)
       throws IOException, ConfigInvalidException {
-    return update(message, accountId, fromConsumer(update));
+    return update(message, accountId, AccountsUpdate.fromConsumer(update));
   }
 
   /**
@@ -372,57 +191,15 @@
    *     after the retry timeout exceeded
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
+  @CanIgnoreReturnValue
   public Optional<AccountState> update(
       String message, Account.Id accountId, ConfigureDeltaFromState configureDeltaFromState)
-      throws LockFailureException, IOException, ConfigInvalidException {
+      throws IOException, ConfigInvalidException {
     return updateBatch(
             ImmutableList.of(new UpdateArguments(message, accountId, configureDeltaFromState)))
         .get(0);
   }
 
-  private ExecutableUpdate createExecutableUpdate(UpdateArguments updateArguments) {
-    return repo -> {
-      AccountConfig accountConfig = read(repo, updateArguments.accountId);
-      CachedPreferences defaultPreferences =
-          CachedPreferences.fromConfig(VersionedDefaultPreferences.get(repo, allUsersName));
-      Optional<AccountState> accountState =
-          AccountState.fromAccountConfig(externalIds, accountConfig, defaultPreferences);
-      if (!accountState.isPresent()) {
-        return null;
-      }
-
-      AccountDelta.Builder deltaBuilder = AccountDelta.builder();
-      updateArguments.configureDeltaFromState.configure(accountState.get(), deltaBuilder);
-
-      AccountDelta delta = deltaBuilder.build();
-      accountConfig.setAccountDelta(delta);
-      ExternalIdNotes.checkSameAccount(
-          Iterables.concat(
-              delta.getCreatedExternalIds(),
-              delta.getUpdatedExternalIds(),
-              delta.getDeletedExternalIds()),
-          updateArguments.accountId);
-
-      if (delta.hasExternalIdUpdates()) {
-        // Only load the externalIds if they are going to be updated
-        // This makes e.g. preferences updates faster.
-        if (externalIdNotes == null) {
-          externalIdNotes =
-              extIdNotesLoader.load(
-                  repo, accountConfig.getExternalIdsRev().orElse(ObjectId.zeroId()));
-        }
-        externalIdNotes.replace(delta.getDeletedExternalIds(), delta.getCreatedExternalIds());
-        externalIdNotes.upsert(delta.getUpdatedExternalIds());
-      }
-
-      CachedPreferences cachedDefaultPreferences =
-          CachedPreferences.fromConfig(VersionedDefaultPreferences.get(repo, allUsersName));
-
-      return new UpdatedAccount(
-          updateArguments.message, accountConfig, cachedDefaultPreferences, false);
-    };
-  }
-
   /**
    * Updates multiple different accounts atomically. This will only store a single new value (aka
    * set of all external IDs of the host) in the external ID cache, which is important for storage
@@ -438,200 +215,25 @@
     checkArgument(
         updates.stream().map(u -> u.accountId.get()).distinct().count() == updates.size(),
         "updates must all be for different accounts");
-    return execute(updates.stream().map(this::createExecutableUpdate).collect(toList()));
+    return executeUpdates(updates);
   }
 
-  private AccountConfig read(Repository allUsersRepo, Account.Id accountId)
-      throws IOException, ConfigInvalidException {
-    AccountConfig accountConfig = new AccountConfig(accountId, allUsersName, allUsersRepo).load();
-    afterReadRevision.run();
-    return accountConfig;
-  }
+  /**
+   * Deletes all the account state data.
+   *
+   * @param message commit message for the account update, must not be {@code null or empty}
+   * @param accountId ID of the account
+   * @throws IOException if updating the user branch fails due to an IO error
+   * @throws ConfigInvalidException if any of the account fields has an invalid value
+   */
+  public abstract void delete(String message, Account.Id accountId)
+      throws IOException, ConfigInvalidException;
 
-  private ImmutableList<Optional<AccountState>> execute(List<ExecutableUpdate> executableUpdates)
-      throws IOException, ConfigInvalidException {
-    try (RefUpdateContext ctx = RefUpdateContext.open(ACCOUNTS_UPDATE)) {
-      List<Optional<AccountState>> accountState = new ArrayList<>();
-      List<UpdatedAccount> updatedAccounts = new ArrayList<>();
-      executeWithRetry(
-          () -> {
+  protected abstract ImmutableList<Optional<AccountState>> executeUpdates(
+      List<UpdateArguments> updates) throws ConfigInvalidException, IOException;
 
-            // Reset state for retry.
-            externalIdNotes = null;
-            accountState.clear();
-            updatedAccounts.clear();
-            try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
-              for (ExecutableUpdate executableUpdate : executableUpdates) {
-                updatedAccounts.add(executableUpdate.execute(allUsersRepo));
-              }
-              commit(
-                  allUsersRepo,
-                  updatedAccounts.stream().filter(Objects::nonNull).collect(toList()));
-              for (UpdatedAccount ua : updatedAccounts) {
-                accountState.add(ua == null ? Optional.empty() : ua.getAccountState());
-              }
-            }
-            return null;
-          });
-
-      return ImmutableList.copyOf(accountState);
-    }
-  }
-
-  private void executeWithRetry(Action<Void> action) throws IOException, ConfigInvalidException {
-    try {
-      retryHelper.accountUpdate("updateAccount", action).call();
-    } catch (Exception e) {
-      Throwables.throwIfUnchecked(e);
-      Throwables.throwIfInstanceOf(e, IOException.class);
-      Throwables.throwIfInstanceOf(e, ConfigInvalidException.class);
-      throw new StorageException(e);
-    }
-  }
-
-  private ExternalIdNotes createExternalIdNotes(
-      Repository allUsersRepo, Optional<ObjectId> rev, Account.Id accountId, AccountDelta update)
-      throws IOException, ConfigInvalidException, DuplicateKeyException {
-    ExternalIdNotes.checkSameAccount(
-        Iterables.concat(
-            update.getCreatedExternalIds(),
-            update.getUpdatedExternalIds(),
-            update.getDeletedExternalIds()),
-        accountId);
-
-    ExternalIdNotes extIdNotes = extIdNotesLoader.load(allUsersRepo, rev.orElse(ObjectId.zeroId()));
-    extIdNotes.replace(update.getDeletedExternalIds(), update.getCreatedExternalIds());
-    extIdNotes.upsert(update.getUpdatedExternalIds());
-    return extIdNotes;
-  }
-
-  private void commit(Repository allUsersRepo, List<UpdatedAccount> updatedAccounts)
-      throws IOException {
-    if (updatedAccounts.isEmpty()) {
-      return;
-    }
-
-    beforeCommit.run();
-
-    BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
-    //  External ids may be not updated if:
-    //  * externalIdNotes is not loaded  (there were no externalId updates in the delta)
-    //  * new revCommit is identical to the previous externalId tip
-    boolean externalIdsUpdated = false;
-    if (externalIdNotes != null) {
-      String externalIdUpdateMessage =
-          updatedAccounts.size() == 1
-              ? Iterables.getOnlyElement(updatedAccounts).message
-              : "Batch update for " + updatedAccounts.size() + " accounts";
-      ObjectId oldExternalIdsRevision = externalIdNotes.getRevision();
-      // These update the same ref, so they need to be stacked on top of one another using the same
-      // ExternalIdNotes instance.
-      RevCommit revCommit =
-          commitExternalIdUpdates(externalIdUpdateMessage, allUsersRepo, batchRefUpdate);
-      externalIdsUpdated = !Objects.equals(revCommit.getId(), oldExternalIdsRevision);
-    }
-    for (UpdatedAccount updatedAccount : updatedAccounts) {
-
-      // These updates are all for different refs (because batches never update the same account
-      // more than once), so there can be multiple commits in the same batch, all with the same base
-      // revision in their AccountConfig.
-      // We allow empty commits:
-      // 1) When creating a new account, so that the user branch gets created with an empty commit
-      // when no account properties are set and hence no
-      // 'account.config' file will be created.
-      // 2) When updating "refs/meta/external-ids", so that refs/users/* meta ref is updated too.
-      // This allows to schedule reindexing of account transactionally  on refs/users/* meta
-      // updates.
-      boolean allowEmptyCommit = externalIdsUpdated || updatedAccount.created;
-      commitAccountConfig(
-          updatedAccount.message,
-          allUsersRepo,
-          batchRefUpdate,
-          updatedAccount.accountConfig,
-          allowEmptyCommit);
-    }
-
-    RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
-
-    Set<Account.Id> accountsToSkipForReindex = getUpdatedAccountIds(batchRefUpdate);
-    if (externalIdsUpdated) {
-      extIdNotesLoader.updateExternalIdCacheAndMaybeReindexAccounts(
-          externalIdNotes, accountsToSkipForReindex);
-    }
-
-    gitRefUpdated.fire(
-        allUsersName, batchRefUpdate, currentUser.map(IdentifiedUser::state).orElse(null));
-  }
-
-  private static Set<Account.Id> getUpdatedAccountIds(BatchRefUpdate batchRefUpdate) {
-    return batchRefUpdate.getCommands().stream()
-        .map(c -> Account.Id.fromRef(c.getRefName()))
-        .filter(Objects::nonNull)
-        .collect(toSet());
-  }
-
-  private void commitAccountConfig(
-      String message,
-      Repository allUsersRepo,
-      BatchRefUpdate batchRefUpdate,
-      AccountConfig accountConfig,
-      boolean allowEmptyCommit)
-      throws IOException {
-    try (MetaDataUpdate md = createMetaDataUpdate(message, allUsersRepo, batchRefUpdate)) {
-      md.setAllowEmpty(allowEmptyCommit);
-      accountConfig.commit(md);
-    }
-  }
-
-  private RevCommit commitExternalIdUpdates(
-      String message, Repository allUsersRepo, BatchRefUpdate batchRefUpdate) throws IOException {
-    try (MetaDataUpdate md = createMetaDataUpdate(message, allUsersRepo, batchRefUpdate)) {
-      return externalIdNotes.commit(md);
-    }
-  }
-
-  private MetaDataUpdate createMetaDataUpdate(
-      String message, Repository allUsersRepo, BatchRefUpdate batchRefUpdate) {
-    MetaDataUpdate metaDataUpdate =
-        metaDataUpdateInternalFactory.get().create(allUsersName, allUsersRepo, batchRefUpdate);
-    if (!message.endsWith("\n")) {
-      message = message + "\n";
-    }
-
-    metaDataUpdate.getCommitBuilder().setMessage(message);
-    metaDataUpdate.getCommitBuilder().setCommitter(committerIdent);
-    metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
-    return metaDataUpdate;
-  }
-
-  private static void doNothing() {}
-
-  @FunctionalInterface
-  private interface ExecutableUpdate {
-    UpdatedAccount execute(Repository allUsersRepo) throws IOException, ConfigInvalidException;
-  }
-
-  private class UpdatedAccount {
-    final String message;
-    final AccountConfig accountConfig;
-    final CachedPreferences defaultPreferences;
-    final boolean created;
-
-    UpdatedAccount(
-        String message,
-        AccountConfig accountConfig,
-        CachedPreferences defaultPreferences,
-        boolean created) {
-      checkState(!Strings.isNullOrEmpty(message), "message for account update must be set");
-      this.message = requireNonNull(message);
-      this.accountConfig = requireNonNull(accountConfig);
-      this.defaultPreferences = defaultPreferences;
-      this.created = created;
-    }
-
-    Optional<AccountState> getAccountState() throws IOException {
-      return AccountState.fromAccountConfig(
-          externalIds, accountConfig, externalIdNotes, defaultPreferences);
-    }
+  private static PersonIdent createPersonIdent(
+      PersonIdent serverIdent, Optional<IdentifiedUser> user) {
+    return user.isPresent() ? user.get().newCommitterIdent(serverIdent) : serverIdent;
   }
 }
diff --git a/java/com/google/gerrit/server/account/CachedAccountDetails.java b/java/com/google/gerrit/server/account/CachedAccountDetails.java
index 2ab6174..e167a23 100644
--- a/java/com/google/gerrit/server/account/CachedAccountDetails.java
+++ b/java/com/google/gerrit/server/account/CachedAccountDetails.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.config.CachedPreferences;
 import java.time.Instant;
 import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 
 /** Details of an account that are cached persistently in {@link AccountCache}. */
@@ -40,21 +41,21 @@
 public abstract class CachedAccountDetails {
   @AutoValue
   public abstract static class Key {
-    static Key create(Account.Id accountId, ObjectId id) {
+    public static Key create(Account.Id accountId, ObjectId id) {
       return new AutoValue_CachedAccountDetails_Key(accountId, id.copy());
     }
 
     /** Identifier of the account. */
-    abstract Account.Id accountId();
+    public abstract Account.Id accountId();
 
     /**
      * Git revision at which the account was loaded. Corresponds to a revision on the account ref
      * ({@code refs/users/<sharded-id>}).
      */
-    abstract ObjectId id();
+    public abstract ObjectId id();
 
     /** Serializer used to read this entity from and write it to a persistent storage. */
-    enum Serializer implements CacheSerializer<Key> {
+    public enum Serializer implements CacheSerializer<Key> {
       INSTANCE;
 
       @Override
@@ -77,16 +78,17 @@
   }
 
   /** Essential attributes of the account, such as name or registration time. */
-  abstract Account account();
+  public abstract Account account();
 
   /** Projects that the user has configured to watch. */
-  abstract ImmutableMap<ProjectWatches.ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>>
+  public abstract ImmutableMap<
+          ProjectWatches.ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>>
       projectWatches();
 
   /** Preferences that this user has. Serialized as Git-config style string. */
-  abstract CachedPreferences preferences();
+  public abstract CachedPreferences preferences();
 
-  static CachedAccountDetails create(
+  public static CachedAccountDetails create(
       Account account,
       ImmutableMap<ProjectWatches.ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>>
           projectWatches,
@@ -95,7 +97,7 @@
   }
 
   /** Serializer used to read this entity from and write it to a persistent storage. */
-  enum Serializer implements CacheSerializer<CachedAccountDetails> {
+  public enum Serializer implements CacheSerializer<CachedAccountDetails> {
     INSTANCE;
 
     @Override
@@ -131,7 +133,11 @@
         serialized.addProjectWatchProto(proto);
       }
 
-      serialized.setUserPreferences(cachedAccountDetails.preferences().config());
+      Optional<Cache.CachedPreferencesProto> cachedPreferencesProto =
+          cachedAccountDetails.preferences().nonEmptyConfig();
+      if (cachedPreferencesProto.isPresent()) {
+        serialized.setUserPreferences(cachedPreferencesProto.get());
+      }
       return Protos.toByteArray(serialized.build());
     }
 
@@ -166,7 +172,7 @@
       return CachedAccountDetails.create(
           account,
           projectWatches.build(),
-          CachedPreferences.fromString(proto.getUserPreferences()));
+          CachedPreferences.fromCachedPreferencesProto(proto.getUserPreferences()));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/account/VersionedAccountDestinations.java b/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
index f1cf9fe..41a02a9 100644
--- a/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
+++ b/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
@@ -15,20 +15,19 @@
 package com.google.gerrit.server.account;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.FileMode;
 
-/** User configured named destinations. */
+/** User or Group configured named destinations. */
 public class VersionedAccountDestinations extends VersionedMetaData {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static VersionedAccountDestinations forUser(Account.Id id) {
-    return new VersionedAccountDestinations(RefNames.refsUsers(id));
+  public static VersionedAccountDestinations forBranch(BranchNameKey branch) {
+    return new VersionedAccountDestinations(branch.branch());
   }
 
   private final String ref;
diff --git a/java/com/google/gerrit/server/account/VersionedAccountQueries.java b/java/com/google/gerrit/server/account/VersionedAccountQueries.java
index 5e63875..0269ccf 100644
--- a/java/com/google/gerrit/server/account/VersionedAccountQueries.java
+++ b/java/com/google/gerrit/server/account/VersionedAccountQueries.java
@@ -18,8 +18,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import java.io.IOException;
@@ -37,8 +36,8 @@
 public class VersionedAccountQueries extends VersionedMetaData {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static VersionedAccountQueries forUser(Account.Id id) {
-    return new VersionedAccountQueries(RefNames.refsUsers(id));
+  public static VersionedAccountQueries forBranch(BranchNameKey branch) {
+    return new VersionedAccountQueries(branch.branch());
   }
 
   private final String ref;
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
index 9196db8..1a6428c 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -27,7 +27,6 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.AuthType;
-import com.google.gerrit.git.ObjectIds;
 import java.io.Serializable;
 import java.util.Collection;
 import java.util.Locale;
@@ -100,10 +99,10 @@
 
   private static final long serialVersionUID = 1L;
 
-  static final String EXTERNAL_ID_SECTION = "externalId";
-  static final String ACCOUNT_ID_KEY = "accountId";
-  static final String EMAIL_KEY = "email";
-  static final String PASSWORD_KEY = "password";
+  public static final String EXTERNAL_ID_SECTION = "externalId";
+  public static final String ACCOUNT_ID_KEY = "accountId";
+  public static final String EMAIL_KEY = "email";
+  public static final String PASSWORD_KEY = "password";
 
   /**
    * Scheme used to label accounts created, when using the LDAP-based authentication types {@link
@@ -175,7 +174,6 @@
      *
      * @return the parsed external ID key
      */
-    @VisibleForTesting
     public static Key parse(String externalId, boolean isCaseInsensitive) {
       int c = externalId.indexOf(':');
       if (c < 1 || c >= externalId.length() - 1) {
@@ -254,7 +252,6 @@
     }
   }
 
-  @VisibleForTesting
   public static ExternalId create(
       Key key,
       Account.Id accountId,
@@ -294,15 +291,6 @@
     return key().isScheme(scheme);
   }
 
-  public byte[] toByteArray() {
-    checkState(blobId() != null, "Missing blobId in external ID %s", key().get());
-    byte[] b = new byte[2 * ObjectIds.STR_LEN + 1];
-    key().sha1().copyTo(b, 0);
-    b[ObjectIds.STR_LEN] = ':';
-    blobId().copyTo(b, ObjectIds.STR_LEN + 1);
-    return b;
-  }
-
   /**
    * For checking if two external IDs are equals the blobId is excluded and external IDs that have
    * different blob IDs but identical other fields are considered equal. This way an external ID
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
index fe8feac..a23e7bc 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.entities.Account;
 import java.io.IOException;
 import java.util.Optional;
-import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * Caches external IDs of all accounts. Note that the granularity is "revision" only, so each update
@@ -35,8 +34,6 @@
 
   ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException;
 
-  ImmutableSet<ExternalId> byAccount(Account.Id accountId, ObjectId rev) throws IOException;
-
   ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException;
 
   ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException;
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java b/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
index b16f73f..d226565 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
@@ -14,33 +14,10 @@
 
 package com.google.gerrit.server.account.externalids;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.Objects.requireNonNull;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.Iterables;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.server.account.HashedPassword;
-import com.google.gerrit.server.config.AuthConfig;
-import java.util.Set;
-import javax.inject.Inject;
-import javax.inject.Singleton;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
 
-@Singleton
-public class ExternalIdFactory {
-  private final ExternalIdKeyFactory externalIdKeyFactory;
-  private AuthConfig authConfig;
-
-  @Inject
-  public ExternalIdFactory(ExternalIdKeyFactory externalIdKeyFactory, AuthConfig authConfig) {
-    this.externalIdKeyFactory = externalIdKeyFactory;
-    this.authConfig = authConfig;
-  }
-
+public interface ExternalIdFactory {
   /**
    * Creates an external ID.
    *
@@ -50,9 +27,7 @@
    * @param accountId the ID of the account to which the external ID belongs
    * @return the created external ID
    */
-  public ExternalId create(String scheme, String id, Account.Id accountId) {
-    return create(externalIdKeyFactory.create(scheme, id), accountId, null, null);
-  }
+  ExternalId create(String scheme, String id, Account.Id accountId);
 
   /**
    * Creates an external ID.
@@ -65,14 +40,12 @@
    * @param hashedPassword the hashed password of the external ID, may be {@code null}
    * @return the created external ID
    */
-  public ExternalId create(
+  ExternalId create(
       String scheme,
       String id,
       Account.Id accountId,
       @Nullable String email,
-      @Nullable String hashedPassword) {
-    return create(externalIdKeyFactory.create(scheme, id), accountId, email, hashedPassword);
-  }
+      @Nullable String hashedPassword);
 
   /**
    * Creates an external ID.
@@ -81,9 +54,7 @@
    * @param accountId the ID of the account to which the external ID belongs
    * @return the created external ID
    */
-  public ExternalId create(ExternalId.Key key, Account.Id accountId) {
-    return create(key, accountId, null, null);
-  }
+  ExternalId create(ExternalId.Key key, Account.Id accountId);
 
   /**
    * Creates an external ID.
@@ -94,14 +65,11 @@
    * @param hashedPassword the hashed password of the external ID, may be {@code null}
    * @return the created external ID
    */
-  public ExternalId create(
+  ExternalId create(
       ExternalId.Key key,
       Account.Id accountId,
       @Nullable String email,
-      @Nullable String hashedPassword) {
-    return create(
-        key, accountId, Strings.emptyToNull(email), Strings.emptyToNull(hashedPassword), null);
-  }
+      @Nullable String hashedPassword);
 
   /**
    * Creates an external ID adding a hashed password computed from a plain password.
@@ -112,16 +80,11 @@
    * @param plainPassword the plain HTTP password, may be {@code null}
    * @return the created external ID
    */
-  public ExternalId createWithPassword(
+  ExternalId createWithPassword(
       ExternalId.Key key,
       Account.Id accountId,
       @Nullable String email,
-      @Nullable String plainPassword) {
-    plainPassword = Strings.emptyToNull(plainPassword);
-    String hashedPassword =
-        plainPassword != null ? HashedPassword.fromPassword(plainPassword).encode() : null;
-    return create(key, accountId, email, hashedPassword);
-  }
+      @Nullable String plainPassword);
 
   /**
    * Create a external ID for a username (scheme "username").
@@ -131,14 +94,7 @@
    * @param plainPassword the plain HTTP password, may be {@code null}
    * @return the created external ID
    */
-  public ExternalId createUsername(
-      String id, Account.Id accountId, @Nullable String plainPassword) {
-    return createWithPassword(
-        externalIdKeyFactory.create(ExternalId.SCHEME_USERNAME, id),
-        accountId,
-        null,
-        plainPassword);
-  }
+  ExternalId createUsername(String id, Account.Id accountId, @Nullable String plainPassword);
 
   /**
    * Creates an external ID with an email.
@@ -150,10 +106,8 @@
    * @param email the email of the external ID, may be {@code null}
    * @return the created external ID
    */
-  public ExternalId createWithEmail(
-      String scheme, String id, Account.Id accountId, @Nullable String email) {
-    return createWithEmail(externalIdKeyFactory.create(scheme, id), accountId, email);
-  }
+  ExternalId createWithEmail(
+      String scheme, String id, Account.Id accountId, @Nullable String email);
 
   /**
    * Creates an external ID with an email.
@@ -163,10 +117,7 @@
    * @param email the email of the external ID, may be {@code null}
    * @return the created external ID
    */
-  public ExternalId createWithEmail(
-      ExternalId.Key key, Account.Id accountId, @Nullable String email) {
-    return create(key, accountId, Strings.emptyToNull(email), null);
-  }
+  ExternalId createWithEmail(ExternalId.Key key, Account.Id accountId, @Nullable String email);
 
   /**
    * Creates an external ID using the `mailto`-scheme.
@@ -175,162 +126,5 @@
    * @param email the email of the external ID, may be {@code null}
    * @return the created external ID
    */
-  public ExternalId createEmail(Account.Id accountId, String email) {
-    return createWithEmail(ExternalId.SCHEME_MAILTO, email, accountId, requireNonNull(email));
-  }
-
-  ExternalId create(ExternalId extId, @Nullable ObjectId blobId) {
-    return create(extId.key(), extId.accountId(), extId.email(), extId.password(), blobId);
-  }
-
-  /**
-   * Creates an external ID.
-   *
-   * @param key the external Id key
-   * @param accountId the ID of the account to which the external ID belongs
-   * @param email the email of the external ID, may be {@code null}
-   * @param hashedPassword the hashed password of the external ID, may be {@code null}
-   * @param blobId the ID of the note blob in the external IDs branch that stores this external ID.
-   *     {@code null} if the external ID was created in code and is not yet stored in Git.
-   * @return the created external ID
-   */
-  public ExternalId create(
-      ExternalId.Key key,
-      Account.Id accountId,
-      @Nullable String email,
-      @Nullable String hashedPassword,
-      @Nullable ObjectId blobId) {
-    return ExternalId.create(
-        key, accountId, Strings.emptyToNull(email), Strings.emptyToNull(hashedPassword), blobId);
-  }
-
-  /**
-   * Parses an external ID from a byte array that contains the external ID as a Git config file
-   * text.
-   *
-   * <p>The Git config must have exactly one externalId subsection with an accountId and optionally
-   * email and password:
-   *
-   * <pre>
-   * [externalId "username:jdoe"]
-   *   accountId = 1003407
-   *   email = jdoe@example.com
-   *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
-   * </pre>
-   *
-   * @param noteId the SHA-1 sum of the external ID used as the note's ID
-   * @param raw a byte array that contains the external ID as a Git config file text.
-   * @param blobId the ID of the note blob in the external IDs branch that stores this external ID.
-   *     {@code null} if the external ID was created in code and is not yet stored in Git.
-   * @return the parsed external ID
-   */
-  public ExternalId parse(String noteId, byte[] raw, ObjectId blobId)
-      throws ConfigInvalidException {
-    requireNonNull(blobId);
-
-    Config externalIdConfig = new Config();
-    try {
-      externalIdConfig.fromText(new String(raw, UTF_8));
-    } catch (ConfigInvalidException e) {
-      throw invalidConfig(noteId, e.getMessage());
-    }
-
-    Set<String> externalIdKeys = externalIdConfig.getSubsections(ExternalId.EXTERNAL_ID_SECTION);
-    if (externalIdKeys.size() != 1) {
-      throw invalidConfig(
-          noteId,
-          String.format(
-              "Expected exactly 1 '%s' section, found %d",
-              ExternalId.EXTERNAL_ID_SECTION, externalIdKeys.size()));
-    }
-
-    String externalIdKeyStr = Iterables.getOnlyElement(externalIdKeys);
-    ExternalId.Key externalIdKey = externalIdKeyFactory.parse(externalIdKeyStr);
-    if (externalIdKey == null) {
-      throw invalidConfig(noteId, String.format("External ID %s is invalid", externalIdKeyStr));
-    }
-
-    if (!externalIdKey.sha1().getName().equals(noteId)) {
-      if (!authConfig.isUserNameCaseInsensitiveMigrationMode()) {
-        throw invalidConfig(
-            noteId,
-            String.format(
-                "SHA1 of external ID '%s' does not match note ID '%s'", externalIdKeyStr, noteId));
-      }
-
-      if (!externalIdKey.caseSensitiveSha1().getName().equals(noteId)) {
-        throw invalidConfig(
-            noteId,
-            String.format(
-                "Neither case sensitive nor case insensitive SHA1 of external ID '%s' match note ID"
-                    + " '%s'",
-                externalIdKeyStr, noteId));
-      }
-      externalIdKey =
-          externalIdKeyFactory.create(externalIdKey.scheme(), externalIdKey.id(), false);
-    }
-
-    String email =
-        externalIdConfig.getString(
-            ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.EMAIL_KEY);
-    String password =
-        externalIdConfig.getString(
-            ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.PASSWORD_KEY);
-    int accountId = readAccountId(noteId, externalIdConfig, externalIdKeyStr);
-
-    return create(
-        externalIdKey,
-        Account.id(accountId),
-        Strings.emptyToNull(email),
-        Strings.emptyToNull(password),
-        blobId);
-  }
-
-  private static int readAccountId(String noteId, Config externalIdConfig, String externalIdKeyStr)
-      throws ConfigInvalidException {
-    String accountIdStr =
-        externalIdConfig.getString(
-            ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.ACCOUNT_ID_KEY);
-    if (accountIdStr == null) {
-      throw invalidConfig(
-          noteId,
-          String.format(
-              "Value for '%s.%s.%s' is missing, expected account ID",
-              ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.ACCOUNT_ID_KEY));
-    }
-
-    try {
-      int accountId =
-          externalIdConfig.getInt(
-              ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.ACCOUNT_ID_KEY, -1);
-      if (accountId < 0) {
-        throw invalidConfig(
-            noteId,
-            String.format(
-                "Value %s for '%s.%s.%s' is invalid, expected account ID",
-                accountIdStr,
-                ExternalId.EXTERNAL_ID_SECTION,
-                externalIdKeyStr,
-                ExternalId.ACCOUNT_ID_KEY));
-      }
-      return accountId;
-    } catch (IllegalArgumentException e) {
-      ConfigInvalidException newException =
-          invalidConfig(
-              noteId,
-              String.format(
-                  "Value %s for '%s.%s.%s' is invalid, expected account ID",
-                  accountIdStr,
-                  ExternalId.EXTERNAL_ID_SECTION,
-                  externalIdKeyStr,
-                  ExternalId.ACCOUNT_ID_KEY));
-      newException.initCause(e);
-      throw newException;
-    }
-  }
-
-  private static ConfigInvalidException invalidConfig(String noteId, String message) {
-    return new ConfigInvalidException(
-        String.format("Invalid external ID config for note '%s': %s", noteId, message));
-  }
+  ExternalId createEmail(Account.Id accountId, String email);
 }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java b/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
index da7b357..2d3e241 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
@@ -19,7 +19,6 @@
 public class ExternalIdModule extends AbstractModule {
   @Override
   protected void configure() {
-    bind(ExternalIdFactory.class);
     bind(ExternalIdKeyFactory.class);
     bind(PasswordVerifier.class);
   }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdUpsertPreprocessor.java b/java/com/google/gerrit/server/account/externalids/ExternalIdUpsertPreprocessor.java
index c0697db..6d21072 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdUpsertPreprocessor.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdUpsertPreprocessor.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account.externalids;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNotes;
 
 /**
  * This optional preprocessor is called in {@link ExternalIdNotes} before an update is committed.
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIds.java b/java/com/google/gerrit/server/account/externalids/ExternalIds.java
index 9450ff5..0755a6d 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIds.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIds.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 The Android Open Source Project
+// Copyright (C) 2023 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.
@@ -14,102 +14,33 @@
 
 package com.google.gerrit.server.account.externalids;
 
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
-
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.server.config.AuthConfig;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
 
-/**
- * Class to access external IDs.
- *
- * <p>The external IDs are either read from NoteDb or retrieved from the cache.
- */
-@Singleton
-public class ExternalIds {
-  private final ExternalIdReader externalIdReader;
-  private final ExternalIdCache externalIdCache;
-  private final AuthConfig authConfig;
-  private final ExternalIdKeyFactory externalIdKeyFactory;
-
-  @Inject
-  public ExternalIds(
-      ExternalIdReader externalIdReader,
-      ExternalIdCache externalIdCache,
-      ExternalIdKeyFactory externalIdKeyFactory,
-      AuthConfig authConfig) {
-    this.externalIdReader = externalIdReader;
-    this.externalIdCache = externalIdCache;
-    this.externalIdKeyFactory = externalIdKeyFactory;
-    this.authConfig = authConfig;
-  }
-
+public interface ExternalIds {
   /** Returns all external IDs. */
-  public ImmutableSet<ExternalId> all() throws IOException, ConfigInvalidException {
-    return externalIdReader.all();
-  }
-
-  /** Returns all external IDs from the specified revision of the refs/meta/external-ids branch. */
-  public ImmutableSet<ExternalId> all(ObjectId rev) throws IOException, ConfigInvalidException {
-    return externalIdReader.all(rev);
-  }
+  ImmutableSet<ExternalId> all() throws IOException, ConfigInvalidException;
 
   /** Returns the specified external ID. */
-  public Optional<ExternalId> get(ExternalId.Key key) throws IOException {
-    Optional<ExternalId> externalId = Optional.empty();
-    if (authConfig.isUserNameCaseInsensitiveMigrationMode()) {
-      externalId =
-          externalIdCache.byKey(externalIdKeyFactory.create(key.scheme(), key.id(), false));
-    }
-    if (!externalId.isPresent()) {
-      externalId = externalIdCache.byKey(key);
-    }
-    return externalId;
-  }
-
-  /** Returns the specified external ID from the given revision. */
-  public Optional<ExternalId> get(ExternalId.Key key, ObjectId rev)
-      throws IOException, ConfigInvalidException {
-    return externalIdReader.get(key, rev);
-  }
+  Optional<ExternalId> get(ExternalId.Key key) throws IOException;
 
   /** Returns the external IDs of the specified account. */
-  public ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException {
-    return externalIdCache.byAccount(accountId);
-  }
+  ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException;
 
-  /** Returns the external IDs of the specified account that have the given scheme. */
-  public ImmutableSet<ExternalId> byAccount(Account.Id accountId, String scheme)
-      throws IOException {
-    return byAccount(accountId).stream()
-        .filter(e -> e.key().isScheme(scheme))
-        .collect(toImmutableSet());
-  }
-
-  /** Returns the external IDs of the specified account. */
-  public ImmutableSet<ExternalId> byAccount(Account.Id accountId, ObjectId rev) throws IOException {
-    return externalIdCache.byAccount(accountId, rev);
-  }
-
-  /** Returns the external IDs of the specified account that have the given scheme. */
-  public ImmutableSet<ExternalId> byAccount(Account.Id accountId, String scheme, ObjectId rev)
-      throws IOException {
-    return byAccount(accountId, rev).stream()
-        .filter(e -> e.key().isScheme(scheme))
-        .collect(toImmutableSet());
-  }
+  /**
+   * Returns the external IDs of the specified account that have the given scheme.
+   *
+   * <p>Callers to this method should care about accuracy rather than latency. For better latency
+   * performance, call {@link ExternalIdCache#byAccount} directly.
+   */
+  ImmutableSet<ExternalId> byAccount(Account.Id accountId, String scheme) throws IOException;
 
   /** Returns all external IDs by account. */
-  public ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException {
-    return externalIdCache.allByAccount();
-  }
+  ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException;
 
   /**
    * Returns the external ID with the given email.
@@ -117,16 +48,10 @@
    * <p>Each email should belong to a single external ID only. This means if more than one external
    * ID is returned there is an inconsistency in the external IDs.
    *
-   * <p>The external IDs are retrieved from the external ID cache. Each access to the external ID
-   * cache requires reading the SHA1 of the refs/meta/external-ids branch. If external IDs for
-   * multiple emails are needed it is more efficient to use {@link #byEmails(String...)} as this
-   * method reads the SHA1 of the refs/meta/external-ids branch only once (and not once per email).
-   *
-   * @see #byEmails(String...)
+   * <p>Callers to this method should care about accuracy rather than latency. For better latency
+   * performance, call {@link ExternalIdCache#byEmail(String)} directly.
    */
-  public ImmutableSet<ExternalId> byEmail(String email) throws IOException {
-    return externalIdCache.byEmail(email);
-  }
+  ImmutableSet<ExternalId> byEmail(String email) throws IOException;
 
   /**
    * Returns the external IDs for the given emails.
@@ -134,20 +59,10 @@
    * <p>Each email should belong to a single external ID only. This means if more than one external
    * ID for an email is returned there is an inconsistency in the external IDs.
    *
-   * <p>The external IDs are retrieved from the external ID cache. Each access to the external ID
-   * cache requires reading the SHA1 of the refs/meta/external-ids branch. If external IDs for
-   * multiple emails are needed it is more efficient to use this method instead of {@link
-   * #byEmail(String)} as this method reads the SHA1 of the refs/meta/external-ids branch only once
-   * (and not once per email).
+   * <p>Callers to this method should care about accuracy rather than latency. For better latency
+   * performance, call {@link ExternalIdCache#byEmails(String...)} directly.
    *
    * @see #byEmail(String)
    */
-  public ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException {
-    return externalIdCache.byEmails(emails);
-  }
-
-  /** Returns all external IDs by email. */
-  public ImmutableSetMultimap<String, ExternalId> allByEmail() throws IOException {
-    return externalIdCache.allByEmail();
-  }
+  ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException;
 }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
index 4e67e3d..56115b2 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
@@ -14,140 +14,17 @@
 
 package com.google.gerrit.server.account.externalids;
 
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
-import static java.util.stream.Collectors.joining;
-
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
 import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.HashedPassword;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.Note;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevWalk;
 
-@Singleton
-public class ExternalIdsConsistencyChecker {
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsers;
-  private final AccountCache accountCache;
-  private final OutgoingEmailValidator validator;
-  private final ExternalIdFactory externalIdFactory;
+public interface ExternalIdsConsistencyChecker {
+  List<ConsistencyCheckInfo.ConsistencyProblemInfo> check(AccountCache accountCache)
+      throws IOException, ConfigInvalidException;
 
-  @Inject
-  ExternalIdsConsistencyChecker(
-      GitRepositoryManager repoManager,
-      AllUsersName allUsers,
-      AccountCache accountCache,
-      OutgoingEmailValidator validator,
-      ExternalIdFactory externalIdFactory) {
-    this.repoManager = repoManager;
-    this.allUsers = allUsers;
-    this.accountCache = accountCache;
-    this.validator = validator;
-    this.externalIdFactory = externalIdFactory;
-  }
-
-  public List<ConsistencyProblemInfo> check() throws IOException, ConfigInvalidException {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      return check(ExternalIdNotes.loadReadOnly(allUsers, repo, null, externalIdFactory, false));
-    }
-  }
-
-  public List<ConsistencyProblemInfo> check(ObjectId rev)
-      throws IOException, ConfigInvalidException {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      return check(ExternalIdNotes.loadReadOnly(allUsers, repo, rev, externalIdFactory, false));
-    }
-  }
-
-  private List<ConsistencyProblemInfo> check(ExternalIdNotes extIdNotes) throws IOException {
-    List<ConsistencyProblemInfo> problems = new ArrayList<>();
-
-    ListMultimap<String, ExternalId> emails = MultimapBuilder.hashKeys().arrayListValues().build();
-
-    try (RevWalk rw = new RevWalk(extIdNotes.getRepository())) {
-      NoteMap noteMap = extIdNotes.getNoteMap();
-      for (Note note : noteMap) {
-        byte[] raw = ExternalIdNotes.readNoteData(rw, note.getData());
-        try {
-          ExternalId extId = externalIdFactory.parse(note.getName(), raw, note.getData());
-          problems.addAll(validateExternalId(extId));
-
-          if (extId.email() != null) {
-            String email = extId.email();
-            if (emails.get(email).stream()
-                .noneMatch(e -> e.accountId().get() == extId.accountId().get())) {
-              emails.put(email, extId);
-            }
-          }
-        } catch (ConfigInvalidException e) {
-          addError(String.format(e.getMessage()), problems);
-        }
-      }
-    }
-
-    emails.asMap().entrySet().stream()
-        .filter(e -> e.getValue().size() > 1)
-        .forEach(
-            e ->
-                addError(
-                    String.format(
-                        "Email '%s' is not unique, it's used by the following external IDs: %s",
-                        e.getKey(),
-                        e.getValue().stream()
-                            .map(k -> "'" + k.key().get() + "'")
-                            .sorted()
-                            .collect(joining(", "))),
-                    problems));
-
-    return problems;
-  }
-
-  private List<ConsistencyProblemInfo> validateExternalId(ExternalId extId) {
-    List<ConsistencyProblemInfo> problems = new ArrayList<>();
-
-    if (!accountCache.get(extId.accountId()).isPresent()) {
-      addError(
-          String.format(
-              "External ID '%s' belongs to account that doesn't exist: %s",
-              extId.key().get(), extId.accountId().get()),
-          problems);
-    }
-
-    if (extId.email() != null && !validator.isValid(extId.email())) {
-      addError(
-          String.format(
-              "External ID '%s' has an invalid email: %s", extId.key().get(), extId.email()),
-          problems);
-    }
-
-    if (extId.password() != null && extId.isScheme(SCHEME_USERNAME)) {
-      try {
-        HashedPassword.decode(extId.password());
-      } catch (HashedPassword.DecoderException e) {
-        addError(
-            String.format(
-                "External ID '%s' has an invalid password: %s", extId.key().get(), e.getMessage()),
-            problems);
-      }
-    }
-
-    return problems;
-  }
-
-  private static void addError(String error, List<ConsistencyProblemInfo> problems) {
-    problems.add(new ConsistencyProblemInfo(ConsistencyProblemInfo.Status.ERROR, error));
-  }
+  List<ConsistencyCheckInfo.ConsistencyProblemInfo> check(AccountCache accountCache, ObjectId rev)
+      throws IOException, ConfigInvalidException;
 }
diff --git a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/AllExternalIds.java
similarity index 96%
rename from java/com/google/gerrit/server/account/externalids/AllExternalIds.java
rename to java/com/google/gerrit/server/account/externalids/storage/notedb/AllExternalIds.java
index 14aa368..8660b01 100644
--- a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/AllExternalIds.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.account.externalids;
+package com.google.gerrit.server.account.externalids.storage.notedb;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto;
 import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto.ExternalIdProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
diff --git a/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/DisabledExternalIdCache.java
similarity index 88%
rename from java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
rename to java/com/google/gerrit/server/account/externalids/storage/notedb/DisabledExternalIdCache.java
index 2d1ec1a..8e53277 100644
--- a/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/DisabledExternalIdCache.java
@@ -12,16 +12,17 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.account.externalids;
+package com.google.gerrit.server.account.externalids.storage.notedb;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
 import com.google.inject.AbstractModule;
 import com.google.inject.Module;
 import java.io.IOException;
 import java.util.Optional;
-import org.eclipse.jgit.lib.ObjectId;
 
 public class DisabledExternalIdCache implements ExternalIdCache {
   public static Module module() {
@@ -45,11 +46,6 @@
   }
 
   @Override
-  public ImmutableSet<ExternalId> byAccount(Account.Id accountId, ObjectId rev) {
-    throw new UnsupportedOperationException();
-  }
-
-  @Override
   public ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException {
     throw new UnsupportedOperationException();
   }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCacheImpl.java
similarity index 86%
rename from java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
rename to java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCacheImpl.java
index af8e19f..dbfe205 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCacheImpl.java
@@ -12,12 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.account.externalids;
+package com.google.gerrit.server.account.externalids.storage.notedb;
 
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
@@ -59,8 +61,7 @@
     return get().byAccount().get(accountId);
   }
 
-  @Override
-  public ImmutableSet<ExternalId> byAccount(Account.Id accountId, ObjectId rev) throws IOException {
+  ImmutableSet<ExternalId> byAccount(Account.Id accountId, ObjectId rev) throws IOException {
     return get(rev).byAccount().get(accountId);
   }
 
@@ -69,6 +70,14 @@
     return get().byAccount();
   }
 
+  /**
+   * Each access to the external ID cache requires reading the SHA1 of the refs/meta/external-ids
+   * branch. If external IDs for multiple emails are needed it is more efficient to use {@link
+   * #byEmails(String...)} as this method reads the SHA1 of the refs/meta/external-ids branch only
+   * once (and not once per email).
+   *
+   * @see #byEmails(String...)
+   */
   @Override
   public ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException {
     AllExternalIds allExternalIds = get();
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCacheLoader.java
similarity index 97%
rename from java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
rename to java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCacheLoader.java
index bf281a5..db890fc 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCacheLoader.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.account.externalids;
+package com.google.gerrit.server.account.externalids.storage.notedb;
 
 import com.google.common.annotations.VisibleForTesting;