Merge branch 'stable-2.14'

* stable-2.14:
  ChangeScreen: Expect that current revision can be null
  Update docs for accountPatchReviewDb

Change-Id: I908b92c6d41e01fe5287c42a389888bc6a3de108
diff --git a/.bazelproject b/.bazelproject
index 41bb27f..e3a7a9c 100644
--- a/.bazelproject
+++ b/.bazelproject
@@ -18,3 +18,6 @@
 java_language_level: 8
 
 workspace_type: java
+
+build_flags:
+  --javacopt=-g
diff --git a/.mailmap b/.mailmap
index ebf2781..f14afde 100644
--- a/.mailmap
+++ b/.mailmap
@@ -38,8 +38,10 @@
 Richard Möhn <richard.moehn@posteo.de>                                                      <richard.moehn@fu-berlin.de>
 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>
 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 <sven.selberg@sonymobile.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>
diff --git a/.settings/org.eclipse.jdt.ui.prefs b/.settings/org.eclipse.jdt.ui.prefs
index 3d5f5f6..18c15dd 100644
--- a/.settings/org.eclipse.jdt.ui.prefs
+++ b/.settings/org.eclipse.jdt.ui.prefs
@@ -2,4 +2,4 @@
 org.eclipse.jdt.ui.ignorelowercasenames=true
 org.eclipse.jdt.ui.ondemandthreshold=99
 org.eclipse.jdt.ui.staticondemandthreshold=99
-org.eclipse.jdt.ui.text.custom_code_templates=<?xml version\="1.0" encoding\="UTF-8" standalone\="no"?><templates/>
+org.eclipse.jdt.ui.text.custom_code_templates=<?xml version\="1.0" encoding\="UTF-8" standalone\="no"?><templates><template autoinsert\="true" context\="gettercomment_context" deleted\="false" description\="Comment for getter method" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.gettercomment" name\="gettercomment">/**\n * @return the ${bare_field_name}\n */</template><template autoinsert\="true" context\="settercomment_context" deleted\="false" description\="Comment for setter method" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.settercomment" name\="settercomment">/**\n * @param ${param} the ${bare_field_name} to set\n */</template><template autoinsert\="true" context\="constructorcomment_context" deleted\="false" description\="Comment for created constructors" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.constructorcomment" name\="constructorcomment">/**\n * ${tags}\n */</template><template autoinsert\="false" context\="filecomment_context" deleted\="false" description\="Comment for created Java files" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.filecomment" name\="filecomment">// Copyright (C) ${year} The Android Open Source Project\n//\n// Licensed under the Apache License, Version 2.0 (the "License");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http\://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an "AS IS" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.</template><template autoinsert\="true" context\="typecomment_context" deleted\="false" description\="Comment for created types" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.typecomment" name\="typecomment">/**\n * @author ${user}\n *\n * ${tags}\n */</template><template autoinsert\="true" context\="fieldcomment_context" deleted\="false" description\="Comment for fields" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.fieldcomment" name\="fieldcomment">/**\n * \n */</template><template autoinsert\="true" context\="methodcomment_context" deleted\="false" description\="Comment for non-overriding methods" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.methodcomment" name\="methodcomment">/**\n * ${tags}\n */</template><template autoinsert\="true" context\="overridecomment_context" deleted\="false" description\="Comment for overriding methods" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.overridecomment" name\="overridecomment">/* (non-Javadoc)\n * ${see_to_overridden}\n */</template><template autoinsert\="true" context\="delegatecomment_context" deleted\="false" description\="Comment for delegate methods" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.delegatecomment" name\="delegatecomment">/**\n * ${tags}\n * ${see_to_target}\n */</template><template autoinsert\="false" context\="newtype_context" deleted\="false" description\="Newly created files" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.newtype" name\="newtype">${filecomment}\n\n${package_declaration}\n\n${typecomment}\n${type_declaration}</template><template autoinsert\="false" context\="classbody_context" deleted\="false" description\="Code in new class type bodies" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.classbody" name\="classbody"/><template autoinsert\="true" context\="interfacebody_context" deleted\="false" description\="Code in new interface type bodies" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.interfacebody" name\="interfacebody">\n</template><template autoinsert\="true" context\="enumbody_context" deleted\="false" description\="Code in new enum type bodies" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.enumbody" name\="enumbody">\n</template><template autoinsert\="true" context\="annotationbody_context" deleted\="false" description\="Code in new annotation type bodies" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.annotationbody" name\="annotationbody">\n</template><template autoinsert\="false" context\="catchblock_context" deleted\="false" description\="Code in new catch blocks" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.catchblock" name\="catchblock">${exception_var}.printStackTrace();</template><template autoinsert\="false" context\="methodbody_context" deleted\="false" description\="Code in created method stubs" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.methodbody" name\="methodbody">${body_statement}</template><template autoinsert\="false" context\="constructorbody_context" deleted\="false" description\="Code in created constructor stubs" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.constructorbody" name\="constructorbody">${body_statement}</template><template autoinsert\="true" context\="getterbody_context" deleted\="false" description\="Code in created getters" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.getterbody" name\="getterbody">return ${field};</template><template autoinsert\="true" context\="setterbody_context" deleted\="false" description\="Code in created setters" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.setterbody" name\="setterbody">${field} \= ${param};</template></templates>
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index f64f739..20d4e45 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -850,6 +850,15 @@
 Note that this permission is named `submitAs` in the `project.config`
 file.
 
+[[category_view_private_changes]]
+=== View Private Changes
+
+This category permits users to view all private changes.
+
+The change owner and any explicitly added reviewers can always see
+private changes (even without having the `View Private Changes` access
+right assigned).
+
 [[category_view_drafts]]
 === View Drafts
 
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index c5c284b..67eac4c 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -420,8 +420,14 @@
 the "Switch Account" link is displayed next to "Sign Out".
 +
 When `auth.type` does not normally enable this URL administrators may
-set this to `login/` or `$canonicalWebUrl/login`, allowing users to
-begin a new web session.
+set this to `login/`, allowing users to begin a new web session. This value
+is used as an href in PolyGerrit and the GWT UI, so absolute URLs like
+`https://someotherhost/login` work as well.
++
+If a ${path} parameter is included, then PolyGerrit will substitute the
+currently viewed path in the link. Be aware that this path will include
+a leading slash, so a value like this might be appropriate: `/login${path}`.
+Note: in the GWT UI this substitution for ${path} is *always* `/`.
 
 [[auth.cookiePath]]auth.cookiePath::
 +
@@ -1015,7 +1021,7 @@
 +
 If 0 the update polling is disabled.
 +
-Default is 30 seconds.
+Default is 5 minutes.
 
 [[change.allowBlame]]change.allowBlame::
 +
@@ -1127,6 +1133,16 @@
 Default is "Reply and score". In the user interface it becomes "Reply
 and score (Shortcut: a)".
 
+[[change.robotCommentSizeLimit]]change.robotCommentSizeLimit::
++
+Maximum allowed size of a robot comment that will be accepted. Robot comments
+which exceed the indicated size will be rejected on addition. The specified
+value is interpreted as the maximum size in bytes of the JSON representation of
+the robot comment. Common unit suffixes of 'k', 'm', or 'g' are supported.
+Zero or negative values allow robot comments of unlimited size.
++
+The default limit is 1024kB.
+
 [[changeCleanup]]
 === Section changeCleanup
 
@@ -2620,6 +2636,19 @@
 +
 Defaults to 1024.
 
+[[index.reindexAfterRefUpdate]]index.reindexAfterRefUpdate::
++
+Whether to reindex all affected open changes after a ref is updated. This
+includes reindexing all open changes to recompute the "mergeable" bit every time
+the destination branch moves, as well as reindexing changes to take into account
+new project configuration (e.g. label definitions).
++
+Leaving this enabled may result in fresher results, but may cause performance
+problems if there are lots of open changes on a project whose branches advance
+frequently.
++
+Defaults to true.
+
 ==== Lucene configuration
 
 Open and closed changes are indexed in separate indexes named
@@ -3836,6 +3865,13 @@
 Defaults to an empty string which adds <<sendemail.from,sendemail.from>> as
 Reply-To if inbound email is enabled and the review's author otherwise.
 
+[[sendemail.allowTLD]]sendemail.allowTLD::
++
+List of custom TLDs to allow sending emails to in addition to those specified
+in the link:http://data.iana.org/TLD/[IANA list].
++
+Defaults to an empty list, meaning no additional TLDs are allowed.
+
 [[site]]
 === Section site
 
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 34f39c8..90b0a83 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -291,6 +291,20 @@
 check. If the `branchOrder` section is not defined then the mergeability of a
 change into other branches will not be done.
 
+[[reviewer-section]]
+=== reviewer section
+
+Defines config options to adjust a project's reviewer workflow such as enabling
+reviewers and CCs by email.
+
+[[reviewer.enableByEmail]]reviewer.enableByEmail::
++
+A boolean indicating if reviewers and CCs that do not currently have a Gerrit
+account can be added to a change by providing their email address.
+
+Default is `INHERIT`, which means that this property is inherited from
+the parent project. If the property is not set in any parent project, the
+default value is `FALSE`.
 
 [[file-groups]]
 == The file +groups+
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index 823424e..405b1d7 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -154,8 +154,8 @@
 To format Java source code, Gerrit uses the
 link:https://github.com/google/google-java-format[`google-java-format`]
 tool (version 1.3), and to format Bazel BUILD and WORKSPACE files the
-link:https://github.com/bazelbuild/buildifier[`buildifier`] tool. These
-tools automatically apply format according to the style guides; this
+link:https://github.com/bazelbuild/buildifier[`buildifier`] tool (version 0.4.5).
+These tools automatically apply format according to the style guides; this
 streamlines code review by reducing the need for time-consuming, tedious,
 and contentious discussions about trivial issues like whitespace.
 
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index 39fa333..9743779 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -10,8 +10,8 @@
 [[setup]]
 == Project Setup
 
-In your Eclipse installation's `eclipse.ini` file, add the following line in
-the `vmargs` section:
+In your Eclipse installation's link:https://wiki.eclipse.org/Eclipse.ini[`eclipse.ini`] file,
+add the following line in the `vmargs` section:
 
 ----
   -DmaxCompiledUnitsAtOnce=10000
@@ -30,7 +30,8 @@
   AutoAnnotation_Commands_named cannot be resolved to a type
 ----
 
-In Eclipse, choose 'Import existing project' and select the `gerrit` project
+First, generate the Eclipse project by running the `tools/eclipse/project.py` script.
+Then, in Eclipse, choose 'Import existing project' and select the `gerrit` project
 from the current working directory.
 
 Expand the `gerrit` project, right-click on the `eclipse-out` folder, select
diff --git a/Documentation/dev-note-db.txt b/Documentation/dev-note-db.txt
index dd3b316..e84fecf 100644
--- a/Documentation/dev-note-db.txt
+++ b/Documentation/dev-note-db.txt
@@ -32,8 +32,8 @@
 - Storing the rest of account data is a work in progress.
 - Storing group data is a work in progress.
 
-To match the current configuration of `googlesource.com`, paste the following
-config snippet in your `gerrit.config`:
+To use a configuration similar to the current state of `googlesource.com`, paste
+the following config snippet in your `gerrit.config`:
 
 ----
 [noteDb "changes"]
@@ -43,6 +43,12 @@
   disableReviewDb = true
 ----
 
+This is the configuration that will be produced if you enable experimental
+NoteDb support on a new site with `init`.
+
+`googlesource.com` additionally uses `fuseUpdates = true`, because its repo
+backend supports atomic multi-ref transactions. Native JGit doesn't, so setting
+this option on a dev server would fail.
 
 For an example NoteDb change, poke around at this one:
 ----
@@ -96,6 +102,11 @@
   implementation of the `rebuild-note-db` program does not do this. +
   In this phase, it would be possible to delete the Changes tables out from
   under a running server with no effect.
+- `noteDb.changes.fuseUpdates=true`: Code and meta updates within a single
+  repository are fused into a single atomic `BatchRefUpdate`, so they either
+  all succeed or all fail. This mode is only possible on a backend that
+  supports atomic ref updates, which notably excludes the default file repository
+  backend.
 
 [[migration]]
 == Migration
diff --git a/Documentation/dev-plugin-pg-styling.txt b/Documentation/dev-plugin-pg-styling.txt
new file mode 100644
index 0000000..618d984
--- /dev/null
+++ b/Documentation/dev-plugin-pg-styling.txt
@@ -0,0 +1,61 @@
+= Gerrit Code Review - PolyGerrit Plugin Styling
+
+CAUTION: Work in progress. Hard hat area. +
+This document will be populated with details along with implementation. +
+link:https://groups.google.com/d/topic/repo-discuss/vb8WJ4m0hK0/discussion[Join the discussion.]
+
+== Plugin styles
+
+Plugins may provide link:https://www.polymer-project.org/2.0/docs/devguide/style-shadow-dom#style-modules[Polymer style modules] for UI CSS-based customization.
+
+PolyGerrit UI implements number of styling endpoints, which apply CSS mixins link:https://tabatkins.github.io/specs/css-apply-rule/[using @apply] to its direct contents.
+
+NOTE: Only items (ie CSS properties and mixin targets) documented here are guaranteed to work in the long term, since they are covered by integration tests. +
+When there is a need to add new property or endpoint, please link:https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit%20Issue[file a bug] stating your usecase to track and maintain for future releases.
+
+Plugin should be html-based and imported following PolyGerrit's link:dev-plugins-pg.html#loading[dev guide].
+
+Plugin should provide Style Module, for example:
+
+``` html
+  <dom-module id="some-style">
+    <style>
+      :root {
+        --css-mixin-name: {
+          property: value;
+        }
+      }
+    </style>
+  </dom-module>
+```
+
+Plugin should register style module with a styling endpoint using `Plugin.prototype.registerStyleModule(endpointName, styleModuleName)`, for example:
+
+``` js
+  Gerrit.install(function(plugin) {
+    plugin.registerStyleModule('some-endpoint', 'some-style');
+  });
+```
+
+== Available styling endpoints
+=== change-metadata
+Following custom css mixins are recognized:
+
+* `--change-metadata-assignee`
++
+is applied to `gr-change-metadata section.assignee`
+* `--change-metadata-label-status`
++
+is applied to `gr-change-metadata section.labelStatus`
+* `--change-metadata-strategy`
++
+is applied to `gr-change-metadata section.strategy`
+* `--change-metadata-topic`
++
+is applied to `gr-change-metadata section.topic`
+
+Following CSS properties have link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html[long-term support via integration test]:
+
+* `display`
++
+can be set to `none` to hide a section.
diff --git a/Documentation/dev-plugins-pg.txt b/Documentation/dev-plugins-pg.txt
new file mode 100644
index 0000000..77748a4
--- /dev/null
+++ b/Documentation/dev-plugins-pg.txt
@@ -0,0 +1,25 @@
+= Gerrit Code Review - PolyGerrit Plugin Development
+
+CAUTION: Work in progress. Hard hat area. +
+This document will be populated with details along with implementation. +
+link:https://groups.google.com/d/topic/repo-discuss/vb8WJ4m0hK0/discussion[Join the discussion.]
+
+[[loading]]
+== Plugin loading and initialization
+
+link:https://gerrit-review.googlesource.com/Documentation/js-api.html#_entry_point[Entry point] for the plugin and the loading method is based on link:http://w3c.github.io/webcomponents/spec/imports/[HTML Imports] spec.
+
+* Plugin provides index.html, similar to link:https://gerrit-review.googlesource.com/Documentation/dev-plugins.html#deployment[.js Web UI plugins]
+* index.html contains a `dom-module` tag with a script that uses `Gerrit.install()`.
+* PolyGerrit imports index.html along with all required resources defined in it (fonts, styles, etc)
+* For standalone plugins, the entry point file is a `pluginname.html` file located in `gerrit-site/plugins` folder, where pluginname is an alphanumeric plugin name.
+
+Here's a sample `myplugin.html`:
+
+``` html
+<dom-module id="my-plugin">
+  <script>
+    Gerrit.install(function() { console.log('Ready.'); });
+  </script>
+</dom-module>
+```
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 89b63cc..64b1c0d 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -708,6 +708,99 @@
     }
 ====
 
+[[command_options]]
+=== Command Options ===
+
+Plugins can provide additional options for each of the gerrit ssh and the
+REST API commands by implementing the DynamicBean interface and registering
+it to a command class name in the plugin module's `configure()` method. The
+plugin's name will be prepended to the name of each @Option annotation found
+on the DynamicBean object provided by the plugin. The example below shows a
+plugin that adds an option to log a value from the gerrit 'ban-commits'
+ssh command.
+
+[source, java]
+----
+public class SshModule extends AbstractModule {
+  private static final Logger log = LoggerFactory.getLogger(SshModule.class);
+
+  @Override
+  protected void configure() {
+    bind(DynamicOptions.DynamicBean.class)
+        .annotatedWith(Exports.named(
+        com.google.gerrit.sshd.commands.BanCommitCommand.class))
+        .to(BanOptions.class);
+  }
+
+  public static class BanOptions implements DynamicOptions.DynamicBean {
+    @Option(name = "--log", aliases = { "-l" }, usage = "Say Hello in the Log")
+    private void parse(String arg) {
+      log.error("Say Hello in the Log " + arg);
+    }
+  }
+----
+
+[[query_attributes]]
+=== Query Attributes ===
+
+Plugins can provide additional attributes to be returned in Gerrit queries by
+implementing the ChangeAttributeFactory interface and registering it to the
+ChangeQueryProcessor.ChangeAttributeFactory class in the plugin module's
+'configure()' method. The new attribute(s) will be output under a "plugin"
+attribute in the change query output.
+
+The example below shows a plugin that adds two attributes ('exampleName' and
+'changeValue'), to the change query output.
+
+[source, java]
+----
+public class Module extends AbstractModule {
+  @Override
+  protected void configure() {
+    bind(ChangeAttributeFactory.class)
+        .annotatedWith(Exports.named("example"))
+        .to(AttributeFactory.class);
+  }
+}
+
+public class AttributeFactory implements ChangeAttributeFactory {
+
+  public class PluginAttribute extends PluginDefinedInfo {
+    public String exampleName;
+    public String changeValue;
+
+    public PluginAttribute(ChangeData c) {
+      this.exampleName = "Attribute Example";
+      this.changeValue = Integer.toString(c.getId().get());
+    }
+  }
+
+  @Override
+  public PluginDefinedInfo create(ChangeData c, ChangeQueryProcessor qp, String plugin) {
+    return new PluginAttribute(c);
+  }
+}
+----
+
+Example
+----
+
+ssh -p 29418 localhost gerrit query "change:1" --format json
+
+Output:
+
+{
+   "url" : "http://localhost:8080/1",
+   "plugins" : [
+      {
+         "name" : "myplugin-name",
+         "exampleName" : "Attribute Example",
+         "changeValue" : "1"
+      }
+   ],
+    ...
+}
+----
 
 [[simple-configuration]]
 == Simple Configuration in `gerrit.config`
@@ -1213,6 +1306,7 @@
   @Override
   public void onPluginLoad() {
     Plugin.get().panel(GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK,
+        "my_panel_name",
         new Panel.EntryPoint() {
           @Override
           public void onLoad(Panel panel) {
@@ -1224,6 +1318,23 @@
 }
 ----
 
+Change Screen panel ordering may be specified in the
+project config. Values may be either "plugin name" or
+"plugin name"."panel name".
+Panels not specified in the config will be added
+to the end in load order. Panels specified in the config that
+are not found will be ignored.
+
+Example config:
+----
+[extension-panels "CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK"]
+        panel = helloworld.change_id
+        panel = myotherplugin
+        panel = myplugin.my_panel_name
+----
+
+
+
 [[actions]]
 === Actions
 
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index 2a857b2..5e07fc7 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -16,8 +16,8 @@
 == Gerrit Release Type
 
 Here are some guidelines on release approaches depending on the
-type of release you want to make (`stable-fix`, `stable`, `RC0`,
-`RC1`...).
+type of release you want to make (`stable-fix`, `stable`, `rc0`,
+`rc1`...).
 
 [[stable]]
 === Stable
@@ -27,19 +27,19 @@
 
 * Propose the release with any plans/objectives to the mailing list
 
-* Create a Gerrit `RC0`
+* Create a Gerrit `rc0`
 
-* If needed create a Gerrit `RC1`
+* If needed create a Gerrit `rc1`
 
 [NOTE]
 You may let in a few features to this release
 
-* If needed create a Gerrit `RC2`
+* If needed create a Gerrit `rc2`
 
 [NOTE]
 There should be no new features in this release, only bug fixes
 
-* Finally create the `stable` release (no `RC`)
+* Finally create the `stable` release (no `rc`)
 
 
 === Stable-Fix
@@ -75,7 +75,6 @@
 
 To create a Gerrit release the following steps have to be done:
 
-. link:#subproject[Release Subprojects]
 . link:#build-gerrit[Build the Gerrit Release]
 . link:#publish-gerrit[Publish the Gerrit Release]
 .. link:#publish-to-maven-central[Publish the Gerrit artifacts to Maven Central]
@@ -90,34 +89,10 @@
 . link:#merge-stable[Merge `stable` into `master`]
 
 
-[[subproject]]
-=== Release Subprojects
-
-The subprojects to be released are:
-
-* `gwtjsonrpc`
-* `gwtorm`
-* `prolog-cafe`
-
-For each subproject do:
-
-* Check the dependency to the Subproject in the Gerrit parent `pom.xml`:
-+
-If a `SNAPSHOT` version of the subproject is referenced the subproject
-needs to be released so that Gerrit can reference a released version of
-the subproject.
-
-* link:dev-release-subproject.html#make-snapshot[Make a snapshot and test it]
-* link:dev-release-subproject.html#prepare-release[Prepare the Release]
-* link:dev-release-subproject.html#publish-release[Publish the Release]
-
-* Update the `artifact`, `sha1`, and `src_sha1` values in the `maven_jar`
-for the Subproject in `WORKSPACE` to the released version.
-
 [[update-versions]]
 === Update Versions and Create Release Tag
 
-Before doing the release build, the `GERRIT_VERSION` in the `VERSION`
+Before doing the release build, the `GERRIT_VERSION` in the `version.bzl`
 file must be updated, e.g. change it from `2.5-SNAPSHOT` to `2.5`.
 
 In addition the version must be updated in a number of pom.xml files.
@@ -383,7 +358,7 @@
 Use the `version` tool to set the version in the `version.bzl` file:
 
 ----
- ./tools/version.py 2.11-SNAPSHOT
+ ./tools/version.py 2.6-SNAPSHOT
 ----
 
 Verify that the changes made by the tool are sane, then commit them, push
diff --git a/Documentation/dev-stars.txt b/Documentation/dev-stars.txt
index 553ac5b..1fb871a 100644
--- a/Documentation/dev-stars.txt
+++ b/Documentation/dev-stars.txt
@@ -61,6 +61,19 @@
 
 The ignore star is represented by the special star label 'ignore'.
 
+[[mute-star]]
+== Mute Star
+
+If the "mute/<patchset_id>"-star is set by a user, and <patchset_id>
+matches the current patch set, the change is always reported as "reviewed"
+in the ChangeInfo.
+
+This allows users to "de-highlight" changes in a dashboard until a new
+patchset has been uploaded.
+
+The ChangeInfo muted-field will show if the change is currently in a
+mute state.
+
 [[query-stars]]
 == Query Stars
 
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index 61b9c30..5dcd947 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -482,9 +482,54 @@
   $ git push origin HEAD:refs/heads/master -o topic=multi-master
 ----
 
+[[private-changes]]
+== Private Changes
+
+Private changes are changes that are only visible to their owners and
+reviewers. Private changes are useful in a number of cases:
+
+* You want to check what the change looks before formal review starts.
+  By marking the change private without reviewers, nobody can't
+  prematurely comment on your changes.
+
+* You want to use Gerrit to sync data between different devices. By
+  creating a private throwaway change without reviewers, you can push
+  from one device, and fetch to another device.
+
+* You want to do code review on a change that has sensitive
+  aspects. By reviewing a security fix in a private change,
+  outsiders can't discover the fix before it is pushed out. Even after
+  merging the change, the review can be kept private.
+
+To create a private change, you push it with the `private` option.
+
+.Push a private change
+----
+  $ git commit
+  $ git push origin HEAD:refs/for/master%private
+----
+
+The change will remain private on subsequent pushes until you specify
+the `remove-private` option. Alternatively, the web UI provides buttons
+to mark a change private and non-private again.
+
+When pushing a private change with a commit that is authored by another
+user, the other user will not be automatically added as a reviewer and
+must be explicitly added.
+
+For CI systems that must verify private changes, a special permission
+can be granted
+(link:access-control.html#category_view_private_changes[View Private Changes]).
+In that case, care should be taken to prevent the CI system from
+exposing secret details.
+
+
 [[drafts]]
 == Working with Drafts
 
+Drafts is a deprecated feature and will be removed soon. Consider using
+private changes instead.
+
 Changes can be uploaded as drafts. By default draft changes are only
 visible to the change owner. This gives you the possibility to have
 some staging before making your changes visible to the reviewers. Draft
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 091dbb3..e90e3e5 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -90,6 +90,9 @@
 * `notedb/auto_rebuild_latency`: NoteDb auto-rebuilding latency by table.
 * `notedb/auto_rebuild_failure_count`: NoteDb auto-rebuilding attempts that
 failed by table.
+* `notedb/external_id_update_count`: Total number of external ID updates.
+* `notedb/read_all_external_ids_latency`: Latency for reading all
+external ID's from NoteDb.
 
 === Reviewer Suggestion
 
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 69afaa5..58389fd 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -2107,6 +2107,164 @@
   }
 ----
 
+[[set-work-in-pogress]]
+== Set Work-In-Progress
+--
+'POST /changes/link:#change-id[\{change-id\}]/wip'
+--
+
+Marks the change as not ready for review yet.
+
+The request body does not need to include a
+link:#work-in-progress-input[WorkInProgressInput] entity if no review comment
+is added. Actions that create a new patch set in a WIP change default to
+notifying *OWNER* instead of *ALL*.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/wip HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "message": "Refactoring needs to be done before we can proceed here."
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+----
+
+[[set-ready-for-review]]
+== Set Ready-For-Review
+--
+'POST /changes/link:#change-id[\{change-id\}]/ready'
+--
+
+Marks the change as ready for review (set WIP property to false).
+
+Activates notifications of reviewer. The request body does not need
+to include a link:#work-in-progress-input[WorkInProgressInput] entity
+if no review comment is added.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/ready HTTP/1.0
+  Content-Type: application/json;charset=UTF-8
+
+  {
+    "message": "Refactoring is done."
+  }
+
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+----
+
+[[mark-private]]
+=== Mark Private
+--
+'PUT /changes/link:#change-id[\{change-id\}]/private'
+--
+
+Marks the change to be private. Note users can only mark own changes as private.
+
+.Request
+----
+  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/private HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 201 Created
+----
+
+If the change was already private the response is "`200 OK`".
+
+[[unmark-private]]
+=== Unmark Private
+--
+'DELETE /changes/link:#change-id[\{change-id\}]/private'
+--
+
+Marks the change to be non-private. Note users can only unmark own private
+changes.
+
+.Request
+----
+  DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/private HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
+If the change was already not private, the response is "`409 Conflict`".
+
+[[ignore]]
+=== Ignore
+--
+'PUT /changes/link:#change-id[\{change-id\}]/ignore'
+--
+
+Marks a change as ignored. The change will not be shown in the incoming
+reviews dashboard, and email notifications will be suppressed.
+
+.Request
+----
+  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/ignore HTTP/1.0
+----
+
+[[unignore]]
+=== Unignore
+--
+'PUT /changes/link:#change-id[\{change-id\}]/unignore'
+--
+
+Un-marks a change as ignored.
+
+.Request
+----
+  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/unignore HTTP/1.0
+----
+
+[[mute]]
+=== Mute
+--
+'PUT /changes/link:#change-id[\{change-id\}]/mute'
+--
+
+Marks a change as muted.
+
+This allows users to "de-highlight" changes in their dashboard until a new
+patch set is uploaded.
+
+This differs from the link:#ignore[ignore] endpoint, which will mute
+emails and hide the change from dashboard completely until it is
+link:#unignore[unignored] again.
+
+
+.Request
+----
+  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/mute HTTP/1.0
+----
+
+[[unmute]]
+=== Unmute
+--
+'PUT /changes/link:#change-id[\{change-id\}]/unmute'
+--
+
+Unmutes a change.
+
+.Request
+----
+  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/unmute HTTP/1.0
+----
+
 [[edit-endpoints]]
 == Change Edit Endpoints
 
@@ -2704,16 +2862,16 @@
 
   )]}'
   {
+    "input": "john.doe@example.com",
     "reviewers": [
       {
-        "input": "john.doe@example.com",
+        "_account_id": 1000096,
+        "name": "John Doe",
+        "email": "john.doe@example.com"
         "approvals": {
           "Verified": " 0",
           "Code-Review": " 0"
         },
-        "_account_id": 1000096,
-        "name": "John Doe",
-        "email": "john.doe@example.com"
       }
     ]
   }
@@ -2764,6 +2922,41 @@
   }
 ----
 
+If link:config-project-config.html#reviewer.enableByEmail[reviewer.enableByEmail] is set
+for the project, reviewers and CCs are not required to have a Gerrit account. If you POST
+an email address of a reviewer or CC then, they will be added to the change even if they
+don't have a Gerrit account.
+
+If this option is disabled, the request would fail with `400 Bad Request` if the email
+address can't be resolved to an active Gerrit account.
+
+Note that the name is optional so both "un.registered@reviewer.com" and
+"John Doe <un.registered@reviewer.com>" are valid inputs.
+
+Reviewers without Gerrit accounts can only be added on changes visible to anonymous users.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "reviewer": "John Doe <un.registered@reviewer.com>"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "input": "John Doe <un.registered@reviewer.com>"
+  }
+----
+
 [[delete-reviewer]]
 === Delete Reviewer
 --
@@ -3311,6 +3504,11 @@
 The review must be provided in the request body as a
 link:#review-input[ReviewInput] entity.
 
+A review cannot be set on a change edit. Trying to post a review for a
+change edit fails with `409 Conflict`.
+
+This API method can be used to set labels:
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/review HTTP/1.0
@@ -3346,8 +3544,9 @@
   }
 ----
 
-As response a link:#review-info[ReviewInfo] entity is returned that
-describes the applied labels.
+As response a link:#review-result[ReviewResult] entity is returned that
+describes the applied labels and any added reviewers (e.g. yourself,
+if you set a label but weren't previously a reviewer on this CL).
 
 .Response
 ----
@@ -3363,11 +3562,8 @@
   }
 ----
 
-A review cannot be set on a change edit. Trying to post a review for a
-change edit fails with `409 Conflict`.
-
-It is also possible to add one or more reviewers to a change simultaneously
-with a review.
+It is also possible to add one or more reviewers or CCs
+to a change simultaneously with a review.
 
 .Request
 ----
@@ -3375,16 +3571,17 @@
   Content-Type: application/json; charset=UTF-8
 
   {
-    "message": "Looks good to me, but Jane and John should also take a look.",
-    "labels": {
-      "Code-Review": 1
-    },
+    "message": "I don't have context here. Jane and maybe John and the project leads should take a look.",
     "reviewers": [
       {
         "reviewer": "jane.roe@example.com"
       },
       {
-        "reviewer": "john.doe@example.com"
+        "reviewer": "john.doe@example.com",
+        "state": "CC"
+      }
+      {
+        "reviewer": "MyProjectVerifiers",
       }
     ]
   }
@@ -3392,8 +3589,8 @@
 
 Each element of the `reviewers` list is an instance of
 link:#reviewer-input[ReviewerInput]. The corresponding result of
-adding each reviewer will be returned in a list of
-link:#add-reviewer-result[AddReviewerResult].
+adding each reviewer will be returned in a map of inputs to
+link:#add-reviewer-result[AddReviewerResult]s.
 
 .Response
 ----
@@ -3403,36 +3600,66 @@
 
   )]}'
   {
-    "labels": {
-      "Code-Review": 1
-    },
-    "reviewers": [
-      {
+    "reviewers": {
+      "jane.roe@example.com": {
         "input": "jane.roe@example.com",
-        "approvals": {
-          "Verified": " 0",
-          "Code-Review": " 0"
-        },
-        "_account_id": 1000097,
-        "name": "Jane Roe",
-        "email": "jane.roe@example.com"
+        "reviewers": [
+          {
+            "_account_id": 1000097,
+            "name": "Jane Roe",
+            "email": "jane.roe@example.com"
+            "approvals": {
+              "Verified": " 0",
+              "Code-Review": " 0"
+            },
+          },
+        ]
       },
-      {
+      "john.doe@example.com": {
         "input": "john.doe@example.com",
-        "approvals": {
-          "Verified": " 0",
-          "Code-Review": " 0"
-        },
-        "_account_id": 1000096,
-        "name": "John Doe",
-        "email": "john.doe@example.com"
+        "ccs": [
+          {
+            "_account_id": 1000096,
+            "name": "John Doe",
+            "email": "john.doe@example.com"
+            "approvals": {
+              "Verified": " 0",
+              "Code-Review": " 0"
+            },
+          }
+        ]
+      },
+      "MyProjectVerifiers": {
+        "input": "MyProjectVerifiers",
+        "reviewers": [
+          {
+            "_account_id": 1000098,
+            "name": "Alice Ansel",
+            "email": "alice.ansel@example.com"
+            "approvals": {
+              "Verified": " 0",
+              "Code-Review": " 0"
+            },
+          },
+          {
+            "_account_id": 1000099,
+            "name": "Bob Bollard",
+            "email": "bob.bollard@example.com"
+            "approvals": {
+              "Verified": " 0",
+              "Code-Review": " 0"
+            },
+          },
+        ]
       }
-    ]
+    }
   }
 ----
 
 If there are any errors returned for reviewers, the entire review request will
-be rejected with `400 Bad Request`.
+be rejected with `400 Bad Request`. None of the entries will have the
+`reviewers` or `ccs` field set, and those which specifically failed will have
+the `errors` field set containing details of why they failed.
 
 .Error Response
 ----
@@ -3443,6 +3670,13 @@
   )]}'
   {
     "reviewers": {
+      "jane.roe@example.com": {
+        "input": "jane.roe@example.com",
+        "error": "Account of jane.roe@example.com is inactive."
+      },
+      "john.doe@example.com": {
+        "input": "john.doe@example.com"
+      },
       "MyProjectVerifiers": {
         "input": "MyProjectVerifiers",
         "error": "The group My Group has 15 members. Do you want to add them all as reviewers?",
@@ -4256,6 +4490,72 @@
   }
 ----
 
+[[apply-fix]]
+=== Apply Fix
+--
+'POST /changes/<<change-id,\{change-id\}>>/revisions/<<revision-id,\{revision-id\}>>/fixes/<<fix-id,\{fix-id\}>>/apply'
+--
+
+Applies a suggested fix by creating a change edit which includes the
+modifications indicated by the fix suggestion. If a change edit already exists,
+it will be updated accordingly. A fix can only be applied if no change edit
+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.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/fixes/8f605a55_f6aa4ecc/apply HTTP/1.0
+----
+
+If the fix was successfully applied, an <<edit-info,EditInfo>> describing the
+resulting change edit is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+    Content-Disposition: attachment
+    Content-Type: application/json; charset=UTF-8
+
+    )]}'
+    {
+      "commit":{
+        "parents":[
+          {
+            "commit":"1eee2c9d8f352483781e772f35dc586a69ff5646",
+          }
+        ],
+        "author":{
+          "name":"John Doe",
+          "email":"john.doe@example.com",
+          "date":"2013-05-07 15:21:27.000000000",
+          "tz":120
+         },
+         "committer":{
+           "name":"Jane Doe",
+           "email":"jane.doe@example.com",
+           "date":"2013-05-07 15:35:43.000000000",
+           "tz":120
+         },
+         "subject":"Implement feature X",
+         "message":"Implement feature X\n\nWith this feature ..."
+      },
+      "base_revision":"674ac754f91e64a0efb8087e59a176484bd534d1"
+    }
+----
+
+If the application failed e.g. due to conflicts with an existing change edit,
+the response "`409 Conflict`" including an error message in the response body
+is returned.
+
+.Response
+----
+  HTTP/1.1 409 Conflict
+  Content-Disposition: attachment
+  Content-Type: text/plain; charset=UTF-8
+
+  The existing change edit could not be merged with another tree.
+----
+
 [[list-files]]
 === List Files
 --
@@ -4710,7 +5010,8 @@
 Cherry picks a revision to a destination branch.
 
 The commit message and destination branch must be provided in the request body inside a
-link:#cherrypick-input[CherryPickInput] entity.
+link:#cherrypick-input[CherryPickInput] entity.  If the commit message
+does not specify a Change-Id, a new one is picked for the destination change.
 
 .Request
 ----
@@ -5140,6 +5441,8 @@
 change. The labels are lexicographically sorted.
 |`reviewed`           |not set if `false`|
 Whether the change was reviewed by the calling user.
+|`muted`              |not set if `false`|
+Whether the change has been link:#mute[muted] by the calling user.
 Only set if link:#reviewed[reviewed] is requested.
 |`submit_type`        |optional|
 The link:project-configuration.html#submit_type[submit type] of the change. +
@@ -5211,6 +5514,10 @@
 |`problems`           |optional|
 A list of link:#problem-info[ProblemInfo] entities describing potential
 problems with this change. Only set if link:#check[CHECK] is set.
+|`is_private`         |optional, not set if `false`|
+When present, change is marked as private.
+|`work_in_progress`   |optional, not set if `false`|
+When present, change is marked as Work In Progress.
 |==================================
 
 [[change-input]]
@@ -5259,6 +5566,10 @@
 Author of the message as an
 link:rest-api-accounts.html#account-info[AccountInfo] entity. +
 Unset if written by the Gerrit system.
+|`real_author`         |optional|
+Real author of the message as an
+link:rest-api-accounts.html#account-info[AccountInfo] entity. +
+Set if the message was posted on behalf of another user.
 |`date`            ||
 The link:rest-api.html#timestamp[timestamp] this message was posted.
 |`message`            ||The text left by the user.
@@ -5592,7 +5903,7 @@
 |`commit`       ||The commit of change edit as
 link:#commit-info[CommitInfo] entity.
 |`base_revision`||The revision of the patch set the change edit is based on.
-|`fetch`        ||
+|`fetch`        |optional|
 Information about how to fetch this patch set. The fetch information is
 provided as a map that maps the protocol name ("`git`", "`http`",
 "`ssh`") to link:#fetch-info[FetchInfo] entities.
@@ -5671,8 +5982,8 @@
 for input objects.
 |`description`      ||A description of the suggested fix.
 |`replacements`     ||A list of <<fix-replacement-info,FixReplacementInfo>>
-entities indicating how the content of the file on which the comment was placed
-should be modified. They should refer to non-overlapping regions.
+entities indicating how the content of one or several files should be modified.
+Within a file, they should refer to non-overlapping regions.
 |==========================
 
 [[fix-replacement-info]]
@@ -5683,10 +5994,13 @@
 [options="header",cols="1,6"]
 |==========================
 |Field Name      |Description
-|`path`          |The path of the file which should be modified. Modifications
-are only allowed for the file on which the corresponding comment was placed.
+|`path`          |The path of the file which should be modified. Any file in
+the repository may be modified.
 |`range`         |A <<comment-range,CommentRange>> indicating which content
-of the file should be replaced.
+of the file should be replaced. Lines in the file are assumed to be separated
+by the line feed character, the carriage return character, the carriage return
+followed by the line feed character, or one of the other Unicode linebreak
+sequences supported by Java.
 |`replacement`   |The content which should be used instead of the current one.
 |==========================
 
@@ -5799,7 +6113,9 @@
 |===========================
 |Field Name    ||Description
 |`all`         |optional|List of all approvals for this label as a list
-of link:#approval-info[ApprovalInfo] entities.
+of link:#approval-info[ApprovalInfo] entities. Items in this list may
+not represent actual votes cast by users; if a user votes on any label,
+a corresponding ApprovalInfo will appear in this list for all labels.
 |`values`      |optional|A map of all values that are allowed for this
 label. The map maps the values ("`-2`", "`-1`", " `0`", "`+1`", "`+2`")
 to the value descriptions.
@@ -6120,6 +6436,27 @@
 link:rest-api-accounts.html#account-id[\{account-id\}] the review
 should be posted on behalf of. To use this option the caller must
 have been granted `labelAs-NAME` permission for all keys of labels.
+|`reviewers`              |optional|
+A list of link:rest-api-changes.html#reviewer-input[ReviewerInput]
+representing reviewers that should be added to the change.
+|============================
+
+[[review-result]]
+=== ReviewResult
+The `ReviewResult` entity contains information regarding the updates
+that were made to a review.
+
+[options="header",cols="1,^1,5"]
+|============================
+|Field Name               ||Description
+|`labels`                 |optional|
+Map of labels to values after the review was posted. Null if any reviewer
+additions were rejected.
+|`reviewers`              |optional|
+Map of account or group identifier to
+link:rest-api-changes.html#add-reviewer-result[AddReviewerResult]
+representing the outcome of adding as a reviewer.
+Absent if no reviewer additions were requested.
 |============================
 
 [[reviewer-info]]
@@ -6138,6 +6475,10 @@
 |`approvals`   |
 The approvals of the reviewer as a map that maps the label names to the
 approval values ("`-2`", "`-1`", "`0`", "`+1`", "`+2`").
+|`_account_id`   |
+This field is inherited from `AccountInfo` but is optional here if an
+unregistered reviewer was added by email. See
+link:rest-api-changes.html#add-reviewer[add-reviewer] for details.
 |==========================
 
 [[reviewer-input]]
@@ -6226,6 +6567,9 @@
 patch set as a link:#push-certificate-info[PushCertificateInfo] entity.
 This field is always set if the option is requested; if no push
 certificate was provided, it is set to an empty object.
+|`description` |optional|
+The description of this patchset, as displayed in the patchset
+selector menu. May be null if no description is set.
 |===========================
 
 [[robot-comment-info]]
@@ -6435,6 +6779,18 @@
 |`image_url`|URL to the icon of the link.
 |======================
 
+[[work-in-progress-input]]
+=== WorkInProgressInput
+The `WorkInProgressInput` entity contains additional information for a change
+set to WorkInProgress/ReadyForReview.
+
+[options="header",cols="1,^1,5"]
+|=============================
+|Field Name      ||Description
+|`message`       |optional|
+Message to be added as a review comment to the change being set WorkInProgress/ReadyForReview.
+|=============================
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 62e3ee4..fd35a29 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -138,6 +138,91 @@
   }
 ----
 
+[[check-consistency]]
+=== Check Consistency
+--
+'POST /config/server/check.consistency'
+--
+
+Runs consistency checks and returns detected problems.
+
+Input for the consistency checks that should be run must be provided in
+the request body inside a
+link:#consistency-check-input[ConsistencyCheckInput] entity.
+
+.Request
+----
+  POST /config/server/check.consistency HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "check_account_external_ids": {}
+  }
+----
+
+As result a link:#consistency-check-info[ConsistencyCheckInfo] entity
+is returned that contains detected consistency problems.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "results": {
+      "account_external_id_result": {
+        "problems": [
+          {
+            "status": "ERROR",
+            "message": "External ID \u0027uuid:ccb8d323-1361-45aa-8874-41987a660c46\u0027 belongs to account that doesn\u0027t exist: 1000012"
+          }
+        ]
+      }
+    }
+  }
+----
+
+[[check-access]]
+=== Check Access
+--
+'POST /config/server/check.access'
+--
+
+Runs access checks for other users.
+
+Input for the access checks that should be run must be provided in
+the request body inside a
+link:#access-check-input[AccessCheckInput] entity.
+
+.Request
+----
+  POST /config/server/check HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "project": "medium",
+    "account": "Kristen.Burns@gerritcodereview.com",
+    "ref": "refs/heads/secret/bla"
+  }
+----
+
+The result is a link:#access-check-info[AccessCheckInfo] entity
+detailing the read access of the given user for the given project (or
+project-ref combination).
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "message": "user Kristen Burns \u003cKristen.Burns@gerritcodereview.com\u003e (1000098) cannot see ref refs/heads/secret/master in project medium",
+    "status": 403
+  }
+----
+
 [[confirm-email]]
 === Confirm Email
 --
@@ -1221,6 +1306,34 @@
 [[json-entities]]
 == JSON Entities
 
+[[access-check-info]]
+=== AccessCheckInfo
+The `AccessCheckInfo` entity is the result of a
+an access check.
+
+[options="header",cols="1,^1,5"]
+|=========================================
+|Field Name                  ||Description
+|`status`||The HTTP status code for the access.
+200 means success, 403 means denied and 404 means the project does not
+exist.
+|`message`|optional|A clarifying message if `status` is not 200.
+=========================================
+
+[[access-check-input]]
+=== AccessCheckInput
+The `AccessCheckInput` entity is a tuple of (account, project)  or
+(account, project, ref) for which we want to check access.
+
+[options="header",cols="1,^1,5"]
+|=========================================
+|Field Name                  ||Description
+|`account`||The account for which to check access
+|`project`||The project for which to check access
+|`ref`|optional|The refname for which to check access
+=========================================
+
+
 [[auth-info]]
 === AuthInfo
 The `AuthInfo` entity contains information about the authentication
@@ -1365,6 +1478,66 @@
 the whole topic is submitted].
 |=============================
 
+[[check-account-external-ids-input]]
+=== CheckAccountExternalIdsInput
+The `CheckAccountExternalIdsInput` entity contains input for the
+account external IDs consistency check.
+
+Currently this entity contains no fields.
+
+[[check-account-external-ids-result-info]]
+=== CheckAccountExternalIdsResultInfo
+The `CheckAccountExternalIdsResultInfo` entity contains the result of
+running the account external IDs consistency check.
+
+[options="header",cols="1,6"]
+|======================
+|Field Name|Description
+|`problems`|A list of link:#consistency-problem-info[
+ConsistencyProblemInfo] entities.
+|======================
+
+[[consistency-check-info]]
+=== ConsistencyCheckInfo
+The `ConsistencyCheckInfo` entity contains the results of running
+consistency checks.
+
+[options="header",cols="1,^1,5"]
+|================================================
+|Field Name                         ||Description
+|`check_account_external_ids_result`|optional|
+The result of running the account external ID consistency check as a
+link:#check-account-external-ids-result-info[
+CheckAccountExternalIdsResultInfo] entity.
+|================================================
+
+[[consistency-check-input]]
+=== ConsistencyCheckInput
+The `ConsistencyCheckInput` entity contains information about which
+consistency checks should be run.
+
+[options="header",cols="1,^1,5"]
+|=========================================
+|Field Name                  ||Description
+|`check_account_external_ids`|optional|
+Input for the account external ID consistency check as
+link:#check-account-external-ids-input[CheckAccountExternalIdsInput]
+entity.
+|=========================================
+
+[[consistency-problem-info]]
+=== ConsistencyProblemInfo
+The `ConsistencyProblemInfo` entity contains information about a
+consistency problem.
+
+[options="header",cols="1,6"]
+|======================
+|Field Name|Description
+|`status`  |The status of the consistency problem. +
+Possible values are `ERROR` and `WARNING`.
+|`message` |Message describing the consistency problem.
+|======================
+
 [[download-info]]
 === DownloadInfo
 The `DownloadInfo` entity contains information about supported download
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 72c6a39..17b0192 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -2060,6 +2060,60 @@
   Ly8gQ29weXJpZ2h0IChDKSAyMDEwIFRoZSBBbmRyb2lkIE9wZW4gU291cmNlIFByb2plY...
 ----
 
+
+[[cherry-pick-commit]]
+=== Cherry Pick Commit
+--
+'POST /projects/link:#project-name[\{project-name\}]/commits/link:#commit-id[\{commit-id\}]/cherrypick'
+--
+
+Cherry-picks a commit of a project to a destination branch.
+
+The destination branch must be provided in the request body inside a
+link:rest-api-changes.html#cherrypick-input[CherryPickInput] entity.
+If the commit message is not set, the commit message of the source
+commit will be used.
+
+.Request
+----
+  POST /projects/work%2Fmy-project/commits/a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96/cherrypick HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "message" : "Implementing Feature X",
+    "destination" : "release-branch"
+  }
+----
+
+As response a link:rest-api-changes.html#change-info[ChangeInfo] entity is returned that
+describes the resulting cherry-picked change.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9941",
+    "project": "myProject",
+    "branch": "release-branch",
+    "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9941",
+    "subject": "Implementing Feature X",
+    "status": "NEW",
+    "created": "2013-02-01 09:59:32.126000000",
+    "updated": "2013-02-21 11:16:36.775000000",
+    "mergeable": true,
+    "insertions": 12,
+    "deletions": 11,
+    "_number": 3965,
+    "owner": {
+      "name": "John Doe"
+    }
+  }
+----
+
 [[dashboard-endpoints]]
 == Dashboard Endpoints
 
diff --git a/Documentation/rest-api.txt b/Documentation/rest-api.txt
index 7928512..8e2bff6 100644
--- a/Documentation/rest-api.txt
+++ b/Documentation/rest-api.txt
@@ -78,6 +78,12 @@
 `Accept-Encoding` request header is set to `gzip`. This may
 save on network transfer time for larger responses.
 
+[[input]]
+=== Input Format
+Unknown JSON parameters will simply be ignored by Gerrit without causing
+an exception. This also applies to case-sensitive parameters, such as
+map keys.
+
 [[timestamp]]
 === Timestamp
 Timestamps are given in UTC and have the format
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 392d5cf..74ae568 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -367,6 +367,22 @@
 Mergeability of abandoned changes is not computed. This operator will
 not find any abandoned but mergeable changes.
 
+[[ignored]]
+is:ignored::
++
+True if the change is ignored. Same as `star:ignore`.
+
+[[private]]
+is:private::
++
+True if the change is private, ie. only visible to owner and its
+reviewers.
+
+[[workInProgress]]
+is:wip::
++
+True if the change is Work In Progress.
+
 [[status]]
 status:open, status:pending::
 +
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index 9efbb21..259540e 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -212,6 +212,42 @@
   git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental -o topic=driver/i42
 ----
 
+[[private]]
+==== Private Changes
+
+To push a private change or to turn a change private on push the `private`
+option can be specified:
+
+----
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master%private
+----
+
+Omitting the `private` option when pushing updates to a private change
+doesn't make change non-private again. To remove the private
+flag from a change on push, explicitly specify the `remove-private` option:
+
+----
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master%remove-private
+----
+
+[[wip]]
+==== Work-In-Progress Changes
+
+To push a wip change or to turn a change to wip the `work-in-progress` (or `wip`)
+option can be specified:
+
+----
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master%wip
+----
+
+Omitting the `wip` option when pushing updates to a wip change
+doesn't make change ready again. To remove the `wip`
+flag from a change on push, explicitly specify the `ready` option:
+
+----
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master%ready
+----
+
 [[message]]
 ==== Message
 
diff --git a/README.md b/README.md
index 78c8477..1ca01d5 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@
 ## Source
 
 Our canonical Git repository is located on [googlesource.com](https://gerrit.googlesource.com/gerrit).
-There is a mirror of the repository on [Github](https://github.com/gerrit-review/gerrit).
+There is a mirror of the repository on [Github](https://github.com/GerritCodeReview/gerrit).
 
 ## Reporting bugs
 
diff --git a/WORKSPACE b/WORKSPACE
index b2caccd..3b46d22 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -4,6 +4,40 @@
 load("//lib/codemirror:cm.bzl", "CM_VERSION", "DIFF_MATCH_PATCH_VERSION")
 load("//plugins:external_plugin_deps.bzl", "external_plugin_deps")
 
+http_archive(
+    name = "io_bazel_rules_closure",
+    strip_prefix = "rules_closure-0.4.1",
+    sha256 = "ba5e2e10cdc4027702f96e9bdc536c6595decafa94847d08ae28c6cb48225124",
+    url = "http://bazel-mirror.storage.googleapis.com/github.com/bazelbuild/rules_closure/archive/0.4.1.tar.gz",
+)
+
+# File is specific to Polymer and copied from the Closure Github -- should be
+# synced any time there are major changes to Polymer.
+# https://github.com/google/closure-compiler/blob/master/contrib/externs/polymer-1.0.js
+http_file(
+    name = "polymer_closure",
+    sha256 = "5a589bdba674e1fec7188e9251c8624ebf2d4d969beb6635f9148f420d1e08b1",
+    url = "https://raw.githubusercontent.com/google/closure-compiler/775609aad61e14aef289ebec4bfc09ad88877f9e/contrib/externs/polymer-1.0.js",
+)
+
+load("@io_bazel_rules_closure//closure:defs.bzl", "closure_repositories")
+
+# Prevent redundant loading of dependencies.
+closure_repositories(
+    omit_aopalliance=True,
+    omit_args4j=True,
+    omit_jsr305=True,
+    omit_gson=True,
+    omit_guava=True,
+    omit_guice=True,
+    omit_soy=True,
+    omit_icu4j=True,
+    omit_asm=True,
+    omit_asm_analysis=True,
+    omit_asm_commons=True,
+    omit_asm_util=True,
+)
+
 ANTLR_VERS = "3.5.2"
 
 maven_jar(
@@ -169,8 +203,8 @@
 
 maven_jar(
     name = "joda_time",
-    artifact = "joda-time:joda-time:2.9.4",
-    sha1 = "1c295b462f16702ebe720bbb08f62e1ba80da41b",
+    artifact = "joda-time:joda-time:2.9.8",
+    sha1 = "03986e1763e5df02ad7fc040ecb555193a8436bb",
 )
 
 maven_jar(
@@ -201,8 +235,8 @@
 
 maven_jar(
     name = "juniversalchardet",
-    artifact = "com.googlecode.juniversalchardet:juniversalchardet:1.0.3",
-    sha1 = "cd49678784c46aa8789c060538e0154013bb421b",
+    artifact = "com.github.albfernandez:juniversalchardet:2.0.0",
+    sha1 = "28c59f58f5adcc307604602e2aa89e2aca14c554",
 )
 
 SLF4J_VERS = "1.7.7"
@@ -257,8 +291,8 @@
 
 maven_jar(
     name = "commons_codec",
-    artifact = "commons-codec:commons-codec:1.4",
-    sha1 = "4216af16d38465bbab0f3dff8efa14204f7a399a",
+    artifact = "commons-codec:commons-codec:1.10",
+    sha1 = "4b95f4897fa13f2cd904aee711aeafc0c5295cd8",
 )
 
 maven_jar(
@@ -269,8 +303,8 @@
 
 maven_jar(
     name = "commons_compress",
-    artifact = "org.apache.commons:commons-compress:1.12",
-    sha1 = "84caa68576e345eb5e7ae61a0e5a9229eb100d7b",
+    artifact = "org.apache.commons:commons-compress:1.13",
+    sha1 = "15c5e9584200122924e50203ae210b57616b75ee",
 )
 
 maven_jar(
@@ -311,8 +345,8 @@
 
 maven_jar(
     name = "commons_validator",
-    artifact = "commons-validator:commons-validator:1.5.1",
-    sha1 = "86d05a46e8f064b300657f751b5a98c62807e2a0",
+    artifact = "commons-validator:commons-validator:1.6",
+    sha1 = "e989d1e87cdd60575df0765ed5bac65c905d7908",
 )
 
 maven_jar(
@@ -323,8 +357,8 @@
 
 maven_jar(
     name = "pegdown",
-    artifact = "org.pegdown:pegdown:1.4.2",
-    sha1 = "d96db502ed832df867ff5d918f05b51ba3879ea7",
+    artifact = "org.pegdown:pegdown:1.6.0",
+    sha1 = "231ae49d913467deb2027d0b8a0b68b231deef4f",
 )
 
 maven_jar(
@@ -409,8 +443,8 @@
 
 maven_jar(
     name = "auto_value",
-    artifact = "com.google.auto.value:auto-value:1.4",
-    sha1 = "6d1448fcd13074bd3658ef915022410b7c48343b",
+    artifact = "com.google.auto.value:auto-value:1.4.1",
+    sha1 = "8172ebbd7970188aff304c8a420b9f17168f6f48",
 )
 
 maven_jar(
@@ -419,84 +453,84 @@
     sha1 = "18a9a2ce6abf32ea1b5fd31dae5210ad93f4e5e3",
 )
 
-LUCENE_VERS = "5.5.2"
+LUCENE_VERS = "5.5.4"
 
 maven_jar(
     name = "lucene_core",
     artifact = "org.apache.lucene:lucene-core:" + LUCENE_VERS,
-    sha1 = "de5e5c3161ea01e89f2a09a14391f9b7ed66cdbb",
+    sha1 = "ab9c77e75cf142aa6e284b310c8395617bd9b19b",
 )
 
 maven_jar(
     name = "lucene_analyzers_common",
     artifact = "org.apache.lucene:lucene-analyzers-common:" + LUCENE_VERS,
-    sha1 = "f0bc3114a6b43f8e64a33c471d5b9e8ddc51564d",
+    sha1 = "08ce9d34c8124c80e176e8332ee947480bbb9576",
 )
 
 maven_jar(
     name = "lucene_codecs",
     artifact = "org.apache.lucene:lucene-codecs:" + LUCENE_VERS,
-    sha1 = "e01fe463d9490bb1b4a6a168e771f7b7255a50b1",
+    sha1 = "afdad570668469b1734fbd32b8f98561561bed48",
 )
 
 maven_jar(
     name = "backward_codecs",
     artifact = "org.apache.lucene:lucene-backward-codecs:" + LUCENE_VERS,
-    sha1 = "c5cfcd7a8cf48a0144b61fb991c8e50a0bf868d5",
+    sha1 = "a933f42e758c54c43083398127ea7342b54d8212",
 )
 
 maven_jar(
     name = "lucene_misc",
     artifact = "org.apache.lucene:lucene-misc:" + LUCENE_VERS,
-    sha1 = "37bbe5a2fb429499dfbe75d750d1778881fff45d",
+    sha1 = "a74388857f73614e528ae44d742c60187cb55a5a",
 )
 
 maven_jar(
     name = "lucene_queryparser",
     artifact = "org.apache.lucene:lucene-queryparser:" + LUCENE_VERS,
-    sha1 = "8ac921563e744463605284c6d9d2d95e1be5b87c",
+    sha1 = "8a06fad4675473d98d93b61fea529e3f464bf69e",
 )
 
 maven_jar(
     name = "lucene_highlighter",
     artifact = "org.apache.lucene:lucene-highlighter:" + LUCENE_VERS,
-    sha1 = "d127ac514e9df965ab0b57d92bbe0c68d3d145b8",
+    sha1 = "433f53f03f1b14337c08d54e507a5410905376fa",
 )
 
 maven_jar(
     name = "lucene_join",
     artifact = "org.apache.lucene:lucene-join:" + LUCENE_VERS,
-    sha1 = "dac1b322508f3f2696ecc49a97311d34d8382054",
+    sha1 = "23f9a909a244ed3b28b37c5bb21a6e33e6c0a339",
 )
 
 maven_jar(
     name = "lucene_memory",
     artifact = "org.apache.lucene:lucene-memory:" + LUCENE_VERS,
-    sha1 = "7409db9863d8fbc265c27793c6cc7511304182c2",
+    sha1 = "4dbdc2e1a24837722294762a9edb479f79092ab9",
 )
 
 maven_jar(
     name = "lucene_sandbox",
     artifact = "org.apache.lucene:lucene-sandbox:" + LUCENE_VERS,
-    sha1 = "30a91f120706ba66732d5a974b56c6971b3c8a16",
+    sha1 = "49498bbb2adc333e98bdca4bf6170ae770cbad11",
 )
 
 maven_jar(
     name = "lucene_spatial",
     artifact = "org.apache.lucene:lucene-spatial:" + LUCENE_VERS,
-    sha1 = "8ed7a9a43d78222038573dd1c295a61f3c0bb0db",
+    sha1 = "0217d302dc0ef4d9b8b475ffe327d83c1e0ceba5",
 )
 
 maven_jar(
     name = "lucene_suggest",
     artifact = "org.apache.lucene:lucene-suggest:" + LUCENE_VERS,
-    sha1 = "e8316b37dddcf2092a54dab2ce6aad0d5ad78585",
+    sha1 = "0f46dbb3229eed62dff10d008172c885e0e028c8",
 )
 
 maven_jar(
     name = "lucene_queries",
     artifact = "org.apache.lucene:lucene-queries:" + LUCENE_VERS,
-    sha1 = "692f1ad887cf4e006a23f45019e6de30f3312d3f",
+    sha1 = "f915357b8b4b43742ab48f1401dedcaa12dfa37a",
 )
 
 maven_jar(
@@ -701,9 +735,10 @@
     sha1 = "2862787ce34cb6f385ada891e36ec7f9e7bd0902",
 )
 
+# When bumping the easymock version number, make sure to also move powermock to a compatible version
 maven_jar(
     name = "easymock",
-    artifact = "org.easymock:easymock:3.1",  # When bumping the version
+    artifact = "org.easymock:easymock:3.1",
     sha1 = "3e127311a86fc2e8f550ef8ee4abe094bbcf7e7e",
 )
 
@@ -890,8 +925,8 @@
 
 maven_jar(
     name = "elasticsearch",
-    artifact = "org.elasticsearch:elasticsearch:2.4.4",
-    sha1 = "e69930bc794c539d34778e665d6f8ccbffd42c6f",
+    artifact = "org.elasticsearch:elasticsearch:2.4.5",
+    sha1 = "daafe48ae06592029a2fedca1fe2ac0f5eec3185",
 )
 
 # Java REST client for Elasticsearch.
@@ -1067,8 +1102,8 @@
 bower_archive(
     name = "polymer",
     package = "polymer/polymer",
-    sha1 = "f2563ed9c8571057814b78d8f6cf275eeb953eeb",
-    version = "1.7.1",
+    sha1 = "2c7dd638d55ea91242525139cba18a308b9426d5",
+    version = "1.9.1",
 )
 
 bower_archive(
@@ -1097,8 +1132,8 @@
 bower_archive(
     name = "web-component-tester",
     package = "web-component-tester",
-    sha1 = "a4a9bc7815a22d143e8f8593e37b3c2028b8c20f",
-    version = "5.0.0",
+    sha1 = "4e778f8b7d784ba2a069d83d0cd146125c5c4fcb",
+    version = "5.0.1",
 )
 
 # Bower component transitive dependencies.
diff --git a/contrib/abandon_stale.py b/contrib/abandon_stale.py
index f62c767..44e7e0e 100755
--- a/contrib/abandon_stale.py
+++ b/contrib/abandon_stale.py
@@ -59,7 +59,11 @@
                       help='gerrit server URL')
     parser.add_option('-b', '--basic-auth', dest='basic_auth',
                       action='store_true',
-                      help='use HTTP basic authentication instead of digest')
+                      help='(deprecated) use HTTP basic authentication instead'
+                      ' of digest')
+    parser.add_option('-d', '--digest-auth', dest='digest_auth',
+                      action='store_true',
+                      help='use HTTP digest authentication instead of basic')
     parser.add_option('-n', '--dry-run', dest='dry_run',
                       action='store_true',
                       help='enable dry-run mode: show stale changes but do '
@@ -115,10 +119,10 @@
     message = "Abandoning after %s %s or more of inactivity." % \
         (match.group(1), match.group(2))
 
-    if options.basic_auth:
-        auth_type = HTTPBasicAuthFromNetrc
-    else:
+    if options.digest_auth:
         auth_type = HTTPDigestAuthFromNetrc
+    else:
+        auth_type = HTTPBasicAuthFromNetrc
 
     try:
         auth = auth_type(url=options.gerrit_url)
diff --git a/contrib/populate-fixture-data.py b/contrib/populate-fixture-data.py
index b77c41a..0e3dffe 100644
--- a/contrib/populate-fixture-data.py
+++ b/contrib/populate-fixture-data.py
@@ -46,7 +46,7 @@
 PLUGINS_URL = BASE_URL + "plugins/"
 PROJECTS_URL = BASE_URL + "projects/"
 
-ADMIN_DIGEST = requests.auth.HTTPDigestAuth("admin", "secret")
+ADMIN_BASIC_AUTH = requests.auth.HTTPBasicAuth("admin", "secret")
 
 # GROUP_ADMIN stores a GroupInfo for the admin group (see Gerrit rest docs)
 # In addition, GROUP_ADMIN["name"] stores the admin group"s name.
@@ -151,8 +151,8 @@
   return json_string
 
 
-def digest_auth(user):
-  return requests.auth.HTTPDigestAuth(user["username"], user["http_password"])
+def basic_auth(user):
+  return requests.auth.HTTPBasicAuth(user["username"], user["http_password"])
 
 
 def fetch_admin_group():
@@ -160,7 +160,7 @@
   # Get admin group
   r = json.loads(clean(requests.get(GROUPS_URL + "?suggest=ad&p=All-Projects",
                                     headers=HEADERS,
-                                    auth=ADMIN_DIGEST).text))
+                                    auth=ADMIN_BASIC_AUTH).text))
   admin_group_name = r.keys()[0]
   GROUP_ADMIN = r[admin_group_name]
   GROUP_ADMIN["name"] = admin_group_name
@@ -225,7 +225,7 @@
     requests.put(GROUPS_URL + g["name"],
                  json.dumps(g),
                  headers=HEADERS,
-                 auth=ADMIN_DIGEST)
+                 auth=ADMIN_BASIC_AUTH)
   return [g["name"] for g in groups]
 
 
@@ -247,7 +247,7 @@
     requests.put(PROJECTS_URL + p["name"],
                  json.dumps(p),
                  headers=HEADERS,
-                 auth=ADMIN_DIGEST)
+                 auth=ADMIN_BASIC_AUTH)
   return [p["name"] for p in projects]
 
 
@@ -256,7 +256,7 @@
     requests.put(ACCOUNTS_URL + user["username"],
                  json.dumps(user),
                  headers=HEADERS,
-                 auth=ADMIN_DIGEST)
+                 auth=ADMIN_BASIC_AUTH)
 
 
 def create_change(user, project_name):
@@ -270,7 +270,7 @@
   requests.post(CHANGES_URL,
                 json.dumps(change),
                 headers=HEADERS,
-                auth=digest_auth(user))
+                auth=basic_auth(user))
 
 
 def clean_up():
diff --git a/gerrit-acceptance-framework/pom.xml b/gerrit-acceptance-framework/pom.xml
index 5e1cd05..152a003 100644
--- a/gerrit-acceptance-framework/pom.xml
+++ b/gerrit-acceptance-framework/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>2.14</version>
+  <version>2.15-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index c4be97c..6345c67 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -64,6 +64,7 @@
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.ChangeFinder;
@@ -88,6 +89,7 @@
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.send.EmailHeader;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -346,7 +348,6 @@
     }
 
     server.getTestInjector().injectMembers(this);
-    notesMigration.setFromEnv();
     Transport.register(inProcessProtocol);
     toClose = Collections.synchronizedList(new ArrayList<Repository>());
     admin = accounts.admin();
@@ -492,12 +493,22 @@
 
   protected TestRepository<InMemoryRepository> cloneProject(
       Project.NameKey p, TestAccount testAccount) throws Exception {
+    return GitUtil.cloneProject(p, registerRepoConnection(p, testAccount));
+  }
+
+  /**
+   * Register a repository connection over the test protocol.
+   *
+   * @return a URI string that can be used to connect to this repository for both fetch and push.
+   */
+  protected String registerRepoConnection(Project.NameKey p, TestAccount testAccount)
+      throws Exception {
     InProcessProtocol.Context ctx =
         new InProcessProtocol.Context(
             reviewDbProvider, identifiedUserFactory, testAccount.getId(), p);
     Repository repo = repoManager.openRepository(p);
     toClose.add(repo);
-    return GitUtil.cloneProject(p, inProcessProtocol.register(ctx, repo).toString());
+    return inProcessProtocol.register(ctx, repo).toString();
   }
 
   protected void afterTest() throws Exception {
@@ -516,6 +527,7 @@
       server.stop();
       server = null;
     }
+    notesMigration.resetFromEnv();
   }
 
   protected TestRepository<?>.CommitBuilder commitBuilder() throws Exception {
@@ -594,6 +606,10 @@
     return pushTo("refs/drafts/master");
   }
 
+  protected PushOneCommit.Result createWorkInProgressChange() throws Exception {
+    return pushTo("refs/for/master%wip");
+  }
+
   protected PushOneCommit.Result createChange(String subject, String fileName, String content)
       throws Exception {
     PushOneCommit push =
@@ -1071,6 +1087,8 @@
   /**
    * Fetches each bundle into a newly cloned repository, then it applies the bundle, and returns the
    * resulting tree id.
+   *
+   * <p>Omits NoteDb meta refs.
    */
   protected Map<Branch.NameKey, ObjectId> fetchFromBundles(BinaryResult bundles) throws Exception {
     assertThat(bundles.getContentType()).isEqualTo("application/x-zip");
@@ -1104,11 +1122,12 @@
                   NullProgressMonitor.INSTANCE,
                   Arrays.asList(new RefSpec("refs/*:refs/preview/*")));
           for (Ref r : fr.getAdvertisedRefs()) {
-            String branchName = r.getName();
-            Branch.NameKey n = new Branch.NameKey(proj, branchName);
-
+            String refName = r.getName();
+            if (RefNames.isNoteDbMetaRef(refName)) {
+              continue;
+            }
             RevCommit c = localRepo.getRevWalk().parseCommit(r.getObjectId());
-            ret.put(n, c.getTree().copy());
+            ret.put(new Branch.NameKey(proj, refName), c.getTree().copy());
           }
         }
       }
@@ -1201,21 +1220,29 @@
   }
 
   protected void assertNotifyTo(TestAccount expected) {
+    assertNotifyTo(expected.emailAddress);
+  }
+
+  protected void assertNotifyTo(Address expected) {
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(expected.emailAddress);
+    assertThat(m.rcpt()).containsExactly(expected);
     assertThat(((EmailHeader.AddressList) m.headers().get("To")).getAddressList())
-        .containsExactly(expected.emailAddress);
+        .containsExactly(expected);
     assertThat(m.headers().get("CC").isEmpty()).isTrue();
   }
 
   protected void assertNotifyCc(TestAccount expected) {
+    assertNotifyCc(expected.emailAddress);
+  }
+
+  protected void assertNotifyCc(Address expected) {
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(expected.emailAddress);
+    assertThat(m.rcpt()).containsExactly(expected);
     assertThat(m.headers().get("To").isEmpty()).isTrue();
     assertThat(((EmailHeader.AddressList) m.headers().get("CC")).getAddressList())
-        .containsExactly(expected.emailAddress);
+        .containsExactly(expected);
   }
 
   protected void assertNotifyBcc(TestAccount expected) {
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java
index e136bb3..711b8cf 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -18,6 +18,7 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.US_ASCII;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -25,10 +26,11 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountByEmailCache;
 import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gerrit.testutil.SshMode;
@@ -51,6 +53,7 @@
   private final Map<String, TestAccount> accounts;
 
   private final SchemaFactory<ReviewDb> reviewDbProvider;
+  private final AccountsUpdate.Server accountsUpdate;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final GroupCache groupCache;
   private final SshKeyCache sshKeyCache;
@@ -62,6 +65,7 @@
   @Inject
   AccountCreator(
       SchemaFactory<ReviewDb> schema,
+      AccountsUpdate.Server accountsUpdate,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       GroupCache groupCache,
       SshKeyCache sshKeyCache,
@@ -71,6 +75,7 @@
       ExternalIdsUpdate.Server externalIdsUpdate) {
     accounts = new HashMap<>();
     reviewDbProvider = schema;
+    this.accountsUpdate = accountsUpdate;
     this.authorizedKeys = authorizedKeys;
     this.groupCache = groupCache;
     this.sshKeyCache = sshKeyCache;
@@ -81,7 +86,12 @@
   }
 
   public synchronized TestAccount create(
-      String username, String email, String fullName, String... groups) throws Exception {
+      @Nullable String username,
+      @Nullable String email,
+      @Nullable String fullName,
+      String... groups)
+      throws Exception {
+
     TestAccount account = accounts.get(username);
     if (account != null) {
       return account;
@@ -90,8 +100,11 @@
       Account.Id id = new Account.Id(db.nextAccountId());
 
       List<ExternalId> extIds = new ArrayList<>(2);
-      String httpPass = "http-pass";
-      extIds.add(ExternalId.createUsername(username, id, httpPass));
+      String httpPass = null;
+      if (username != null) {
+        httpPass = "http-pass";
+        extIds.add(ExternalId.createUsername(username, id, httpPass));
+      }
 
       if (email != null) {
         extIds.add(ExternalId.createEmail(id, email));
@@ -101,7 +114,7 @@
       Account a = new Account(id, TimeUtil.nowTs());
       a.setFullName(fullName);
       a.setPreferredEmail(email);
-      db.accounts().insert(Collections.singleton(a));
+      accountsUpdate.create().insert(db, a);
 
       if (groups != null) {
         for (String n : groups) {
@@ -114,28 +127,36 @@
       }
 
       KeyPair sshKey = null;
-      if (SshMode.useSsh()) {
+      if (SshMode.useSsh() && username != null) {
         sshKey = genSshKey();
         authorizedKeys.addKey(id, publicKey(sshKey, email));
         sshKeyCache.evict(username);
       }
 
-      accountCache.evictByUsername(username);
+      if (username != null) {
+        accountCache.evictByUsername(username);
+      }
       byEmailCache.evict(email);
 
       indexer.index(id);
 
       account = new TestAccount(id, username, email, fullName, sshKey, httpPass);
-      accounts.put(username, account);
+      if (username != null) {
+        accounts.put(username, account);
+      }
       return account;
     }
   }
 
-  public TestAccount create(String username, String group) throws Exception {
+  public TestAccount create(@Nullable String username, String group) throws Exception {
     return create(username, null, username, group);
   }
 
-  public TestAccount create(String username) throws Exception {
+  public TestAccount create() throws Exception {
+    return create(null);
+  }
+
+  public TestAccount create(@Nullable String username) throws Exception {
     return create(username, null, username, (String[]) null);
   }
 
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java
index 58cdf96..1e741a8 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java
@@ -45,7 +45,6 @@
 import java.net.URI;
 import java.nio.file.Paths;
 import java.util.concurrent.BrokenBarrierException;
-import java.util.concurrent.Callable;
 import java.util.concurrent.CyclicBarrier;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -133,17 +132,14 @@
   static GerritServer start(Description desc, Config baseConfig) throws Exception {
     Config cfg = desc.buildConfig(baseConfig);
     Logger.getLogger("com.google.gerrit").setLevel(Level.DEBUG);
-    final CyclicBarrier serverStarted = new CyclicBarrier(2);
-    final Daemon daemon =
+    CyclicBarrier serverStarted = new CyclicBarrier(2);
+    Daemon daemon =
         new Daemon(
-            new Runnable() {
-              @Override
-              public void run() {
-                try {
-                  serverStarted.await();
-                } catch (InterruptedException | BrokenBarrierException e) {
-                  throw new RuntimeException(e);
-                }
+            () -> {
+              try {
+                serverStarted.await();
+              } catch (InterruptedException | BrokenBarrierException e) {
+                throw new RuntimeException(e);
               }
             },
             Paths.get(baseConfig.getString("gerrit", null, "tempSiteDir")));
@@ -173,24 +169,17 @@
       @SuppressWarnings("unused")
       Future<?> possiblyIgnoredError =
           daemonService.submit(
-              new Callable<Void>() {
-                @Override
-                public Void call() throws Exception {
-                  int rc =
-                      daemon.main(
-                          new String[] {
-                            "-d",
-                            site.getPath(),
-                            "--headless",
-                            "--console-log",
-                            "--show-stack-trace",
-                          });
-                  if (rc != 0) {
-                    System.err.println("Failed to start Gerrit daemon");
-                    serverStarted.reset();
-                  }
-                  return null;
+              () -> {
+                int rc =
+                    daemon.main(
+                        new String[] {
+                          "-d", site.getPath(), "--headless", "--console-log", "--show-stack-trace",
+                        });
+                if (rc != 0) {
+                  System.err.println("Failed to start Gerrit daemon");
+                  serverStarted.reset();
                 }
+                return null;
               });
       serverStarted.await();
       System.out.println("Gerrit Server Started");
@@ -239,6 +228,7 @@
     cfg.setInt("sshd", null, "commandStartThreads", 1);
     cfg.setInt("receive", null, "threadPoolSize", 1);
     cfg.setInt("index", null, "threads", 1);
+    cfg.setBoolean("index", null, "reindexAfterRefUpdate", false);
   }
 
   private static Injector createTestInjector(Daemon daemon) throws Exception {
diff --git a/gerrit-acceptance-tests/BUILD b/gerrit-acceptance-tests/BUILD
index 3875dc5..c593783 100644
--- a/gerrit-acceptance-tests/BUILD
+++ b/gerrit-acceptance-tests/BUILD
@@ -1,7 +1,10 @@
+RESOURCES = glob(["src/test/resources/**/*"])
+
 java_library(
     name = "lib",
     testonly = 1,
     srcs = ["src/test/java/com/google/gerrit/acceptance/Dummy.java"],
+    resources = RESOURCES,
     visibility = ["//visibility:public"],
     exports = [
         "//gerrit-acceptance-framework:lib",
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index e34223e8..b32349b 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.acceptance.GitUtil.deleteRef;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
 import static com.google.gerrit.gpg.PublicKeyStore.REFS_GPG_KEYS;
 import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
@@ -26,6 +27,7 @@
 import static com.google.gerrit.gpg.testutil.TestKeys.validKeyWithoutExpiration;
 import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
 import static com.google.gerrit.server.StarredChangesUtil.IGNORE_LABEL;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -37,21 +39,26 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.io.BaseEncoding;
+import com.google.common.util.concurrent.AtomicLongMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AccountCreator;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.SshKeyInfo;
+import com.google.gerrit.extensions.events.AccountIndexedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -59,17 +66,18 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.gpg.Fingerprint;
 import com.google.gerrit.gpg.PublicKeyStore;
-import com.google.gerrit.gpg.server.GpgKeys;
 import com.google.gerrit.gpg.testutil.TestKey;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.AccountByEmailCache;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
 import com.google.gerrit.server.account.WatchConfig;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
 import com.google.gerrit.server.project.RefPattern;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.testutil.ConfigSuite;
@@ -96,7 +104,11 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushCertificateIdent;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.junit.After;
 import org.junit.Assert;
 import org.junit.Before;
@@ -116,12 +128,29 @@
 
   @Inject private AccountByEmailCache byEmailCache;
 
+  @Inject private ExternalIds externalIds;
+
   @Inject private ExternalIdsUpdate.User externalIdsUpdateFactory;
 
+  @Inject private DynamicSet<AccountIndexedListener> accountIndexedListeners;
+
+  private AccountIndexedCounter accountIndexedCounter;
+  private RegistrationHandle accountIndexEventCounterHandle;
   private ExternalIdsUpdate externalIdsUpdate;
   private List<ExternalId> savedExternalIds;
 
   @Before
+  public void addAccountIndexEventCounter() {
+    accountIndexedCounter = new AccountIndexedCounter();
+    accountIndexEventCounterHandle = accountIndexedListeners.add(accountIndexedCounter);
+  }
+
+  @After
+  public void removeAccountIndexEventCounter() {
+    accountIndexEventCounterHandle.remove();
+  }
+
+  @Before
   public void saveExternalIds() throws Exception {
     externalIdsUpdate = externalIdsUpdateFactory.create();
 
@@ -176,11 +205,33 @@
   }
 
   @Test
+  public void create() throws Exception {
+    TestAccount foo = accounts.create("foo");
+    AccountInfo info = gApi.accounts().id(foo.id.get()).get();
+    assertThat(info.username).isEqualTo("foo");
+    accountIndexedCounter.assertReindexOf(foo, 2); // account creation + adding SSH keys
+
+    // check user branch
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      Ref ref = repo.exactRef(RefNames.refsUsers(foo.getId()));
+      assertThat(ref).isNotNull();
+      RevCommit c = rw.parseCommit(ref.getObjectId());
+      long timestampDiffMs =
+          Math.abs(
+              c.getCommitTime() * 1000L
+                  - accountCache.get(foo.getId()).getAccount().getRegisteredOn().getTime());
+      assertThat(timestampDiffMs).isAtMost(ChangeRebuilderImpl.MAX_WINDOW_MS);
+    }
+  }
+
+  @Test
   public void get() throws Exception {
     AccountInfo info = gApi.accounts().id("admin").get();
     assertThat(info.name).isEqualTo("Administrator");
     assertThat(info.email).isEqualTo("admin@example.com");
     assertThat(info.username).isEqualTo("admin");
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
@@ -188,6 +239,7 @@
     AccountInfo info = gApi.accounts().id("admin").get();
     AccountInfo infoByIntId = gApi.accounts().id(info._accountId).get();
     assertThat(info.name).isEqualTo(infoByIntId.name);
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
@@ -197,6 +249,7 @@
 
     info = gApi.accounts().id("self").get();
     assertUser(info, admin);
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
@@ -204,8 +257,11 @@
     assertThat(gApi.accounts().id("user").getActive()).isTrue();
     gApi.accounts().id("user").setActive(false);
     assertThat(gApi.accounts().id("user").getActive()).isFalse();
+    accountIndexedCounter.assertReindexOf(user);
+
     gApi.accounts().id("user").setActive(true);
     assertThat(gApi.accounts().id("user").getActive()).isTrue();
+    accountIndexedCounter.assertReindexOf(user);
   }
 
   @Test
@@ -242,6 +298,7 @@
     change = info(triplet);
     assertThat(change.starred).isNull();
     assertThat(change.stars).isNull();
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
@@ -282,6 +339,7 @@
     assertThat(starredChange._number).isEqualTo(r.getChange().getId().get());
     assertThat(starredChange.starred).isNull();
     assertThat(starredChange.stars).containsExactly("red", "yellow").inOrder();
+    accountIndexedCounter.assertNoReindex();
 
     setApiUser(user);
     exception.expect(AuthException.class);
@@ -318,17 +376,20 @@
     gApi.accounts()
         .self()
         .setStars(triplet, new StarsInput(ImmutableSet.of(DEFAULT_LABEL, "blue", IGNORE_LABEL)));
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
   public void ignoreChange() throws Exception {
+    TestAccount user2 = accounts.user2();
+    accountIndexedCounter.clear();
+
     PushOneCommit.Result r = createChange();
 
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = user.email;
     gApi.changes().id(r.getChangeId()).addReviewer(in);
 
-    TestAccount user2 = accounts.user2();
     in = new AddReviewerInput();
     in.reviewer = user2.email;
     gApi.changes().id(r.getChangeId()).addReviewer(in);
@@ -342,6 +403,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     assertThat(messages.get(0).rcpt()).containsExactly(user2.emailAddress);
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
@@ -362,6 +424,7 @@
     Message message = messages.get(0);
     assertThat(message.rcpt()).containsExactly(user.emailAddress);
     assertMailReplyTo(message, admin.email);
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
@@ -376,17 +439,12 @@
 
     List<AccountInfo> emptyResult = gApi.accounts().suggestAccounts("unknown").get();
     assertThat(emptyResult).isEmpty();
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
   public void addEmail() throws Exception {
-    List<String> emails =
-        ImmutableList.of(
-            "new.email@example.com",
-            "new.email@example.systems",
-
-            // Not in the list of TLDs but added to override in OutgoingEmailValidator
-            "new.email@example.local");
+    List<String> emails = ImmutableList.of("new.email@example.com", "new.email@example.systems");
     Set<String> currentEmails = getEmails();
     for (String email : emails) {
       assertThat(currentEmails).doesNotContain(email);
@@ -394,6 +452,7 @@
       input.email = email;
       input.noConfirmation = true;
       gApi.accounts().self().addEmail(input);
+      accountIndexedCounter.assertReindexOf(admin);
     }
 
     resetCurrentApiUser();
@@ -414,7 +473,7 @@
             "@example.com",
 
             // Non-supported TLD  (see tlds-alpha-by-domain.txt)
-            "new.email@example.blog");
+            "new.email@example.africa");
     for (String email : emails) {
       EmailInput input = new EmailInput();
       input.email = email;
@@ -426,6 +485,18 @@
         assertThat(e).hasMessageThat().isEqualTo("invalid email address");
       }
     }
+    accountIndexedCounter.assertNoReindex();
+  }
+
+  @Test
+  public void cannotAddNonConfirmedEmailWithoutModifyAccountPermission() throws Exception {
+    TestAccount account = accounts.create(name("user"));
+    EmailInput input = new EmailInput();
+    input.email = "test@test.com";
+    input.noConfirmation = true;
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.accounts().id(account.username).addEmail(input);
   }
 
   @Test
@@ -439,7 +510,9 @@
     resetCurrentApiUser();
     assertThat(getEmails()).contains(email);
 
+    accountIndexedCounter.clear();
     gApi.accounts().self().deleteEmail(input.email);
+    accountIndexedCounter.assertReindexOf(admin);
 
     resetCurrentApiUser();
     assertThat(getEmails()).doesNotContain(email);
@@ -456,6 +529,7 @@
             ExternalId.createWithEmail(ExternalId.Key.parse(extId2), admin.id, email));
     externalIdsUpdateFactory.create().insert(db, extIds);
     accountCache.evict(admin.id);
+    accountIndexedCounter.assertReindexOf(admin);
     assertThat(
             gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
         .containsAllOf(extId1, extId2);
@@ -464,6 +538,7 @@
     assertThat(getEmails()).contains(email);
 
     gApi.accounts().self().deleteEmail(email);
+    accountIndexedCounter.assertReindexOf(admin, 2); // for each deleted external ID once
 
     resetCurrentApiUser();
     assertThat(getEmails()).doesNotContain(email);
@@ -479,6 +554,7 @@
     input.email = email;
     input.noConfirmation = true;
     gApi.accounts().id(user.id.get()).addEmail(input);
+    accountIndexedCounter.assertReindexOf(user);
 
     setApiUser(user);
     assertThat(getEmails()).contains(email);
@@ -486,13 +562,14 @@
     // admin can delete email of user
     setApiUser(admin);
     gApi.accounts().id(user.id.get()).deleteEmail(email);
+    accountIndexedCounter.assertReindexOf(user);
 
     setApiUser(user);
     assertThat(getEmails()).doesNotContain(email);
 
     // user cannot delete email of admin
     exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to delete email address");
+    exception.expectMessage("modify account not permitted");
     gApi.accounts().id(admin.id.get()).deleteEmail(admin.email);
   }
 
@@ -528,17 +605,14 @@
       admin.status = status;
       info = gApi.accounts().self().get();
       assertUser(info, admin);
+      accountIndexedCounter.assertReindexOf(admin);
     }
   }
 
   @Test
+  @Sandboxed
   public void fetchUserBranch() throws Exception {
-    // change something in the user preferences to ensure that the user branch
-    // is created
     setApiUser(user);
-    GeneralPreferencesInfo input = new GeneralPreferencesInfo();
-    input.changesPerPage = GeneralPreferencesInfo.defaults().changesPerPage + 10;
-    gApi.accounts().self().setPreferences(input);
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, user);
     String userRefName = RefNames.refsUsers(user.id);
@@ -584,34 +658,25 @@
     exception.expect(TransportException.class);
     exception.expectMessage("Remote does not have " + otherUserRefName + " available for fetch.");
     fetch(allUsersRepo, otherUserRefName + ":otherUserRef");
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
   public void pushToUserBranch() throws Exception {
-    // change something in the user preferences to ensure that the user branch
-    // is created
-    GeneralPreferencesInfo input = new GeneralPreferencesInfo();
-    input.changesPerPage = GeneralPreferencesInfo.defaults().changesPerPage + 10;
-    gApi.accounts().self().setPreferences(input);
-
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
     allUsersRepo.reset("userRef");
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
     push.to(RefNames.refsUsers(admin.id)).assertOkStatus();
+    accountIndexedCounter.assertReindexOf(admin);
 
     push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
     push.to(RefNames.REFS_USERS_SELF).assertOkStatus();
+    accountIndexedCounter.assertReindexOf(admin);
   }
 
   @Test
   public void pushToUserBranchForReview() throws Exception {
-    // change something in the user preferences to ensure that the user branch
-    // is created
-    GeneralPreferencesInfo input = new GeneralPreferencesInfo();
-    input.changesPerPage = GeneralPreferencesInfo.defaults().changesPerPage + 10;
-    gApi.accounts().self().setPreferences(input);
-
     String userRefName = RefNames.refsUsers(admin.id);
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, userRefName + ":userRef");
@@ -619,26 +684,24 @@
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
     PushOneCommit.Result r = push.to(MagicBranch.NEW_CHANGE + userRefName);
     r.assertOkStatus();
+    accountIndexedCounter.assertNoReindex();
     assertThat(r.getChange().change().getDest().get()).isEqualTo(userRefName);
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).current().submit();
+    accountIndexedCounter.assertReindexOf(admin);
 
     push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
     r = push.to(MagicBranch.NEW_CHANGE + RefNames.REFS_USERS_SELF);
     r.assertOkStatus();
+    accountIndexedCounter.assertNoReindex();
     assertThat(r.getChange().change().getDest().get()).isEqualTo(userRefName);
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).current().submit();
+    accountIndexedCounter.assertReindexOf(admin);
   }
 
   @Test
   public void pushWatchConfigToUserBranch() throws Exception {
-    // change something in the user preferences to ensure that the user branch
-    // is created
-    GeneralPreferencesInfo input = new GeneralPreferencesInfo();
-    input.changesPerPage = GeneralPreferencesInfo.defaults().changesPerPage + 10;
-    gApi.accounts().self().setPreferences(input);
-
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
     allUsersRepo.reset("userRef");
@@ -658,6 +721,7 @@
             WatchConfig.WATCH_CONFIG,
             wc.toText());
     push.to(RefNames.REFS_USERS_SELF).assertOkStatus();
+    accountIndexedCounter.assertReindexOf(admin);
 
     String invalidNotifyValue = "]invalid[";
     wc.setString(WatchConfig.PROJECT, project.get(), WatchConfig.KEY_NOTIFY, invalidNotifyValue);
@@ -678,6 +742,52 @@
   }
 
   @Test
+  @Sandboxed
+  public void cannotDeleteUserBranch() throws Exception {
+    grant(
+        Permission.DELETE,
+        allUsers,
+        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
+        true,
+        REGISTERED_USERS);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    String userRef = RefNames.refsUsers(admin.id);
+    PushResult r = deleteRef(allUsersRepo, userRef);
+    RemoteRefUpdate refUpdate = r.getRemoteUpdate(userRef);
+    assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
+    assertThat(refUpdate.getMessage()).contains("Not allowed to delete user branch.");
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(userRef)).isNotNull();
+    }
+  }
+
+  @Test
+  @Sandboxed
+  public void deleteUserBranchWithAccessDatabaseCapability() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    grant(
+        Permission.DELETE,
+        allUsers,
+        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
+        true,
+        REGISTERED_USERS);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    String userRef = RefNames.refsUsers(admin.id);
+    PushResult r = deleteRef(allUsersRepo, userRef);
+    RemoteRefUpdate refUpdate = r.getRemoteUpdate(userRef);
+    assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.OK);
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(userRef)).isNull();
+    }
+
+    // TODO(ekempin): assert that account was deleted from cache and index
+  }
+
+  @Test
   public void addGpgKey() throws Exception {
     TestKey key = validKeyWithoutExpiration();
     String id = key.getKeyIdString();
@@ -715,6 +825,7 @@
     addExternalIdEmail(admin, "test5@example.com");
     externalIdsUpdate.insert(db, ExternalId.create("foo", "myId", user.getId()));
     accountCache.evict(user.getId());
+    accountIndexedCounter.assertReindexOf(user);
 
     TestKey key = validKeyWithSecondUserId();
     addGpgKey(key.getPublicKeyArmored());
@@ -735,6 +846,7 @@
     }
     gApi.accounts().self().putGpgKeys(toAdd, ImmutableList.<String>of());
     assertKeys(keys);
+    accountIndexedCounter.assertReindexOf(admin);
   }
 
   @Test
@@ -746,6 +858,7 @@
     assertKeys(key);
 
     gApi.accounts().self().gpgKey(id).delete();
+    accountIndexedCounter.assertReindexOf(admin);
     assertKeys();
 
     exception.expect(ResourceNotFoundException.class);
@@ -770,6 +883,7 @@
                 ImmutableList.of(key5.getKeyIdString()));
     assertThat(infos.keySet()).containsExactly(key1.getKeyIdString(), key2.getKeyIdString());
     assertKeys(key1, key2);
+    accountIndexedCounter.assertReindexOf(admin);
 
     infos =
         gApi.accounts()
@@ -781,6 +895,7 @@
     assertKeyMapContains(key5, infos);
     assertThat(infos.get(key1.getKeyIdString()).key).isNull();
     assertKeys(key2, key5);
+    accountIndexedCounter.assertReindexOf(admin);
 
     exception.expect(BadRequestException.class);
     exception.expectMessage("Cannot both add and delete key: " + keyToString(key2.getPublicKey()));
@@ -803,6 +918,7 @@
     SshKeyInfo key = info.get(0);
     String inital = AccountCreator.publicKey(admin.sshKey, admin.email);
     assertThat(key.sshPublicKey).isEqualTo(inital);
+    accountIndexedCounter.assertNoReindex();
 
     // Add a new key
     String newKey = AccountCreator.publicKey(AccountCreator.genSshKey(), admin.email);
@@ -810,12 +926,14 @@
     info = gApi.accounts().self().listSshKeys();
     assertThat(info).hasSize(2);
     assertSequenceNumbers(info);
+    accountIndexedCounter.assertReindexOf(admin);
 
     // Add an existing key (the request succeeds, but the key isn't added again)
     gApi.accounts().self().addSshKey(inital);
     info = gApi.accounts().self().listSshKeys();
     assertThat(info).hasSize(2);
     assertSequenceNumbers(info);
+    accountIndexedCounter.assertNoReindex();
 
     // Add another new key
     String newKey2 = AccountCreator.publicKey(AccountCreator.genSshKey(), admin.email);
@@ -823,6 +941,7 @@
     info = gApi.accounts().self().listSshKeys();
     assertThat(info).hasSize(3);
     assertSequenceNumbers(info);
+    accountIndexedCounter.assertReindexOf(admin);
 
     // Delete second key
     gApi.accounts().self().deleteSshKey(2);
@@ -830,6 +949,7 @@
     assertThat(info).hasSize(2);
     assertThat(info.get(0).seq).isEqualTo(1);
     assertThat(info.get(1).seq).isEqualTo(3);
+    accountIndexedCounter.assertReindexOf(admin);
   }
 
   // reindex is tested by {@link AbstractQueryAccountsTest#reindex}
@@ -838,14 +958,16 @@
     // admin can reindex any account
     setApiUser(admin);
     gApi.accounts().id(user.username).index();
+    accountIndexedCounter.assertReindexOf(user);
 
     // user can reindex own account
     setApiUser(user);
     gApi.accounts().self().index();
+    accountIndexedCounter.assertReindexOf(user);
 
     // user cannot reindex any account
     exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to index account");
+    exception.expectMessage("modify account not permitted");
     gApi.accounts().id(admin.username).index();
   }
 
@@ -913,7 +1035,11 @@
     Iterable<String> expectedFps =
         expected.transform(k -> BaseEncoding.base16().encode(k.getPublicKey().getFingerprint()));
     Iterable<String> actualFps =
-        GpgKeys.getGpgExtIds(db, currAccountId).transform(e -> e.key().id());
+        externalIds
+            .byAccount(db, currAccountId, SCHEME_GPGKEY)
+            .stream()
+            .map(e -> e.key().id())
+            .collect(toSet());
     assertThat(actualFps).named("external IDs in database").containsExactlyElementsIn(expectedFps);
 
     // Check raw stored keys.
@@ -942,11 +1068,15 @@
         db, ExternalId.createWithEmail(name("test"), email, account.getId(), email));
     // Clear saved AccountState and ExternalIds.
     accountCache.evict(account.getId());
+    accountIndexedCounter.assertReindexOf(account);
     setApiUser(account);
   }
 
   private Map<String, GpgKeyInfo> addGpgKey(String armored) throws Exception {
-    return gApi.accounts().self().putGpgKeys(ImmutableList.of(armored), ImmutableList.<String>of());
+    Map<String, GpgKeyInfo> gpgKeys =
+        gApi.accounts().self().putGpgKeys(ImmutableList.of(armored), ImmutableList.<String>of());
+    accountIndexedCounter.assertReindexOf(gApi.accounts().self().get());
+    return gpgKeys;
   }
 
   private void assertUser(AccountInfo info, TestAccount account) throws Exception {
@@ -964,4 +1094,44 @@
     assertThat(accounts).hasSize(1);
     assertThat(Iterables.getOnlyElement(accounts)).isEqualTo(expectedAccount.getId());
   }
+
+  private static class AccountIndexedCounter implements AccountIndexedListener {
+    private final AtomicLongMap<Integer> countsByAccount = AtomicLongMap.create();
+
+    @Override
+    public void onAccountIndexed(int id) {
+      countsByAccount.incrementAndGet(id);
+    }
+
+    void clear() {
+      countsByAccount.clear();
+    }
+
+    long getCount(Account.Id accountId) {
+      return countsByAccount.get(accountId.get());
+    }
+
+    void assertReindexOf(TestAccount testAccount) {
+      assertReindexOf(testAccount, 1);
+    }
+
+    void assertReindexOf(AccountInfo accountInfo) {
+      assertReindexOf(new Account.Id(accountInfo._accountId), 1);
+    }
+
+    void assertReindexOf(TestAccount testAccount, int expectedCount) {
+      assertThat(getCount(testAccount.id)).isEqualTo(expectedCount);
+      assertThat(countsByAccount).hasSize(1);
+      clear();
+    }
+
+    void assertReindexOf(Account.Id accountId, int expectedCount) {
+      assertThat(getCount(accountId)).isEqualTo(expectedCount);
+      countsByAccount.remove(accountId.get());
+    }
+
+    void assertNoReindex() {
+      assertThat(countsByAccount).isEmpty();
+    }
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
index c92006b..0456aa1 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -54,6 +54,7 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -64,9 +65,11 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.Comment.Range;
+import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
@@ -90,7 +93,6 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -100,6 +102,7 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
@@ -181,6 +184,139 @@
   }
 
   @Test
+  public void setPrivateByOwner() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
+
+    setApiUser(user);
+    String changeId = result.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isFalse();
+
+    gApi.changes().id(changeId).setPrivate(true);
+    ChangeInfo info = gApi.changes().id(changeId).get();
+    assertThat(info.isPrivate).isTrue();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set private");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_PRIVATE);
+
+    gApi.changes().id(changeId).setPrivate(false);
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.isPrivate).isFalse();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Unset private");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_UNSET_PRIVATE);
+  }
+
+  @Test
+  public void setPrivateByOtherUser() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
+
+    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isFalse();
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to mark private");
+    gApi.changes().id(result.getChangeId()).setPrivate(true);
+  }
+
+  @Test
+  public void accessPrivate() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
+
+    setApiUser(user);
+    gApi.changes().id(result.getChangeId()).setPrivate(true);
+    // Owner can always access its private changes.
+    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
+
+    // Add admin as a reviewer.
+    gApi.changes().id(result.getChangeId()).addReviewer(admin.getId().toString());
+
+    // This change should be visible for admin as a reviewer.
+    setApiUser(admin);
+    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
+
+    // Remove admin from reviewers.
+    gApi.changes().id(result.getChangeId()).reviewer(admin.getId().toString()).remove();
+
+    // This change should not be visible for admin anymore.
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("Not found: " + result.getChangeId());
+    gApi.changes().id(result.getChangeId());
+  }
+
+  @Test
+  public void privateChangeOfOtherUserCanBeAccessedWithPermission() throws Exception {
+    PushOneCommit.Result result = createChange();
+    gApi.changes().id(result.getChangeId()).setPrivate(true);
+
+    allow(Permission.VIEW_PRIVATE_CHANGES, REGISTERED_USERS, "refs/*");
+    setApiUser(user);
+    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
+  }
+
+  @Test
+  public void setWorkInProgressNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result rwip = createChange();
+    String changeId = rwip.getChangeId();
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to set work in progress");
+    gApi.changes().id(changeId).setWorkInProgress();
+  }
+
+  @Test
+  public void setReadyForReviewNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result rready = createChange();
+    String changeId = rready.getChangeId();
+    gApi.changes().id(changeId).setWorkInProgress();
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to set ready for review");
+    gApi.changes().id(changeId).setReadyForReview();
+  }
+
+  @Test
+  public void toggleWorkInProgressState() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // With message
+    gApi.changes().id(changeId).setWorkInProgress("Needs some refactoring");
+
+    ChangeInfo info = gApi.changes().id(changeId).get();
+
+    assertThat(info.workInProgress).isTrue();
+    assertThat(Iterables.getLast(info.messages).message).contains("Needs some refactoring");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_WIP);
+
+    gApi.changes().id(changeId).setReadyForReview("PTAL");
+
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.workInProgress).isFalse();
+    assertThat(Iterables.getLast(info.messages).message).contains("PTAL");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_READY);
+
+    // No message
+    gApi.changes().id(changeId).setWorkInProgress();
+
+    info = gApi.changes().id(changeId).get();
+
+    assertThat(info.workInProgress).isTrue();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set Work In Progress");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_WIP);
+
+    gApi.changes().id(changeId).setReadyForReview();
+
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.workInProgress).isFalse();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set Ready For Review");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_READY);
+  }
+
+  @Test
   public void getAmbiguous() throws Exception {
     PushOneCommit.Result r1 = createChange();
     String changeId = r1.getChangeId();
@@ -280,6 +416,30 @@
   }
 
   @Test
+  public void abandonNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("abandon not permitted");
+    gApi.changes().id(changeId).abandon();
+  }
+
+  @Test
+  public void abandonAndRestoreAllowedWithPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+    grant(Permission.ABANDON, project, "refs/heads/master", false, REGISTERED_USERS);
+    setApiUser(user);
+    gApi.changes().id(changeId).abandon();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.ABANDONED);
+    gApi.changes().id(changeId).restore();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+  }
+
+  @Test
   public void restore() throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -298,6 +458,19 @@
   }
 
   @Test
+  public void restoreNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+    gApi.changes().id(changeId).abandon();
+    setApiUser(user);
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.ABANDONED);
+    exception.expect(AuthException.class);
+    exception.expectMessage("restore not permitted");
+    gApi.changes().id(changeId).restore();
+  }
+
+  @Test
   public void revert() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
@@ -456,11 +629,10 @@
     PushOneCommit.Result changeResult =
         pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
     String changeId = changeResult.getChangeId();
-    Change.Id id = changeResult.getChange().getId();
 
     setApiUser(user);
     exception.expect(AuthException.class);
-    exception.expectMessage(String.format("Deleting change %s is not permitted", id));
+    exception.expectMessage("delete not permitted");
     gApi.changes().id(changeId).delete();
   }
 
@@ -473,11 +645,16 @@
       PushOneCommit.Result changeResult =
           pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
       String changeId = changeResult.getChangeId();
+      int id = changeResult.getChange().getId().id;
+      RevCommit commit = changeResult.getCommit();
 
       setApiUser(user);
       gApi.changes().id(changeId).delete();
 
       assertThat(query(changeId)).isEmpty();
+
+      String ref = new Change.Id(id).toRefPrefix() + "1";
+      eventRecorder.assertRefUpdatedEvents(project.get(), ref, null, commit, commit, null);
     } finally {
       removePermission(Permission.DELETE_OWN_CHANGES, project, "refs/*");
     }
@@ -504,11 +681,10 @@
     try {
       PushOneCommit.Result changeResult = createChange();
       String changeId = changeResult.getChangeId();
-      Change.Id id = changeResult.getChange().getId();
 
       setApiUser(user);
       exception.expect(AuthException.class);
-      exception.expectMessage(String.format("Deleting change %s is not permitted", id));
+      exception.expectMessage("delete not permitted");
       gApi.changes().id(changeId).delete();
     } finally {
       removePermission(Permission.DELETE_OWN_CHANGES, project, "refs/*");
@@ -532,13 +708,12 @@
     PushOneCommit.Result changeResult =
         pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
     String changeId = changeResult.getChangeId();
-    Change.Id id = changeResult.getChange().getId();
 
     setApiUser(user);
     gApi.changes().id(changeId).abandon();
 
     exception.expect(AuthException.class);
-    exception.expectMessage(String.format("Deleting change %s is not permitted", id));
+    exception.expectMessage("delete not permitted");
     gApi.changes().id(changeId).delete();
   }
 
@@ -560,12 +735,11 @@
   public void deleteMergedChange() throws Exception {
     PushOneCommit.Result changeResult = createChange();
     String changeId = changeResult.getChangeId();
-    Change.Id id = changeResult.getChange().getId();
 
     merge(changeResult);
 
     exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage(String.format("Deleting merged change %s is not allowed", id));
+    exception.expectMessage("delete not permitted");
     gApi.changes().id(changeId).delete();
   }
 
@@ -578,13 +752,12 @@
       PushOneCommit.Result changeResult =
           pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
       String changeId = changeResult.getChangeId();
-      Change.Id id = changeResult.getChange().getId();
 
       merge(changeResult);
 
       setApiUser(user);
       exception.expect(MethodNotAllowedException.class);
-      exception.expectMessage(String.format("Deleting merged change %s is not allowed", id));
+      exception.expectMessage("delete not permitted");
       gApi.changes().id(changeId).delete();
     } finally {
       removePermission(Permission.DELETE_OWN_CHANGES, project, "refs/*");
@@ -961,23 +1134,52 @@
     setApiUser(admin);
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = user.email;
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Change not visible to " + user.email);
-    gApi.changes().id(result.getChangeId()).addReviewer(in);
+    AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+
+    assertThat(r.input).isEqualTo(user.email);
+    assertThat(r.error).contains("does not have permission to see this change");
+    assertThat(r.reviewers).isNull();
   }
 
   @Test
   public void addReviewerThatIsInactive() throws Exception {
-    PushOneCommit.Result r = createChange();
+    PushOneCommit.Result result = createChange();
 
     String username = name("new-user");
     gApi.accounts().create(username).setActive(false);
 
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = username;
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Account of " + username + " is inactive.");
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
+    AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+
+    assertThat(r.input).isEqualTo(username);
+    assertThat(r.error).contains("identifies an inactive account");
+    assertThat(r.reviewers).isNull();
+  }
+
+  @Test
+  public void addReviewerThatIsInactiveEmailFallback() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+
+    PushOneCommit.Result result = createChange();
+
+    String username = "user@domain.com";
+    gApi.accounts().create(username).setActive(false);
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = username;
+    in.state = ReviewerState.CC;
+    AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+
+    assertThat(r.input).isEqualTo(username);
+    assertThat(r.error).isNull();
+    // When adding by email, the reviewers field is also empty because we can't
+    // render a ReviewerInfo object for a non-account.
+    assertThat(r.reviewers).isNull();
   }
 
   @Test
@@ -1603,7 +1805,7 @@
             Iterables.getOnlyElement(query("project:{" + project.get() + "} owner:self")).changeId)
         .isEqualTo(r.getChangeId());
     setApiUser(user);
-    assertThat(query("owner:self")).isEmpty();
+    assertThat(query("owner:self project:{" + project.get() + "}")).isEmpty();
   }
 
   @Test
@@ -1631,6 +1833,26 @@
   }
 
   @Test
+  public void editTopicWithoutPermissionNotAllowed() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("");
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("edit topic name not permitted");
+    gApi.changes().id(r.getChangeId()).topic("mytopic");
+  }
+
+  @Test
+  public void editTopicWithPermissionAllowed() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("");
+    grant(Permission.EDIT_TOPIC_NAME, project, "refs/heads/master", false, REGISTERED_USERS);
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).topic("mytopic");
+    assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("mytopic");
+  }
+
+  @Test
   public void submitted() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
@@ -1657,6 +1879,26 @@
   }
 
   @Test
+  public void submitNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("submit not permitted");
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+  }
+
+  @Test
+  public void submitAllowedWithPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    grant(Permission.SUBMIT, project, "refs/heads/master", false, REGISTERED_USERS);
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+    assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
   public void check() throws Exception {
     // TODO(dborowitz): Re-enable when ConsistencyChecker supports NoteDb.
     assume().that(notesMigration.enabled()).isFalse();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/ProjectIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/ProjectIT.java
index e30e9b3..8688409 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -25,7 +25,10 @@
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.DescriptionInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -106,7 +109,7 @@
   }
 
   @Test
-  public void description() throws Exception {
+  public void descriptionChangeCausesRefUpdate() throws Exception {
     RevCommit initialHead = getRemoteHead(project, RefNames.REFS_CONFIG);
     assertThat(gApi.projects().name(project.get()).description()).isEmpty();
     DescriptionInput in = new DescriptionInput();
@@ -120,7 +123,19 @@
   }
 
   @Test
-  public void config() throws Exception {
+  public void descriptionIsDeletedWhenNotSpecified() throws Exception {
+    assertThat(gApi.projects().name(project.get()).description()).isEmpty();
+    DescriptionInput in = new DescriptionInput();
+    in.description = "new project description";
+    gApi.projects().name(project.get()).description(in);
+    assertThat(gApi.projects().name(project.get()).description()).isEqualTo(in.description);
+    in.description = null;
+    gApi.projects().name(project.get()).description(in);
+    assertThat(gApi.projects().name(project.get()).description()).isEmpty();
+  }
+
+  @Test
+  public void configChangeCausesRefUpdate() throws Exception {
     RevCommit initialHead = getRemoteHead(project, RefNames.REFS_CONFIG);
 
     ConfigInfo info = gApi.projects().name(project.get()).config();
@@ -136,4 +151,77 @@
     eventRecorder.assertRefUpdatedEvents(
         project.get(), RefNames.REFS_CONFIG, initialHead, updatedHead);
   }
+
+  @Test
+  public void setConfig() throws Exception {
+    ConfigInput input = createTestConfigInput();
+    ConfigInfo info = gApi.projects().name(project.get()).config(input);
+    assertThat(info.description).isEqualTo(input.description);
+    assertThat(info.useContributorAgreements.configuredValue)
+        .isEqualTo(input.useContributorAgreements);
+    assertThat(info.useContentMerge.configuredValue).isEqualTo(input.useContentMerge);
+    assertThat(info.useSignedOffBy.configuredValue).isEqualTo(input.useSignedOffBy);
+    assertThat(info.createNewChangeForAllNotInTarget.configuredValue)
+        .isEqualTo(input.createNewChangeForAllNotInTarget);
+    assertThat(info.requireChangeId.configuredValue).isEqualTo(input.requireChangeId);
+    assertThat(info.rejectImplicitMerges.configuredValue).isEqualTo(input.rejectImplicitMerges);
+    assertThat(info.enableReviewerByEmail.configuredValue).isEqualTo(input.enableReviewerByEmail);
+    assertThat(info.createNewChangeForAllNotInTarget.configuredValue)
+        .isEqualTo(input.createNewChangeForAllNotInTarget);
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo(input.maxObjectSizeLimit);
+    assertThat(info.submitType).isEqualTo(input.submitType);
+    assertThat(info.state).isEqualTo(input.state);
+  }
+
+  @Test
+  public void setPartialConfig() throws Exception {
+    ConfigInput input = createTestConfigInput();
+    ConfigInfo info = gApi.projects().name(project.get()).config(input);
+
+    ConfigInput partialInput = new ConfigInput();
+    partialInput.useContributorAgreements = InheritableBoolean.FALSE;
+    info = gApi.projects().name(project.get()).config(partialInput);
+
+    assertThat(info.description).isNull();
+    assertThat(info.useContributorAgreements.configuredValue)
+        .isEqualTo(partialInput.useContributorAgreements);
+    assertThat(info.useContentMerge.configuredValue).isEqualTo(input.useContentMerge);
+    assertThat(info.useSignedOffBy.configuredValue).isEqualTo(input.useSignedOffBy);
+    assertThat(info.createNewChangeForAllNotInTarget.configuredValue)
+        .isEqualTo(input.createNewChangeForAllNotInTarget);
+    assertThat(info.requireChangeId.configuredValue).isEqualTo(input.requireChangeId);
+    assertThat(info.rejectImplicitMerges.configuredValue).isEqualTo(input.rejectImplicitMerges);
+    assertThat(info.enableReviewerByEmail.configuredValue).isEqualTo(input.enableReviewerByEmail);
+    assertThat(info.createNewChangeForAllNotInTarget.configuredValue)
+        .isEqualTo(input.createNewChangeForAllNotInTarget);
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo(input.maxObjectSizeLimit);
+    assertThat(info.submitType).isEqualTo(input.submitType);
+    assertThat(info.state).isEqualTo(input.state);
+  }
+
+  @Test
+  public void nonOwnerCannotSetConfig() throws Exception {
+    ConfigInput input = createTestConfigInput();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("restricted to project owner");
+    gApi.projects().name(project.get()).config(input);
+  }
+
+  private ConfigInput createTestConfigInput() {
+    ConfigInput input = new ConfigInput();
+    input.description = "some description";
+    input.useContributorAgreements = InheritableBoolean.TRUE;
+    input.useContentMerge = InheritableBoolean.TRUE;
+    input.useSignedOffBy = InheritableBoolean.TRUE;
+    input.createNewChangeForAllNotInTarget = InheritableBoolean.TRUE;
+    input.requireChangeId = InheritableBoolean.TRUE;
+    input.rejectImplicitMerges = InheritableBoolean.TRUE;
+    input.enableReviewerByEmail = InheritableBoolean.TRUE;
+    input.createNewChangeForAllNotInTarget = InheritableBoolean.TRUE;
+    input.maxObjectSizeLimit = "5m";
+    input.submitType = SubmitType.CHERRY_PICK;
+    input.state = ProjectState.HIDDEN;
+    return input;
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 577634e..3a535ba 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -24,6 +24,7 @@
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
 import static com.google.gerrit.reviewdb.client.Patch.MERGE_LIST;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 import static org.junit.Assert.fail;
@@ -38,6 +39,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.DraftApi;
@@ -59,6 +61,7 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.MergeableInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ETagView;
@@ -282,6 +285,15 @@
   }
 
   @Test
+  public void voteNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("is restricted");
+    gApi.changes().id(r.getChange().getId().get()).current().review(ReviewInput.approve());
+  }
+
+  @Test
   public void deleteDraft() throws Exception {
     PushOneCommit.Result r = createDraft();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).delete();
@@ -326,6 +338,28 @@
   }
 
   @Test
+  public void cherryPickSetChangeId() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    String id = "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbe3f";
+    in.message = "it goes to foo branch\n\nChange-Id: " + id;
+
+    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
+    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
+
+    assertThat(orig.get().messages).hasSize(1);
+    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
+
+    ChangeInfo changeInfo = cherry.get();
+
+    // The cherry-pick honors the ChangeId specified in the input message:
+    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(revInfo).isNotNull();
+    assertThat(revInfo.commit.message).endsWith(id + "\n");
+  }
+
+  @Test
   public void cherryPickwithNoTopic() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master");
     CherryPickInput in = new CherryPickInput();
@@ -733,14 +767,36 @@
   @Test
   public void description() throws Exception {
     PushOneCommit.Result r = createChange();
-    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description())
-        .isEqualTo("");
+    assertDescription(r, "");
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("test");
-    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description())
-        .isEqualTo("test");
+    assertDescription(r, "test");
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("");
+    assertDescription(r, "");
+  }
+
+  @Test
+  public void setDescriptionNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertDescription(r, "");
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("edit description not permitted");
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("test");
+  }
+
+  @Test
+  public void setDescriptionAllowedWithPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertDescription(r, "");
+    grant(Permission.OWNER, project, "refs/heads/master", false, REGISTERED_USERS);
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("test");
+    assertDescription(r, "test");
+  }
+
+  private void assertDescription(PushOneCommit.Result r, String expected) throws Exception {
     assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description())
-        .isEqualTo("");
+        .isEqualTo(expected);
   }
 
   @Test
@@ -940,9 +996,9 @@
     recommend(r.getChangeId());
 
     // check if it's blocked to delete a vote on a non-current patch set.
+    setApiUser(admin);
     exception.expect(MethodNotAllowedException.class);
     exception.expectMessage("Cannot access on non-current patch set");
-    setApiUser(admin);
     gApi.changes()
         .id(r.getChangeId())
         .revision(r.getCommit().getName())
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
index 11df473..e525e96 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -16,34 +16,51 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.extensions.common.EditInfoSubject.assertThat;
 import static com.google.gerrit.extensions.common.RobotCommentInfoSubject.assertThatList;
+import static java.util.stream.Collectors.toList;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.FixReplacementInfo;
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.BinaryResultSubject;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import org.hamcrest.core.StringContains;
+import java.util.Objects;
+import java.util.Optional;
 import org.junit.Before;
 import org.junit.Test;
 
 public class RobotCommentsIT extends AbstractDaemonTest {
+  private static final String FILE_NAME = "file_to_fix.txt";
+  private static final String FILE_NAME2 = "another_file_to_fix.txt";
+  private static final String FILE_CONTENT =
+      "First line\nSecond line\nThird line\nFourth line\nFifth line\nSixth line"
+          + "\nSeventh line\nEighth line\nNinth line\nTenth line\n";
+  private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
+
   private String changeId;
   private FixReplacementInfo fixReplacementInfo;
   private FixSuggestionInfo fixSuggestionInfo;
@@ -51,7 +68,14 @@
 
   @Before
   public void setUp() throws Exception {
-    PushOneCommit.Result changeResult = createChange();
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            "Provide files which can be used for fixes",
+            ImmutableMap.of(FILE_NAME, FILE_CONTENT, FILE_NAME2, FILE_CONTENT2));
+    PushOneCommit.Result changeResult = push.to("refs/for/master");
     changeId = changeResult.getChangeId();
 
     fixReplacementInfo = createFixReplacementInfo();
@@ -151,6 +175,75 @@
   }
 
   @Test
+  public void hugeRobotCommentIsRejected() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    int defaultSizeLimit = 1024 * 1024;
+    int sizeOfRest = 451;
+    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit - sizeOfRest + 1);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("limit");
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  public void reasonablyLargeRobotCommentIsAccepted() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    int defaultSizeLimit = 1024 * 1024;
+    int sizeOfRest = 451;
+    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit - sizeOfRest);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    assertThat(robotCommentInfos).hasSize(1);
+  }
+
+  @Test
+  @GerritConfig(name = "change.robotCommentSizeLimit", value = "10k")
+  public void maximumAllowedSizeOfRobotCommentCanBeAdjusted() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    int sizeLimit = 10 * 1024;
+    fixReplacementInfo.replacement = getStringFor(sizeLimit);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("limit");
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  @GerritConfig(name = "change.robotCommentSizeLimit", value = "0")
+  public void zeroForMaximumAllowedSizeOfRobotCommentRemovesRestriction() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    int defaultSizeLimit = 1024 * 1024;
+    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    assertThat(robotCommentInfos).hasSize(1);
+  }
+
+  @Test
+  @GerritConfig(name = "change.robotCommentSizeLimit", value = "-1")
+  public void negativeValueForMaximumAllowedSizeOfRobotCommentRemovesRestriction()
+      throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    int defaultSizeLimit = 1024 * 1024;
+    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    assertThat(robotCommentInfos).hasSize(1);
+  }
+
+  @Test
   public void addedFixSuggestionCanBeRetrieved() throws Exception {
     assume().that(notesMigration.enabled()).isTrue();
 
@@ -263,21 +356,6 @@
   }
 
   @Test
-  public void pathOfFixReplacementMustReferToFileOfComment() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
-
-    fixReplacementInfo.path = "anotherFile.txt";
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        String.format(
-            "Replacements may only be specified "
-                + "for the file %s on which the robot comment was added",
-            withFixRobotCommentInput.path));
-    addRobotComment(changeId, withFixRobotCommentInput);
-  }
-
-  @Test
   public void rangeOfFixReplacementIsAcceptedAsIs() throws Exception {
     assume().that(notesMigration.enabled()).isTrue();
 
@@ -313,11 +391,115 @@
 
     fixReplacementInfo.range = createRange(13, 9, 5, 10);
     exception.expect(BadRequestException.class);
-    exception.expectMessage(new StringContains("Range (13:9 - 5:10)"));
+    exception.expectMessage("Range (13:9 - 5:10)");
     addRobotComment(changeId, withFixRobotCommentInput);
   }
 
   @Test
+  public void rangesOfFixReplacementsOfSameFixSuggestionForSameFileMayNotOverlap()
+      throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Second modification\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("overlap");
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  public void rangesOfFixReplacementsOfSameFixSuggestionForDifferentFileMayOverlap()
+      throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME2;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Second modification\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    assertThatList(robotCommentInfos).onlyElement().fixSuggestions().hasSize(1);
+  }
+
+  @Test
+  public void rangesOfFixReplacementsOfDifferentFixSuggestionsForSameFileMayOverlap()
+      throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Second modification\n";
+    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+    withFixRobotCommentInput.fixSuggestions =
+        ImmutableList.of(fixSuggestionInfo1, fixSuggestionInfo2);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    assertThatList(robotCommentInfos).onlyElement().fixSuggestions().hasSize(2);
+  }
+
+  @Test
+  public void fixReplacementsDoNotNeedToBeOrderedAccordingToRange() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Second modification\n";
+
+    FixReplacementInfo fixReplacementInfo3 = new FixReplacementInfo();
+    fixReplacementInfo3.path = FILE_NAME;
+    fixReplacementInfo3.range = createRange(4, 0, 5, 0);
+    fixReplacementInfo3.replacement = "Third modification\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo2, fixReplacementInfo1, fixReplacementInfo3);
+    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    assertThatList(robotCommentInfos).onlyElement().onlyFixSuggestion().replacements().hasSize(3);
+  }
+
+  @Test
   public void replacementStringOfFixReplacementIsAcceptedAsIs() throws Exception {
     assume().that(notesMigration.enabled()).isTrue();
 
@@ -349,13 +531,490 @@
   }
 
   @Test
+  public void fixWithinALineCanBeApplied() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nSecond line\nTModified contentrd line\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void fixSpanningMultipleLinesCanBeApplied() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content\n5";
+    fixReplacementInfo.range = createRange(3, 2, 5, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nSecond line\nThModified content\n5th line\nSixth line\nSeventh line\n"
+                + "Eighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void fixWithTwoCloseReplacementsOnSameFileCanBeApplied() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nSome other modified content\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void twoFixesOnSameFileCanBeApplied() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(8, 0, 9, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+    RobotCommentInput robotCommentInput1 = createRobotCommentInput(fixSuggestionInfo1);
+    RobotCommentInput robotCommentInput2 = createRobotCommentInput(fixSuggestionInfo2);
+    addRobotComment(changeId, robotCommentInput1);
+    addRobotComment(changeId, robotCommentInput2);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(1));
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
+                + "Seventh line\nSome other modified content\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void twoConflictingFixesOnSameFileCannotBeApplied() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+    RobotCommentInput robotCommentInput1 = createRobotCommentInput(fixSuggestionInfo1);
+    RobotCommentInput robotCommentInput2 = createRobotCommentInput(fixSuggestionInfo2);
+    addRobotComment(changeId, robotCommentInput1);
+    addRobotComment(changeId, robotCommentInput2);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("merge");
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(1));
+  }
+
+  @Test
+  public void twoFixesOfSameRobotCommentCanBeApplied() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(8, 0, 9, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+    withFixRobotCommentInput.fixSuggestions =
+        ImmutableList.of(fixSuggestionInfo1, fixSuggestionInfo2);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(1));
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
+                + "Seventh line\nSome other modified content\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void fixReferringToDifferentFileThanRobotCommentCanBeApplied() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME2;
+    fixReplacementInfo.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo.replacement = "Modified content\n";
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME2);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo("1st line\nModified content\n3rd line\n");
+  }
+
+  @Test
+  public void fixInvolvingTwoFilesCanBeApplied() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME2;
+    fixReplacementInfo2.range = createRange(1, 0, 2, 0);
+    fixReplacementInfo2.replacement = "Different file modification\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
+                + "Seventh line\nEighth line\nNinth line\nTenth line\n");
+    Optional<BinaryResult> file2 = gApi.changes().id(changeId).edit().getFile(FILE_NAME2);
+    BinaryResultSubject.assertThat(file2)
+        .value()
+        .asString()
+        .isEqualTo("Different file modification\n2nd line\n3rd line\n");
+  }
+
+  @Test
+  public void fixReferringToNonExistentFileCannotBeApplied() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    fixReplacementInfo.path = "a_non_existent_file.txt";
+    fixReplacementInfo.range = createRange(1, 0, 2, 0);
+    fixReplacementInfo.replacement = "Modified content\n";
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    exception.expect(ResourceNotFoundException.class);
+    gApi.changes().id(changeId).current().applyFix(fixId);
+  }
+
+  @Test
+  public void fixOnPreviousPatchSetWithoutChangeEditCannotBeApplied() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    // Remember patch set and add another one.
+    String previousRevision = gApi.changes().id(changeId).get().currentRevision;
+    amendChange(changeId);
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("current");
+    gApi.changes().id(changeId).revision(previousRevision).applyFix(fixId);
+  }
+
+  @Test
+  public void fixOnPreviousPatchSetWithExistingChangeEditCanBeApplied() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    // Create an empty change edit.
+    gApi.changes().id(changeId).edit().create();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    // Remember patch set and add another one.
+    String previousRevision = gApi.changes().id(changeId).get().currentRevision;
+    amendChange(changeId);
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    EditInfo editInfo = gApi.changes().id(changeId).revision(previousRevision).applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nSecond line\nTModified contentrd line\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+    assertThat(editInfo).baseRevision().isEqualTo(previousRevision);
+  }
+
+  @Test
+  public void fixOnCurrentPatchSetWithChangeEditOnPreviousPatchSetCannotBeApplied()
+      throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    // Create an empty change edit.
+    gApi.changes().id(changeId).edit().create();
+
+    // Add another patch set.
+    amendChange(changeId);
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("based");
+    gApi.changes().id(changeId).current().applyFix(fixId);
+  }
+
+  @Test
+  public void fixDoesNotModifyCommitMessageOfChangeEdit() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    String changeEditCommitMessage = "This is the commit message of the change edit.\n";
+    gApi.changes().id(changeId).edit().modifyCommitMessage(changeEditCommitMessage);
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage).isEqualTo(changeEditCommitMessage);
+  }
+
+  @Test
+  public void applyingFixTwiceIsIdempotent() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+    String expectedEditCommit =
+        gApi.changes().id(changeId).edit().get().map(edit -> edit.commit.commit).orElse("");
+
+    // Apply the fix again.
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<EditInfo> editInfo = gApi.changes().id(changeId).edit().get();
+    assertThat(editInfo).value().commit().commit().isEqualTo(expectedEditCommit);
+  }
+
+  @Test
+  public void nonExistentFixCannotBeApplied() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+    String nonExistentFixId = fixId + "_non-existent";
+
+    exception.expect(ResourceNotFoundException.class);
+    gApi.changes().id(changeId).current().applyFix(nonExistentFixId);
+  }
+
+  @Test
+  public void applyingFixReturnsEditInfoForCreatedChangeEdit() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    EditInfo editInfo = gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<EditInfo> expectedEditInfo = gApi.changes().id(changeId).edit().get();
+    String expectedEditCommit = expectedEditInfo.map(edit -> edit.commit.commit).orElse("");
+    assertThat(editInfo).commit().commit().isEqualTo(expectedEditCommit);
+    String expectedBaseRevision = expectedEditInfo.map(edit -> edit.baseRevision).orElse("");
+    assertThat(editInfo).baseRevision().isEqualTo(expectedBaseRevision);
+  }
+
+  @Test
+  public void applyingFixOnTopOfChangeEditReturnsEditInfoForUpdatedChangeEdit() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    gApi.changes().id(changeId).edit().create();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    EditInfo editInfo = gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<EditInfo> expectedEditInfo = gApi.changes().id(changeId).edit().get();
+    String expectedEditCommit = expectedEditInfo.map(edit -> edit.commit.commit).orElse("");
+    assertThat(editInfo).commit().commit().isEqualTo(expectedEditCommit);
+    String expectedBaseRevision = expectedEditInfo.map(edit -> edit.baseRevision).orElse("");
+    assertThat(editInfo).baseRevision().isEqualTo(expectedBaseRevision);
+  }
+
+  @Test
+  public void createdChangeEditIsBasedOnCurrentPatchSet() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+    String currentRevision = gApi.changes().id(changeId).get().currentRevision;
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    EditInfo editInfo = gApi.changes().id(changeId).current().applyFix(fixId);
+
+    assertThat(editInfo).baseRevision().isEqualTo(currentRevision);
+  }
+
+  @Test
   public void robotCommentsNotSupportedWithoutNoteDb() throws Exception {
     assume().that(notesMigration.enabled()).isFalse();
 
     RobotCommentInput in = createRobotCommentInput();
     ReviewInput reviewInput = new ReviewInput();
     Map<String, List<RobotCommentInput>> robotComments = new HashMap<>();
-    robotComments.put(FILE_NAME, Collections.singletonList(in));
+    robotComments.put(in.path, ImmutableList.of(in));
     reviewInput.robotComments = robotComments;
     reviewInput.message = "comment test";
 
@@ -389,7 +1048,7 @@
     }
   }
 
-  private RobotCommentInput createRobotCommentInputWithMandatoryFields() {
+  private static RobotCommentInput createRobotCommentInputWithMandatoryFields() {
     RobotCommentInput in = new RobotCommentInput();
     in.robotId = "happyRobot";
     in.robotRunId = "1";
@@ -399,7 +1058,8 @@
     return in;
   }
 
-  private RobotCommentInput createRobotCommentInput(FixSuggestionInfo... fixSuggestionInfos) {
+  private static RobotCommentInput createRobotCommentInput(
+      FixSuggestionInfo... fixSuggestionInfos) {
     RobotCommentInput in = createRobotCommentInputWithMandatoryFields();
     in.url = "http://www.happy-robot.com";
     in.properties = new HashMap<>();
@@ -409,7 +1069,8 @@
     return in;
   }
 
-  private FixSuggestionInfo createFixSuggestionInfo(FixReplacementInfo... fixReplacementInfos) {
+  private static FixSuggestionInfo createFixSuggestionInfo(
+      FixReplacementInfo... fixReplacementInfos) {
     FixSuggestionInfo newFixSuggestionInfo = new FixSuggestionInfo();
     newFixSuggestionInfo.fixId = "An ID which must be overwritten.";
     newFixSuggestionInfo.description = "A description for a suggested fix.";
@@ -417,15 +1078,15 @@
     return newFixSuggestionInfo;
   }
 
-  private FixReplacementInfo createFixReplacementInfo() {
+  private static FixReplacementInfo createFixReplacementInfo() {
     FixReplacementInfo newFixReplacementInfo = new FixReplacementInfo();
     newFixReplacementInfo.path = FILE_NAME;
     newFixReplacementInfo.replacement = "some replacement code";
-    newFixReplacementInfo.range = createRange(3, 12, 15, 4);
+    newFixReplacementInfo.range = createRange(3, 9, 8, 4);
     return newFixReplacementInfo;
   }
 
-  private Comment.Range createRange(
+  private static Comment.Range createRange(
       int startLine, int startCharacter, int endLine, int endCharacter) {
     Comment.Range range = new Comment.Range();
     range.startLine = startLine;
@@ -439,8 +1100,7 @@
       throws Exception {
     ReviewInput reviewInput = new ReviewInput();
     reviewInput.robotComments =
-        Collections.singletonMap(
-            robotCommentInput.path, Collections.singletonList(robotCommentInput));
+        Collections.singletonMap(robotCommentInput.path, ImmutableList.of(robotCommentInput));
     reviewInput.message = "robot comment test";
     gApi.changes().id(targetChangeId).current().review(reviewInput);
   }
@@ -470,4 +1130,22 @@
       assertThat(c.path).isNull();
     }
   }
+
+  private static String getStringFor(int numberOfBytes) {
+    char[] chars = new char[numberOfBytes];
+    // 'a' will require one byte even when mapped to a JSON string
+    Arrays.fill(chars, 'a');
+    return new String(chars);
+  }
+
+  private static List<String> getFixIds(List<RobotCommentInfo> robotComments) {
+    assertThatList(robotComments).isNotNull();
+    return robotComments
+        .stream()
+        .map(robotCommentInfo -> robotCommentInfo.fixSuggestions)
+        .filter(Objects::nonNull)
+        .flatMap(List::stream)
+        .map(fixSuggestionInfo -> fixSuggestionInfo.fixId)
+        .collect(toList());
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 31ca9df..0a03125 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -376,6 +376,70 @@
   }
 
   @Test
+  public void pushPrivateChange() throws Exception {
+    // Push a private change.
+    PushOneCommit.Result r = pushTo("refs/for/master%private");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isPrivate()).isTrue();
+
+    // Pushing a new patch set without --private doesn't remove the privacy flag from the change.
+    r = amendChange(r.getChangeId(), "refs/for/master");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isPrivate()).isTrue();
+
+    // Remove the privacy flag from the change.
+    r = amendChange(r.getChangeId(), "refs/for/master%remove-private");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isPrivate()).isFalse();
+
+    // Normal push: privacy flag is not added back.
+    r = amendChange(r.getChangeId(), "refs/for/master");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isPrivate()).isFalse();
+
+    // Make the change private again.
+    r = pushTo("refs/for/master%private");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isPrivate()).isTrue();
+
+    // Can't use --private and --remove-private together.
+    r = pushTo("refs/for/master%private,remove-private");
+    r.assertErrorStatus();
+  }
+
+  @Test
+  public void pushWorkInProgressChange() throws Exception {
+    // Push a work-in-progress change.
+    PushOneCommit.Result r = pushTo("refs/for/master%wip");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
+
+    // Pushing a new patch set without --wip doesn't remove the wip flag from the change.
+    r = amendChange(r.getChangeId(), "refs/for/master");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
+
+    // Remove the wip flag from the change.
+    r = amendChange(r.getChangeId(), "refs/for/master%ready");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isWorkInProgress()).isFalse();
+
+    // Normal push: wip flag is not added back.
+    r = amendChange(r.getChangeId(), "refs/for/master");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isWorkInProgress()).isFalse();
+
+    // Make the change work-in-progress again.
+    r = pushTo("refs/for/master%wip");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
+
+    // Can't use --wip and --ready together.
+    r = pushTo("refs/for/master%wip,ready");
+    r.assertErrorStatus();
+  }
+
+  @Test
   public void pushForMasterAsDraft() throws Exception {
     // create draft by pushing to 'refs/drafts/'
     PushOneCommit.Result r = pushTo("refs/drafts/master");
@@ -864,7 +928,14 @@
 
     PushResult pr =
         GitUtil.pushHead(testRepo, "refs/for/foo%base=" + rBase.getCommit().name(), false, false);
-    assertThat(pr.getMessages()).contains("changes: new: 1, refs: 1, done");
+
+    if (notesMigration.fuseUpdates()) {
+      // InMemoryRepository's atomic BatchRefUpdate implementation doesn't update the progress
+      // monitor. That's fine, we just care that there was at least one new change and no errors.
+      assertThat(pr.getMessages()).contains("changes: new: 1, done");
+    } else {
+      assertThat(pr.getMessages()).contains("changes: new: 1, refs: 1, done");
+    }
 
     assertTwoChangesWithSameRevision(r);
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index b900cc7..2a4c188 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -18,6 +18,7 @@
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.util.stream.Collectors.toList;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
@@ -54,6 +55,8 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.LsRemoteCommand;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -237,7 +240,6 @@
   @Test
   public void uploadPackSubsetOfBranchesVisibleWithEdit() throws Exception {
     allow(Permission.READ, REGISTERED_USERS, "refs/heads/master");
-    deny(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
 
     Change c = notesFactory.createChecked(db, project, c1.getId()).getChange();
     String changeId = c.getKey().get();
@@ -262,6 +264,34 @@
   }
 
   @Test
+  public void uploadPackSubsetOfBranchesVisibleWithEditForOtherUser() throws Exception {
+    allow(Permission.READ, REGISTERED_USERS, "refs/heads/master");
+    allow(Permission.VIEW_PRIVATE_CHANGES, REGISTERED_USERS, "refs/*");
+
+    Change c = notesFactory.createChecked(db, project, c1.getId()).getChange();
+    String changeId = c.getKey().get();
+
+    // Admin's edit is visible.
+    setApiUser(admin);
+    gApi.changes().id(changeId).edit().create();
+
+    // User's edit is visible.
+    setApiUser(user);
+    gApi.changes().id(changeId).edit().create();
+
+    assertUploadPackRefs(
+        "HEAD",
+        r1 + "1",
+        r1 + "meta",
+        r3 + "1",
+        r3 + "meta",
+        "refs/heads/master",
+        "refs/tags/master-tag",
+        "refs/users/00/1000000/edit-" + c1.getId() + "/1",
+        "refs/users/01/1000001/edit-" + c1.getId() + "/1");
+  }
+
+  @Test
   public void uploadPackSubsetOfRefsVisibleWithAccessDatabase() throws Exception {
     allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
     try {
@@ -470,6 +500,46 @@
     assertThat(getReceivePackRefs().additionalHaves()).containsExactly(obj(c4, 1));
   }
 
+  @Test
+  public void advertisedReferencesOmitPrivateChangesOfOtherUsers() throws Exception {
+    allow(Permission.READ, REGISTERED_USERS, "refs/heads/master");
+
+    TestRepository<?> userTestRepository = cloneProject(project, user);
+    try (Git git = userTestRepository.git()) {
+      LsRemoteCommand lsRemoteCommand = git.lsRemote();
+      String change3RefName = c3.currentPatchSet().getRefName();
+
+      List<String> initialRefNames =
+          lsRemoteCommand.call().stream().map(Ref::getName).collect(toList());
+      assertWithMessage("Precondition violated").that(initialRefNames).contains(change3RefName);
+
+      gApi.changes().id(c3.getId().get()).setPrivate(true);
+
+      List<String> refNames = lsRemoteCommand.call().stream().map(Ref::getName).collect(toList());
+      assertThat(refNames).doesNotContain(change3RefName);
+    }
+  }
+
+  @Test
+  public void advertisedReferencesIncludePrivateChangesWhenAllRefsMayBeRead() throws Exception {
+    allow(Permission.READ, REGISTERED_USERS, "refs/*");
+
+    TestRepository<?> userTestRepository = cloneProject(project, user);
+    try (Git git = userTestRepository.git()) {
+      LsRemoteCommand lsRemoteCommand = git.lsRemote();
+      String change3RefName = c3.currentPatchSet().getRefName();
+
+      List<String> initialRefNames =
+          lsRemoteCommand.call().stream().map(Ref::getName).collect(toList());
+      assertWithMessage("Precondition violated").that(initialRefNames).contains(change3RefName);
+
+      gApi.changes().id(c3.getId().get()).setPrivate(true);
+
+      List<String> refNames = lsRemoteCommand.call().stream().map(Ref::getName).collect(toList());
+      assertThat(refNames).contains(change3RefName);
+    }
+  }
+
   /**
    * Assert that refs seen by a non-admin user match expected.
    *
@@ -500,7 +570,7 @@
       throws Exception {
     List<String> expected = new ArrayList<>(expectedWithMeta.length);
     for (String r : expectedWithMeta) {
-      if (notesMigration.writeChanges() || !r.endsWith(RefNames.META_SUFFIX)) {
+      if (notesMigration.commitChangeWrites() || !r.endsWith(RefNames.META_SUFFIX)) {
         expected.add(r);
       }
     }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/CheckAccessIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/CheckAccessIT.java
new file mode 100644
index 0000000..e7b5cc0
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/CheckAccessIT.java
@@ -0,0 +1,140 @@
+// Copyright (C) 2017 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.acceptance.rest.account;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.config.AccessCheckInfo;
+import com.google.gerrit.extensions.api.config.AccessCheckInput;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import java.util.List;
+import java.util.Map;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CheckAccessIT extends AbstractDaemonTest {
+
+  private Project.NameKey normalProject;
+  private Project.NameKey secretProject;
+  private Project.NameKey secretRefProject;
+  private TestAccount privilegedUser;
+  private AccountGroup privilegedGroup;
+
+  @Before
+  public void setUp() throws Exception {
+    normalProject = createProject("normal");
+    secretProject = createProject("secret");
+    secretRefProject = createProject("secretRef");
+    privilegedGroup = groupCache.get(new AccountGroup.NameKey(createGroup("privilegedGroup")));
+
+    privilegedUser = accounts.create("privilegedUser", "snowden@nsa.gov", "Ed Snowden");
+    gApi.groups().id(privilegedGroup.getGroupUUID().get()).addMembers(privilegedUser.username);
+
+    assertThat(gApi.groups().id(privilegedGroup.getGroupUUID().get()).members().get(0).email)
+        .contains("snowden");
+
+    // deny(secretProject, Permission.READ, SystemGroupBackend.REGISTERED_USERS, "refs/*");
+    grant(Permission.READ, secretProject, "refs/*", false, privilegedGroup.getGroupUUID());
+    block(Permission.READ, SystemGroupBackend.REGISTERED_USERS, "refs/*", secretProject);
+
+    // deny/grant/block arg ordering is screwy.
+    deny(secretRefProject, Permission.READ, SystemGroupBackend.ANONYMOUS_USERS, "refs/*");
+    grant(
+        Permission.READ,
+        secretRefProject,
+        "refs/heads/secret/*",
+        false,
+        privilegedGroup.getGroupUUID());
+    block(
+        Permission.READ,
+        SystemGroupBackend.REGISTERED_USERS,
+        "refs/heads/secret/*",
+        secretRefProject);
+    grant(
+        Permission.READ,
+        secretRefProject,
+        "refs/heads/*",
+        false,
+        SystemGroupBackend.REGISTERED_USERS);
+  }
+
+  @Test
+  public void invalidInputs() {
+    List<AccessCheckInput> inputs =
+        ImmutableList.of(
+            new AccessCheckInput(),
+            new AccessCheckInput(user.email, null, null),
+            new AccessCheckInput(null, normalProject.toString(), null),
+            new AccessCheckInput("doesnotexist@invalid.com", normalProject.toString(), null));
+    for (AccessCheckInput input : inputs) {
+      try {
+        gApi.config().server().checkAccess(input);
+        fail(String.format("want RestApiException for %s", newGson().toJson(input)));
+      } catch (RestApiException e) {
+
+      }
+    }
+  }
+
+  @Test
+  public void accessible() {
+    Map<AccessCheckInput, Integer> inputs =
+        ImmutableMap.of(
+            new AccessCheckInput(user.email, normalProject.get(), null), 200,
+            new AccessCheckInput(user.email, secretProject.get(), null), 403,
+            new AccessCheckInput(user.email, "nonexistent", null), 404,
+            new AccessCheckInput(privilegedUser.email, normalProject.get(), null), 200,
+            new AccessCheckInput(privilegedUser.email, secretProject.get(), null), 200);
+
+    for (Map.Entry<AccessCheckInput, Integer> entry : inputs.entrySet()) {
+      String in = newGson().toJson(entry.getKey());
+      AccessCheckInfo info = null;
+
+      try {
+        info = gApi.config().server().checkAccess(entry.getKey());
+      } catch (RestApiException e) {
+        fail(String.format("check.check(%s): exception %s", in, e));
+      }
+
+      int want = entry.getValue();
+      if (want != info.status) {
+        fail(String.format("check.access(%s) = %d, want %d", in, info.status, want));
+      }
+
+      switch (want) {
+        case 403:
+          assertThat(info.message).contains("cannot see");
+          break;
+        case 404:
+          assertThat(info.message).contains("does not exist");
+          break;
+        case 200:
+          assertThat(info.message).isNull();
+          break;
+        default:
+          fail(String.format("unknown code %d", want));
+      }
+    }
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index 06b8f68..f6c70b0 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -16,25 +16,40 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 import static org.junit.Assert.fail;
 
 import com.github.rholder.retry.BlockStrategy;
 import com.github.rholder.retry.Retryer;
 import com.github.rholder.retry.RetryerBuilder;
 import com.github.rholder.retry.StopStrategies;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput.CheckAccountExternalIdsInput;
 import com.google.gerrit.extensions.common.AccountExternalIdInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIds;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.account.externalids.DisabledExternalIdCache;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdReader;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate.RefsMetaExternalIdsUpdate;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.LockFailureException;
 import com.google.gson.reflect.TypeToken;
@@ -44,25 +59,35 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Locale;
+import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.api.errors.TransportException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.MutableInteger;
 import org.junit.Test;
 
 @Sandboxed
 public class ExternalIdIT extends AbstractDaemonTest {
   @Inject private AllUsersName allUsers;
-
   @Inject private ExternalIdsUpdate.Server extIdsUpdate;
-
   @Inject private ExternalIds externalIds;
+  @Inject private ExternalIdReader externalIdReader;
+  @Inject private MetricMaker metricMaker;
 
   @Test
-  public void getExternalIDs() throws Exception {
+  public void getExternalIds() throws Exception {
     Collection<ExternalId> expectedIds = accountCache.get(user.getId()).getExternalIds();
 
     List<AccountExternalIdInfo> expectedIdInfos = new ArrayList<>();
@@ -89,7 +114,7 @@
   }
 
   @Test
-  public void deleteExternalIDs() throws Exception {
+  public void deleteExternalIds() throws Exception {
     setApiUser(user);
     List<AccountExternalIdInfo> externalIds = gApi.accounts().self().getExternalIds();
 
@@ -115,7 +140,19 @@
   }
 
   @Test
-  public void deleteExternalIDs_Conflict() throws Exception {
+  public void deleteExternalIdOfPreferredEmail() throws Exception {
+    String preferredEmail = gApi.accounts().self().get().email;
+    assertThat(preferredEmail).isNotNull();
+
+    gApi.accounts()
+        .self()
+        .deleteExternalIds(
+            ImmutableList.of(ExternalId.Key.create(SCHEME_MAILTO, preferredEmail).get()));
+    assertThat(gApi.accounts().self().get().email).isNull();
+  }
+
+  @Test
+  public void deleteExternalIds_Conflict() throws Exception {
     List<String> toDelete = new ArrayList<>();
     String externalIdStr = "username:" + user.username;
     toDelete.add(externalIdStr);
@@ -126,7 +163,7 @@
   }
 
   @Test
-  public void deleteExternalIDs_UnprocessableEntity() throws Exception {
+  public void deleteExternalIds_UnprocessableEntity() throws Exception {
     List<String> toDelete = new ArrayList<>();
     String externalIdStr = "mailto:user@domain.com";
     toDelete.add(externalIdStr);
@@ -172,8 +209,279 @@
   }
 
   @Test
+  @GerritConfig(name = "user.readExternalIdsFromGit", value = "true")
+  public void readExternalIdsWhenInvalidExternalIdsExist() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    resetCurrentApiUser();
+
+    insertValidExternalIds();
+    insertInvalidButParsableExternalIds();
+
+    Set<ExternalId> parseableExtIds = externalIds.all(db);
+
+    insertNonParsableExternalIds();
+
+    Set<ExternalId> extIds = externalIds.all(db);
+    assertThat(extIds).containsExactlyElementsIn(parseableExtIds);
+
+    for (ExternalId parseableExtId : parseableExtIds) {
+      ExternalId extId = externalIds.get(db, parseableExtId.key());
+      assertThat(extId).isEqualTo(parseableExtId);
+    }
+  }
+
+  @Test
+  public void checkConsistency() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    resetCurrentApiUser();
+
+    insertValidExternalIds();
+
+    ConsistencyCheckInput input = new ConsistencyCheckInput();
+    input.checkAccountExternalIds = new CheckAccountExternalIdsInput();
+    ConsistencyCheckInfo checkInfo = gApi.config().server().checkConsistency(input);
+    assertThat(checkInfo.checkAccountExternalIdsResult.problems).isEmpty();
+
+    Set<ConsistencyProblemInfo> expectedProblems = new HashSet<>();
+    expectedProblems.addAll(insertInvalidButParsableExternalIds());
+    expectedProblems.addAll(insertNonParsableExternalIds());
+
+    checkInfo = gApi.config().server().checkConsistency(input);
+    assertThat(checkInfo.checkAccountExternalIdsResult.problems).hasSize(expectedProblems.size());
+    assertThat(checkInfo.checkAccountExternalIdsResult.problems)
+        .containsExactlyElementsIn(expectedProblems);
+  }
+
+  @Test
+  public void checkConsistencyNotAllowed() throws Exception {
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to run consistency checks");
+    gApi.config().server().checkConsistency(new ConsistencyCheckInput());
+  }
+
+  private ConsistencyProblemInfo consistencyError(String message) {
+    return new ConsistencyProblemInfo(ConsistencyProblemInfo.Status.ERROR, message);
+  }
+
+  private void insertValidExternalIds() throws IOException, ConfigInvalidException, OrmException {
+    MutableInteger i = new MutableInteger();
+    String scheme = "valid";
+    ExternalIdsUpdate u = extIdsUpdate.create();
+
+    // create valid external IDs
+    u.insert(
+        db,
+        ExternalId.createWithPassword(
+            ExternalId.Key.parse(nextId(scheme, i)),
+            admin.id,
+            "admin.other@example.com",
+            "secret-password"));
+    u.insert(db, createExternalIdWithOtherCaseEmail(nextId(scheme, i)));
+  }
+
+  private Set<ConsistencyProblemInfo> insertInvalidButParsableExternalIds()
+      throws IOException, ConfigInvalidException, OrmException {
+    MutableInteger i = new MutableInteger();
+    String scheme = "invalid";
+    ExternalIdsUpdate u = extIdsUpdate.create();
+
+    Set<ConsistencyProblemInfo> expectedProblems = new HashSet<>();
+    ExternalId extIdForNonExistingAccount =
+        createExternalIdForNonExistingAccount(nextId(scheme, i));
+    u.insert(db, extIdForNonExistingAccount);
+    expectedProblems.add(
+        consistencyError(
+            "External ID '"
+                + extIdForNonExistingAccount.key().get()
+                + "' belongs to account that doesn't exist: "
+                + extIdForNonExistingAccount.accountId().get()));
+
+    ExternalId extIdWithInvalidEmail = createExternalIdWithInvalidEmail(nextId(scheme, i));
+    u.insert(db, extIdWithInvalidEmail);
+    expectedProblems.add(
+        consistencyError(
+            "External ID '"
+                + extIdWithInvalidEmail.key().get()
+                + "' has an invalid email: "
+                + extIdWithInvalidEmail.email()));
+
+    ExternalId extIdWithDuplicateEmail = createExternalIdWithDuplicateEmail(nextId(scheme, i));
+    u.insert(db, extIdWithDuplicateEmail);
+    expectedProblems.add(
+        consistencyError(
+            "Email '"
+                + extIdWithDuplicateEmail.email()
+                + "' is not unique, it's used by the following external IDs: '"
+                + extIdWithDuplicateEmail.key().get()
+                + "', 'mailto:"
+                + extIdWithDuplicateEmail.email()
+                + "'"));
+
+    ExternalId extIdWithBadPassword = createExternalIdWithBadPassword("admin-username");
+    u.insert(db, extIdWithBadPassword);
+    expectedProblems.add(
+        consistencyError(
+            "External ID '"
+                + extIdWithBadPassword.key().get()
+                + "' has an invalid password: unrecognized algorithm"));
+
+    return expectedProblems;
+  }
+
+  private Set<ConsistencyProblemInfo> insertNonParsableExternalIds() throws IOException {
+    MutableInteger i = new MutableInteger();
+    String scheme = "corrupt";
+
+    Set<ConsistencyProblemInfo> expectedProblems = new HashSet<>();
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      String externalId = nextId(scheme, i);
+      String noteId = insertExternalIdWithoutAccountId(repo, rw, externalId);
+      expectedProblems.add(
+          consistencyError(
+              "Invalid external ID config for note '"
+                  + noteId
+                  + "': Value for 'externalId."
+                  + externalId
+                  + ".accountId' is missing, expected account ID"));
+
+      externalId = nextId(scheme, i);
+      noteId = insertExternalIdWithKeyThatDoesntMatchNoteId(repo, rw, externalId);
+      expectedProblems.add(
+          consistencyError(
+              "Invalid external ID config for note '"
+                  + noteId
+                  + "': SHA1 of external ID '"
+                  + externalId
+                  + "' does not match note ID '"
+                  + noteId
+                  + "'"));
+
+      noteId = insertExternalIdWithInvalidConfig(repo, rw, nextId(scheme, i));
+      expectedProblems.add(
+          consistencyError(
+              "Invalid external ID config for note '" + noteId + "': Invalid line in config file"));
+
+      noteId = insertExternalIdWithEmptyNote(repo, rw, nextId(scheme, i));
+      expectedProblems.add(
+          consistencyError(
+              "Invalid external ID config for note '"
+                  + noteId
+                  + "': Expected exactly 1 'externalId' section, found 0"));
+    }
+
+    return expectedProblems;
+  }
+
+  private ExternalId createExternalIdWithOtherCaseEmail(String externalId) {
+    return ExternalId.createWithPassword(
+        ExternalId.Key.parse(externalId), admin.id, admin.email.toUpperCase(Locale.US), "password");
+  }
+
+  private String insertExternalIdWithoutAccountId(Repository repo, RevWalk rw, String externalId)
+      throws IOException {
+    ObjectId rev = ExternalIdReader.readRevision(repo);
+    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+
+    ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), admin.id);
+
+    try (ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId noteId = extId.key().sha1();
+      Config c = new Config();
+      extId.writeToConfig(c);
+      c.unset("externalId", extId.key().get(), "accountId");
+      byte[] raw = c.toText().getBytes(UTF_8);
+      ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+      noteMap.set(noteId, dataBlob);
+
+      ExternalIdsUpdate.commit(
+          repo, rw, ins, rev, noteMap, "Add external ID", admin.getIdent(), admin.getIdent());
+      return noteId.getName();
+    }
+  }
+
+  private String insertExternalIdWithKeyThatDoesntMatchNoteId(
+      Repository repo, RevWalk rw, String externalId) throws IOException {
+    ObjectId rev = ExternalIdReader.readRevision(repo);
+    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+
+    ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), admin.id);
+
+    try (ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId noteId = ExternalId.Key.parse(externalId + "x").sha1();
+      Config c = new Config();
+      extId.writeToConfig(c);
+      byte[] raw = c.toText().getBytes(UTF_8);
+      ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+      noteMap.set(noteId, dataBlob);
+
+      ExternalIdsUpdate.commit(
+          repo, rw, ins, rev, noteMap, "Add external ID", admin.getIdent(), admin.getIdent());
+      return noteId.getName();
+    }
+  }
+
+  private String insertExternalIdWithInvalidConfig(Repository repo, RevWalk rw, String externalId)
+      throws IOException {
+    ObjectId rev = ExternalIdReader.readRevision(repo);
+    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+
+    try (ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
+      byte[] raw = "bad-config".getBytes(UTF_8);
+      ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+      noteMap.set(noteId, dataBlob);
+
+      ExternalIdsUpdate.commit(
+          repo, rw, ins, rev, noteMap, "Add external ID", admin.getIdent(), admin.getIdent());
+      return noteId.getName();
+    }
+  }
+
+  private String insertExternalIdWithEmptyNote(Repository repo, RevWalk rw, String externalId)
+      throws IOException {
+    ObjectId rev = ExternalIdReader.readRevision(repo);
+    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+
+    try (ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
+      byte[] raw = "".getBytes(UTF_8);
+      ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+      noteMap.set(noteId, dataBlob);
+
+      ExternalIdsUpdate.commit(
+          repo, rw, ins, rev, noteMap, "Add external ID", admin.getIdent(), admin.getIdent());
+      return noteId.getName();
+    }
+  }
+
+  private ExternalId createExternalIdForNonExistingAccount(String externalId) {
+    return ExternalId.create(ExternalId.Key.parse(externalId), new Account.Id(1));
+  }
+
+  private ExternalId createExternalIdWithInvalidEmail(String externalId) {
+    return ExternalId.createWithEmail(ExternalId.Key.parse(externalId), admin.id, "invalid-email");
+  }
+
+  private ExternalId createExternalIdWithDuplicateEmail(String externalId) {
+    return ExternalId.createWithEmail(ExternalId.Key.parse(externalId), admin.id, admin.email);
+  }
+
+  private ExternalId createExternalIdWithBadPassword(String username) {
+    return ExternalId.create(
+        ExternalId.Key.create(SCHEME_USERNAME, username),
+        admin.id,
+        null,
+        "non-hashed-password-is-not-allowed");
+  }
+
+  private static String nextId(String scheme, MutableInteger i) {
+    return scheme + ":foo" + ++i.value;
+  }
+
+  @Test
   public void retryOnLockFailure() throws Exception {
-    Retryer<Void> retryer =
+    Retryer<RefsMetaExternalIdsUpdate> retryer =
         ExternalIdsUpdate.retryerBuilder()
             .withBlockStrategy(
                 new BlockStrategy() {
@@ -192,6 +500,9 @@
         new ExternalIdsUpdate(
             repoManager,
             allUsers,
+            metricMaker,
+            externalIds,
+            new DisabledExternalIdCache(),
             serverIdent.get(),
             serverIdent.get(),
             () -> {
@@ -208,8 +519,8 @@
     update.insert(db, ExternalId.create(fooId, admin.id));
     assertThat(doneBgUpdate.get()).isTrue();
 
-    assertThat(externalIds.get(fooId)).isNotNull();
-    assertThat(externalIds.get(barId)).isNotNull();
+    assertThat(externalIds.get(db, fooId)).isNotNull();
+    assertThat(externalIds.get(db, barId)).isNotNull();
   }
 
   @Test
@@ -224,6 +535,9 @@
         new ExternalIdsUpdate(
             repoManager,
             allUsers,
+            metricMaker,
+            externalIds,
+            new DisabledExternalIdCache(),
             serverIdent.get(),
             serverIdent.get(),
             () -> {
@@ -235,7 +549,7 @@
                 // Ignore, the successful insertion of the external ID is asserted later
               }
             },
-            RetryerBuilder.<Void>newBuilder()
+            RetryerBuilder.<RefsMetaExternalIdsUpdate>newBuilder()
                 .retryIfException(e -> e instanceof LockFailureException)
                 .withStopStrategy(StopStrategies.stopAfterAttempt(extIdsKeys.length))
                 .build());
@@ -248,7 +562,87 @@
     }
     assertThat(bgCounter.get()).isEqualTo(extIdsKeys.length);
     for (ExternalId.Key extIdKey : extIdsKeys) {
-      assertThat(externalIds.get(extIdKey)).isNotNull();
+      assertThat(externalIds.get(db, extIdKey)).isNotNull();
+    }
+  }
+
+  @Test
+  public void readExternalIdWithAccountIdThatCanBeExpressedInKiB() throws Exception {
+    ExternalId.Key extIdKey = ExternalId.Key.parse("foo:bar");
+    Account.Id accountId = new Account.Id(1024 * 100);
+    extIdsUpdate.create().insert(db, ExternalId.create(extIdKey, accountId));
+    ExternalId extId = externalIds.get(db, extIdKey);
+    assertThat(extId.accountId()).isEqualTo(accountId);
+  }
+
+  @Test
+  @GerritConfig(name = "user.readExternalIdsFromGit", value = "true")
+  public void checkNoReloadAfterUpdate() throws Exception {
+    Set<ExternalId> expectedExtIds = new HashSet<>(externalIds.byAccount(db, admin.id));
+    externalIdReader.setFailOnLoad(true);
+
+    // insert external ID
+    ExternalId extId = ExternalId.create("foo", "bar", admin.id);
+    extIdsUpdate.create().insert(db, extId);
+    expectedExtIds.add(extId);
+    assertThat(externalIds.byAccount(db, admin.id)).containsExactlyElementsIn(expectedExtIds);
+
+    // update external ID
+    expectedExtIds.remove(extId);
+    extId = ExternalId.createWithEmail("foo", "bar", admin.id, "foo.bar@example.com");
+    extIdsUpdate.create().upsert(db, extId);
+    expectedExtIds.add(extId);
+    assertThat(externalIds.byAccount(db, admin.id)).containsExactlyElementsIn(expectedExtIds);
+
+    // delete external ID
+    extIdsUpdate.create().delete(db, extId);
+    expectedExtIds.remove(extId);
+    assertThat(externalIds.byAccount(db, admin.id)).containsExactlyElementsIn(expectedExtIds);
+  }
+
+  @Test
+  @GerritConfig(name = "user.readExternalIdsFromGit", value = "true")
+  public void byAccountFailIfReadingExternalIdsFails() throws Exception {
+    externalIdReader.setFailOnLoad(true);
+
+    // update external ID branch so that external IDs need to be reloaded
+    insertExtIdBehindGerritsBack(ExternalId.create("foo", "bar", admin.id));
+
+    exception.expect(IOException.class);
+    externalIds.byAccount(db, admin.id);
+  }
+
+  @Test
+  @GerritConfig(name = "user.readExternalIdsFromGit", value = "true")
+  public void byEmailFailIfReadingExternalIdsFails() throws Exception {
+    externalIdReader.setFailOnLoad(true);
+
+    // update external ID branch so that external IDs need to be reloaded
+    insertExtIdBehindGerritsBack(ExternalId.create("foo", "bar", admin.id));
+
+    exception.expect(IOException.class);
+    externalIds.byEmail(db, admin.email);
+  }
+
+  @Test
+  @GerritConfig(name = "user.readExternalIdsFromGit", value = "true")
+  public void byAccountUpdateExternalIdsBehindGerritsBack() throws Exception {
+    Set<ExternalId> expectedExternalIds = new HashSet<>(externalIds.byAccount(db, admin.id));
+    ExternalId newExtId = ExternalId.create("foo", "bar", admin.id);
+    insertExtIdBehindGerritsBack(newExtId);
+    expectedExternalIds.add(newExtId);
+    assertThat(externalIds.byAccount(db, admin.id)).containsExactlyElementsIn(expectedExternalIds);
+  }
+
+  private void insertExtIdBehindGerritsBack(ExternalId extId) throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo);
+        ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId rev = ExternalIdReader.readRevision(repo);
+      NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+      ExternalIdsUpdate.insert(rw, ins, noteMap, extId);
+      ExternalIdsUpdate.commit(
+          repo, rw, ins, rev, noteMap, "insert new ID", serverIdent.get(), serverIdent.get());
     }
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index c69391c..b221ec5 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -39,8 +39,10 @@
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
@@ -62,6 +64,7 @@
 import com.google.gerrit.server.project.Util;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
+import java.util.EnumSet;
 import org.apache.http.Header;
 import org.apache.http.message.BasicHeader;
 import org.junit.After;
@@ -311,7 +314,7 @@
     in.label("Code-Review", 1);
 
     exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("on_behalf_of account " + user.id + " cannot see destination ref");
+    exception.expectMessage("on_behalf_of account " + user.id + " cannot see change");
     revision.review(in);
   }
 
@@ -375,7 +378,7 @@
     SubmitInput in = new SubmitInput();
     in.onBehalfOf = admin2.email;
     exception.expect(AuthException.class);
-    exception.expectMessage("submit on behalf of not permitted");
+    exception.expectMessage("submit as not permitted");
     gApi.changes().id(project.get() + "~master~" + r.getChangeId()).current().submit(in);
   }
 
@@ -390,7 +393,7 @@
     SubmitInput in = new SubmitInput();
     in.onBehalfOf = user.email;
     exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("on_behalf_of account " + user.id + " cannot see destination ref");
+    exception.expectMessage("on_behalf_of account " + user.id + " cannot see change");
     gApi.changes().id(changeId).current().submit(in);
   }
 
@@ -529,6 +532,28 @@
     assertThat(m.getRealAuthor()).isEqualTo(admin.id); // not user2
   }
 
+  @Test
+  public void changeMessageCreatedOnBehalfOfHasRealUser() throws Exception {
+    allowCodeReviewOnBehalfOf();
+
+    PushOneCommit.Result r = createChange();
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.message = "Message on behalf of";
+    in.label("Code-Review", 1);
+
+    setApiUser(accounts.user2());
+    gApi.changes().id(r.getChangeId()).revision(r.getPatchSetId().getId()).review(in);
+
+    ChangeInfo info =
+        gApi.changes().id(r.getChangeId()).get(EnumSet.of(ListChangesOption.MESSAGES));
+    assertThat(info.messages).hasSize(2);
+
+    ChangeMessageInfo changeMessageInfo = Iterables.getLast(info.messages);
+    assertThat(changeMessageInfo.realAuthor).isNotNull();
+    assertThat(changeMessageInfo.realAuthor._accountId).isEqualTo(accounts.user2().id.get());
+  }
+
   private void allowCodeReviewOnBehalfOf() throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     LabelType codeReviewType = Util.codeReview();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
index 9378591..17dabde 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
@@ -18,23 +18,16 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.PutUsername;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import java.util.Collections;
 import org.junit.Test;
 
 public class PutUsernameIT extends AbstractDaemonTest {
-  @Inject private SchemaFactory<ReviewDb> reviewDbProvider;
-
   @Test
   public void set() throws Exception {
     PutUsername.Input in = new PutUsername.Input();
     in.username = "myUsername";
-    RestResponse r = adminRestSession.put("/accounts/" + createUser().get() + "/username", in);
+    RestResponse r =
+        adminRestSession.put("/accounts/" + accounts.create().id.get() + "/username", in);
     r.assertOK();
     assertThat(newGson().fromJson(r.getReader(), String.class)).isEqualTo(in.username);
   }
@@ -43,7 +36,9 @@
   public void setExisting_Conflict() throws Exception {
     PutUsername.Input in = new PutUsername.Input();
     in.username = admin.username;
-    adminRestSession.put("/accounts/" + createUser().get() + "/username", in).assertConflict();
+    adminRestSession
+        .put("/accounts/" + accounts.create().id.get() + "/username", in)
+        .assertConflict();
   }
 
   @Test
@@ -57,13 +52,4 @@
   public void delete_MethodNotAllowed() throws Exception {
     adminRestSession.put("/accounts/" + admin.username + "/username").assertMethodNotAllowed();
   }
-
-  private Account.Id createUser() throws Exception {
-    try (ReviewDb db = reviewDbProvider.open()) {
-      Account.Id id = new Account.Id(db.nextAccountId());
-      Account a = new Account(id, TimeUtil.nowTs());
-      db.accounts().insert(Collections.singleton(a));
-      return id;
-    }
-  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 94fa99b..359883a 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.Truth8.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
@@ -95,6 +96,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -494,6 +496,20 @@
   }
 
   @Test
+  public void submitWorkInProgressChange() throws Exception {
+    PushOneCommit.Result change = createWorkInProgressChange();
+    Change.Id num = change.getChange().getId();
+    submitWithConflict(
+        change.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n"
+            + "Change "
+            + num
+            + ": Change "
+            + num
+            + " is work in progress");
+  }
+
+  @Test
   public void submitDraftPatchSet() throws Exception {
     PushOneCommit.Result change = createChange();
     PushOneCommit.Result draft = amendChangeAsDraft(change.getChangeId());
@@ -756,11 +772,16 @@
         new OnSubmitValidationListener() {
           @Override
           public void preBranchUpdate(Arguments args) throws ValidationException {
-            assertThat(args.getCommands().keySet()).contains("refs/heads/master");
-            try (RevWalk rw = args.newRevWalk()) {
-              rw.parseBody(rw.parseCommit(args.getCommands().get("refs/heads/master").getNewId()));
+            String master = "refs/heads/master";
+            assertThat(args.getCommands()).containsKey(master);
+            ReceiveCommand cmd = args.getCommands().get(master);
+            ObjectId newMasterId = cmd.getNewId();
+            try (Repository repo = repoManager.openRepository(args.getProject())) {
+              assertThat(repo.exactRef(master).getObjectId()).isEqualTo(cmd.getOldId());
+              assertThat(args.getRef(master)).hasValue(newMasterId);
+              args.getRevWalk().parseBody(args.getRevWalk().parseCommit(newMasterId));
             } catch (IOException e) {
-              assertThat(e).isNull();
+              throw new AssertionError("failed checking new ref value", e);
             }
             projectsCalled.add(args.getProject().get());
             if (projectsCalled.size() == 2) {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
index 0250db1..edf5420 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
@@ -130,6 +130,9 @@
 
   @Test
   public void repairChangeStateAfterFailure() throws Exception {
+    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
+    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
+
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index d8aa35c..b94b062 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.getChangeId;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -244,6 +245,9 @@
 
   @Test
   public void repairChangeStateAfterFailure() throws Exception {
+    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
+    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
+
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AssigneeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
index 35ba1a2..a809df9 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
@@ -16,19 +16,26 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.gerrit.testutil.TestTimeUtil;
 import java.util.Iterator;
 import java.util.List;
+import org.eclipse.jgit.transport.RefSpec;
 import org.junit.AfterClass;
 import org.junit.BeforeClass;
 import org.junit.Test;
@@ -124,6 +131,43 @@
     assertThat(deleteAssignee(r)).isNull();
   }
 
+  @Test
+  @Sandboxed
+  public void setAssigneeToInactiveUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.accounts().id(user.getId().get()).setActive(false);
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("is not active");
+    setAssignee(r, user.email);
+  }
+
+  @Test
+  public void setAssigneeForNonVisibleChange() throws Exception {
+    git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config")).call();
+    testRepo.reset(RefNames.REFS_CONFIG);
+    PushOneCommit.Result r = createChange("refs/for/refs/meta/config");
+    exception.expect(AuthException.class);
+    exception.expectMessage("read not permitted");
+    setAssignee(r, user.email);
+  }
+
+  @Test
+  public void setAssigneeNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not permitted");
+    setAssignee(r, user.email);
+  }
+
+  @Test
+  public void setAssigneeAllowedWithPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    grant(Permission.EDIT_ASSIGNEE, project, "refs/heads/master", false, REGISTERED_USERS);
+    setApiUser(user);
+    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
+  }
+
   private AccountInfo getAssignee(PushOneCommit.Result r) throws Exception {
     return gApi.changes().id(r.getChange().getId().get()).getAssignee();
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
new file mode 100644
index 0000000..4a874a4
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
@@ -0,0 +1,350 @@
+// Copyright (C) 2017 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.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerResult;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.testutil.FakeEmailSender.Message;
+import java.util.EnumSet;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class ChangeReviewersByEmailIT extends AbstractDaemonTest {
+
+  @Before
+  public void setUp() throws Exception {
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+  }
+
+  @Test
+  public void addByEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput input = new AddReviewerInput();
+      input.reviewer = toRfcAddressString(acc);
+      input.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(input);
+
+      ChangeInfo info =
+          gApi.changes().id(r.getChangeId()).get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+      assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(acc)));
+      // All reviewers added by email should be removable
+      assertThat(info.removableReviewers).isEqualTo(ImmutableList.of(acc));
+    }
+  }
+
+  @Test
+  public void addByEmailAndById() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo byEmail = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+    AccountInfo byId = new AccountInfo(user.id.get());
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput inputByEmail = new AddReviewerInput();
+      inputByEmail.reviewer = toRfcAddressString(byEmail);
+      inputByEmail.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(inputByEmail);
+
+      AddReviewerInput inputById = new AddReviewerInput();
+      inputById.reviewer = user.email;
+      inputById.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(inputById);
+
+      ChangeInfo info =
+          gApi.changes().id(r.getChangeId()).get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+      assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(byId, byEmail)));
+      // All reviewers (both by id and by email) should be removable
+      assertThat(info.removableReviewers).isEqualTo(ImmutableList.of(byId, byEmail));
+    }
+  }
+
+  @Test
+  public void removeByEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput addInput = new AddReviewerInput();
+      addInput.reviewer = toRfcAddressString(acc);
+      addInput.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(addInput);
+
+      gApi.changes().id(r.getChangeId()).reviewer(acc.email).remove();
+
+      ChangeInfo info =
+          gApi.changes().id(r.getChangeId()).get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+      assertThat(info.reviewers).isEmpty();
+    }
+  }
+
+  @Test
+  public void convertFromCCToReviewer() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerInput addInput = new AddReviewerInput();
+    addInput.reviewer = toRfcAddressString(acc);
+    addInput.state = ReviewerState.CC;
+    gApi.changes().id(r.getChangeId()).addReviewer(addInput);
+
+    AddReviewerInput modifyInput = new AddReviewerInput();
+    modifyInput.reviewer = addInput.reviewer;
+    modifyInput.state = ReviewerState.REVIEWER;
+    gApi.changes().id(r.getChangeId()).addReviewer(modifyInput);
+
+    ChangeInfo info =
+        gApi.changes().id(r.getChangeId()).get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+    assertThat(info.reviewers)
+        .isEqualTo(ImmutableMap.of(ReviewerState.REVIEWER, ImmutableList.of(acc)));
+  }
+
+  @Test
+  public void addedReviewersGetNotified() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput input = new AddReviewerInput();
+      input.reviewer = toRfcAddressString(acc);
+      input.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(input);
+
+      List<Message> messages = sender.getMessages();
+      assertThat(messages).hasSize(1);
+      assertThat(messages.get(0).rcpt()).containsExactly(Address.parse(input.reviewer));
+      sender.clear();
+    }
+  }
+
+  @Test
+  public void removingReviewerTriggersNotification() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput addInput = new AddReviewerInput();
+      addInput.reviewer = toRfcAddressString(acc);
+      addInput.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(addInput);
+
+      // Review change as user
+      ReviewInput reviewInput = new ReviewInput();
+      reviewInput.message = "I have a comment";
+      setApiUser(user);
+      revision(r).review(reviewInput);
+      setApiUser(admin);
+
+      sender.clear();
+
+      // Delete as admin
+      gApi.changes().id(r.getChangeId()).reviewer(addInput.reviewer).remove();
+
+      List<Message> messages = sender.getMessages();
+      assertThat(messages).hasSize(1);
+      assertThat(messages.get(0).rcpt())
+          .containsExactly(Address.parse(addInput.reviewer), user.emailAddress);
+      sender.clear();
+    }
+  }
+
+  @Test
+  public void reviewerAndCCReceiveRegularNotification() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput input = new AddReviewerInput();
+      input.reviewer = toRfcAddressString(acc);
+      input.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(input);
+      sender.clear();
+
+      gApi.changes()
+          .id(r.getChangeId())
+          .revision(r.getCommit().name())
+          .review(ReviewInput.approve());
+
+      if (state == ReviewerState.CC) {
+        assertNotifyCc(Address.parse(input.reviewer));
+      } else {
+        assertNotifyTo(Address.parse(input.reviewer));
+      }
+    }
+  }
+
+  @Test
+  public void reviewerAndCCReceiveSameEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    PushOneCommit.Result r = createChange();
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      for (int i = 0; i < 10; i++) {
+        AddReviewerInput input = new AddReviewerInput();
+        input.reviewer = String.format("%s-%s@gerritcodereview.com", state, i);
+        input.state = state;
+        gApi.changes().id(r.getChangeId()).addReviewer(input);
+      }
+    }
+
+    // Also add user as a regular reviewer
+    AddReviewerInput input = new AddReviewerInput();
+    input.reviewer = user.email;
+    input.state = ReviewerState.REVIEWER;
+    gApi.changes().id(r.getChangeId()).addReviewer(input);
+
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    // Assert that only one email was sent out to everyone
+    assertThat(sender.getMessages()).hasSize(1);
+  }
+
+  @Test
+  public void addingMultipleReviewersAndCCsAtOnceSendsOnlyOneEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    PushOneCommit.Result r = createChange();
+    ReviewInput reviewInput = new ReviewInput();
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      for (int i = 0; i < 10; i++) {
+        reviewInput.reviewer(String.format("%s-%s@gerritcodereview.com", state, i), state, true);
+      }
+    }
+    assertThat(reviewInput.reviewers).hasSize(20);
+
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(reviewInput);
+    assertThat(sender.getMessages()).hasSize(1);
+  }
+
+  @Test
+  public void rejectMissingEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer("");
+    assertThat(result.error).isEqualTo(" is not a valid user identifier");
+    assertThat(result.reviewers).isNull();
+  }
+
+  @Test
+  public void rejectMalformedEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@");
+    assertThat(result.error).isEqualTo("Foo Bar <foo.bar@ is not a valid user identifier");
+    assertThat(result.reviewers).isNull();
+  }
+
+  @Test
+  public void rejectOnNonPublicChange() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    PushOneCommit.Result r = createDraftChange();
+
+    AddReviewerResult result =
+        gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@gerritcodereview.com>");
+    assertThat(result.error)
+        .isEqualTo(
+            "Foo Bar <foo.bar@gerritcodereview.com> does not have permission to see this change");
+    assertThat(result.reviewers).isNull();
+  }
+
+  @Test
+  public void rejectWhenFeatureIsDisabled() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.FALSE;
+    gApi.projects().name(project.get()).config(conf);
+
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerResult result =
+        gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@gerritcodereview.com>");
+    assertThat(result.error)
+        .isEqualTo(
+            "Foo Bar <foo.bar@gerritcodereview.com> does not identify a registered user or group");
+    assertThat(result.reviewers).isNull();
+  }
+
+  @Test
+  public void reviewersByEmailAreServedFromIndex() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput input = new AddReviewerInput();
+      input.reviewer = toRfcAddressString(acc);
+      input.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(input);
+
+      notesMigration.setFailOnLoad(true);
+      try {
+        ChangeInfo info =
+            Iterables.getOnlyElement(
+                gApi.changes()
+                    .query(r.getChangeId())
+                    .withOption(ListChangesOption.DETAILED_LABELS)
+                    .get());
+        assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(acc)));
+      } finally {
+        notesMigration.setFailOnLoad(false);
+      }
+    }
+  }
+
+  private static String toRfcAddressString(AccountInfo info) {
+    return (new Address(info.name, info.email)).toString();
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index 66966c3..0809bf2 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -16,18 +16,24 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.NotifyInfo;
+import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
 import com.google.gerrit.extensions.client.ReviewerState;
@@ -42,6 +48,7 @@
 import com.google.gson.stream.JsonReader;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
@@ -655,6 +662,64 @@
     assertThat(reviewerResult.ccs).hasSize(1);
   }
 
+  @Test
+  public void removingReviewerRemovesTheirVote() throws Exception {
+    String crLabel = "Code-Review";
+    PushOneCommit.Result r = createChange();
+    ReviewInput input = ReviewInput.approve().reviewer(admin.email);
+    ReviewResult addResult = review(r.getChangeId(), r.getCommit().name(), input);
+    assertThat(addResult.reviewers).isNotNull();
+    assertThat(addResult.reviewers).hasSize(1);
+
+    Map<String, LabelInfo> changeLabels = getChangeLabels(r.getChangeId());
+    assertThat(changeLabels.get(crLabel).all).hasSize(1);
+
+    RestResponse deleteResult = deleteReviewer(r.getChangeId(), admin);
+    deleteResult.assertNoContent();
+
+    changeLabels = getChangeLabels(r.getChangeId());
+    assertThat(changeLabels.get(crLabel).all).isNull();
+
+    // Check that the vote is gone even after the reviewer is added back
+    addReviewer(r.getChangeId(), admin.email);
+    changeLabels = getChangeLabels(r.getChangeId());
+    assertThat(changeLabels.get(crLabel).all).isNull();
+  }
+
+  @Test
+  public void notifyDetailsWorkOnPostReview() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestAccount userToNotify = createAccounts(1, "notify-details-post-review").get(0);
+
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.reviewer(user.email, ReviewerState.REVIEWER, true);
+    reviewInput.notify = NotifyHandling.NONE;
+    reviewInput.notifyDetails =
+        ImmutableMap.of(RecipientType.TO, new NotifyInfo(ImmutableList.of(userToNotify.email)));
+
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(userToNotify.emailAddress);
+  }
+
+  @Test
+  public void notifyDetailsWorkOnPostReviewers() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestAccount userToNotify = createAccounts(1, "notify-details-post-reviewers").get(0);
+
+    AddReviewerInput addReviewer = new AddReviewerInput();
+    addReviewer.reviewer = user.email;
+    addReviewer.notify = NotifyHandling.NONE;
+    addReviewer.notifyDetails =
+        ImmutableMap.of(RecipientType.TO, new NotifyInfo(ImmutableList.of(userToNotify.email)));
+
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).addReviewer(addReviewer);
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(userToNotify.emailAddress);
+  }
+
   private AddReviewerResult addReviewer(String changeId, String reviewer) throws Exception {
     return addReviewer(changeId, reviewer, SC_OK);
   }
@@ -735,4 +800,8 @@
     }
     return result;
   }
+
+  private Map<String, LabelInfo> getChangeLabels(String changeId) throws Exception {
+    return gApi.changes().id(changeId).get(EnumSet.of(DETAILED_LABELS)).labels;
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index f79b5fa..146b5ca 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -23,6 +23,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.RestResponse;
@@ -30,11 +31,15 @@
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -45,10 +50,13 @@
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.gerrit.testutil.TestTimeUtil;
+import java.util.Iterator;
 import java.util.List;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -278,6 +286,79 @@
     assertCreateSucceeds(in);
   }
 
+  @Test
+  public void cherryPickCommitWithoutChangeId() throws Exception {
+    // This test is a little superfluous, since the current cherry-pick code ignores
+    // the commit message of the to-be-cherry-picked change, using the one in
+    // CherryPickInput instead.
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "foo";
+    input.message = "it goes to foo branch";
+    gApi.projects().name(project.get()).branch(input.destination).create(new BranchInput());
+
+    RevCommit revCommit = createNewCommitWithoutChangeId();
+    ChangeInfo changeInfo =
+        gApi.projects().name(project.get()).commit(revCommit.getName()).cherryPick(input).get();
+
+    assertThat(changeInfo.messages).hasSize(1);
+    Iterator<ChangeMessageInfo> messageIterator = changeInfo.messages.iterator();
+    String expectedMessage =
+        String.format("Patch Set 1: Cherry Picked from commit %s.", revCommit.getName());
+    assertThat(messageIterator.next().message).isEqualTo(expectedMessage);
+
+    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(revInfo).isNotNull();
+    CommitInfo commitInfo = revInfo.commit;
+    assertThat(commitInfo.message)
+        .isEqualTo(input.message + "\n\nChange-Id: " + changeInfo.changeId + "\n");
+  }
+
+  @Test
+  public void cherryPickCommitWithChangeId() throws Exception {
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "foo";
+
+    RevCommit revCommit = createChange().getCommit();
+    List<String> footers = revCommit.getFooterLines("Change-Id");
+    assertThat(footers).hasSize(1);
+    String changeId = footers.get(0);
+
+    input.message = "it goes to foo branch\n\nChange-Id: " + changeId;
+    gApi.projects().name(project.get()).branch(input.destination).create(new BranchInput());
+
+    ChangeInfo changeInfo =
+        gApi.projects().name(project.get()).commit(revCommit.getName()).cherryPick(input).get();
+
+    assertThat(changeInfo.messages).hasSize(1);
+    Iterator<ChangeMessageInfo> messageIterator = changeInfo.messages.iterator();
+    String expectedMessage =
+        String.format("Patch Set 1: Cherry Picked from commit %s.", revCommit.getName());
+    assertThat(messageIterator.next().message).isEqualTo(expectedMessage);
+
+    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(revInfo).isNotNull();
+    assertThat(revInfo.commit.message).isEqualTo(input.message + "\n");
+  }
+
+  private RevCommit createNewCommitWithoutChangeId() throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk walk = new RevWalk(repo)) {
+      Ref ref = repo.exactRef("refs/heads/master");
+      RevCommit tip = null;
+      if (ref != null) {
+        tip = walk.parseCommit(ref.getObjectId());
+      }
+      TestRepository<?> testSrcRepo = new TestRepository<>(repo);
+      TestRepository<?>.BranchBuilder builder = testSrcRepo.branch("refs/heads/master");
+      RevCommit revCommit =
+          tip == null
+              ? builder.commit().message("commit 1").add("a.txt", "content").create()
+              : builder.commit().parent(tip).message("commit 1").add("a.txt", "content").create();
+      assertThat(GitUtil.getChangeId(testSrcRepo, revCommit).isPresent()).isFalse();
+      return revCommit;
+    }
+  }
+
   private ChangeInput newChangeInput(ChangeStatus status) {
     ChangeInput in = new ChangeInput();
     in.project = project.get();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java
index 244efbf..228b478 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java
@@ -92,7 +92,7 @@
     din.message = "comment on a.txt";
     gApi.changes().id(changeId).current().createDraft(din);
 
-    if (notesMigration.writeChanges()) {
+    if (notesMigration.commitChangeWrites()) {
       assertThat(getDraftRef(admin, id)).isNotNull();
     }
 
@@ -110,7 +110,7 @@
     deletePatchSet(changeId, ps);
     assertThat(queryProvider.get().byKeyPrefix(changeId)).isEmpty();
 
-    if (notesMigration.writeChanges()) {
+    if (notesMigration.commitChangeWrites()) {
       assertThat(getDraftRef(admin, id)).isNull();
       assertThat(getMetaRef(id)).isNull();
     }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java
index da7d7b5..26e6847 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java
@@ -82,14 +82,13 @@
     PushOneCommit.Result changeResult = createDraftChange();
     changeResult.assertOkStatus();
     String changeId = changeResult.getChangeId();
-    Change.Id id = changeResult.getChange().getId();
 
     // The user needs to be able to see the draft change (which reviewers can).
     gApi.changes().id(changeId).addReviewer(user.fullName);
 
     setApiUser(user);
     exception.expect(AuthException.class);
-    exception.expectMessage(String.format("Deleting change %s is not permitted", id));
+    exception.expectMessage("delete not permitted");
     gApi.changes().id(changeId).delete();
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
index 18925b4..d8b7a52 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
@@ -252,7 +252,7 @@
     PushOneCommit.Result r = createChange();
     setApiUser(user);
     exception.expect(AuthException.class);
-    exception.expectMessage("Editing hashtags not permitted");
+    exception.expectMessage("edit hashtags not permitted");
     addHashtags(r, "MyHashtag");
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index 26a91aa..5235b14 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -388,6 +389,9 @@
 
   @Test
   public void repairChangeStateAfterFailure() throws Exception {
+    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
+    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
+
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 65ad499..b65cc0e 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 
 import com.google.common.collect.Iterables;
@@ -145,6 +146,9 @@
 
   @Test
   public void repairChangeStateAfterFailure() throws Exception {
+    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
+    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
+
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     Change.Id id = change.getChange().getId();
     SubmitInput failAfterRefUpdates = new TestSubmitInput(new SubmitInput(), true);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index f51bbf5..351623a 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -168,7 +168,7 @@
     assertThat(i.change.largeChange).isEqualTo(500);
     assertThat(i.change.replyTooltip).startsWith("Reply and score");
     assertThat(i.change.replyLabel).isEqualTo("Reply\u2026");
-    assertThat(i.change.updateDelay).isEqualTo(30);
+    assertThat(i.change.updateDelay).isEqualTo(300);
 
     // download
     assertThat(i.download.archives).containsExactly("tar", "tbz2", "tgz", "txz");
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/AccessIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/AccessIT.java
index 839f166..8695498 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -218,7 +218,7 @@
 
     setApiUser(user);
     exception.expect(AuthException.class);
-    exception.expectMessage("not administrator");
+    exception.expectMessage("administrate server not permitted");
     gApi.projects().name(newProjectName).access(accessInput);
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index e0346b3..329716f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -41,11 +41,11 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ConsistencyChecker;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.project.ChangeControl;
@@ -68,7 +68,6 @@
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.ReceiveCommand;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -92,6 +91,8 @@
 
   @Inject private Sequences sequences;
 
+  @Inject private AccountsUpdate.Server accountsUpdate;
+
   private RevCommit tip;
   private Account.Id adminId;
   private ConsistencyChecker checker;
@@ -121,7 +122,7 @@
   public void missingOwner() throws Exception {
     TestAccount owner = accounts.create("missing");
     ChangeControl ctl = insertChange(owner);
-    db.accounts().deleteKeys(singleton(owner.getId()));
+    accountsUpdate.create().deleteByKey(db, owner.getId());
 
     assertProblems(ctl, null, problem("Missing change owner: " + owner.getId()));
   }
@@ -802,7 +803,7 @@
       ins =
           changeInserterFactory
               .create(id, commit, dest)
-              .setValidatePolicy(CommitValidators.Policy.NONE)
+              .setValidate(false)
               .setNotify(NotifyHandling.NONE)
               .setFireRevisionCreated(false)
               .setSendMail(false);
@@ -826,7 +827,7 @@
       ins =
           patchSetInserterFactory
               .create(ctl, nextPatchSetId(ctl), commit)
-              .setValidatePolicy(CommitValidators.Policy.NONE)
+              .setValidate(false)
               .setFireRevisionCreated(false)
               .setNotify(NotifyHandling.NONE);
       bu.addOp(ctl.getId(), ins).execute();
@@ -920,7 +921,7 @@
           new BatchUpdateOp() {
             @Override
             public void updateRepo(RepoContext ctx) throws IOException {
-              ctx.addRefUpdate(new ReceiveCommand(oldId, newId, dest));
+              ctx.addRefUpdate(oldId, newId, dest);
             }
 
             @Override
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index 8d9885c..fcbad4f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -517,7 +517,7 @@
   }
 
   @Test
-  @GerritConfig(name = "index.testReindexAfterUpdate", value = "false")
+  @GerritConfig(name = "index.testAutoReindexIfStale", value = "false")
   public void getRelatedForStaleChange() throws Exception {
     RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
 
@@ -586,7 +586,7 @@
             public boolean updateChange(ChangeContext ctx) throws OrmException {
               PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
               psUtil.setGroups(ctx.getDb(), ctx.getUpdate(psId), ps, ImmutableList.<String>of());
-              ctx.bumpLastUpdatedOn(false);
+              ctx.dontBumpLastUpdatedOn();
               return true;
             }
           });
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java
new file mode 100644
index 0000000..cb039ad
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java
@@ -0,0 +1,100 @@
+// 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.acceptance.server.mail;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.inject.Inject;
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.lang.reflect.Field;
+import org.junit.After;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class EmailValidatorIT extends AbstractDaemonTest {
+  private static final String UNSUPPORTED_PREFIX = "#! ";
+
+  @Inject private OutgoingEmailValidator validator;
+
+  @BeforeClass
+  public static void setUpClass() throws Exception {
+    // Reset before first use, in case other tests have already run in this JVM.
+    resetDomainValidator();
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    resetDomainValidator();
+  }
+
+  private static void resetDomainValidator() throws Exception {
+    Class<?> c = Class.forName("org.apache.commons.validator.routines.DomainValidator");
+    Field f = c.getDeclaredField("inUse");
+    f.setAccessible(true);
+    f.setBoolean(c, false);
+  }
+
+  @Test
+  @GerritConfig(name = "sendemail.allowTLD", value = "example")
+  public void testCustomTopLevelDomain() throws Exception {
+    assertThat(validator.isValid("foo@bar.local")).isFalse();
+    assertThat(validator.isValid("foo@bar.example")).isTrue();
+    assertThat(validator.isValid("foo@example")).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "sendemail.allowTLD", value = "a")
+  public void testCustomTopLevelDomainOneCharacter() throws Exception {
+    assertThat(validator.isValid("foo@bar.local")).isFalse();
+    assertThat(validator.isValid("foo@bar.a")).isTrue();
+    assertThat(validator.isValid("foo@a")).isTrue();
+  }
+
+  @Test
+  public void validateTopLevelDomains() throws Exception {
+    try (InputStream in = this.getClass().getResourceAsStream("tlds-alpha-by-domain.txt")) {
+      if (in == null) {
+        throw new Exception("TLD list not found");
+      }
+      BufferedReader r = new BufferedReader(new InputStreamReader(in));
+      String tld;
+      while ((tld = r.readLine()) != null) {
+        if (tld.startsWith("# ") || tld.startsWith("XN--")) {
+          // Ignore comments and non-latin domains
+          continue;
+        }
+        if (tld.startsWith(UNSUPPORTED_PREFIX)) {
+          String test = "test@example." + tld.toLowerCase().substring(UNSUPPORTED_PREFIX.length());
+          assert_()
+              .withFailureMessage("expected invalid TLD \"" + test + "\"")
+              .that(validator.isValid(test))
+              .isFalse();
+        } else {
+          String test = "test@example." + tld.toLowerCase();
+          assert_()
+              .withFailureMessage("failed to validate TLD \"" + test + "\"")
+              .that(validator.isValid(test))
+              .isTrue();
+        }
+      }
+    }
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
index 9d15daf..15b74bd 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
@@ -120,7 +120,7 @@
     // unintentional auto-rebuilding of the change in NoteDb during the read
     // path of the reindex-if-stale check. For the purposes of this test, we
     // want precise control over when auto-rebuilding happens.
-    cfg.setBoolean("index", null, "testReindexAfterUpdate", false);
+    cfg.setBoolean("index", null, "testAutoReindexIfStale", false);
 
     return cfg;
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
new file mode 100644
index 0000000..4a5d496
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
@@ -0,0 +1,145 @@
+// Copyright (C) 2017 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.acceptance.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static java.util.stream.Collectors.toList;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+public class NoteDbOnlyIT extends AbstractDaemonTest {
+  @Inject private BatchUpdate.Factory batchUpdateFactory;
+
+  @Before
+  public void setUp() throws Exception {
+    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
+  }
+
+  @Test
+  public void updateChangeFailureRollsBackRefUpdate() throws Exception {
+    assume().that(notesMigration.fuseUpdates()).isTrue();
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    String master = "refs/heads/master";
+    String backup = "refs/backup/master";
+    ObjectId master1 = getRef(master).get();
+    assertThat(getRef(backup)).isEmpty();
+
+    // Toy op that copies the value of refs/heads/master to refs/backup/master.
+    BatchUpdateOp backupMasterOp =
+        new BatchUpdateOp() {
+          ObjectId newId;
+
+          @Override
+          public void updateRepo(RepoContext ctx) throws IOException {
+            ObjectId oldId = ctx.getRepoView().getRef(backup).orElse(ObjectId.zeroId());
+            newId = ctx.getRepoView().getRef(master).get();
+            ctx.addRefUpdate(oldId, newId, backup);
+          }
+
+          @Override
+          public boolean updateChange(ChangeContext ctx) {
+            ctx.getUpdate(ctx.getChange().currentPatchSetId())
+                .setChangeMessage("Backed up master branch to " + newId.name());
+            return true;
+          }
+        };
+
+    try (BatchUpdate bu = newBatchUpdate()) {
+      bu.addOp(id, backupMasterOp);
+      bu.execute();
+    }
+
+    // Ensure backupMasterOp worked.
+    assertThat(getRef(backup)).hasValue(master1);
+    assertThat(getMessages(id)).contains("Backed up master branch to " + master1.name());
+
+    // Advance master by submitting the change.
+    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(id.get()).current().submit();
+    ObjectId master2 = getRef(master).get();
+    assertThat(master2).isNotEqualTo(master1);
+    int msgCount = getMessages(id).size();
+
+    try (BatchUpdate bu = newBatchUpdate()) {
+      // This time, we attempt to back up master, but we fail during updateChange.
+      bu.addOp(id, backupMasterOp);
+      String msg = "Change is bad";
+      bu.addOp(
+          id,
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) throws ResourceConflictException {
+              throw new ResourceConflictException(msg);
+            }
+          });
+      try {
+        bu.execute();
+        assert_().fail("expected ResourceConflictException");
+      } catch (ResourceConflictException e) {
+        assertThat(e).hasMessageThat().isEqualTo(msg);
+      }
+    }
+
+    // If updateChange hadn't failed, backup would have been updated to master2.
+    assertThat(getRef(backup)).hasValue(master1);
+    assertThat(getMessages(id)).hasSize(msgCount);
+  }
+
+  private BatchUpdate newBatchUpdate() {
+    return batchUpdateFactory.create(
+        db, project, identifiedUserFactory.create(user.getId()), TimeUtil.nowTs());
+  }
+
+  private Optional<ObjectId> getRef(String name) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      return Optional.ofNullable(repo.exactRef(name)).map(Ref::getObjectId);
+    }
+  }
+
+  private List<String> getMessages(Change.Id id) throws Exception {
+    return gApi.changes()
+        .id(id.get())
+        .get(EnumSet.of(ListChangesOption.MESSAGES))
+        .messages
+        .stream()
+        .map(m -> m.message)
+        .collect(toList());
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index 174fb76..4eadaa9 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
@@ -39,7 +38,6 @@
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.inject.Inject;
-import java.util.Collections;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.List;
@@ -162,6 +160,127 @@
   }
 
   @Test
+  public void noNotificationForPrivateChangesForWatchersInNotifyConfig() throws Exception {
+    Address addr = new Address("Watcher", "watcher@example.com");
+    NotifyConfig nc = new NotifyConfig();
+    nc.addEmail(addr);
+    nc.setName("team");
+    nc.setHeader(NotifyConfig.Header.TO);
+    nc.setTypes(EnumSet.of(NotifyType.NEW_CHANGES, NotifyType.ALL_COMMENTS));
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.putNotifyConfig("team", nc);
+    saveProjectConfig(project, cfg);
+
+    sender.clear();
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "private change", "a", "a1")
+            .to("refs/for/master%private");
+    r.assertOkStatus();
+
+    assertThat(sender.getMessages()).isEmpty();
+
+    setApiUser(admin);
+    ReviewInput in = new ReviewInput();
+    in.message = "comment";
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void noNotificationForChangeThatIsTurnedPrivateForWatchersInNotifyConfig()
+      throws Exception {
+    Address addr = new Address("Watcher", "watcher@example.com");
+    NotifyConfig nc = new NotifyConfig();
+    nc.addEmail(addr);
+    nc.setName("team");
+    nc.setHeader(NotifyConfig.Header.TO);
+    nc.setTypes(EnumSet.of(NotifyType.NEW_PATCHSETS));
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.putNotifyConfig("team", nc);
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "subject", "a", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    sender.clear();
+
+    r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "subject", "a", "a2", r.getChangeId())
+            .to("refs/for/master%private");
+    r.assertOkStatus();
+
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void noNotificationForWipChangesForWatchersInNotifyConfig() throws Exception {
+    Address addr = new Address("Watcher", "watcher@example.com");
+    NotifyConfig nc = new NotifyConfig();
+    nc.addEmail(addr);
+    nc.setName("team");
+    nc.setHeader(NotifyConfig.Header.TO);
+    nc.setTypes(EnumSet.of(NotifyType.NEW_CHANGES, NotifyType.ALL_COMMENTS));
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.putNotifyConfig("team", nc);
+    saveProjectConfig(project, cfg);
+
+    sender.clear();
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "wip change", "a", "a1")
+            .to("refs/for/master%wip");
+    r.assertOkStatus();
+
+    assertThat(sender.getMessages()).isEmpty();
+
+    setApiUser(admin);
+    ReviewInput in = new ReviewInput();
+    in.message = "comment";
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void noNotificationForChangeThatIsTurnedWipForWatchersInNotifyConfig() throws Exception {
+    Address addr = new Address("Watcher", "watcher@example.com");
+    NotifyConfig nc = new NotifyConfig();
+    nc.addEmail(addr);
+    nc.setName("team");
+    nc.setHeader(NotifyConfig.Header.TO);
+    nc.setTypes(EnumSet.of(NotifyType.NEW_PATCHSETS));
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.putNotifyConfig("team", nc);
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "subject", "a", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    sender.clear();
+
+    r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "subject", "a", "a2", r.getChangeId())
+            .to("refs/for/master%wip");
+    r.assertOkStatus();
+
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
   public void watchProject() throws Exception {
     // watch project
     String watchedProject = createProject("watchedProject").get();
@@ -520,9 +639,7 @@
   @Test
   public void deleteAllProjectWatchesIfWatchConfigIsTheOnlyFileInUserBranch() throws Exception {
     // Create account that has no files in its refs/users/ branch.
-    Account.Id id = new Account.Id(db.nextAccountId());
-    Account a = new Account(id, TimeUtil.nowTs());
-    db.accounts().insert(Collections.singleton(a));
+    Account.Id id = accounts.create().id;
 
     // Add a project watch so that a watch.config file in the refs/users/ branch is created.
     Map<ProjectWatchKey, Set<NotifyType>> watches = new HashMap<>();
@@ -535,4 +652,69 @@
     watchConfig.deleteAllProjectWatches(id);
     assertThat(watchConfig.getProjectWatches(id)).isEmpty();
   }
+
+  @Test
+  public void watchProjectNoNotificationForPrivateChange() throws Exception {
+    // watch project
+    String watchedProject = createProject("watchedProject").get();
+    setApiUser(user);
+    watch(watchedProject, null);
+
+    // push a private change to watched project -> should not trigger email notification
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(new Project.NameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), watchedRepo, "private change", "a", "a1")
+            .to("refs/for/master%private");
+    r.assertOkStatus();
+
+    // assert email notification
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void watchProjectNotifyOnPrivateChange() throws Exception {
+    String watchedProject = createProject("watchedProject").get();
+
+    // create group that can view all private changes
+    GroupInfo groupThatCanViewPrivateChanges =
+        gApi.groups().create("groupThatCanViewPrivateChanges").get();
+    grant(
+        Permission.VIEW_PRIVATE_CHANGES,
+        new Project.NameKey(watchedProject),
+        "refs/*",
+        false,
+        new AccountGroup.UUID(groupThatCanViewPrivateChanges.id));
+
+    // watch project as user that can't view private changes
+    setApiUser(user);
+    watch(watchedProject, null);
+
+    // watch project as user that can view all private change
+    TestAccount userThatCanViewPrivateChanges =
+        accounts.create("user2", "user2@test.com", "User2", groupThatCanViewPrivateChanges.name);
+    setApiUser(userThatCanViewPrivateChanges);
+    watch(watchedProject, null);
+
+    // push a private change to watched project -> should trigger email notification for
+    // userThatCanViewPrivateChanges, but not for user
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(new Project.NameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), watchedRepo, "TRIGGER", "a", "a1")
+            .to("refs/for/master%private");
+    r.assertOkStatus();
+
+    // assert email notification
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(userThatCanViewPrivateChanges.emailAddress);
+    assertThat(m.body()).contains("Change subject: TRIGGER\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
index b1efb4a..19fcff9 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
@@ -83,10 +83,7 @@
     assertThat(userSshSession.hasError()).isTrue();
     String error = userSshSession.getError();
     assertThat(error).isNotNull();
-    assertError(
-        "One of the following capabilities is required to access this"
-            + " resource: [runGC, maintainServer]",
-        error);
+    assertError("maintain server not permitted", error);
   }
 
   @Test
diff --git a/gerrit-server/src/test/resources/com/google/gerrit/server/mail/send/tlds-alpha-by-domain.txt b/gerrit-acceptance-tests/src/test/resources/com/google/gerrit/acceptance/server/mail/tlds-alpha-by-domain.txt
similarity index 82%
rename from gerrit-server/src/test/resources/com/google/gerrit/server/mail/send/tlds-alpha-by-domain.txt
rename to gerrit-acceptance-tests/src/test/resources/com/google/gerrit/acceptance/server/mail/tlds-alpha-by-domain.txt
index 9edf6a4..4231f76 100644
--- a/gerrit-server/src/test/resources/com/google/gerrit/server/mail/send/tlds-alpha-by-domain.txt
+++ b/gerrit-acceptance-tests/src/test/resources/com/google/gerrit/acceptance/server/mail/tlds-alpha-by-domain.txt
@@ -1,10 +1,13 @@
-# Version 2016060601, Last Updated Tue Jun  7 07:07:01 2016 UTC
+# Version 2017032102, Last Updated Wed Mar 22 07:07:01 2017 UTC
 # From http://data.iana.org/TLD/tlds-alpha-by-domain.txt
 AAA
 AARP
+ABARTH
 ABB
 ABBOTT
 ABBVIE
+ABC
+ABLE
 ABOGADO
 ABUDHABI
 AC
@@ -22,30 +25,43 @@
 AE
 AEG
 AERO
-#! AETNA
+AETNA
 AF
+AFAMILYCOMPANY
 AFL
+#! AFRICA
 AG
 AGAKHAN
 AGENCY
 AI
 AIG
+AIGO
+AIRBUS
 AIRFORCE
 AIRTEL
 AKDN
 AL
+ALFAROMEO
 ALIBABA
 ALIPAY
 ALLFINANZ
+ALLSTATE
 ALLY
 ALSACE
+ALSTOM
 AM
+AMERICANEXPRESS
+AMERICANFAMILY
+AMEX
+AMFAM
 AMICA
 AMSTERDAM
 ANALYTICS
 ANDROID
 ANQUAN
+ANZ
 AO
+AOL
 APARTMENTS
 APP
 APPLE
@@ -56,16 +72,21 @@
 ARCHI
 ARMY
 ARPA
+ART
 ARTE
 AS
+ASDA
 ASIA
 ASSOCIATES
 AT
+ATHLETA
 ATTORNEY
 AU
 AUCTION
 AUDI
+AUDIBLE
 AUDIO
+AUSPOST
 AUTHOR
 AUTO
 AUTOS
@@ -79,6 +100,8 @@
 BA
 BABY
 BAIDU
+BANAMEX
+BANANAREPUBLIC
 BAND
 BANK
 BAR
@@ -87,20 +110,25 @@
 BARCLAYS
 BAREFOOT
 BARGAINS
+BASEBALL
+BASKETBALL
 BAUHAUS
 BAYERN
 BB
 BBC
+BBT
 BBVA
 BCG
 BCN
 BD
 BE
 BEATS
+BEAUTY
 BEER
 BENTLEY
 BERLIN
 BEST
+BESTBUY
 BET
 BF
 BG
@@ -117,7 +145,9 @@
 BJ
 BLACK
 BLACKFRIDAY
-#! BLOG
+BLANCO
+BLOCKBUSTER
+BLOG
 BLOOMBERG
 BLUE
 BM
@@ -129,15 +159,19 @@
 BO
 BOATS
 BOEHRINGER
+BOFA
 BOM
 BOND
 BOO
 BOOK
+BOOKING
 BOOTS
 BOSCH
 BOSTIK
+BOSTON
 BOT
 BOUTIQUE
+BOX
 BR
 BRADESCO
 BRIDGESTONE
@@ -164,12 +198,15 @@
 CAFE
 CAL
 CALL
+CALVINKLEIN
+CAM
 CAMERA
 CAMP
 CANCERRESEARCH
 CANON
 CAPETOWN
 CAPITAL
+CAPITALONE
 CAR
 CARAVAN
 CARDS
@@ -179,12 +216,17 @@
 CARS
 CARTIER
 CASA
+CASE
+CASEIH
 CASH
 CASINO
 CAT
 CATERING
+CATHOLIC
 CBA
 CBN
+CBRE
+CBS
 CC
 CD
 CEB
@@ -201,14 +243,18 @@
 CHASE
 CHAT
 CHEAP
+CHINTAI
 CHLOE
 CHRISTMAS
 CHROME
+CHRYSLER
 CHURCH
 CI
 CIPRIANI
 CIRCLE
 CISCO
+CITADEL
+CITI
 CITIC
 CITY
 CITYEATS
@@ -232,6 +278,7 @@
 COLLEGE
 COLOGNE
 COM
+COMCAST
 COMMBANK
 COMMUNITY
 COMPANY
@@ -244,6 +291,7 @@
 CONTACT
 CONTRACTORS
 COOKING
+COOKINGCHANNEL
 COOL
 COOP
 CORSICA
@@ -258,6 +306,7 @@
 CRICKET
 CROWN
 CRS
+CRUISE
 CRUISES
 CSC
 CU
@@ -272,13 +321,15 @@
 DABUR
 DAD
 DANCE
+DATA
 DATE
 DATING
 DATSUN
 DAY
 DCLK
-#! DDS
+DDS
 DE
+DEAL
 DEALER
 DEALS
 DEGREE
@@ -292,33 +343,44 @@
 DESI
 DESIGN
 DEV
-#! DHL
+DHL
 DIAMONDS
 DIET
 DIGITAL
 DIRECT
 DIRECTORY
 DISCOUNT
+DISCOVER
+DISH
+DIY
 DJ
 DK
 DM
 DNP
 DO
 DOCS
+DOCTOR
+DODGE
 DOG
 DOHA
 DOMAINS
-#! DOT
+DOT
 DOWNLOAD
 DRIVE
-#! DTV
+DTV
 DUBAI
+DUCK
+DUNLOP
+DUNS
+DUPONT
 DURBAN
 DVAG
+DVR
 DZ
 EARTH
 EAT
 EC
+ECO
 EDEKA
 EDU
 EDUCATION
@@ -330,13 +392,16 @@
 ENGINEER
 ENGINEERING
 ENTERPRISES
+EPOST
 EPSON
 EQUIPMENT
 ER
+ERICSSON
 ERNI
 ES
 ESQ
 ESTATE
+ESURANCE
 ET
 EU
 EUROVISION
@@ -356,15 +421,22 @@
 FAN
 FANS
 FARM
+FARMERS
 FASHION
 FAST
+FEDEX
 FEEDBACK
+FERRARI
 FERRERO
 FI
+FIAT
+FIDELITY
+FIDO
 FILM
 FINAL
 FINANCE
 FINANCIAL
+FIRE
 FIRESTONE
 FIRMDALE
 FISH
@@ -375,14 +447,15 @@
 FK
 FLICKR
 FLIGHTS
-#! FLIR
+FLIR
 FLORIST
 FLOWERS
-FLSMIDTH
 FLY
 FM
 FO
 FOO
+FOOD
+FOODNETWORK
 FOOTBALL
 FORD
 FOREX
@@ -391,11 +464,16 @@
 FOUNDATION
 FOX
 FR
+FREE
 FRESENIUS
 FRL
 FROGANS
+FRONTDOOR
 FRONTIER
 FTR
+FUJITSU
+FUJIXEROX
+FUN
 FUND
 FURNITURE
 FUTBOL
@@ -406,7 +484,8 @@
 GALLO
 GALLUP
 GAME
-#! GAMES
+GAMES
+GAP
 GARDEN
 GB
 GBIZ
@@ -416,6 +495,7 @@
 GEA
 GENT
 GENTING
+GEORGE
 GF
 GG
 GGEE
@@ -426,6 +506,7 @@
 GIVES
 GIVING
 GL
+GLADE
 GLASS
 GLE
 GLOBAL
@@ -436,10 +517,13 @@
 GMO
 GMX
 GN
+GODADDY
 GOLD
 GOLDPOINT
 GOLF
 GOO
+GOODHANDS
+GOODYEAR
 GOOG
 GOOGLE
 GOP
@@ -457,7 +541,7 @@
 GS
 GT
 GU
-#! GUARDIAN
+GUARDIAN
 GUCCI
 GUGE
 GUIDE
@@ -465,9 +549,12 @@
 GURU
 GW
 GY
+HAIR
 HAMBURG
 HANGOUT
 HAUS
+HBO
+HDFC
 HDFCBANK
 HEALTH
 HEALTHCARE
@@ -475,23 +562,29 @@
 HELSINKI
 HERE
 HERMES
+HGTV
 HIPHOP
-#! HISAMITSU
+HISAMITSU
 HITACHI
 HIV
 HK
-#! HKT
+HKT
 HM
 HN
 HOCKEY
 HOLDINGS
 HOLIDAY
 HOMEDEPOT
+HOMEGOODS
 HOMES
+HOMESENSE
 HONDA
+HONEYWELL
 HORSE
+HOSPITAL
 HOST
 HOSTING
+HOT
 HOTELES
 HOTMAIL
 HOUSE
@@ -501,6 +594,8 @@
 HT
 HTC
 HU
+HUGHES
+HYATT
 HYUNDAI
 IBM
 ICBC
@@ -508,11 +603,13 @@
 ICU
 ID
 IE
+IEEE
 IFM
-IINET
+IKANO
 IL
 IM
 IMAMAT
+IMDB
 IMMO
 IMMOBILIEN
 IN
@@ -525,7 +622,9 @@
 INSURANCE
 INSURE
 INT
+INTEL
 INTERNATIONAL
+INTUIT
 INVESTMENTS
 IO
 IPIRANGA
@@ -539,14 +638,18 @@
 ISTANBUL
 IT
 ITAU
+ITV
+IVECO
 IWC
 JAGUAR
 JAVA
 JCB
 JCP
 JE
+JEEP
 JETZT
 JEWELRY
+JIO
 JLC
 JLL
 JM
@@ -561,6 +664,7 @@
 JPMORGAN
 JPRS
 JUEGOS
+JUNIPER
 KAUFEN
 KDDI
 KE
@@ -574,12 +678,14 @@
 KIA
 KIM
 KINDER
+KINDLE
 KITCHEN
 KIWI
 KM
 KN
 KOELN
 KOMATSU
+KOSHER
 KP
 KPMG
 KPN
@@ -593,14 +699,18 @@
 KZ
 LA
 LACAIXA
+LADBROKES
 LAMBORGHINI
 LAMER
 LANCASTER
+LANCIA
+LANCOME
 LAND
 LANDROVER
 LANXESS
 LASALLE
 LAT
+LATINO
 LATROBE
 LAW
 LAWYER
@@ -609,7 +719,9 @@
 LDS
 LEASE
 LECLERC
+LEFRAK
 LEGAL
+LEGO
 LEXUS
 LGBT
 LI
@@ -620,37 +732,43 @@
 LIFESTYLE
 LIGHTING
 LIKE
+LILLY
 LIMITED
 LIMO
 LINCOLN
 LINDE
 LINK
-#! LIPSY
+LIPSY
 LIVE
 LIVING
 LIXIL
 LK
 LOAN
 LOANS
-#! LOCKER
+LOCKER
 LOCUS
+LOFT
 LOL
 LONDON
 LOTTE
 LOTTO
 LOVE
+LPL
+LPLFINANCIAL
 LR
 LS
 LT
 LTD
 LTDA
 LU
+LUNDBECK
 LUPIN
 LUXE
 LUXURY
 LV
 LY
 MA
+MACYS
 MADRID
 MAIF
 MAISON
@@ -662,9 +780,14 @@
 MARKETING
 MARKETS
 MARRIOTT
-#! MATTEL
+MARSHALLS
+MASERATI
+MATTEL
 MBA
 MC
+MCD
+MCDONALDS
+MCKINSEY
 MD
 ME
 MED
@@ -676,22 +799,26 @@
 MEN
 MENU
 MEO
-#! METLIFE
+METLIFE
 MG
 MH
 MIAMI
 MICROSOFT
 MIL
 MINI
+MINT
+MIT
+MITSUBISHI
 MK
 ML
-#! MLB
+MLB
 MLS
 MM
 MMA
 MN
 MO
 MOBI
+MOBILE
 MOBILY
 MODA
 MOE
@@ -699,10 +826,13 @@
 MOM
 MONASH
 MONEY
+MONSTER
 MONTBLANC
+MOPAR
 MORMON
 MORTGAGE
 MOSCOW
+MOTO
 MOTORCYCLES
 MOV
 MOVIE
@@ -711,6 +841,7 @@
 MQ
 MR
 MS
+MSD
 MT
 MTN
 MTPC
@@ -718,37 +849,42 @@
 MU
 MUSEUM
 MUTUAL
-MUTUELLE
 MV
 MW
 MX
 MY
 MZ
 NA
+NAB
 NADEX
 NAGOYA
 NAME
+NATIONWIDE
 NATURA
 NAVY
+NBA
 NC
 NE
 NEC
 NET
 NETBANK
-#! NETFLIX
+NETFLIX
 NETWORK
 NEUSTAR
 NEW
+NEWHOLLAND
 NEWS
-#! NEXT
-#! NEXTDIRECT
+NEXT
+NEXTDIRECT
 NEXUS
 NF
+NFL
 NG
 NGO
 NHK
 NI
 NICO
+NIKE
 NIKON
 NINJA
 NISSAN
@@ -758,8 +894,9 @@
 NOKIA
 NORTHWESTERNMUTUAL
 NORTON
+NOW
 NOWRUZ
-#! NOWTV
+NOWTV
 NP
 NR
 NRA
@@ -769,30 +906,37 @@
 NYC
 NZ
 OBI
+OBSERVER
+OFF
 OFFICE
 OKINAWA
-#! OLAYAN
-#! OLAYANGROUP
-#! OLLO
+OLAYAN
+OLAYANGROUP
+OLDNAVY
+OLLO
 OM
 OMEGA
 ONE
 ONG
 ONL
 ONLINE
+ONYOURSIDE
 OOO
+OPEN
 ORACLE
 ORANGE
 ORG
 ORGANIC
+ORIENTEXPRESS
 ORIGINS
 OSAKA
 OTSUKA
-#! OTT
+OTT
 OVH
 PA
 PAGE
 PAMPEREDCHEF
+PANASONIC
 PANERAI
 PARIS
 PARS
@@ -800,14 +944,17 @@
 PARTS
 PARTY
 PASSAGENS
-#! PCCW
+PAY
+PCCW
 PE
 PET
 PF
+PFIZER
 PG
 PH
 PHARMACY
 PHILIPS
+PHONE
 PHOTO
 PHOTOGRAPHY
 PHOTOS
@@ -820,7 +967,7 @@
 PIN
 PING
 PINK
-#! PIONEER
+PIONEER
 PIZZA
 PK
 PL
@@ -831,13 +978,17 @@
 PLUS
 PM
 PN
+PNC
 POHL
 POKER
+POLITIE
 PORN
 POST
 PR
+PRAMERICA
 PRAXI
 PRESS
+PRIME
 PRO
 PROD
 PRODUCTIONS
@@ -847,6 +998,8 @@
 PROPERTIES
 PROPERTY
 PROTECTION
+PRU
+PRUDENTIAL
 PS
 PT
 PUB
@@ -857,10 +1010,13 @@
 QPON
 QUEBEC
 QUEST
+QVC
 RACING
+RADIO
+RAID
 RE
 READ
-#! REALESTATE
+REALESTATE
 REALTOR
 REALTY
 RECIPES
@@ -871,6 +1027,7 @@
 REISE
 REISEN
 REIT
+RELIANCE
 REN
 RENT
 RENTALS
@@ -883,14 +1040,18 @@
 REVIEWS
 REXROTH
 RICH
-#! RICHARDLI
+RICHARDLI
 RICOH
+RIGHTATHOME
+RIL
 RIO
 RIP
+RMIT
 RO
 ROCHER
 ROCKS
 RODEO
+ROGERS
 ROOM
 RS
 RSVP
@@ -907,6 +1068,7 @@
 SAKURA
 SALE
 SALON
+SAMSCLUB
 SAMSUNG
 SANDVIK
 SANDVIKCOROMANT
@@ -915,6 +1077,7 @@
 SAPO
 SARL
 SAS
+SAVE
 SAXO
 SB
 SBI
@@ -929,16 +1092,19 @@
 SCHULE
 SCHWARZ
 SCIENCE
+SCJOHNSON
 SCOR
 SCOT
 SD
 SE
 SEAT
+SECURE
 SECURITY
 SEEK
 SELECT
 SENER
 SERVICES
+SES
 SEVEN
 SEW
 SEX
@@ -946,17 +1112,21 @@
 SFR
 SG
 SH
+SHANGRILA
 SHARP
 SHAW
 SHELL
 SHIA
 SHIKSHA
 SHOES
-#! SHOP
+SHOP
+SHOPPING
 SHOUJI
 SHOW
+SHOWTIME
 SHRIRAM
 SI
+SILK
 SINA
 SINGLES
 SITE
@@ -967,7 +1137,9 @@
 SKY
 SKYPE
 SL
+SLING
 SM
+SMART
 SMILE
 SN
 SNCF
@@ -988,8 +1160,10 @@
 SPREADBETTING
 SR
 SRL
+SRT
 ST
 STADA
+STAPLES
 STAR
 STARHUB
 STATEBANK
@@ -1014,6 +1188,7 @@
 SUZUKI
 SV
 SWATCH
+SWIFTCOVER
 SWISS
 SX
 SY
@@ -1025,6 +1200,7 @@
 TAIPEI
 TALK
 TAOBAO
+TARGET
 TATAMOTORS
 TATAR
 TATTOO
@@ -1033,6 +1209,7 @@
 TC
 TCI
 TD
+TDK
 TEAM
 TECH
 TECHNOLOGY
@@ -1048,6 +1225,7 @@
 THD
 THEATER
 THEATRE
+TIAA
 TICKETS
 TIENDA
 TIFFANY
@@ -1055,7 +1233,10 @@
 TIRES
 TIROL
 TJ
+TJMAXX
+TJX
 TK
+TKMAXX
 TL
 TM
 TMALL
@@ -1077,6 +1258,7 @@
 TRADING
 TRAINING
 TRAVEL
+TRAVELCHANNEL
 TRAVELERS
 TRAVELERSINSURANCE
 TRUST
@@ -1091,20 +1273,23 @@
 TW
 TZ
 UA
+UBANK
 UBS
+UCONNECT
 UG
 UK
 UNICOM
 UNIVERSITY
 UNO
 UOL
-#! UPS
+UPS
 US
 UY
 UZ
 VA
 VACATIONS
 VANA
+VANGUARD
 VC
 VE
 VEGAS
@@ -1122,14 +1307,17 @@
 VIN
 VIP
 VIRGIN
+VISA
 VISION
 VISTA
 VISTAPRINT
 VIVA
+VIVO
 VLAANDEREN
 VN
 VODKA
 VOLKSWAGEN
+VOLVO
 VOTE
 VOTING
 VOTO
@@ -1137,10 +1325,11 @@
 VU
 VUELOS
 WALES
+WALMART
 WALTER
 WANG
 WANGGOU
-#! WARMAN
+WARMAN
 WATCH
 WATCHES
 WEATHER
@@ -1160,16 +1349,20 @@
 WIN
 WINDOWS
 WINE
+WINNERS
 WME
 WOLTERSKLUWER
+WOODSIDE
 WORK
 WORKS
 WORLD
+WOW
 WS
 WTC
 WTF
 XBOX
 XEROX
+XFINITY
 XIHUAN
 XIN
 XN--11B4C3D
@@ -1179,22 +1372,27 @@
 XN--3BST00M
 XN--3DS443G
 XN--3E0B707E
+XN--3OQ18VL8PN36A
 XN--3PXU8K
 XN--42C2D9A
 XN--45BRJ9C
 XN--45Q11C
 XN--4GBRIM
+XN--54B7FTA0CC
 XN--55QW42G
 XN--55QX5D
+XN--5SU34J936BGSG
 XN--5TZM5G
 XN--6FRZ82G
 XN--6QQ986B3XL
 XN--80ADXHKS
 XN--80AO21A
+XN--80AQECDR1A
 XN--80ASEHDB
 XN--80ASWG
 XN--8Y0A063A
 XN--90A3AC
+XN--90AE
 XN--90AIS
 XN--9DBQ2A
 XN--9ET52U
@@ -1229,6 +1427,7 @@
 XN--G2XX48C
 XN--GCKR3F0F
 XN--GECRJ9C
+XN--GK3AT1E
 XN--H2BRJ9C
 XN--HXT814E
 XN--I1B6B1A6A2E
@@ -1252,12 +1451,14 @@
 XN--MGBA7C0BBN0A
 XN--MGBAAM7A8H
 XN--MGBAB2BD
+XN--MGBAI9AZGQP6J
 XN--MGBAYH7GPA
 XN--MGBB9FBPOB
 XN--MGBBH1A71E
 XN--MGBC0A9AZCG
 XN--MGBCA7DZDO
 XN--MGBERP4A5D4AR
+XN--MGBI4ECEXP
 XN--MGBPL2FH
 XN--MGBT3DHD
 XN--MGBTX2B
@@ -1287,6 +1488,7 @@
 XN--SES554G
 XN--T60B56A
 XN--TCKWE
+XN--TIQ49XQYJ
 XN--UNUP4Y
 XN--VERMGENSBERATER-CTB
 XN--VERMGENSBERATUNG-PWB
@@ -1319,10 +1521,11 @@
 YT
 YUN
 ZA
-#! ZAPPOS
+ZAPPOS
 ZARA
 ZERO
 ZIP
+ZIPPO
 ZM
 ZONE
 ZUERICH
diff --git a/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index 3b86c95..8bdc410 100644
--- a/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -131,7 +131,22 @@
 
   @Override
   public V get(K key, Callable<? extends V> valueLoader) throws ExecutionException {
-    return mem.get(key, new LoadingCallable(key, valueLoader)).value;
+    return mem.get(
+            key,
+            () -> {
+              if (store.mightContain(key)) {
+                ValueHolder<V> h = store.getIfPresent(key);
+                if (h != null) {
+                  return h;
+                }
+              }
+
+              ValueHolder<V> h = new ValueHolder<>(valueLoader.call());
+              h.created = TimeUtil.nowMs();
+              executor.execute(() -> store.put(key, h));
+              return h;
+            })
+        .value;
   }
 
   @Override
@@ -239,31 +254,6 @@
     }
   }
 
-  private class LoadingCallable implements Callable<ValueHolder<V>> {
-    private final K key;
-    private final Callable<? extends V> loader;
-
-    LoadingCallable(K key, Callable<? extends V> loader) {
-      this.key = key;
-      this.loader = loader;
-    }
-
-    @Override
-    public ValueHolder<V> call() throws Exception {
-      if (store.mightContain(key)) {
-        ValueHolder<V> h = store.getIfPresent(key);
-        if (h != null) {
-          return h;
-        }
-      }
-
-      final ValueHolder<V> h = new ValueHolder<>(loader.call());
-      h.created = TimeUtil.nowMs();
-      executor.execute(() -> store.put(key, h));
-      return h;
-    }
-  }
-
   private static class KeyType<K> {
     String columnType() {
       return "OTHER";
diff --git a/gerrit-cache-h2/src/test/java/com/google/gerrit/server/cache/h2/H2CacheTest.java b/gerrit-cache-h2/src/test/java/com/google/gerrit/server/cache/h2/H2CacheTest.java
index 15e0de0..80bca6d 100644
--- a/gerrit-cache-h2/src/test/java/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/gerrit-cache-h2/src/test/java/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.server.cache.h2.H2CacheImpl.SqlStore;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
 import com.google.inject.TypeLiteral;
-import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import org.junit.Before;
@@ -54,12 +53,9 @@
     assertTrue(
         impl.get(
             "foo",
-            new Callable<Boolean>() {
-              @Override
-              public Boolean call() throws Exception {
-                called.set(true);
-                return true;
-              }
+            () -> {
+              called.set(true);
+              return true;
             }));
     assertTrue("used Callable", called.get());
     assertTrue("exists in cache", impl.getIfPresent("foo"));
@@ -70,12 +66,9 @@
     assertTrue(
         impl.get(
             "foo",
-            new Callable<Boolean>() {
-              @Override
-              public Boolean call() throws Exception {
-                called.set(true);
-                return true;
-              }
+            () -> {
+              called.set(true);
+              return true;
             }));
     assertFalse("did not invoke Callable", called.get());
   }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GlobalCapability.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/GlobalCapability.java
index 4c9b64a..6fd0e77 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/GlobalCapability.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/GlobalCapability.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.common.data;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
@@ -115,6 +116,9 @@
 
   private static final List<String> NAMES_ALL;
   private static final List<String> NAMES_LC;
+  private static final String[] RANGE_NAMES = {
+    QUERY_LIMIT, BATCH_CHANGES_LIMIT,
+  };
 
   static {
     NAMES_ALL = new ArrayList<>();
@@ -158,7 +162,16 @@
 
   /** @return true if the capability should have a range attached. */
   public static boolean hasRange(String varName) {
-    return QUERY_LIMIT.equalsIgnoreCase(varName) || BATCH_CHANGES_LIMIT.equalsIgnoreCase(varName);
+    for (String n : RANGE_NAMES) {
+      if (n.equalsIgnoreCase(varName)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  public static List<String> getRangeNames() {
+    return Collections.unmodifiableList(Arrays.asList(RANGE_NAMES));
   }
 
   /** @return the valid range for the capability if it has one, otherwise null. */
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java
index 47c5224..6222c1b 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java
@@ -47,6 +47,7 @@
   public static final String SUBMIT = "submit";
   public static final String SUBMIT_AS = "submitAs";
   public static final String VIEW_DRAFTS = "viewDrafts";
+  public static final String VIEW_PRIVATE_CHANGES = "viewPrivateChanges";
 
   private static final List<String> NAMES_LC;
   private static final int LABEL_INDEX;
@@ -74,6 +75,7 @@
     NAMES_LC.add(SUBMIT.toLowerCase());
     NAMES_LC.add(SUBMIT_AS.toLowerCase());
     NAMES_LC.add(VIEW_DRAFTS.toLowerCase());
+    NAMES_LC.add(VIEW_PRIVATE_CHANGES.toLowerCase());
     NAMES_LC.add(EDIT_TOPIC_NAME.toLowerCase());
     NAMES_LC.add(EDIT_HASHTAGS.toLowerCase());
     NAMES_LC.add(EDIT_ASSIGNEE.toLowerCase());
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
index 2a46c40..c13d8d7 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
@@ -38,9 +38,9 @@
 import com.google.gson.JsonObject;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import io.searchbox.client.JestResult;
 import io.searchbox.core.Bulk;
 import io.searchbox.core.Bulk.Builder;
@@ -76,7 +76,7 @@
   private final AccountMapping mapping;
   private final Provider<AccountCache> accountCache;
 
-  @AssistedInject
+  @Inject
   ElasticAccountIndex(
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
index 4f90de5..b0adbaf 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.reviewdb.client.Change.Id;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
@@ -54,9 +55,9 @@
 import com.google.gson.JsonObject;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import io.searchbox.client.JestResult;
 import io.searchbox.core.Bulk;
 import io.searchbox.core.Bulk.Builder;
@@ -99,7 +100,7 @@
   private final Provider<ReviewDb> db;
   private final ChangeData.Factory changeDataFactory;
 
-  @AssistedInject
+  @Inject
   ElasticChangeIndex(
       @GerritServerConfig Config cfg,
       Provider<ReviewDb> db,
@@ -343,6 +344,16 @@
         cd.setReviewers(ReviewerSet.empty());
       }
 
+      if (source.get(ChangeField.REVIEWER_BY_EMAIL.getName()) != null) {
+        cd.setReviewersByEmail(
+            ChangeField.parseReviewerByEmailFieldValues(
+                FluentIterable.from(
+                        source.get(ChangeField.REVIEWER_BY_EMAIL.getName()).getAsJsonArray())
+                    .transform(JsonElement::getAsString)));
+      } else if (fields.contains(ChangeField.REVIEWER_BY_EMAIL.getName())) {
+        cd.setReviewersByEmail(ReviewerByEmailSet.empty());
+      }
+
       decodeSubmitRecords(
           source,
           ChangeField.STORED_SUBMIT_RECORD_STRICT.getName(),
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
index e2c34f2..018f8d8 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
@@ -35,9 +35,9 @@
 import com.google.gson.JsonObject;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import io.searchbox.client.JestResult;
 import io.searchbox.core.Bulk;
 import io.searchbox.core.Bulk.Builder;
@@ -73,7 +73,7 @@
   private final GroupMapping mapping;
   private final Provider<GroupCache> groupCache;
 
-  @AssistedInject
+  @Inject
   ElasticGroupIndex(
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
index 65065f9..2108815 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
@@ -72,6 +72,6 @@
   @Provides
   @Singleton
   IndexConfig getIndexConfig(@GerritServerConfig Config cfg) {
-    return IndexConfig.fromConfig(cfg);
+    return IndexConfig.fromConfig(cfg).separateChangeSubIndexes(true).build();
   }
 }
diff --git a/gerrit-extension-api/pom.xml b/gerrit-extension-api/pom.xml
index 5cc0c7e..2c884be 100644
--- a/gerrit-extension-api/pom.xml
+++ b/gerrit-extension-api/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>2.14</version>
+  <version>2.15-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Exports.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Exports.java
index 05fd5b2..1295ea0 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Exports.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Exports.java
@@ -21,5 +21,10 @@
     return new ExportImpl(name);
   }
 
+  /** Create an annotation to export based on a cannonical class name. */
+  public static Export named(Class<?> clazz) {
+    return named(clazz.getCanonicalName());
+  }
+
   private Exports() {}
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/GlobalOrPluginPermission.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/GlobalOrPluginPermission.java
new file mode 100644
index 0000000..deae084
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/GlobalOrPluginPermission.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2017 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.access;
+
+/**
+ * A {@link com.google.gerrit.server.permissions.GlobalPermission} or a {@link PluginPermission}.
+ */
+public interface GlobalOrPluginPermission {
+  /** @return name used in {@code project.config} permissions. */
+  public String permissionName();
+
+  /** @return readable identifier of this permission for exception message. */
+  public String describeForException();
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PluginPermission.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PluginPermission.java
new file mode 100644
index 0000000..33a85cd
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PluginPermission.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2017 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.access;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import java.util.Objects;
+
+/** A global capability type permission used by a plugin. */
+public class PluginPermission implements GlobalOrPluginPermission {
+  private final String pluginName;
+  private final String capability;
+
+  public PluginPermission(String pluginName, String capability) {
+    this.pluginName = checkNotNull(pluginName, "pluginName");
+    this.capability = checkNotNull(capability, "capability");
+  }
+
+  public String pluginName() {
+    return pluginName;
+  }
+
+  public String capability() {
+    return capability;
+  }
+
+  @Override
+  public String permissionName() {
+    return pluginName + '-' + capability;
+  }
+
+  @Override
+  public String describeForException() {
+    return capability + " for plugin " + pluginName;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(pluginName, capability);
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (other instanceof PluginPermission) {
+      PluginPermission b = (PluginPermission) other;
+      return pluginName.equals(b.pluginName) && capability.equals(b.capability);
+    }
+    return false;
+  }
+
+  @Override
+  public String toString() {
+    return "PluginPermission[plugin=" + pluginName + ", capability=" + capability + ']';
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 27fdc18..8d7a452 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -85,6 +85,34 @@
 
   void move(MoveInput in) throws RestApiException;
 
+  void setPrivate(boolean value) throws RestApiException;
+
+  void setWorkInProgress(String message) throws RestApiException;
+
+  void setReadyForReview(String message) throws RestApiException;
+
+  default void setWorkInProgress() throws RestApiException {
+    setWorkInProgress(null);
+  }
+
+  default void setReadyForReview() throws RestApiException {
+    setReadyForReview(null);
+  }
+
+  /**
+   * Ignore or un-ignore this change.
+   *
+   * @param ignore ignore the change if true
+   */
+  void ignore(boolean ignore) throws RestApiException;
+
+  /**
+   * Mute or un-mute this change.
+   *
+   * @param mute mute the change if true
+   */
+  void mute(boolean mute) throws RestApiException;
+
   /**
    * Create a new change that reverts this change.
    *
@@ -129,9 +157,9 @@
 
   IncludedInInfo includedIn() throws RestApiException;
 
-  void addReviewer(AddReviewerInput in) throws RestApiException;
+  AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException;
 
-  void addReviewer(String in) throws RestApiException;
+  AddReviewerResult addReviewer(String in) throws RestApiException;
 
   SuggestedReviewersRequest suggestReviewers() throws RestApiException;
 
@@ -307,6 +335,21 @@
     }
 
     @Override
+    public void setPrivate(boolean value) {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void setWorkInProgress(String message) {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void setReadyForReview(String message) {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public ChangeApi revert() {
       throw new NotImplementedException();
     }
@@ -352,12 +395,12 @@
     }
 
     @Override
-    public void addReviewer(AddReviewerInput in) {
+    public AddReviewerResult addReviewer(AddReviewerInput in) {
       throw new NotImplementedException();
     }
 
     @Override
-    public void addReviewer(String in) {
+    public AddReviewerResult addReviewer(String in) {
       throw new NotImplementedException();
     }
 
@@ -476,5 +519,15 @@
     public ChangeInfo createMergePatchSet(MergePatchSetInput in) {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void ignore(boolean ignore) {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void mute(boolean mute) {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java
index af61481..3a33de9 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java
@@ -25,6 +25,13 @@
    */
   @Nullable public Map<String, String> approvals;
 
+  public static ReviewerInfo byEmail(@Nullable String name, String email) {
+    ReviewerInfo info = new ReviewerInfo();
+    info.name = name;
+    info.email = email;
+    return info;
+  }
+
   public ReviewerInfo(Integer id) {
     super(id);
   }
@@ -33,4 +40,6 @@
   public String toString() {
     return username;
   }
+
+  private ReviewerInfo() {}
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index 5dd4ba4..9969995 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.common.MergeableInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
@@ -86,6 +87,17 @@
 
   List<RobotCommentInfo> robotCommentsAsList() throws RestApiException;
 
+  /**
+   * Applies the indicated fix by creating a new change edit or integrating the fix with the
+   * existing change edit. If no change edit exists before this call, the fix must refer to the
+   * current patch set. If a change edit exists, the fix must refer to the patch set on which the
+   * change edit is based.
+   *
+   * @param fixId the ID of the fix which should be applied
+   * @throws RestApiException if the fix couldn't be applied
+   */
+  EditInfo applyFix(String fixId) throws RestApiException;
+
   DraftApi createDraft(DraftInput in) throws RestApiException;
 
   DraftApi draft(String id) throws RestApiException;
@@ -255,6 +267,11 @@
     }
 
     @Override
+    public EditInfo applyFix(String fixId) {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, List<CommentInfo>> drafts() {
       throw new NotImplementedException();
     }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
new file mode 100644
index 0000000..fab2ec4
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
@@ -0,0 +1,23 @@
+// Copyright (C) 2017 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.config;
+
+public class AccessCheckInfo {
+  public String message;
+  // HTTP status code
+  public int status;
+
+  // for future extension, we may add inputs / results for bulk checks.
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/AccessCheckInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/AccessCheckInput.java
new file mode 100644
index 0000000..80a537c
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/AccessCheckInput.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2017 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.config;
+
+import com.google.gerrit.common.Nullable;
+
+public class AccessCheckInput {
+  public String account;
+  public String project;
+
+  @Nullable public String ref;
+
+  public AccessCheckInput(String account, String project, @Nullable String ref) {
+    this.account = account;
+    this.project = project;
+    this.ref = ref;
+  }
+
+  public AccessCheckInput() {}
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java
new file mode 100644
index 0000000..e3b7dd3
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2017 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.config;
+
+import java.util.List;
+import java.util.Objects;
+
+public class ConsistencyCheckInfo {
+  public CheckAccountExternalIdsResultInfo checkAccountExternalIdsResult;
+
+  public static class CheckAccountExternalIdsResultInfo {
+    public List<ConsistencyProblemInfo> problems;
+
+    public CheckAccountExternalIdsResultInfo(List<ConsistencyProblemInfo> problems) {
+      this.problems = problems;
+    }
+  }
+
+  public static class ConsistencyProblemInfo {
+    public enum Status {
+      ERROR,
+      WARNING,
+    }
+
+    public final Status status;
+    public final String message;
+
+    public ConsistencyProblemInfo(Status status, String message) {
+      this.status = status;
+      this.message = message;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof ConsistencyProblemInfo) {
+        ConsistencyProblemInfo other = ((ConsistencyProblemInfo) o);
+        return Objects.equals(status, other.status) && Objects.equals(message, other.message);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(status, message);
+    }
+
+    @Override
+    public String toString() {
+      return status.name() + ": " + message;
+    }
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInput.java
new file mode 100644
index 0000000..170db0f
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInput.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2017 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.config;
+
+public class ConsistencyCheckInput {
+  public CheckAccountExternalIdsInput checkAccountExternalIds;
+
+  public static class CheckAccountExternalIdsInput {}
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java
index 97f4af0..2280396 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java
@@ -34,6 +34,10 @@
 
   DiffPreferencesInfo setDefaultDiffPreferences(DiffPreferencesInfo in) throws RestApiException;
 
+  ConsistencyCheckInfo checkConsistency(ConsistencyCheckInput in) throws RestApiException;
+
+  AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -68,5 +72,15 @@
     public DiffPreferencesInfo setDefaultDiffPreferences(DiffPreferencesInfo in) {
       throw new NotImplementedException();
     }
+
+    @Override
+    public ConsistencyCheckInfo checkConsistency(ConsistencyCheckInput in) {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public AccessCheckInfo checkAccess(AccessCheckInput in) {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/CommitApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/CommitApi.java
new file mode 100644
index 0000000..85bd952
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/CommitApi.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2017 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.projects;
+
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+public interface CommitApi {
+
+  ChangeApi cherryPick(CherryPickInput input) throws RestApiException;
+
+  /** A default implementation for source compatibility when adding new methods to the interface. */
+  class NotImplemented implements CommitApi {
+    @Override
+    public ChangeApi cherryPick(CherryPickInput input) {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
index cc91a4a..e30a730 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
@@ -31,6 +31,7 @@
   public InheritedBooleanInfo enableSignedPush;
   public InheritedBooleanInfo requireSignedPush;
   public InheritedBooleanInfo rejectImplicitMerges;
+  public InheritedBooleanInfo enableReviewerByEmail;
   public MaxObjectSizeLimitInfo maxObjectSizeLimit;
   public SubmitType submitType;
   public ProjectState state;
@@ -40,6 +41,8 @@
   public Map<String, CommentLinkInfo> commentlinks;
   public ThemeInfo theme;
 
+  public Map<String, List<String>> extensionPanelNames;
+
   public static class InheritedBooleanInfo {
     public Boolean value;
     public InheritableBoolean configuredValue;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
index ae81ea5..03b9772 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
@@ -29,6 +29,7 @@
   public InheritableBoolean enableSignedPush;
   public InheritableBoolean requireSignedPush;
   public InheritableBoolean rejectImplicitMerges;
+  public InheritableBoolean enableReviewerByEmail;
   public String maxObjectSizeLimit;
   public SubmitType submitType;
   public ProjectState state;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index dc2f899..6db13fc 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -125,6 +125,14 @@
   TagApi tag(String ref) throws RestApiException;
 
   /**
+   * Lookup a commit by its {@code ObjectId} string.
+   *
+   * @param commit the {@code ObjectId} string.
+   * @return API for accessing the commit.
+   */
+  CommitApi commit(String commit) throws RestApiException;
+
+  /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
    */
@@ -218,5 +226,10 @@
     public void deleteTags(DeleteTagsInput in) {
       throw new NotImplementedException();
     }
+
+    @Override
+    public CommitApi commit(String commit) {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java
index 2225a99..3307997 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.client;
 
 import java.sql.Timestamp;
+import java.util.Comparator;
 import java.util.Objects;
 
 public abstract class Comment {
@@ -36,7 +37,13 @@
   public String message;
   public Boolean unresolved;
 
-  public static class Range {
+  public static class Range implements Comparable<Range> {
+    private static final Comparator<Range> RANGE_COMPARATOR =
+        Comparator.<Range>comparingInt(range -> range.startLine)
+            .thenComparingInt(range -> range.startCharacter)
+            .thenComparingInt(range -> range.endLine)
+            .thenComparingInt(range -> range.endCharacter);
+
     public int startLine; // 1-based, inclusive
     public int startCharacter; // 0-based, inclusive
     public int endLine; // 1-based, exclusive
@@ -81,6 +88,11 @@
           + endCharacter
           + '}';
     }
+
+    @Override
+    public int compareTo(Range otherRange) {
+      return RANGE_COMPARATOR.compare(this, otherRange);
+    }
   }
 
   public short side() {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountInfo.java
index 2fb32d7..f20509b 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.util.List;
+import java.util.Objects;
 
 public class AccountInfo {
   public Integer _accountId;
@@ -29,4 +30,34 @@
   public AccountInfo(Integer id) {
     this._accountId = id;
   }
+
+  /** To be used ONLY in connection with unregistered reviewers and CCs. */
+  public AccountInfo(String name, String email) {
+    this.name = name;
+    this.email = email;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof AccountInfo) {
+      AccountInfo accountInfo = (AccountInfo) o;
+      return Objects.equals(_accountId, accountInfo._accountId)
+          && Objects.equals(name, accountInfo.name)
+          && Objects.equals(email, accountInfo.email)
+          && Objects.equals(secondaryEmails, accountInfo.secondaryEmails)
+          && Objects.equals(username, accountInfo.username)
+          && Objects.equals(avatars, accountInfo.avatars)
+          && Objects.equals(_moreAccounts, accountInfo._moreAccounts)
+          && Objects.equals(status, accountInfo.status);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        _accountId, name, email, secondaryEmails, username, avatars, _moreAccounts, status);
+  }
+
+  protected AccountInfo() {}
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 3803714..a3750b9 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -36,6 +36,7 @@
   public Timestamp updated;
   public Timestamp submitted;
   public Boolean starred;
+  public Boolean muted;
   public Collection<String> stars;
   public Boolean reviewed;
   public SubmitType submitType;
@@ -44,6 +45,8 @@
   public Integer insertions;
   public Integer deletions;
   public Integer unresolvedCommentCount;
+  public Boolean isPrivate;
+  public Boolean workInProgress;
 
   public int _number;
 
@@ -62,4 +65,5 @@
   public Boolean _moreChanges;
 
   public List<ProblemInfo> problems;
+  public List<PluginDefinedInfo> plugins;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
index e79918f..735b84f 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
@@ -20,6 +20,7 @@
   public String id;
   public String tag;
   public AccountInfo author;
+  public AccountInfo realAuthor;
   public Timestamp date;
   public String message;
   public Integer _revisionNumber;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/EditInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/EditInfo.java
index 9dc92a8..46ef879 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/EditInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/EditInfo.java
@@ -18,6 +18,7 @@
 
 public class EditInfo {
   public CommitInfo commit;
+  public int basePatchSetNumber;
   public String baseRevision;
   public Map<String, FetchInfo> fetch;
   public Map<String, FileInfo> files;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginConfigInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginConfigInfo.java
index 2d1d840..13fc9ec 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginConfigInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginConfigInfo.java
@@ -19,4 +19,5 @@
 public class PluginConfigInfo {
   public Boolean hasAvatars;
   public List<String> jsResourcePaths;
+  public List<String> htmlResourcePaths;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
new file mode 100644
index 0000000..e6fef0f
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
@@ -0,0 +1,19 @@
+// Copyright (C) 2017 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 PluginDefinedInfo {
+  public String name;
+}
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
index 8e503ee..b0429cb 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.gpg;
 
 import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableMap;
@@ -24,7 +24,7 @@
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
index 62d0df7..c3dec61 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
@@ -17,8 +17,8 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import org.eclipse.jgit.lib.Repository;
 
@@ -30,7 +30,7 @@
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
 
-  @AssistedInject
+  @Inject
   GerritPushCertificateChecker(
       GerritPublicKeyChecker.Factory keyCheckerFactory,
       GitRepositoryManager repoManager,
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
index 9aa18fe..14a4c6d 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
@@ -21,8 +21,8 @@
 import com.google.gerrit.gpg.server.GpgKey;
 import com.google.gerrit.gpg.server.GpgKeys;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import org.bouncycastle.openpgp.PGPException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -36,7 +36,7 @@
   private final DeleteGpgKey delete;
   private final GpgKey rsrc;
 
-  @AssistedInject
+  @Inject
   GpgKeyApiImpl(GpgKeys.Get get, DeleteGpgKey delete, @Assisted GpgKey rsrc) {
     this.get = get;
     this.delete = delete;
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
index 50bf57b..64286c4 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.gpg.server;
 
 import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -26,8 +26,8 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java
index 819ad96..13fb368 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java
@@ -14,12 +14,10 @@
 
 package com.google.gerrit.gpg.server;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
@@ -36,11 +34,11 @@
 import com.google.gerrit.gpg.GerritPublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyStore;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResource;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -70,6 +68,7 @@
   private final Provider<CurrentUser> self;
   private final Provider<PublicKeyStore> storeProvider;
   private final GerritPublicKeyChecker.Factory checkerFactory;
+  private final ExternalIds externalIds;
 
   @Inject
   GpgKeys(
@@ -77,12 +76,14 @@
       Provider<ReviewDb> db,
       Provider<CurrentUser> self,
       Provider<PublicKeyStore> storeProvider,
-      GerritPublicKeyChecker.Factory checkerFactory) {
+      GerritPublicKeyChecker.Factory checkerFactory,
+      ExternalIds externalIds) {
     this.views = views;
     this.db = db;
     this.self = self;
     this.storeProvider = storeProvider;
     this.checkerFactory = checkerFactory;
+    this.externalIds = externalIds;
   }
 
   @Override
@@ -198,16 +199,8 @@
     }
   }
 
-  @VisibleForTesting
-  public static FluentIterable<ExternalId> getGpgExtIds(ReviewDb db, Account.Id accountId)
-      throws OrmException {
-    return FluentIterable.from(
-            ExternalId.from(db.accountExternalIds().byAccount(accountId).toList()))
-        .filter(in -> in.isScheme(SCHEME_GPGKEY));
-  }
-
-  private Iterable<ExternalId> getGpgExtIds(AccountResource rsrc) throws OrmException {
-    return getGpgExtIds(db.get(), rsrc.getUser().getAccountId());
+  private Iterable<ExternalId> getGpgExtIds(AccountResource rsrc) throws IOException, OrmException {
+    return externalIds.byAccount(db.get(), rsrc.getUser().getAccountId(), SCHEME_GPGKEY);
   }
 
   private static long keyId(byte[] fp) {
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index 7b825b1..9c04ced 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -16,7 +16,7 @@
 
 import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
 import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toList;
 
@@ -47,8 +47,9 @@
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.mail.send.AddKeySender;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.OrmException;
@@ -91,6 +92,7 @@
   private final AddKeySender.Factory addKeyFactory;
   private final AccountCache accountCache;
   private final Provider<InternalAccountQuery> accountQueryProvider;
+  private final ExternalIds externalIds;
   private final ExternalIdsUpdate.User externalIdsUpdateFactory;
 
   @Inject
@@ -103,6 +105,7 @@
       AddKeySender.Factory addKeyFactory,
       AccountCache accountCache,
       Provider<InternalAccountQuery> accountQueryProvider,
+      ExternalIds externalIds,
       ExternalIdsUpdate.User externalIdsUpdateFactory) {
     this.serverIdent = serverIdent;
     this.db = db;
@@ -112,6 +115,7 @@
     this.addKeyFactory = addKeyFactory;
     this.accountCache = accountCache;
     this.accountQueryProvider = accountQueryProvider;
+    this.externalIds = externalIds;
     this.externalIdsUpdateFactory = externalIdsUpdateFactory;
   }
 
@@ -122,7 +126,7 @@
     GpgKeys.checkVisible(self, rsrc);
 
     Collection<ExternalId> existingExtIds =
-        GpgKeys.getGpgExtIds(db.get(), rsrc.getUser().getAccountId()).toList();
+        externalIds.byAccount(db.get(), rsrc.getUser().getAccountId(), SCHEME_GPGKEY);
     try (PublicKeyStore store = storeProvider.get()) {
       Set<Fingerprint> toRemove = readKeysToRemove(input, existingExtIds);
       List<PGPPublicKeyRing> newKeys = readKeysToAdd(input, toRemove);
diff --git a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
index 862930f..d82f95b 100644
--- a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
+++ b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
@@ -39,8 +39,8 @@
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
index 0de8b68..abcfe31 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
@@ -136,8 +136,12 @@
 
   public final native boolean starred() /*-{ return this.starred ? true : false; }-*/;
 
+  public final native boolean muted() /*-{ return this.muted ? true : false; }-*/;
+
   public final native boolean reviewed() /*-{ return this.reviewed ? true : false; }-*/;
 
+  public final native boolean isPrivate() /*-{ return this.is_private ? true : false; }-*/;
+
   public final native NativeMap<LabelInfo> allLabels() /*-{ return this.labels; }-*/;
 
   public final native LabelInfo label(String n) /*-{ return this.labels[n]; }-*/;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java
index 9f87672..cb947fe 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java
@@ -131,10 +131,13 @@
     suggestions.add("is:open");
     suggestions.add("is:pending");
     suggestions.add("is:draft");
+    suggestions.add("is:private");
     suggestions.add("is:closed");
     suggestions.add("is:merged");
     suggestions.add("is:abandoned");
     suggestions.add("is:mergeable");
+    suggestions.add("is:ignored");
+    suggestions.add("is:wip");
 
     suggestions.add("status:");
     suggestions.add("status:open");
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.java
index 40116af..cb529f4 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.java
@@ -52,8 +52,9 @@
       userEmail.setText(account.email());
     }
     if (showSettingsLink) {
-      if (Gerrit.info().auth().switchAccountUrl() != null) {
-        switchAccount.setHref(Gerrit.info().auth().switchAccountUrl());
+      String switchAccountUrl = Gerrit.info().auth().switchAccountUrl();
+      if (switchAccountUrl != null) {
+        switchAccount.setHref(switchAccountUrl.replace("${path}", "/"));
       } else if (Gerrit.info().auth().isDev() || Gerrit.info().auth().isOpenId()) {
         switchAccount.setHref(Gerrit.selfRedirect("/login"));
       } else {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
index d7fb072..1aecd08 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
@@ -75,6 +75,8 @@
 
   String rejectImplicitMerges();
 
+  String enableReviewerByEmail();
+
   String headingMaxObjectSizeLimit();
 
   String headingGroupOptions();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
index 465bcfc..4c7153e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
@@ -37,6 +37,7 @@
 headingParentProjectName = Rights Inherit From
 parentSuggestions = Parent Suggestion
 columnProjectName = Project Name
+enableReviewerByEmail = Enable adding unregistered users as reviewers and CCs on changes
 
 headingGroupUUID = Group UUID
 headingOwner = Owners
@@ -148,7 +149,8 @@
 	removeReviewer, \
 	submit, \
 	submitAs, \
-	viewDrafts
+	viewDrafts, \
+	viewPrivateChanges
 
 abandon = Abandon
 addPatchSet = Add Patch Set
@@ -174,6 +176,7 @@
 submit = Submit
 submitAs = Submit (On Behalf Of)
 viewDrafts = View Drafts
+viewPrivateChanges = View Private Changes
 
 refErrorEmpty = Reference must be supplied
 refErrorBeginSlash = Reference must not start with '/'
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
index 3645fb9..2f5caf8 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
@@ -86,6 +86,7 @@
   private ListBox enableSignedPush;
   private ListBox requireSignedPush;
   private ListBox rejectImplicitMerges;
+  private ListBox enableReviewerByEmail;
   private NpTextBox maxObjectSizeLimit;
   private Label effectiveMaxObjectSizeLimit;
   private Map<String, Map<String, HasEnabled>> pluginConfigWidgets;
@@ -191,6 +192,7 @@
     requireChangeID.setEnabled(isOwner);
     rejectImplicitMerges.setEnabled(isOwner);
     maxObjectSizeLimit.setEnabled(isOwner);
+    enableReviewerByEmail.setEnabled(isOwner);
 
     if (pluginConfigWidgets != null) {
       for (Map<String, HasEnabled> widgetMap : pluginConfigWidgets.values()) {
@@ -264,6 +266,10 @@
     saveEnabler.listenTo(rejectImplicitMerges);
     grid.addHtml(AdminConstants.I.rejectImplicitMerges(), rejectImplicitMerges);
 
+    enableReviewerByEmail = newInheritedBooleanBox();
+    saveEnabler.listenTo(enableReviewerByEmail);
+    grid.addHtml(AdminConstants.I.enableReviewerByEmail(), enableReviewerByEmail);
+
     maxObjectSizeLimit = new NpTextBox();
     saveEnabler.listenTo(maxObjectSizeLimit);
     effectiveMaxObjectSizeLimit = new Label();
@@ -395,6 +401,7 @@
       setBool(requireSignedPush, result.requireSignedPush());
     }
     setBool(rejectImplicitMerges, result.rejectImplicitMerges());
+    setBool(enableReviewerByEmail, result.enableReviewerByEmail());
     setSubmitType(result.submitType());
     setState(result.state());
     maxObjectSizeLimit.setText(result.maxObjectSizeLimit().configuredValue());
@@ -665,6 +672,7 @@
         esp,
         rsp,
         getBool(rejectImplicitMerges),
+        getBool(enableReviewerByEmail),
         maxObjectSizeLimit.getText().trim(),
         SubmitType.valueOf(submitType.getValue(submitType.getSelectedIndex())),
         ProjectState.valueOf(state.getValue(state.getSelectedIndex())),
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ApiGlue.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ApiGlue.java
index 1555f56..294fa9b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ApiGlue.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ApiGlue.java
@@ -100,9 +100,9 @@
         var s = new SettingsScreenDefinition(p,m,c);
         (this.settingsScreens[n] || (this.settingsScreens[n]=[])).push(s);
       },
-      panel: function(i,c){this._panel(this.getPluginName(),i,c)},
-      _panel: function(n,i,c){
-        var p = new PanelDefinition(n,c);
+      panel: function(i,c,n){this._panel(this.getPluginName(),i,c,n)},
+      _panel: function(n,i,c,x){
+        var p = new PanelDefinition(n,c,x);
         (this.panels[i] || (this.panels[i]=[])).push(p);
       },
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionPanel.java
index 0873363..6d3dd60 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionPanel.java
@@ -22,7 +22,10 @@
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.SimplePanel;
 import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
@@ -32,13 +35,17 @@
   private final List<Context> contexts;
 
   public ExtensionPanel(GerritUiExtensionPoint extensionPoint) {
-    this.extensionPoint = extensionPoint;
-    this.contexts = create();
+    this(extensionPoint, new ArrayList<String>());
   }
 
-  private List<Context> create() {
+  public ExtensionPanel(GerritUiExtensionPoint extensionPoint, List<String> panelNames) {
+    this.extensionPoint = extensionPoint;
+    this.contexts = create(panelNames);
+  }
+
+  private List<Context> create(List<String> panelNames) {
     List<Context> contexts = new ArrayList<>();
-    for (Definition def : Natives.asList(Definition.get(extensionPoint.name()))) {
+    for (Definition def : getOrderedDefs(panelNames)) {
       SimplePanel p = new SimplePanel();
       add(p);
       contexts.add(Context.create(def, p));
@@ -46,6 +53,42 @@
     return contexts;
   }
 
+  private List<Definition> getOrderedDefs(List<String> panelNames) {
+    if (panelNames == null) {
+      panelNames = Collections.emptyList();
+    }
+    Map<String, List<Definition>> defsOrderedByName = new LinkedHashMap<>();
+    for (String name : panelNames) {
+      defsOrderedByName.put(name, new ArrayList<Definition>());
+    }
+    for (Definition def : Natives.asList(Definition.get(extensionPoint.name()))) {
+      addDef(def, defsOrderedByName);
+    }
+    List<Definition> orderedDefs = new ArrayList<>();
+    for (List<Definition> defList : defsOrderedByName.values()) {
+      orderedDefs.addAll(defList);
+    }
+    return orderedDefs;
+  }
+
+  private static void addDef(Definition def, Map<String, List<Definition>> defsOrderedByName) {
+    String panelName = def.getPanelName();
+    if (panelName.equals(def.getPluginName() + ".undefined")) {
+      /* Handle a partially undefined panel name from the
+      javascript layer by generating a random panel name.
+      This maintains support for panels that do not provide a name. */
+      panelName =
+          def.getPluginName() + "." + Long.toHexString(Double.doubleToLongBits(Math.random()));
+    }
+    if (defsOrderedByName.containsKey(panelName)) {
+      defsOrderedByName.get(panelName).add(def);
+    } else if (defsOrderedByName.containsKey(def.getPluginName())) {
+      defsOrderedByName.get(def.getPluginName()).add(def);
+    } else {
+      defsOrderedByName.put(panelName, Collections.singletonList(def));
+    }
+  }
+
   public void put(GerritUiExtensionPoint.Key key, String value) {
     for (Context ctx : contexts) {
       ctx.put(key.name(), value);
@@ -103,9 +146,10 @@
     static final JavaScriptObject TYPE = init();
 
     private static native JavaScriptObject init() /*-{
-      function PanelDefinition(n, c) {
+      function PanelDefinition(n, c, x) {
         this.pluginName = n;
         this.onLoad = c;
+        this.name = x;
       };
       return PanelDefinition;
     }-*/;
@@ -113,6 +157,10 @@
     static native JsArray<Definition> get(String i) /*-{ return $wnd.Gerrit.panels[i] || [] }-*/;
 
     protected Definition() {}
+
+    public final native String getPanelName() /*-{ return this.pluginName + "." + this.name; }-*/;
+
+    public final native String getPluginName() /*-{ return this.pluginName; }-*/;
   }
 
   static class Context extends JavaScriptObject {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/Plugin.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/Plugin.java
index 29787b8..48a812c1 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/Plugin.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/Plugin.java
@@ -68,7 +68,7 @@
       onAction: function(t,n,c){G._onAction(this.name,t,n,c)},
       screen: function(p,c){G._screen(this.name,p,c)},
       settingsScreen: function(p,m,c){G._settingsScreen(this.name,p,m,c)},
-      panel: function(i,c){G._panel(this.name,i,c)},
+      panel: function(i,c,n){G._panel(this.name,i,c,n)},
 
       url: function (u){return G.url(this._url(u))},
       get: function(u,b){@com.google.gerrit.client.api.ActionContext::get(
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginLoader.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginLoader.java
index 1c59dac..8a479dd 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginLoader.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginLoader.java
@@ -28,6 +28,7 @@
 import com.google.gwt.user.client.ui.DialogBox;
 import com.google.gwtexpui.progress.client.ProgressBar;
 import java.util.List;
+import java.util.stream.Collectors;
 
 /** Loads JavaScript plugins with a progress meter visible. */
 public class PluginLoader extends DialogBox {
@@ -38,10 +39,15 @@
     if (plugins == null || plugins.isEmpty()) {
       callback.onSuccess(VoidResult.create());
     } else {
-      self = new PluginLoader(loadTimeout, callback);
-      self.load(plugins);
-      self.startTimers();
-      self.center();
+      plugins = plugins.stream().filter(p -> p.endsWith(".js")).collect(Collectors.toList());
+      if (plugins.isEmpty()) {
+        callback.onSuccess(VoidResult.create());
+      } else {
+        self = new PluginLoader(loadTimeout, callback);
+        self.load(plugins);
+        self.startTimers();
+        self.center();
+      }
     }
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java
index ada28af..b22b79f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java
@@ -48,6 +48,7 @@
     "revert",
     "submit",
     "topic",
+    "private",
     "/",
   };
 
@@ -65,6 +66,9 @@
 
   @UiField Button deleteChange;
 
+  @UiField Button markPrivate;
+  @UiField Button unmarkPrivate;
+
   @UiField Button restore;
   private RestoreAction restoreAction;
 
@@ -122,6 +126,11 @@
       a2b(actions, "restore", restore);
       a2b(actions, "revert", revert);
       a2b(actions, "followup", followUp);
+      if (info.isPrivate()) {
+        a2b(actions, "private", unmarkPrivate);
+      } else {
+        a2b(actions, "private", markPrivate);
+      }
       for (String id : filterNonCore(actions)) {
         add(new ActionButton(info, actions.get(id)));
       }
@@ -192,6 +201,16 @@
     }
   }
 
+  @UiHandler("markPrivate")
+  void onMarkPrivate(@SuppressWarnings("unused") ClickEvent e) {
+    ChangeActions.markPrivate(changeId, markPrivate);
+  }
+
+  @UiHandler("unmarkPrivate")
+  void onUnmarkPrivate(@SuppressWarnings("unused") ClickEvent e) {
+    ChangeActions.unmarkPrivate(changeId, unmarkPrivate);
+  }
+
   @UiHandler("restore")
   void onRestore(@SuppressWarnings("unused") ClickEvent e) {
     if (restoreAction == null) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.ui.xml
index d0e5c3e..60efc8c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.ui.xml
@@ -81,6 +81,12 @@
     <g:Button ui:field='followUp' styleName='' visible='false'>
       <div><ui:msg>Follow-Up</ui:msg></div>
     </g:Button>
+    <g:Button ui:field='markPrivate' styleName='' visible='false'>
+      <div><ui:msg>Mark Private</ui:msg></div>
+    </g:Button>
+    <g:Button ui:field='unmarkPrivate' styleName='' visible='false'>
+      <div><ui:msg>Unmark Private</ui:msg></div>
+    </g:Button>
 
     <g:Button ui:field='submit' styleName='{style.submit}' visible='false'/>
   </g:FlowPanel>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeActions.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeActions.java
index 1be60cc..b8fcab7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeActions.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeActions.java
@@ -37,6 +37,14 @@
     ChangeApi.deleteChange(id.get(), mine(draftButtons));
   }
 
+  static void markPrivate(Change.Id id, Button... draftButtons) {
+    ChangeApi.markPrivate(id.get(), cs(id, draftButtons));
+  }
+
+  static void unmarkPrivate(Change.Id id, Button... draftButtons) {
+    ChangeApi.unmarkPrivate(id.get(), cs(id, draftButtons));
+  }
+
   public static GerritCallback<JavaScriptObject> cs(
       final Change.Id id, final Button... draftButtons) {
     setEnabled(false, draftButtons);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
index 8b699da..1e29be8 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
@@ -198,6 +198,7 @@
   @UiField InlineLabel uploaderName;
 
   @UiField Element statusText;
+  @UiField Element privateText;
   @UiField Image projectSettings;
   @UiField AnchorElement projectSettingsLink;
   @UiField InlineHyperlink projectDashboard;
@@ -308,8 +309,7 @@
               @Override
               public void onSuccess(final ChangeInfo info) {
                 info.init();
-                addExtensionPoints(info, initCurrentRevision(info));
-
+                initCurrentRevision(info);
                 final RevisionInfo rev = info.revision(revision);
                 CallbackGroup group = new CallbackGroup();
                 loadCommit(rev, group);
@@ -378,7 +378,7 @@
     return resolveRevisionToDisplay(info);
   }
 
-  private void addExtensionPoints(ChangeInfo change, RevisionInfo rev) {
+  private void addExtensionPoints(ChangeInfo change, RevisionInfo rev, Entry result) {
     addExtensionPoint(GerritUiExtensionPoint.CHANGE_SCREEN_HEADER, headerExtension, change, rev);
     addExtensionPoint(
         GerritUiExtensionPoint.CHANGE_SCREEN_HEADER_RIGHT_OF_BUTTONS,
@@ -391,7 +391,12 @@
         change,
         rev);
     addExtensionPoint(
-        GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK, changeExtension, change, rev);
+        GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK,
+        changeExtension,
+        change,
+        rev,
+        result.getExtensionPanelNames(
+            GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK.toString()));
     addExtensionPoint(
         GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_RELATED_INFO_BLOCK,
         relatedExtension,
@@ -407,13 +412,22 @@
   }
 
   private void addExtensionPoint(
-      GerritUiExtensionPoint extensionPoint, Panel p, ChangeInfo change, RevisionInfo rev) {
-    ExtensionPanel extensionPanel = new ExtensionPanel(extensionPoint);
+      GerritUiExtensionPoint extensionPoint,
+      Panel p,
+      ChangeInfo change,
+      RevisionInfo rev,
+      List<String> panelNames) {
+    ExtensionPanel extensionPanel = new ExtensionPanel(extensionPoint, panelNames);
     extensionPanel.putObject(GerritUiExtensionPoint.Key.CHANGE_INFO, change);
     extensionPanel.putObject(GerritUiExtensionPoint.Key.REVISION_INFO, rev);
     p.add(extensionPanel);
   }
 
+  private void addExtensionPoint(
+      GerritUiExtensionPoint extensionPoint, Panel p, ChangeInfo change, RevisionInfo rev) {
+    addExtensionPoint(extensionPoint, p, change, rev, Collections.emptyList());
+  }
+
   private boolean enableSignedPush() {
     return Gerrit.info().receive().enableSignedPush();
   }
@@ -1030,6 +1044,14 @@
             loadRevisionInfo();
           }
         });
+    ConfigInfoCache.get(
+        info.projectNameKey(),
+        new GerritCallback<Entry>() {
+          @Override
+          public void onSuccess(Entry entry) {
+            addExtensionPoints(info, rev, entry);
+          }
+        });
   }
 
   private void updateToken(ChangeInfo info, DiffObject base, RevisionInfo rev) {
@@ -1366,6 +1388,10 @@
       statusText.setInnerText(Util.toLongString(s));
     }
 
+    if (info.isPrivate()) {
+      privateText.setInnerText(Util.C.isPrivate());
+    }
+
     if (Gerrit.isSignedIn()) {
       replyAction =
           new ReplyAction(
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml
index 152b157..e2297cb 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml
@@ -99,6 +99,9 @@
     .statusText {
       font-weight: bold;
     }
+    .privateText {
+      font-weight: bold;
+    }
 
     div.popdown {
       display: inline-block;
@@ -376,7 +379,8 @@
           <span class='{style.changeId}'>
             <ui:msg>Change <g:Anchor ui:field='permalink' title='Reload the change (Shortcut: R)'>
               <ui:attribute name='title'/>
-            </g:Anchor> - <span ui:field='statusText' class='{style.statusText}'/></ui:msg>
+            </g:Anchor> - <span ui:field='statusText' class='{style.statusText}'/>
+              <span ui:field='privateText' class='{style.privateText}'/></ui:msg>
           </span>
           <g:SimplePanel ui:field='headerExtension' styleName='{style.headerExtension}'/>
         </div>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java
index f37cbc2..f8bda64 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java
@@ -101,7 +101,7 @@
         + who
         + " -owner:"
         + who
-        + " -star:ignore) OR assignee:"
+        + " -is:ignored) OR assignee:"
         + who
         + ")";
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
index f8a9ba1..f985f31 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
@@ -121,6 +121,14 @@
     change(id).view("assignee").put(input, cb);
   }
 
+  public static void markPrivate(int id, AsyncCallback<JavaScriptObject> cb) {
+    change(id).view("private").put(cb);
+  }
+
+  public static void unmarkPrivate(int id, AsyncCallback<JavaScriptObject> cb) {
+    change(id).view("private").delete(cb);
+  }
+
   public static RestApi comments(int id) {
     return call(id, "comments");
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
index ae64ac0..4543217 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
@@ -33,6 +33,8 @@
 
   String notCurrent();
 
+  String isPrivate();
+
   String changeEdit();
 
   String myDashboardTitle();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
index 01921de..3545a2f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
@@ -7,6 +7,7 @@
 mergeConflict = Merge Conflict
 notCurrent = Not Current
 changeEdit = Change Edit
+isPrivate = (Private)
 
 myDashboardTitle = My Reviews
 unknownDashboardTitle = Code Review Dashboard
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
index adf7cff..055044c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
@@ -237,9 +237,17 @@
 
     Change.Status status = c.status();
     if (status != Change.Status.NEW) {
-      table.setText(row, C_STATUS, Util.toLongString(status));
+      table.setText(
+          row,
+          C_STATUS,
+          Util.toLongString(status) + (c.isPrivate() ? (" " + Util.C.isPrivate()) : ""));
     } else if (!c.mergeable()) {
-      table.setText(row, C_STATUS, Util.C.changeTableNotMergeable());
+      table.setText(
+          row,
+          C_STATUS,
+          Util.C.changeTableNotMergeable() + (c.isPrivate() ? (" " + Util.C.isPrivate()) : ""));
+    } else if (c.isPrivate()) {
+      table.setText(row, C_STATUS, Util.C.isPrivate());
     }
 
     if (c.owner() != null) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ChunkManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ChunkManager.java
index 953bc87..0091f53 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ChunkManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ChunkManager.java
@@ -68,23 +68,15 @@
     colorLines(cm, LineClassWhere.WRAP, color, line, line + cnt);
   }
 
-  void colorLines(
-      final CodeMirror cm,
-      final LineClassWhere where,
-      final String className,
-      final int start,
-      final int end) {
+  void colorLines(CodeMirror cm, LineClassWhere where, String className, int start, int end) {
     if (start < end) {
       for (int line = start; line < end; line++) {
         cm.addLineClass(line, where, className);
       }
       undo.add(
-          new Runnable() {
-            @Override
-            public void run() {
-              for (int line = start; line < end; line++) {
-                cm.removeLineClass(line, where, className);
-              }
+          () -> {
+            for (int line = start; line < end; line++) {
+              cm.removeLineClass(line, where, className);
             }
           });
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
index 587dacc..95b88ac 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
@@ -203,32 +203,26 @@
 
   abstract String getTokenSuffixForActiveLine(CodeMirror cm);
 
-  Runnable signInCallback(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        String token = host.getToken();
-        if (cm.extras().hasActiveLine()) {
-          token += "@" + getTokenSuffixForActiveLine(cm);
-        }
-        Gerrit.doSignIn(token);
+  Runnable signInCallback(CodeMirror cm) {
+    return () -> {
+      String token = host.getToken();
+      if (cm.extras().hasActiveLine()) {
+        token += "@" + getTokenSuffixForActiveLine(cm);
       }
+      Gerrit.doSignIn(token);
     };
   }
 
   abstract void newDraft(CodeMirror cm);
 
-  Runnable newDraftCallback(final CodeMirror cm) {
+  Runnable newDraftCallback(CodeMirror cm) {
     if (!Gerrit.isSignedIn()) {
       return signInCallback(cm);
     }
 
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (cm.extras().hasActiveLine()) {
-          newDraft(cm);
-        }
+    return () -> {
+      if (cm.extras().hasActiveLine()) {
+        newDraft(cm);
       }
     };
   }
@@ -267,52 +261,49 @@
 
   abstract SortedMap<Integer, CommentGroup> getMapForNav(DisplaySide side);
 
-  Runnable commentNav(final CodeMirror src, final Direction dir) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        // Every comment appears in both side maps as a linked pair.
-        // It is only necessary to search one side to find a comment
-        // on either side of the editor pair.
-        SortedMap<Integer, CommentGroup> map = getMapForNav(src.side());
-        int line =
-            src.extras().hasActiveLine() ? src.getLineNumber(src.extras().activeLine()) + 1 : 0;
+  Runnable commentNav(CodeMirror src, Direction dir) {
+    return () -> {
+      // Every comment appears in both side maps as a linked pair.
+      // It is only necessary to search one side to find a comment
+      // on either side of the editor pair.
+      SortedMap<Integer, CommentGroup> map = getMapForNav(src.side());
+      int line =
+          src.extras().hasActiveLine() ? src.getLineNumber(src.extras().activeLine()) + 1 : 0;
 
-        CommentGroup g;
-        if (dir == Direction.NEXT) {
-          map = map.tailMap(line + 1);
+      CommentGroup g;
+      if (dir == Direction.NEXT) {
+        map = map.tailMap(line + 1);
+        if (map.isEmpty()) {
+          return;
+        }
+        g = map.get(map.firstKey());
+        while (g.getBoxCount() == 0) {
+          map = map.tailMap(map.firstKey() + 1);
           if (map.isEmpty()) {
             return;
           }
           g = map.get(map.firstKey());
-          while (g.getBoxCount() == 0) {
-            map = map.tailMap(map.firstKey() + 1);
-            if (map.isEmpty()) {
-              return;
-            }
-            g = map.get(map.firstKey());
-          }
-        } else {
-          map = map.headMap(line);
+        }
+      } else {
+        map = map.headMap(line);
+        if (map.isEmpty()) {
+          return;
+        }
+        g = map.get(map.lastKey());
+        while (g.getBoxCount() == 0) {
+          map = map.headMap(map.lastKey());
           if (map.isEmpty()) {
             return;
           }
           g = map.get(map.lastKey());
-          while (g.getBoxCount() == 0) {
-            map = map.headMap(map.lastKey());
-            if (map.isEmpty()) {
-              return;
-            }
-            g = map.get(map.lastKey());
-          }
         }
-
-        CodeMirror cm = g.getCm();
-        double y = cm.heightAtLine(g.getLine() - 1, "local");
-        cm.setCursor(Pos.create(g.getLine() - 1));
-        cm.scrollToY(y - 0.5 * cm.scrollbarV().getClientHeight());
-        cm.focus();
       }
+
+      CodeMirror cm = g.getCm();
+      double y = cm.heightAtLine(g.getLine() - 1, "local");
+      cm.setCursor(Pos.create(g.getLine() - 1));
+      cm.scrollToY(y - 0.5 * cm.scrollbarV().getClientHeight());
+      cm.focus();
     };
   }
 
@@ -425,26 +416,20 @@
 
   abstract CommentGroup getCommentGroupOnActiveLine(CodeMirror cm);
 
-  Runnable toggleOpenBox(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        CommentGroup group = getCommentGroupOnActiveLine(cm);
-        if (group != null) {
-          group.openCloseLast();
-        }
+  Runnable toggleOpenBox(CodeMirror cm) {
+    return () -> {
+      CommentGroup group = getCommentGroupOnActiveLine(cm);
+      if (group != null) {
+        group.openCloseLast();
       }
     };
   }
 
-  Runnable openCloseAll(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        CommentGroup group = getCommentGroupOnActiveLine(cm);
-        if (group != null) {
-          group.openCloseAll();
-        }
+  Runnable openCloseAll(CodeMirror cm) {
+    return () -> {
+      CommentGroup group = getCommentGroupOnActiveLine(cm);
+      if (group != null) {
+        group.openCloseAll();
       }
     };
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java
index 60a75eb..702383a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java
@@ -336,7 +336,7 @@
     handlers.clear();
   }
 
-  void registerCmEvents(final CodeMirror cm) {
+  void registerCmEvents(CodeMirror cm) {
     cm.on("cursorActivity", updateActiveLine(cm));
     cm.on("focus", updateActiveLine(cm));
     KeyMap keyMap =
@@ -356,170 +356,45 @@
             .on("Shift-O", getCommentManager().openCloseAll(cm))
             .on(
                 "I",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    switch (getIntraLineStatus()) {
-                      case OFF:
-                      case OK:
-                        toggleShowIntraline();
-                        break;
-                      case FAILURE:
-                      case TIMEOUT:
-                      default:
-                        break;
-                    }
+                () -> {
+                  switch (getIntraLineStatus()) {
+                    case OFF:
+                    case OK:
+                      toggleShowIntraline();
+                      break;
+                    case FAILURE:
+                    case TIMEOUT:
+                    default:
+                      break;
                   }
                 })
-            .on(
-                "','",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    prefsAction.show();
-                  }
-                })
-            .on(
-                "Shift-/",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    new ShowHelpCommand().onKeyPress(null);
-                  }
-                })
-            .on(
-                "Space",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.vim().handleKey("<C-d>");
-                  }
-                })
-            .on(
-                "Shift-Space",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.vim().handleKey("<C-u>");
-                  }
-                })
-            .on(
-                "Ctrl-F",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.execCommand("find");
-                  }
-                })
-            .on(
-                "Ctrl-G",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.execCommand("findNext");
-                  }
-                })
+            .on("','", prefsAction::show)
+            .on("Shift-/", () -> new ShowHelpCommand().onKeyPress(null))
+            .on("Space", () -> cm.vim().handleKey("<C-d>"))
+            .on("Shift-Space", () -> cm.vim().handleKey("<C-u>"))
+            .on("Ctrl-F", () -> cm.execCommand("find"))
+            .on("Ctrl-G", () -> cm.execCommand("findNext"))
             .on("Enter", maybeNextCmSearch(cm))
-            .on(
-                "Shift-Ctrl-G",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.execCommand("findPrev");
-                  }
-                })
-            .on(
-                "Shift-Enter",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.execCommand("findPrev");
-                  }
-                })
+            .on("Shift-Ctrl-G", () -> cm.execCommand("findPrev"))
+            .on("Shift-Enter", () -> cm.execCommand("findPrev"))
             .on(
                 "Esc",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.setCursor(cm.getCursor());
-                    cm.execCommand("clearSearch");
-                    cm.vim().handleEx("nohlsearch");
-                  }
+                () -> {
+                  cm.setCursor(cm.getCursor());
+                  cm.execCommand("clearSearch");
+                  cm.vim().handleEx("nohlsearch");
                 })
-            .on(
-                "Ctrl-A",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.execCommand("selectAll");
-                  }
-                })
-            .on(
-                "G O",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    Gerrit.display(PageLinks.toChangeQuery("status:open"));
-                  }
-                })
-            .on(
-                "G M",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    Gerrit.display(PageLinks.toChangeQuery("status:merged"));
-                  }
-                })
-            .on(
-                "G A",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    Gerrit.display(PageLinks.toChangeQuery("status:abandoned"));
-                  }
-                });
+            .on("Ctrl-A", () -> cm.execCommand("selectAll"))
+            .on("G O", () -> Gerrit.display(PageLinks.toChangeQuery("status:open")))
+            .on("G M", () -> Gerrit.display(PageLinks.toChangeQuery("status:merged")))
+            .on("G A", () -> Gerrit.display(PageLinks.toChangeQuery("status:abandoned")));
     if (Gerrit.isSignedIn()) {
       keyMap
-          .on(
-              "G I",
-              new Runnable() {
-                @Override
-                public void run() {
-                  Gerrit.display(PageLinks.MINE);
-                }
-              })
-          .on(
-              "G D",
-              new Runnable() {
-                @Override
-                public void run() {
-                  Gerrit.display(PageLinks.toChangeQuery("owner:self is:draft"));
-                }
-              })
-          .on(
-              "G C",
-              new Runnable() {
-                @Override
-                public void run() {
-                  Gerrit.display(PageLinks.toChangeQuery("has:draft"));
-                }
-              })
-          .on(
-              "G W",
-              new Runnable() {
-                @Override
-                public void run() {
-                  Gerrit.display(PageLinks.toChangeQuery("is:watched status:open"));
-                }
-              })
-          .on(
-              "G S",
-              new Runnable() {
-                @Override
-                public void run() {
-                  Gerrit.display(PageLinks.toChangeQuery("is:starred"));
-                }
-              });
+          .on("G I", () -> Gerrit.display(PageLinks.MINE))
+          .on("G D", () -> Gerrit.display(PageLinks.toChangeQuery("owner:self is:draft")))
+          .on("G C", () -> Gerrit.display(PageLinks.toChangeQuery("has:draft")))
+          .on("G W", () -> Gerrit.display(PageLinks.toChangeQuery("is:watched status:open")))
+          .on("G S", () -> Gerrit.display(PageLinks.toChangeQuery("is:starred")));
     }
 
     if (revision.get() != 0) {
@@ -698,15 +573,12 @@
 
   abstract void setSyntaxHighlighting(boolean b);
 
-  void setContext(final int context) {
+  void setContext(int context) {
     operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            skipManager.removeAll();
-            skipManager.render(context, diff);
-            updateRenderEntireFile();
-          }
+        () -> {
+          skipManager.removeAll();
+          skipManager.render(context, diff);
+          updateRenderEntireFile();
         });
   }
 
@@ -753,21 +625,18 @@
     return line - offset;
   }
 
-  private Runnable openEditScreen(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        LineHandle handle = cm.extras().activeLine();
-        int line = cm.getLineNumber(handle) + 1;
-        if (Patch.COMMIT_MSG.equals(path)) {
-          line = adjustCommitMessageLine(line);
-        }
-        String token = Dispatcher.toEditScreen(revision, path, line);
-        if (!Gerrit.isSignedIn()) {
-          Gerrit.doSignIn(token);
-        } else {
-          Gerrit.display(token);
-        }
+  private Runnable openEditScreen(CodeMirror cm) {
+    return () -> {
+      LineHandle handle = cm.extras().activeLine();
+      int line = cm.getLineNumber(handle) + 1;
+      if (Patch.COMMIT_MSG.equals(path)) {
+        line = adjustCommitMessageLine(line);
+      }
+      String token = Dispatcher.toEditScreen(revision, path, line);
+      if (!Gerrit.isSignedIn()) {
+        Gerrit.doSignIn(token);
+      } else {
+        Gerrit.display(token);
       }
     };
   }
@@ -832,63 +701,51 @@
 
   abstract void operation(Runnable apply);
 
-  private Runnable upToChange(final boolean openReplyBox) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        CallbackGroup group = new CallbackGroup();
-        getCommentManager().saveAllDrafts(group);
-        group.done();
-        group.addListener(
-            new GerritCallback<Void>() {
-              @Override
-              public void onSuccess(Void result) {
-                String rev = String.valueOf(revision.get());
-                Gerrit.display(
-                    PageLinks.toChange(changeId, base.asString(), rev),
-                    new ChangeScreen(changeId, base, rev, openReplyBox, FileTable.Mode.REVIEW));
-              }
-            });
+  private Runnable upToChange(boolean openReplyBox) {
+    return () -> {
+      CallbackGroup group = new CallbackGroup();
+      getCommentManager().saveAllDrafts(group);
+      group.done();
+      group.addListener(
+          new GerritCallback<Void>() {
+            @Override
+            public void onSuccess(Void result) {
+              String rev = String.valueOf(revision.get());
+              Gerrit.display(
+                  PageLinks.toChange(changeId, base.asString(), rev),
+                  new ChangeScreen(changeId, base, rev, openReplyBox, FileTable.Mode.REVIEW));
+            }
+          });
+    };
+  }
+
+  private Runnable maybePrevVimSearch(CodeMirror cm) {
+    return () -> {
+      if (cm.vim().hasSearchHighlight()) {
+        cm.vim().handleKey("N");
+      } else {
+        getCommentManager().commentNav(cm, Direction.NEXT).run();
       }
     };
   }
 
-  private Runnable maybePrevVimSearch(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (cm.vim().hasSearchHighlight()) {
-          cm.vim().handleKey("N");
-        } else {
-          getCommentManager().commentNav(cm, Direction.NEXT).run();
-        }
+  private Runnable maybeNextVimSearch(CodeMirror cm) {
+    return () -> {
+      if (cm.vim().hasSearchHighlight()) {
+        cm.vim().handleKey("n");
+      } else {
+        getChunkManager().diffChunkNav(cm, Direction.NEXT).run();
       }
     };
   }
 
-  private Runnable maybeNextVimSearch(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (cm.vim().hasSearchHighlight()) {
-          cm.vim().handleKey("n");
-        } else {
-          getChunkManager().diffChunkNav(cm, Direction.NEXT).run();
-        }
-      }
-    };
-  }
-
-  Runnable maybeNextCmSearch(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (cm.hasSearchHighlight()) {
-          cm.execCommand("findNext");
-        } else {
-          cm.execCommand("clearSearch");
-          getCommentManager().toggleOpenBox(cm).run();
-        }
+  Runnable maybeNextCmSearch(CodeMirror cm) {
+    return () -> {
+      if (cm.hasSearchHighlight()) {
+        cm.execCommand("findNext");
+      } else {
+        cm.execCommand("clearSearch");
+        getCommentManager().toggleOpenBox(cm).run();
       }
     };
   }
@@ -973,7 +830,7 @@
   }
 
   void reloadDiffInfo() {
-    final int id = ++reloadVersionId;
+    int id = ++reloadVersionId;
     DiffApi.diff(revision, path)
         .base(base.asPatchSetId())
         .wholeFile()
@@ -986,16 +843,13 @@
                 if (id == reloadVersionId && isAttached()) {
                   diff = diffInfo;
                   operation(
-                      new Runnable() {
-                        @Override
-                        public void run() {
-                          skipManager.removeAll();
-                          getChunkManager().reset();
-                          getDiffTable().scrollbar.removeDiffAnnotations();
-                          setShowIntraline(prefs.intralineDifference());
-                          render(diff);
-                          skipManager.render(prefs.context(), diff);
-                        }
+                      () -> {
+                        skipManager.removeAll();
+                        getChunkManager().reset();
+                        getDiffTable().scrollbar.removeDiffAnnotations();
+                        setShowIntraline(prefs.intralineDifference());
+                        render(diff);
+                        skipManager.render(prefs.context(), diff);
                       });
                 }
               }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java
index a2ffb03f..bf9f9e3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java
@@ -318,47 +318,26 @@
   }
 
   Runnable toggleReviewed() {
-    return new Runnable() {
-      @Override
-      public void run() {
-        reviewed.setValue(!reviewed.getValue(), true);
-      }
-    };
+    return () -> reviewed.setValue(!reviewed.getValue(), true);
   }
 
   Runnable navigate(Direction dir) {
     switch (dir) {
       case PREV:
-        return new Runnable() {
-          @Override
-          public void run() {
-            (hasPrev ? prev : up).go();
-          }
-        };
+        return () -> (hasPrev ? prev : up).go();
       case NEXT:
-        return new Runnable() {
-          @Override
-          public void run() {
-            (hasNext ? next : up).go();
-          }
-        };
+        return () -> (hasNext ? next : up).go();
       default:
-        return new Runnable() {
-          @Override
-          public void run() {}
-        };
+        return () -> {};
     }
   }
 
   Runnable reviewedAndNext() {
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (Gerrit.isSignedIn()) {
-          reviewed.setValue(true, true);
-        }
-        navigate(Direction.NEXT).run();
+    return () -> {
+      if (Gerrit.isSignedIn()) {
+        reviewed.setValue(true, true);
       }
+      navigate(Direction.NEXT).run();
     };
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java
index 4d781ea..ed4ac25 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java
@@ -322,13 +322,10 @@
       prefs.tabSize(Math.max(1, Integer.parseInt(v)));
       if (view != null) {
         view.operation(
-            new Runnable() {
-              @Override
-              public void run() {
-                int v = prefs.tabSize();
-                for (CodeMirror cm : view.getCms()) {
-                  cm.setOption("tabSize", v);
-                }
+            () -> {
+              int size = prefs.tabSize();
+              for (CodeMirror cm : view.getCms()) {
+                cm.setOption("tabSize", size);
               }
             });
       }
@@ -341,13 +338,7 @@
     if (v != null && v.length() > 0) {
       prefs.lineLength(Math.max(1, Integer.parseInt(v)));
       if (view != null) {
-        view.operation(
-            new Runnable() {
-              @Override
-              public void run() {
-                view.setLineLength(prefs.lineLength());
-              }
-            });
+        view.operation(() -> view.setLineLength(prefs.lineLength()));
       }
     }
   }
@@ -448,7 +439,7 @@
 
   @UiHandler("mode")
   void onMode(@SuppressWarnings("unused") ChangeEvent e) {
-    final String mode = getSelectedMode();
+    String mode = getSelectedMode();
     prefs.syntaxHighlighting(true);
     syntaxHighlighting.setValue(true, false);
     new ModeInjector()
@@ -461,12 +452,9 @@
                     && Objects.equals(mode, getSelectedMode())
                     && view.isAttached()) {
                   view.operation(
-                      new Runnable() {
-                        @Override
-                        public void run() {
-                          view.getCmFromSide(DisplaySide.A).setOption("mode", mode);
-                          view.getCmFromSide(DisplaySide.B).setOption("mode", mode);
-                        }
+                      () -> {
+                        view.getCmFromSide(DisplaySide.A).setOption("mode", mode);
+                        view.getCmFromSide(DisplaySide.B).setOption("mode", mode);
                       });
                 }
               }
@@ -483,13 +471,10 @@
     prefs.showWhitespaceErrors(e.getValue());
     if (view != null) {
       view.operation(
-          new Runnable() {
-            @Override
-            public void run() {
-              boolean s = prefs.showWhitespaceErrors();
-              for (CodeMirror cm : view.getCms()) {
-                cm.setOption("showTrailingSpace", s);
-              }
+          () -> {
+            boolean s = prefs.showWhitespaceErrors();
+            for (CodeMirror cm : view.getCms()) {
+              cm.setOption("showTrailingSpace", s);
             }
           });
     }
@@ -537,7 +522,7 @@
 
   @UiHandler("theme")
   void onTheme(@SuppressWarnings("unused") ChangeEvent e) {
-    final Theme newTheme = getSelectedTheme();
+    Theme newTheme = getSelectedTheme();
     prefs.theme(newTheme);
     if (view != null) {
       ThemeLoader.loadTheme(
@@ -546,15 +531,12 @@
             @Override
             public void onSuccess(Void result) {
               view.operation(
-                  new Runnable() {
-                    @Override
-                    public void run() {
-                      if (getSelectedTheme() == newTheme && isAttached()) {
-                        String t = newTheme.name().toLowerCase();
-                        view.getCmFromSide(DisplaySide.A).setOption("theme", t);
-                        view.getCmFromSide(DisplaySide.B).setOption("theme", t);
-                        view.setThemeStyles(newTheme.isDark());
-                      }
+                  () -> {
+                    if (getSelectedTheme() == newTheme && isAttached()) {
+                      String t = newTheme.name().toLowerCase();
+                      view.getCmFromSide(DisplaySide.A).setOption("theme", t);
+                      view.getCmFromSide(DisplaySide.B).setOption("theme", t);
+                      view.setThemeStyles(newTheme.isDark());
                     }
                   });
             }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollbarAnnotation.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollbarAnnotation.java
index 6cb9b6a..ecdac46 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollbarAnnotation.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollbarAnnotation.java
@@ -64,12 +64,9 @@
     refresh =
         cmB.on(
             "refresh",
-            new Runnable() {
-              @Override
-              public void run() {
-                if (updateScale()) {
-                  updatePosition();
-                }
+            () -> {
+              if (updateScale()) {
+                updatePosition();
               }
             });
     updateScale();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java
index 1560597..f2b5fa6 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java
@@ -102,14 +102,11 @@
     super.onShowView();
 
     operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            resizeCodeMirror();
-            chunkManager.adjustPadding();
-            cmA.refresh();
-            cmB.refresh();
-          }
+        () -> {
+          resizeCodeMirror();
+          chunkManager.adjustPadding();
+          cmA.refresh();
+          cmB.refresh();
         });
     setLineLength(Patch.COMMIT_MSG.equals(path) ? 72 : prefs.lineLength());
     diffTable.refresh();
@@ -183,8 +180,8 @@
     };
   }
 
-  private void display(final CommentsCollections comments) {
-    final DiffInfo diff = getDiff();
+  private void display(CommentsCollections comments) {
+    DiffInfo diff = getDiff();
     setThemeStyles(prefs.theme().isDark());
     setShowIntraline(prefs.intralineDifference());
     if (prefs.showLineNumbers()) {
@@ -209,18 +206,15 @@
     chunkManager = new SideBySideChunkManager(this, cmA, cmB, diffTable.scrollbar);
 
     operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            // Estimate initial CodeMirror height, fixed up in onShowView.
-            int height = Window.getClientHeight() - (Gerrit.getHeaderFooterHeight() + 18);
-            cmA.setHeight(height);
-            cmB.setHeight(height);
+        () -> {
+          // Estimate initial CodeMirror height, fixed up in onShowView.
+          int height = Window.getClientHeight() - (Gerrit.getHeaderFooterHeight() + 18);
+          cmA.setHeight(height);
+          cmB.setHeight(height);
 
-            render(diff);
-            commentManager.render(comments, prefs.expandAllComments());
-            skipManager.render(prefs.context(), diff);
-          }
+          render(diff);
+          commentManager.render(comments, prefs.expandAllComments());
+          skipManager.render(prefs.context(), diff);
         });
 
     registerCmEvents(cmA);
@@ -319,66 +313,52 @@
   }
 
   @Override
-  Runnable updateActiveLine(final CodeMirror cm) {
-    final CodeMirror other = otherCm(cm);
-    return new Runnable() {
-      @Override
-      public void run() {
-        // The rendering of active lines has to be deferred. Reflow
-        // caused by adding and removing styles chokes Firefox when arrow
-        // key (or j/k) is held down. Performance on Chrome is fine
-        // without the deferral.
-        //
-        Scheduler.get()
-            .scheduleDeferred(
-                new ScheduledCommand() {
-                  @Override
-                  public void execute() {
-                    operation(
-                        new Runnable() {
-                          @Override
-                          public void run() {
-                            LineHandle handle =
-                                cm.getLineHandleVisualStart(cm.getCursor("end").line());
-                            if (!cm.extras().activeLine(handle)) {
-                              return;
-                            }
+  Runnable updateActiveLine(CodeMirror cm) {
+    CodeMirror other = otherCm(cm);
+    return () -> {
+      // The rendering of active lines has to be deferred. Reflow
+      // caused by adding and removing styles chokes Firefox when arrow
+      // key (or j/k) is held down. Performance on Chrome is fine
+      // without the deferral.
+      //
+      Scheduler.get()
+          .scheduleDeferred(
+              new ScheduledCommand() {
+                @Override
+                public void execute() {
+                  operation(
+                      () -> {
+                        LineHandle handle = cm.getLineHandleVisualStart(cm.getCursor("end").line());
+                        if (!cm.extras().activeLine(handle)) {
+                          return;
+                        }
 
-                            LineOnOtherInfo info = lineOnOther(cm.side(), cm.getLineNumber(handle));
-                            if (info.isAligned()) {
-                              other.extras().activeLine(other.getLineHandle(info.getLine()));
-                            } else {
-                              other.extras().clearActiveLine();
-                            }
-                          }
-                        });
-                  }
-                });
-      }
+                        LineOnOtherInfo info = lineOnOther(cm.side(), cm.getLineNumber(handle));
+                        if (info.isAligned()) {
+                          other.extras().activeLine(other.getLineHandle(info.getLine()));
+                        } else {
+                          other.extras().clearActiveLine();
+                        }
+                      });
+                }
+              });
     };
   }
 
-  private Runnable moveCursorToSide(final CodeMirror cmSrc, DisplaySide sideDst) {
-    final CodeMirror cmDst = getCmFromSide(sideDst);
+  private Runnable moveCursorToSide(CodeMirror cmSrc, DisplaySide sideDst) {
+    CodeMirror cmDst = getCmFromSide(sideDst);
     if (cmDst == cmSrc) {
-      return new Runnable() {
-        @Override
-        public void run() {}
-      };
+      return () -> {};
     }
 
-    final DisplaySide sideSrc = cmSrc.side();
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (cmSrc.extras().hasActiveLine()) {
-          cmDst.setCursor(
-              Pos.create(
-                  lineOnOther(sideSrc, cmSrc.getLineNumber(cmSrc.extras().activeLine()))
-                      .getLine()));
-        }
-        cmDst.focus();
+    DisplaySide sideSrc = cmSrc.side();
+    return () -> {
+      if (cmSrc.extras().hasActiveLine()) {
+        cmDst.setCursor(
+            Pos.create(
+                lineOnOther(sideSrc, cmSrc.getLineNumber(cmSrc.extras().activeLine())).getLine()));
       }
+      cmDst.focus();
     };
   }
 
@@ -389,20 +369,8 @@
   }
 
   @Override
-  void operation(final Runnable apply) {
-    cmA.operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            cmB.operation(
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    apply.run();
-                  }
-                });
-          }
-        });
+  void operation(Runnable apply) {
+    cmA.operation(() -> cmB.operation(apply::run));
   }
 
   @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideChunkManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideChunkManager.java
index a78e59e..cfd4226 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideChunkManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideChunkManager.java
@@ -245,16 +245,13 @@
   }
 
   @Override
-  Runnable diffChunkNav(final CodeMirror cm, final Direction dir) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        int line = cm.extras().hasActiveLine() ? cm.getLineNumber(cm.extras().activeLine()) : 0;
-        int res =
-            Collections.binarySearch(
-                chunks, new DiffChunkInfo(cm.side(), line, 0, false), getDiffChunkComparator());
-        diffChunkNavHelper(chunks, host, res, dir);
-      }
+  Runnable diffChunkNav(CodeMirror cm, Direction dir) {
+    return () -> {
+      int line = cm.extras().hasActiveLine() ? cm.getLineNumber(cm.extras().activeLine()) : 0;
+      int res =
+          Collections.binarySearch(
+              chunks, new DiffChunkInfo(cm.side(), line, 0, false), getDiffChunkComparator());
+      diffChunkNavHelper(chunks, host, res, dir);
     };
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentGroup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentGroup.java
index 6fcd6c8..c728f6f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentGroup.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentGroup.java
@@ -88,29 +88,26 @@
   void handleRedraw() {
     getLineWidget()
         .onRedraw(
-            new Runnable() {
-              @Override
-              public void run() {
-                if (canComputeHeight() && peers.peek().canComputeHeight()) {
-                  if (getResizeTimer() != null) {
-                    getResizeTimer().cancel();
-                    setResizeTimer(null);
-                  }
-                  adjustPadding(SideBySideCommentGroup.this, peers.peek());
-                } else if (getResizeTimer() == null) {
-                  setResizeTimer(
-                      new Timer() {
-                        @Override
-                        public void run() {
-                          if (canComputeHeight() && peers.peek().canComputeHeight()) {
-                            cancel();
-                            setResizeTimer(null);
-                            adjustPadding(SideBySideCommentGroup.this, peers.peek());
-                          }
-                        }
-                      });
-                  getResizeTimer().scheduleRepeating(5);
+            () -> {
+              if (canComputeHeight() && peers.peek().canComputeHeight()) {
+                if (getResizeTimer() != null) {
+                  getResizeTimer().cancel();
+                  setResizeTimer(null);
                 }
+                adjustPadding(SideBySideCommentGroup.this, peers.peek());
+              } else if (getResizeTimer() == null) {
+                setResizeTimer(
+                    new Timer() {
+                      @Override
+                      public void run() {
+                        if (canComputeHeight() && peers.peek().canComputeHeight()) {
+                          cancel();
+                          setResizeTimer(null);
+                          adjustPadding(SideBySideCommentGroup.this, peers.peek());
+                        }
+                      }
+                    });
+                getResizeTimer().scheduleRepeating(5);
               }
             });
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java
index 7465c81..c65dcf0 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java
@@ -75,12 +75,7 @@
   }
 
   Runnable toggleA() {
-    return new Runnable() {
-      @Override
-      public void run() {
-        setVisibleA(!isVisibleA());
-      }
-    };
+    return () -> setVisibleA(!isVisibleA());
   }
 
   void setVisibleB(boolean show) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.java
index 03cfd60..eafb10f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.java
@@ -91,12 +91,9 @@
       }
       if (isNew) {
         lineWidget.onFirstRedraw(
-            new Runnable() {
-              @Override
-              public void run() {
-                int w = cm.getGutterElement().getOffsetWidth();
-                getElement().getStyle().setPaddingLeft(w, Unit.PX);
-              }
+            () -> {
+              int w = cm.getGutterElement().getOffsetWidth();
+              getElement().getStyle().setPaddingLeft(w, Unit.PX);
             });
       }
     }
@@ -110,14 +107,7 @@
                 .set("inclusiveLeft", true)
                 .set("inclusiveRight", true));
 
-    textMarker.on(
-        "beforeCursorEnter",
-        new Runnable() {
-          @Override
-          public void run() {
-            expandAll();
-          }
-        });
+    textMarker.on("beforeCursorEnter", this::expandAll);
 
     int skipped = end - start + 1;
     if (skipped <= UP_DOWN_THRESHOLD) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java
index 0f0ba41..8647d68 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java
@@ -30,7 +30,6 @@
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArrayString;
 import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.ScheduledCommand;
 import com.google.gwt.dom.client.Element;
 import com.google.gwt.event.dom.client.FocusEvent;
 import com.google.gwt.event.dom.client.FocusHandler;
@@ -102,12 +101,9 @@
     super.onShowView();
 
     operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            resizeCodeMirror();
-            cm.refresh();
-          }
+        () -> {
+          resizeCodeMirror();
+          cm.refresh();
         });
     setLineLength(Patch.COMMIT_MSG.equals(path) ? 72 : prefs.lineLength());
     diffTable.refresh();
@@ -137,18 +133,15 @@
   }
 
   @Override
-  void registerCmEvents(final CodeMirror cm) {
+  void registerCmEvents(CodeMirror cm) {
     super.registerCmEvents(cm);
 
     cm.on(
         "scroll",
-        new Runnable() {
-          @Override
-          public void run() {
-            ScrollInfo si = cm.getScrollInfo();
-            if (autoHideDiffTableHeader) {
-              updateDiffTableHeader(si);
-            }
+        () -> {
+          ScrollInfo si = cm.getScrollInfo();
+          if (autoHideDiffTableHeader) {
+            updateDiffTableHeader(si);
           }
         });
     maybeRegisterRenderEntireFileKeyMap(cm);
@@ -171,8 +164,8 @@
     };
   }
 
-  private void display(final CommentsCollections comments) {
-    final DiffInfo diff = getDiff();
+  private void display(CommentsCollections comments) {
+    DiffInfo diff = getDiff();
     setThemeStyles(prefs.theme().isDark());
     setShowIntraline(prefs.intralineDifference());
     if (prefs.showLineNumbers()) {
@@ -186,17 +179,14 @@
     chunkManager = new UnifiedChunkManager(this, cm, diffTable.scrollbar);
 
     operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            // Estimate initial CodeMirror height, fixed up in onShowView.
-            int height = Window.getClientHeight() - (Gerrit.getHeaderFooterHeight() + 18);
-            cm.setHeight(height);
+        () -> {
+          // Estimate initial CodeMirror height, fixed up in onShowView.
+          int height = Window.getClientHeight() - (Gerrit.getHeaderFooterHeight() + 18);
+          cm.setHeight(height);
 
-            render(diff);
-            commentManager.render(comments, prefs.expandAllComments());
-            skipManager.render(prefs.context(), diff);
-          }
+          render(diff);
+          commentManager.render(comments, prefs.expandAllComments());
+          skipManager.render(prefs.context(), diff);
         });
 
     registerCmEvents(cm);
@@ -317,25 +307,19 @@
   }
 
   @Override
-  Runnable updateActiveLine(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        // The rendering of active lines has to be deferred. Reflow
-        // caused by adding and removing styles chokes Firefox when arrow
-        // key (or j/k) is held down. Performance on Chrome is fine
-        // without the deferral.
-        //
-        Scheduler.get()
-            .scheduleDeferred(
-                new ScheduledCommand() {
-                  @Override
-                  public void execute() {
-                    LineHandle handle = cm.getLineHandleVisualStart(cm.getCursor("end").line());
-                    cm.extras().activeLine(handle);
-                  }
-                });
-      }
+  Runnable updateActiveLine(CodeMirror cm) {
+    return () -> {
+      // The rendering of active lines has to be deferred. Reflow
+      // caused by adding and removing styles chokes Firefox when arrow
+      // key (or j/k) is held down. Performance on Chrome is fine
+      // without the deferral.
+      //
+      Scheduler.get()
+          .scheduleDeferred(
+              () -> {
+                LineHandle handle = cm.getLineHandleVisualStart(cm.getCursor("end").line());
+                cm.extras().activeLine(handle);
+              });
     };
   }
 
@@ -354,14 +338,8 @@
   }
 
   @Override
-  void operation(final Runnable apply) {
-    cm.operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            apply.run();
-          }
-        });
+  void operation(Runnable apply) {
+    cm.operation(apply::run);
   }
 
   @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java
index 3939f99..1a662e2 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java
@@ -213,18 +213,15 @@
   }
 
   @Override
-  Runnable diffChunkNav(final CodeMirror cm, final Direction dir) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        int line = cm.extras().hasActiveLine() ? cm.getLineNumber(cm.extras().activeLine()) : 0;
-        int res =
-            Collections.binarySearch(
-                chunks,
-                new UnifiedDiffChunkInfo(cm.side(), 0, 0, line, false),
-                getDiffChunkComparatorCmLine());
-        diffChunkNavHelper(chunks, host, res, dir);
-      }
+  Runnable diffChunkNav(CodeMirror cm, Direction dir) {
+    return () -> {
+      int line = cm.extras().hasActiveLine() ? cm.getLineNumber(cm.extras().activeLine()) : 0;
+      int res =
+          Collections.binarySearch(
+              chunks,
+              new UnifiedDiffChunkInfo(cm.side(), 0, 0, line, false),
+              getDiffChunkComparatorCmLine());
+      diffChunkNavHelper(chunks, host, res, dir);
     };
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentGroup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentGroup.java
index a6912df..6d5fba3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentGroup.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentGroup.java
@@ -50,29 +50,26 @@
   void handleRedraw() {
     getLineWidget()
         .onRedraw(
-            new Runnable() {
-              @Override
-              public void run() {
-                if (canComputeHeight()) {
-                  if (getResizeTimer() != null) {
-                    getResizeTimer().cancel();
-                    setResizeTimer(null);
-                  }
-                  reportHeightChange();
-                } else if (getResizeTimer() == null) {
-                  setResizeTimer(
-                      new Timer() {
-                        @Override
-                        public void run() {
-                          if (canComputeHeight()) {
-                            cancel();
-                            setResizeTimer(null);
-                            reportHeightChange();
-                          }
-                        }
-                      });
-                  getResizeTimer().scheduleRepeating(5);
+            () -> {
+              if (canComputeHeight()) {
+                if (getResizeTimer() != null) {
+                  getResizeTimer().cancel();
+                  setResizeTimer(null);
                 }
+                reportHeightChange();
+              } else if (getResizeTimer() == null) {
+                setResizeTimer(
+                    new Timer() {
+                      @Override
+                      public void run() {
+                        if (canComputeHeight()) {
+                          cancel();
+                          setResizeTimer(null);
+                          reportHeightChange();
+                        }
+                      }
+                    });
+                getResizeTimer().scheduleRepeating(5);
               }
             });
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java
index 511944b..3cf00c9 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java
@@ -49,7 +49,6 @@
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.ScheduledCommand;
 import com.google.gwt.dom.client.Element;
 import com.google.gwt.dom.client.Style.Unit;
 import com.google.gwt.event.dom.client.ClickEvent;
@@ -318,12 +317,7 @@
   }
 
   private Runnable gotoLine() {
-    return new Runnable() {
-      @Override
-      public void run() {
-        cmEdit.execCommand("jumpToLine");
-      }
-    };
+    return () -> cmEdit.execCommand("jumpToLine");
   }
 
   @Override
@@ -472,21 +466,9 @@
     cmEdit.setOption(option, value);
   }
 
-  void setTheme(final Theme newTheme) {
-    cmBase.operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            cmBase.setOption("theme", newTheme.name().toLowerCase());
-          }
-        });
-    cmEdit.operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            cmEdit.setOption("theme", newTheme.name().toLowerCase());
-          }
-        });
+  void setTheme(Theme newTheme) {
+    cmBase.operation(() -> cmBase.setOption("theme", newTheme.name().toLowerCase()));
+    cmEdit.operation(() -> cmEdit.setOption("theme", newTheme.name().toLowerCase()));
   }
 
   void setLineLength(int length) {
@@ -504,21 +486,9 @@
     cmEdit.setOption("lineNumbers", show);
   }
 
-  void setShowWhitespaceErrors(final boolean show) {
-    cmBase.operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            cmBase.setOption("showTrailingSpace", show);
-          }
-        });
-    cmEdit.operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            cmEdit.setOption("showTrailingSpace", show);
-          }
-        });
+  void setShowWhitespaceErrors(boolean show) {
+    cmBase.operation(() -> cmBase.setOption("showTrailingSpace", show));
+    cmEdit.operation(() -> cmEdit.setOption("showTrailingSpace", show));
   }
 
   void setShowTabs(boolean show) {
@@ -643,29 +613,13 @@
   }
 
   private Runnable updateCursorPosition() {
-    return new Runnable() {
-      @Override
-      public void run() {
-        // The rendering of active lines has to be deferred. Reflow
-        // caused by adding and removing styles chokes Firefox when arrow
-        // key (or j/k) is held down. Performance on Chrome is fine
-        // without the deferral.
-        //
-        Scheduler.get()
-            .scheduleDeferred(
-                new ScheduledCommand() {
-                  @Override
-                  public void execute() {
-                    cmEdit.operation(
-                        new Runnable() {
-                          @Override
-                          public void run() {
-                            updateActiveLine();
-                          }
-                        });
-                  }
-                });
-      }
+    return () -> {
+      // The rendering of active lines has to be deferred. Reflow
+      // caused by adding and removing styles chokes Firefox when arrow
+      // key (or j/k) is held down. Performance on Chrome is fine
+      // without the deferral.
+      //
+      Scheduler.get().scheduleDeferred(() -> cmEdit.operation(this::updateActiveLine));
     };
   }
 
@@ -683,37 +637,34 @@
   }
 
   private Runnable save() {
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (!cmEdit.isClean(generation)) {
-          close.setEnabled(false);
-          String text = cmEdit.getValue();
-          if (Patch.COMMIT_MSG.equals(path)) {
-            String trimmed = text.trim() + "\r";
-            if (!trimmed.equals(text)) {
-              text = trimmed;
-              cmEdit.setValue(text);
-            }
+    return () -> {
+      if (!cmEdit.isClean(generation)) {
+        close.setEnabled(false);
+        String text = cmEdit.getValue();
+        if (Patch.COMMIT_MSG.equals(path)) {
+          String trimmed = text.trim() + "\r";
+          if (!trimmed.equals(text)) {
+            text = trimmed;
+            cmEdit.setValue(text);
           }
-          final int g = cmEdit.changeGeneration(false);
-          ChangeEditApi.put(
-              revision.getParentKey().get(),
-              path,
-              text,
-              new GerritCallback<VoidResult>() {
-                @Override
-                public void onSuccess(VoidResult result) {
-                  generation = g;
-                  setClean(cmEdit.isClean(g));
-                }
-
-                @Override
-                public void onFailure(final Throwable caught) {
-                  close.setEnabled(true);
-                }
-              });
         }
+        final int g = cmEdit.changeGeneration(false);
+        ChangeEditApi.put(
+            revision.getParentKey().get(),
+            path,
+            text,
+            new GerritCallback<VoidResult>() {
+              @Override
+              public void onSuccess(VoidResult result) {
+                generation = g;
+                setClean(cmEdit.isClean(g));
+              }
+
+              @Override
+              public void onFailure(final Throwable caught) {
+                close.setEnabled(true);
+              }
+            });
       }
     };
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
index 738319d..b889ff7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
@@ -57,6 +57,9 @@
   public final native InheritedBooleanInfo rejectImplicitMerges()
       /*-{ return this.reject_implicit_merges; }-*/ ;
 
+  public final native InheritedBooleanInfo enableReviewerByEmail()
+      /*-{ return this.enable_reviewer_by_email; }-*/ ;
+
   public final SubmitType submitType() {
     return SubmitType.valueOf(submitTypeRaw());
   }
@@ -113,6 +116,9 @@
 
   final native ThemeInfo theme() /*-{ return this.theme; }-*/;
 
+  final native NativeMap<JsArrayString>
+      extensionPanelNames() /*-{ return this.extension_panel_names; }-*/;
+
   protected ConfigInfo() {}
 
   static class CommentLinkInfo extends JavaScriptObject {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfoCache.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfoCache.java
index e41cf120..7182b78 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfoCache.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfoCache.java
@@ -16,12 +16,14 @@
 
 import com.google.gerrit.client.changes.ChangeApi;
 import com.google.gerrit.client.info.ChangeInfo;
+import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.CommentLinkProcessor;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.user.client.rpc.AsyncCallback;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 
 /** Cache of {@link ConfigInfo} objects by project name. */
@@ -48,6 +50,10 @@
     public ThemeInfo getTheme() {
       return info.theme();
     }
+
+    public List<String> getExtensionPanelNames(String extensionPoint) {
+      return Natives.asList(info.extensionPanelNames().get(extensionPoint));
+    }
   }
 
   public static void get(Project.NameKey name, AsyncCallback<Entry> cb) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
index 4be877e..71fa007 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
@@ -147,6 +147,7 @@
       InheritableBoolean enableSignedPush,
       InheritableBoolean requireSignedPush,
       InheritableBoolean rejectImplicitMerges,
+      InheritableBoolean enableReviewerByEmail,
       String maxObjectSizeLimit,
       SubmitType submitType,
       ProjectState state,
@@ -170,6 +171,7 @@
     in.setSubmitType(submitType);
     in.setState(state);
     in.setPluginConfigValues(pluginConfigValues);
+    in.setEnableReviewerByEmail(enableReviewerByEmail);
 
     project(name).view("config").put(in, cb);
   }
@@ -294,6 +296,13 @@
       setRequireSignedPushRaw(v.name());
     }
 
+    final void setEnableReviewerByEmail(InheritableBoolean v) {
+      setEnableReviewerByEmailRaw(v.name());
+    }
+
+    private native void setEnableReviewerByEmailRaw(String v)
+        /*-{ if(v)this.enable_reviewer_by_email=v; }-*/ ;
+
     private native void setRequireSignedPushRaw(String v)
         /*-{ if(v)this.require_signed_push=v; }-*/ ;
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectMap.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectMap.java
index 4327c07..5ff300d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectMap.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectMap.java
@@ -44,9 +44,9 @@
         .get(NativeMap.copyKeysIntoChildren(callback));
   }
 
-  public static void suggest(String prefix, int limit, AsyncCallback<ProjectMap> cb) {
+  public static void suggest(String match, int limit, AsyncCallback<ProjectMap> cb) {
     new RestApi("/projects/")
-        .addParameter("p", prefix)
+        .addParameter("m", match)
         .addParameter("n", limit)
         .addParameterRaw("type", "ALL")
         .addParameterTrue("d") // description
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 9676cd3..f7309ec 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.Provider;
 import com.google.inject.servlet.RequestScoped;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java
index 7a5956e..a77f660 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AccessPath;
@@ -34,6 +35,9 @@
 import com.google.gerrit.server.git.VisibleRefFilter;
 import com.google.gerrit.server.git.validators.UploadValidators;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.inject.AbstractModule;
@@ -67,6 +71,7 @@
 import org.eclipse.jgit.transport.PreUploadHook;
 import org.eclipse.jgit.transport.PreUploadHookChain;
 import org.eclipse.jgit.transport.ReceivePack;
+import org.eclipse.jgit.transport.ServiceMayNotContinueException;
 import org.eclipse.jgit.transport.UploadPack;
 import org.eclipse.jgit.transport.resolver.ReceivePackFactory;
 import org.eclipse.jgit.transport.resolver.RepositoryResolver;
@@ -142,18 +147,26 @@
 
   static class Resolver implements RepositoryResolver<HttpServletRequest> {
     private final GitRepositoryManager manager;
-    private final ProjectControl.Factory projectControlFactory;
+    private final PermissionBackend permissionBackend;
+    private final Provider<CurrentUser> userProvider;
+    private final ProjectControl.GenericFactory projectControlFactory;
 
     @Inject
-    Resolver(GitRepositoryManager manager, ProjectControl.Factory projectControlFactory) {
+    Resolver(
+        GitRepositoryManager manager,
+        PermissionBackend permissionBackend,
+        Provider<CurrentUser> userProvider,
+        ProjectControl.GenericFactory projectControlFactory) {
       this.manager = manager;
+      this.permissionBackend = permissionBackend;
+      this.userProvider = userProvider;
       this.projectControlFactory = projectControlFactory;
     }
 
     @Override
     public Repository open(HttpServletRequest req, String projectName)
         throws RepositoryNotFoundException, ServiceNotAuthorizedException,
-            ServiceNotEnabledException {
+            ServiceNotEnabledException, ServiceMayNotContinueException {
       while (projectName.endsWith("/")) {
         projectName = projectName.substring(0, projectName.length() - 1);
       }
@@ -168,28 +181,31 @@
         }
       }
 
-      final ProjectControl pc;
-      try {
-        pc = projectControlFactory.controlFor(new Project.NameKey(projectName));
-      } catch (NoSuchProjectException err) {
-        throw new RepositoryNotFoundException(projectName);
-      }
-
-      CurrentUser user = pc.getUser();
+      CurrentUser user = userProvider.get();
       user.setAccessPath(AccessPath.GIT);
 
-      if (!pc.isVisible()) {
-        if (user instanceof AnonymousUser) {
-          throw new ServiceNotAuthorizedException();
-        }
-        throw new ServiceNotEnabledException();
-      }
-      req.setAttribute(ATT_CONTROL, pc);
-
       try {
-        return manager.openRepository(pc.getProject().getNameKey());
-      } catch (IOException e) {
-        throw new RepositoryNotFoundException(pc.getProject().getNameKey().get(), e);
+        Project.NameKey nameKey = new Project.NameKey(projectName);
+        ProjectControl pc;
+        try {
+          pc = projectControlFactory.controlFor(nameKey, user);
+        } catch (NoSuchProjectException err) {
+          throw new RepositoryNotFoundException(projectName);
+        }
+        req.setAttribute(ATT_CONTROL, pc);
+
+        try {
+          permissionBackend.user(user).project(nameKey).check(ProjectPermission.ACCESS);
+        } catch (AuthException e) {
+          if (user instanceof AnonymousUser) {
+            throw new ServiceNotAuthorizedException();
+          }
+          throw new ServiceNotEnabledException(e.getMessage());
+        }
+
+        return manager.openRepository(nameKey);
+      } catch (IOException | PermissionBackendException err) {
+        throw new ServiceMayNotContinueException(projectName + " unavailable", err);
       }
     }
   }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java
index 4862a70..47850c4 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java
@@ -19,11 +19,15 @@
 import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
 
 import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -57,6 +61,7 @@
   private final Provider<ReviewDb> db;
   private final boolean enabled;
   private final DynamicItem<WebSession> session;
+  private final PermissionBackend permissionBackend;
   private final AccountResolver accountResolver;
 
   @Inject
@@ -64,10 +69,12 @@
       Provider<ReviewDb> db,
       AuthConfig config,
       DynamicItem<WebSession> session,
+      PermissionBackend permissionBackend,
       AccountResolver accountResolver) {
     this.db = db;
     this.enabled = config.isRunAsEnabled();
     this.session = session;
+    this.permissionBackend = permissionBackend;
     this.accountResolver = accountResolver;
   }
 
@@ -85,12 +92,20 @@
       }
 
       CurrentUser self = session.get().getUser();
-      if (!self.getCapabilities().canRunAs()
+      try {
+        if (!self.isIdentifiedUser()) {
           // Always disallow for anonymous users, even if permitted by the ACL,
           // because that would be crazy.
-          || !self.isIdentifiedUser()) {
+          throw new AuthException("denied");
+        }
+        permissionBackend.user(self).check(GlobalPermission.RUN_AS);
+      } catch (AuthException e) {
         replyError(req, res, SC_FORBIDDEN, "not permitted to use " + RUN_AS, null);
         return;
+      } catch (PermissionBackendException e) {
+        log.warn("cannot check runAs", e);
+        replyError(req, res, SC_INTERNAL_SERVER_ERROR, RUN_AS + " unavailable", null);
+        return;
       }
 
       Account target;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java
index f1600bc..e476f15 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 
 public interface WebSession {
   boolean isSignedIn();
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java
index 59591cc..7884089 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java
@@ -30,7 +30,7 @@
 
 import com.google.common.cache.Cache;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
index b7c6be3..7f6255a 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.httpd.auth.become;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_UUID;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
 
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -30,7 +30,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.gwtorm.server.OrmException;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
index 5a0ed71..3a575a1 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
@@ -17,7 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Strings.emptyToNull;
 import static com.google.common.net.HttpHeaders.AUTHORIZATION;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
@@ -26,7 +26,7 @@
 import com.google.gerrit.httpd.RemoteUserUtil;
 import com.google.gerrit.httpd.WebSession;
 import com.google.gerrit.httpd.raw.HostPageServlet;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.gwtjsonrpc.server.RPCServletUtils;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
index 40b543b..3696c21 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.httpd.auth.container;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_EXTERNAL;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_EXTERNAL;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.common.PageLinks;
@@ -27,7 +27,7 @@
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.gwtorm.server.OrmException;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
index f3abf2d..abc7fda 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
@@ -33,6 +33,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.AnonymousUser;
@@ -44,8 +45,10 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.LocalDiskRepositoryManager;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.ssh.SshInfo;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.inject.Inject;
@@ -92,7 +95,8 @@
   private final Path gitwebCgi;
   private final URI gitwebUrl;
   private final LocalDiskRepositoryManager repoManager;
-  private final ProjectControl.Factory projectControl;
+  private final ProjectCache projectCache;
+  private final PermissionBackend permissionBackend;
   private final Provider<AnonymousUser> anonymousUserProvider;
   private final Provider<CurrentUser> userProvider;
   private final EnvList _env;
@@ -100,7 +104,8 @@
   @Inject
   GitwebServlet(
       GitRepositoryManager repoManager,
-      ProjectControl.Factory projectControl,
+      ProjectCache projectCache,
+      PermissionBackend permissionBackend,
       Provider<AnonymousUser> anonymousUserProvider,
       Provider<CurrentUser> userProvider,
       SitePaths site,
@@ -113,7 +118,8 @@
       throw new ProvisionException("Gitweb can only be used with LocalDiskRepositoryManager");
     }
     this.repoManager = (LocalDiskRepositoryManager) repoManager;
-    this.projectControl = projectControl;
+    this.projectCache = projectCache;
+    this.permissionBackend = permissionBackend;
     this.anonymousUserProvider = anonymousUserProvider;
     this.userProvider = userProvider;
     this.gitwebCgi = gitwebCgiConfig.getGitwebCgi();
@@ -402,35 +408,39 @@
       name = name.substring(0, name.length() - 4);
     }
 
-    final Project.NameKey nameKey = new Project.NameKey(name);
-    final ProjectControl project;
+    Project.NameKey nameKey = new Project.NameKey(name);
     try {
-      project = projectControl.validateFor(nameKey);
-      if (!project.allRefsAreVisible() && !project.isOwner()) {
-        // Pretend the project doesn't exist
-        throw new NoSuchProjectException(nameKey);
+      if (projectCache.checkedGet(nameKey) == null) {
+        notFound(req, rsp);
+        return;
       }
-    } catch (NoSuchProjectException e) {
-      if (userProvider.get().isIdentifiedUser()) {
-        rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
-      } else {
-        // Allow anonymous users a chance to login.
-        // Avoid leaking information by not distinguishing between
-        // project not existing and no access rights.
-        rsp.sendRedirect(getLoginRedirectUrl(req));
-      }
+      permissionBackend.user(userProvider).project(nameKey).check(ProjectPermission.READ);
+    } catch (AuthException e) {
+      notFound(req, rsp);
+      return;
+    } catch (IOException | PermissionBackendException err) {
+      log.error("cannot load " + name, err);
+      rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
       return;
     }
 
     try (Repository repo = repoManager.openRepository(nameKey)) {
       CacheHeaders.setNotCacheable(rsp);
-      exec(req, rsp, project);
+      exec(req, rsp, nameKey);
     } catch (RepositoryNotFoundException e) {
       getServletContext().log("Cannot open repository", e);
       rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
     }
   }
 
+  private void notFound(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
+    if (userProvider.get().isIdentifiedUser()) {
+      rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
+    } else {
+      rsp.sendRedirect(getLoginRedirectUrl(req));
+    }
+  }
+
   private static String getLoginRedirectUrl(HttpServletRequest req) {
     String contextPath = req.getContextPath();
     String loginUrl = contextPath + "/login/";
@@ -462,8 +472,7 @@
     return params;
   }
 
-  private void exec(
-      final HttpServletRequest req, final HttpServletResponse rsp, final ProjectControl project)
+  private void exec(HttpServletRequest req, HttpServletResponse rsp, Project.NameKey project)
       throws IOException {
     final Process proc =
         Runtime.getRuntime()
@@ -512,7 +521,7 @@
     }
   }
 
-  private String[] makeEnv(final HttpServletRequest req, final ProjectControl project) {
+  private String[] makeEnv(HttpServletRequest req, Project.NameKey nameKey) {
     final EnvList env = new EnvList(_env);
     final int contentLength = Math.max(0, req.getContentLength());
 
@@ -551,20 +560,21 @@
     }
 
     env.set("GERRIT_CONTEXT_PATH", req.getContextPath() + "/");
-    env.set("GERRIT_PROJECT_NAME", project.getProject().getName());
+    env.set("GERRIT_PROJECT_NAME", nameKey.get());
 
-    env.set(
-        "GITWEB_PROJECTROOT",
-        repoManager.getBasePath(project.getProject().getNameKey()).toAbsolutePath().toString());
+    env.set("GITWEB_PROJECTROOT", repoManager.getBasePath(nameKey).toAbsolutePath().toString());
 
-    if (project.forUser(anonymousUserProvider.get()).isVisible()) {
+    if (permissionBackend
+        .user(anonymousUserProvider)
+        .project(nameKey)
+        .testOrFalse(ProjectPermission.READ)) {
       env.set("GERRIT_ANONYMOUS_READ", "1");
     }
 
     String remoteUser = null;
-    if (project.getUser().isIdentifiedUser()) {
-      final IdentifiedUser u = project.getUser().asIdentifiedUser();
-      final String user = u.getUserName();
+    if (userProvider.get().isIdentifiedUser()) {
+      IdentifiedUser u = userProvider.get().asIdentifiedUser();
+      String user = u.getUserName();
       env.set("GERRIT_USER_NAME", user);
       if (user != null && !user.isEmpty()) {
         remoteUser = user;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
index b48caf5..1491345 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.httpd.plugins;
 
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.httpd.resources.Resource;
 import com.google.gerrit.httpd.resources.ResourceKey;
 import com.google.gerrit.httpd.resources.ResourceWeigher;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.plugins.ModuleGenerator;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
@@ -62,5 +64,7 @@
                 .weigher(ResourceWeigher.class);
           }
         });
+
+    DynamicMap.mapOf(binder(), DynamicOptions.DynamicBean.class);
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
index 9730032..039fcb3 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
@@ -64,6 +64,7 @@
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.ConcurrentMap;
@@ -637,7 +638,11 @@
     Path path = plugin.getSrcFile();
     if (req.getRequestURI().endsWith(getJsPluginPath(plugin)) && Files.exists(path)) {
       res.setHeader("Content-Length", Long.toString(Files.size(path)));
-      res.setContentType("application/javascript");
+      if (path.toString().toLowerCase(Locale.US).endsWith(".html")) {
+        res.setContentType("text/html");
+      } else {
+        res.setContentType("application/javascript");
+      }
       writeToResponse(res, Files.newInputStream(path));
     } else {
       resourceCache.put(key, Resource.NOT_FOUND);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/CatServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/CatServlet.java
index 7e298aa..298301d 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/CatServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/CatServlet.java
@@ -35,6 +35,7 @@
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * Exports a single version of a patch as a normal file download.
@@ -126,7 +127,7 @@
         try {
           Optional<ChangeEdit> edit = changeEditUtil.byChange(control.getChange());
           if (edit.isPresent()) {
-            revision = edit.get().getRevision().get();
+            revision = ObjectId.toString(edit.get().getEditCommit());
           } else {
             rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
             return;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/HostPageServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/HostPageServlet.java
index 84348d0..93673bc 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/HostPageServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/HostPageServlet.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.gwtjsonrpc.server.JsonServlet;
 import com.google.gwtjsonrpc.server.RPCServletUtils;
@@ -220,7 +221,7 @@
   private DiffPreferencesInfo getDiffPreferences(IdentifiedUser user) {
     try {
       return getDiff.apply(new AccountResource(user));
-    } catch (AuthException | ConfigInvalidException | IOException e) {
+    } catch (AuthException | ConfigInvalidException | IOException | PermissionBackendException e) {
       log.warn("Cannot query account diff preferences", e);
     }
     return DiffPreferencesInfo.defaults();
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java
index ced3121..6960fae 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java
@@ -24,9 +24,11 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.gson.JsonArray;
 import com.google.gson.JsonElement;
@@ -34,6 +36,7 @@
 import com.google.gson.JsonPrimitive;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.inject.Inject;
+import com.google.inject.Injector;
 import java.io.IOException;
 import java.io.StringWriter;
 import java.util.HashSet;
@@ -49,16 +52,27 @@
       ImmutableSet.of("pp", "prettyPrint", "strict", "callback", "alt", "fields");
 
   private final CmdLineParser.Factory parserFactory;
+  private final Injector injector;
+  private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
   @Inject
-  ParameterParser(CmdLineParser.Factory pf) {
+  ParameterParser(
+      CmdLineParser.Factory pf,
+      Injector injector,
+      DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
     this.parserFactory = pf;
+    this.injector = injector;
+    this.dynamicBeans = dynamicBeans;
   }
 
   <T> boolean parse(
       T param, ListMultimap<String, String> in, HttpServletRequest req, HttpServletResponse res)
       throws IOException {
     CmdLineParser clp = parserFactory.create(param);
+    DynamicOptions pluginOptions = new DynamicOptions(param, injector, dynamicBeans);
+    pluginOptions.parseDynamicBeans(clp);
+    pluginOptions.setDynamicBeans();
+    pluginOptions.onBeanParseStart();
     try {
       clp.parseOptionMap(in);
     } catch (CmdLineException | NumberFormatException e) {
@@ -79,6 +93,7 @@
       replyBinaryResult(req, res, BinaryResult.create(msg.toString()).setContentType("text/plain"));
       return false;
     }
+    pluginOptions.onBeanParseEnd();
 
     return true;
   }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index d9dd5d4..3385bf2 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -95,8 +95,10 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.OptionUtil;
 import com.google.gerrit.server.OutputFormat;
-import com.google.gerrit.server.account.CapabilityUtils;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.util.http.RequestUtil;
 import com.google.gson.ExclusionStrategy;
 import com.google.gson.FieldAttributes;
@@ -119,6 +121,7 @@
 import java.io.EOFException;
 import java.io.FilterOutputStream;
 import java.io.IOException;
+import java.io.InputStream;
 import java.io.OutputStream;
 import java.io.OutputStreamWriter;
 import java.io.Writer;
@@ -144,6 +147,7 @@
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.http.server.ServletUtils;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.util.TemporaryBuffer;
 import org.eclipse.jgit.util.TemporaryBuffer.Heap;
@@ -186,6 +190,7 @@
     final Provider<CurrentUser> currentUser;
     final DynamicItem<WebSession> webSession;
     final Provider<ParameterParser> paramParser;
+    final PermissionBackend permissionBackend;
     final AuditService auditService;
     final RestApiMetrics metrics;
     final Pattern allowOrigin;
@@ -195,12 +200,14 @@
         Provider<CurrentUser> currentUser,
         DynamicItem<WebSession> webSession,
         Provider<ParameterParser> paramParser,
+        PermissionBackend permissionBackend,
         AuditService auditService,
         RestApiMetrics metrics,
         @GerritServerConfig Config cfg) {
       this.currentUser = currentUser;
       this.webSession = webSession;
       this.paramParser = paramParser;
+      this.permissionBackend = permissionBackend;
       this.auditService = auditService;
       this.metrics = metrics;
       allowOrigin = makeAllowOrigin(cfg);
@@ -261,7 +268,10 @@
 
       List<IdString> path = splitPath(req);
       RestCollection<RestResource, RestResource> rc = members.get();
-      CapabilityUtils.checkRequiresCapability(globals.currentUser, null, rc.getClass());
+      globals
+          .permissionBackend
+          .user(globals.currentUser)
+          .checkAny(GlobalPermission.fromAnnotation(rc.getClass()));
 
       viewData = new ViewData(null, null);
 
@@ -371,8 +381,10 @@
         RestModifyView<RestResource, Object> m =
             (RestModifyView<RestResource, Object>) viewData.view;
 
-        inputRequestBody = parseRequest(req, inputType(m));
+        Type type = inputType(m);
+        inputRequestBody = parseRequest(req, type);
         result = m.apply(rsrc, inputRequestBody);
+        consumeRawInputRequestBody(req, type);
       } else {
         throw new ResourceNotFoundException();
       }
@@ -666,24 +678,30 @@
       throws IOException, BadRequestException, SecurityException, IllegalArgumentException,
           NoSuchMethodException, IllegalAccessException, InstantiationException,
           InvocationTargetException, MethodNotAllowedException {
+    // HTTP/1.1 requires consuming the request body before writing non-error response (less than
+    // 400). Consume the request body for all but raw input request types here.
     if (isType(JSON_TYPE, req.getContentType())) {
       try (BufferedReader br = req.getReader();
           JsonReader json = new JsonReader(br)) {
-        json.setLenient(true);
-
-        JsonToken first;
         try {
-          first = json.peek();
-        } catch (EOFException e) {
-          throw new BadRequestException("Expected JSON object");
+          json.setLenient(true);
+
+          JsonToken first;
+          try {
+            first = json.peek();
+          } catch (EOFException e) {
+            throw new BadRequestException("Expected JSON object");
+          }
+          if (first == JsonToken.STRING) {
+            return parseString(json.nextString(), type);
+          }
+          return OutputFormat.JSON.newGson().fromJson(json, type);
+        } finally {
+          // Reader.close won't consume the rest of the input. Explicitly consume the request body.
+          br.skip(Long.MAX_VALUE);
         }
-        if (first == JsonToken.STRING) {
-          return parseString(json.nextString(), type);
-        }
-        return OutputFormat.JSON.newGson().fromJson(json, type);
       }
-    } else if (("PUT".equals(req.getMethod()) || "POST".equals(req.getMethod()))
-        && acceptsRawInput(type)) {
+    } else if (rawInputRequest(req, type)) {
       return parseRawInput(req, type);
     } else if ("DELETE".equals(req.getMethod()) && hasNoBody(req)) {
       return null;
@@ -706,6 +724,19 @@
     }
   }
 
+  private void consumeRawInputRequestBody(HttpServletRequest req, Type type) throws IOException {
+    if (rawInputRequest(req, type)) {
+      try (InputStream is = req.getInputStream()) {
+        ServletUtils.consumeRequestBody(is);
+      }
+    }
+  }
+
+  private static boolean rawInputRequest(HttpServletRequest req, Type type) {
+    String method = req.getMethod();
+    return ("PUT".equals(method) || "POST".equals(method)) && acceptsRawInput(type);
+  }
+
   private static boolean hasNoBody(HttpServletRequest req) {
     int len = req.getContentLength();
     String type = req.getContentType();
@@ -1083,9 +1114,12 @@
     return "GET".equals(req.getMethod()) || "HEAD".equals(req.getMethod());
   }
 
-  private void checkRequiresCapability(ViewData viewData) throws AuthException {
-    CapabilityUtils.checkRequiresCapability(
-        globals.currentUser, viewData.pluginName, viewData.view.getClass());
+  private void checkRequiresCapability(ViewData d)
+      throws AuthException, PermissionBackendException {
+    globals
+        .permissionBackend
+        .user(globals.currentUser)
+        .checkAny(GlobalPermission.fromAnnotation(d.pluginName, d.view.getClass()));
   }
 
   private static long handleException(
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
index 75026d3..0e6ae84 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectControl;
@@ -89,7 +90,8 @@
       ProjectConfig config,
       MetaDataUpdate md,
       boolean parentProjectUpdate)
-      throws IOException, NoSuchProjectException, ConfigInvalidException {
+      throws IOException, NoSuchProjectException, ConfigInvalidException,
+          PermissionBackendException {
     RevCommit commit = config.commit(md);
 
     gitRefUpdated.fire(
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
index ca23ec2..3b620f1 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
@@ -24,21 +24,27 @@
 import com.google.gerrit.common.data.RefConfigSection;
 import com.google.gerrit.common.data.WebLinkInfoCommon;
 import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.httpd.rpc.Handler;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.RefControl;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -56,27 +62,32 @@
 
   private final GroupBackend groupBackend;
   private final ProjectCache projectCache;
-  private final ProjectControl.Factory projectControlFactory;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final ProjectControl.GenericFactory projectControlFactory;
   private final GroupControl.Factory groupControlFactory;
   private final MetaDataUpdate.Server metaDataUpdateFactory;
   private final AllProjectsName allProjectsName;
 
   private final Project.NameKey projectName;
-  private ProjectControl pc;
   private WebLinks webLinks;
 
   @Inject
   ProjectAccessFactory(
-      final GroupBackend groupBackend,
-      final ProjectCache projectCache,
-      final ProjectControl.Factory projectControlFactory,
-      final GroupControl.Factory groupControlFactory,
-      final MetaDataUpdate.Server metaDataUpdateFactory,
-      final AllProjectsName allProjectsName,
-      final WebLinks webLinks,
+      GroupBackend groupBackend,
+      ProjectCache projectCache,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      ProjectControl.GenericFactory projectControlFactory,
+      GroupControl.Factory groupControlFactory,
+      MetaDataUpdate.Server metaDataUpdateFactory,
+      AllProjectsName allProjectsName,
+      WebLinks webLinks,
       @Assisted final Project.NameKey name) {
     this.groupBackend = groupBackend;
     this.projectCache = projectCache;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
     this.projectControlFactory = projectControlFactory;
     this.groupControlFactory = groupControlFactory;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
@@ -87,8 +98,10 @@
   }
 
   @Override
-  public ProjectAccess call() throws NoSuchProjectException, IOException, ConfigInvalidException {
-    pc = open();
+  public ProjectAccess call()
+      throws NoSuchProjectException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    ProjectControl pc = checkProjectControl();
 
     // Load the current configuration from the repository, ensuring its the most
     // recent version available. If it differs from what was in the project
@@ -97,16 +110,15 @@
     ProjectConfig config;
     try (MetaDataUpdate md = metaDataUpdateFactory.create(projectName)) {
       config = ProjectConfig.read(md);
-
       if (config.updateGroupNames(groupBackend)) {
         md.setMessage("Update group names\n");
         config.commit(md);
         projectCache.evict(config.getProject());
-        pc = open();
+        pc = checkProjectControl();
       } else if (config.getRevision() != null
           && !config.getRevision().equals(pc.getProjectState().getConfig().getRevision())) {
         projectCache.evict(config.getProject());
-        pc = open();
+        pc = checkProjectControl();
       }
     }
 
@@ -235,9 +247,14 @@
     return Maps.filterEntries(infos, in -> in.getValue() != null);
   }
 
-  private ProjectControl open() throws NoSuchProjectException {
-    return projectControlFactory.validateFor( //
-        projectName, //
-        ProjectControl.OWNER | ProjectControl.VISIBLE);
+  private ProjectControl checkProjectControl()
+      throws NoSuchProjectException, IOException, PermissionBackendException {
+    ProjectControl pc = projectControlFactory.controlFor(projectName, user.get());
+    try {
+      permissionBackend.user(user).project(projectName).check(ProjectPermission.ACCESS);
+    } catch (AuthException e) {
+      throw new NoSuchProjectException(projectName);
+    }
+    return pc;
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
index 0d90190..252d023 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.RefPattern;
@@ -95,7 +96,7 @@
   public final T call()
       throws NoSuchProjectException, IOException, ConfigInvalidException, InvalidNameException,
           NoSuchGroupException, OrmException, UpdateParentFailedException,
-          PermissionDeniedException {
+          PermissionDeniedException, PermissionBackendException {
     final ProjectControl projectControl = projectControlFactory.controlFor(projectName);
 
     Capable r = projectControl.canPushToAtLeastOneRef();
@@ -182,7 +183,7 @@
       MetaDataUpdate md,
       boolean parentProjectUpdate)
       throws IOException, NoSuchProjectException, ConfigInvalidException, OrmException,
-          PermissionDeniedException;
+          PermissionDeniedException, PermissionBackendException;
 
   private void replace(ProjectConfig config, Set<String> toDelete, AccessSection section)
       throws NoSuchGroupException {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
index 9ad1250..8ba0978 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
@@ -37,8 +37,8 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.RefControl;
@@ -53,6 +53,7 @@
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
@@ -138,8 +139,9 @@
       return null;
     }
 
-    try (RevWalk rw = new RevWalk(md.getRepository());
-        ObjectInserter objInserter = md.getRepository().newObjectInserter();
+    try (ObjectInserter objInserter = md.getRepository().newObjectInserter();
+        ObjectReader objReader = objInserter.newReader();
+        RevWalk rw = new RevWalk(objReader);
         BatchUpdate bu =
             updateFactory.create(
                 db, config.getProject().getNameKey(), projectControl.getUser(), TimeUtil.nowTs())) {
@@ -147,7 +149,7 @@
       bu.insertChange(
           changeInserterFactory
               .create(changeId, commit, RefNames.REFS_CONFIG)
-              .setValidatePolicy(CommitValidators.Policy.NONE)
+              .setValidate(false)
               .setUpdateRef(false)); // Created by commitToNewRef.
       bu.execute();
     } catch (UpdateException | RestApiException e) {
@@ -173,7 +175,11 @@
       AddReviewerInput input = new AddReviewerInput();
       input.reviewer = projectOwners;
       reviewersProvider.get().apply(rsrc, input);
-    } catch (IOException | OrmException | RestApiException | UpdateException e) {
+    } catch (IOException
+        | OrmException
+        | RestApiException
+        | UpdateException
+        | PermissionBackendException e) {
       // one of the owner groups is not visible to the user and this it why it
       // can't be added as reviewer
     }
@@ -192,7 +198,11 @@
         AddReviewerInput input = new AddReviewerInput();
         input.reviewer = r.getGroup().getUUID().get();
         reviewersProvider.get().apply(rsrc, input);
-      } catch (IOException | OrmException | RestApiException | UpdateException e) {
+      } catch (IOException
+          | OrmException
+          | RestApiException
+          | UpdateException
+          | PermissionBackendException e) {
         // ignore
       }
     }
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index 5c3183a..9e375e7 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -20,7 +20,6 @@
 import com.google.common.base.Joiner;
 import com.google.common.collect.Sets;
 import com.google.common.util.concurrent.AbstractFuture;
-import com.google.common.util.concurrent.AsyncFunction;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
@@ -98,7 +97,7 @@
     this.sitePaths = sitePaths;
     this.dir = dir;
     this.name = name;
-    final String index = Joiner.on('_').skipNulls().join(name, subIndex);
+    String index = Joiner.on('_').skipNulls().join(name, subIndex);
     IndexWriter delegateWriter;
     long commitPeriod = writerConfig.getCommitWithinMs();
 
@@ -121,28 +120,25 @@
       @SuppressWarnings("unused") // Error handling within Runnable.
       Future<?> possiblyIgnoredError =
           autoCommitExecutor.scheduleAtFixedRate(
-              new Runnable() {
-                @Override
-                public void run() {
+              () -> {
+                try {
+                  if (autoCommitWriter.hasUncommittedChanges()) {
+                    autoCommitWriter.manualFlush();
+                    autoCommitWriter.commit();
+                  }
+                } catch (IOException e) {
+                  log.error("Error committing " + index + " Lucene index", e);
+                } catch (OutOfMemoryError e) {
+                  log.error("Error committing " + index + " Lucene index", e);
                   try {
-                    if (autoCommitWriter.hasUncommittedChanges()) {
-                      autoCommitWriter.manualFlush();
-                      autoCommitWriter.commit();
-                    }
-                  } catch (IOException e) {
-                    log.error("Error committing " + index + " Lucene index", e);
-                  } catch (OutOfMemoryError e) {
-                    log.error("Error committing " + index + " Lucene index", e);
-                    try {
-                      autoCommitWriter.close();
-                    } catch (IOException e2) {
-                      log.error(
-                          "SEVERE: Error closing "
-                              + index
-                              + " Lucene index after OOM;"
-                              + " index may be corrupted.",
-                          e);
-                    }
+                    autoCommitWriter.close();
+                  } catch (IOException e2) {
+                    log.error(
+                        "SEVERE: Error closing "
+                            + index
+                            + " Lucene index after OOM;"
+                            + " index may be corrupted.",
+                        e);
                   }
                 }
               },
@@ -247,48 +243,27 @@
     }
   }
 
-  ListenableFuture<?> insert(final Document doc) {
-    return submit(
-        new Callable<Long>() {
-          @Override
-          public Long call() throws IOException, InterruptedException {
-            return writer.addDocument(doc);
-          }
-        });
+  ListenableFuture<?> insert(Document doc) {
+    return submit(() -> writer.addDocument(doc));
   }
 
-  ListenableFuture<?> replace(final Term term, final Document doc) {
-    return submit(
-        new Callable<Long>() {
-          @Override
-          public Long call() throws IOException, InterruptedException {
-            return writer.updateDocument(term, doc);
-          }
-        });
+  ListenableFuture<?> replace(Term term, Document doc) {
+    return submit(() -> writer.updateDocument(term, doc));
   }
 
-  ListenableFuture<?> delete(final Term term) {
-    return submit(
-        new Callable<Long>() {
-          @Override
-          public Long call() throws IOException, InterruptedException {
-            return writer.deleteDocuments(term);
-          }
-        });
+  ListenableFuture<?> delete(Term term) {
+    return submit(() -> writer.deleteDocuments(term));
   }
 
   private ListenableFuture<?> submit(Callable<Long> task) {
     ListenableFuture<Long> future = Futures.nonCancellationPropagating(writerThread.submit(task));
     return Futures.transformAsync(
         future,
-        new AsyncFunction<Long, Void>() {
-          @Override
-          public ListenableFuture<Void> apply(Long gen) throws InterruptedException {
-            // Tell the reopen thread a future is waiting on this
-            // generation so it uses the min stale time when refreshing.
-            reopenThread.waitForGeneration(gen, 0);
-            return new NrtFuture(gen);
-          }
+        gen -> {
+          // Tell the reopen thread a future is waiting on this
+          // generation so it uses the min stale time when refreshing.
+          reopenThread.waitForGeneration(gen, 0);
+          return new NrtFuture(gen);
         },
         directExecutor());
   }
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 96986a9..3afcb07 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -61,9 +61,9 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.OrmRuntimeException;
 import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.ArrayList;
@@ -121,6 +121,7 @@
   private static final String REF_STATE_PATTERN_FIELD = ChangeField.REF_STATE_PATTERN.getName();
   private static final String REVIEWEDBY_FIELD = ChangeField.REVIEWEDBY.getName();
   private static final String REVIEWER_FIELD = ChangeField.REVIEWER.getName();
+  private static final String REVIEWER_BY_EMAIL_FIELD = ChangeField.REVIEWER_BY_EMAIL.getName();
   private static final String HASHTAG_FIELD = ChangeField.HASHTAG_CASE_AWARE.getName();
   private static final String STAR_FIELD = ChangeField.STAR.getName();
   private static final String SUBMIT_RECORD_LENIENT_FIELD =
@@ -147,7 +148,7 @@
   private final ChangeSubIndex openIndex;
   private final ChangeSubIndex closedIndex;
 
-  @AssistedInject
+  @Inject
   LuceneChangeIndex(
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
@@ -459,6 +460,9 @@
     if (fields.contains(REVIEWER_FIELD)) {
       decodeReviewers(doc, cd);
     }
+    if (fields.contains(REVIEWER_BY_EMAIL_FIELD)) {
+      decodeReviewersByEmail(doc, cd);
+    }
     decodeSubmitRecords(
         doc, SUBMIT_RECORD_STRICT_FIELD, ChangeField.SUBMIT_RULE_OPTIONS_STRICT, cd);
     decodeSubmitRecords(
@@ -555,6 +559,13 @@
             FluentIterable.from(doc.get(REVIEWER_FIELD)).transform(IndexableField::stringValue)));
   }
 
+  private void decodeReviewersByEmail(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    cd.setReviewersByEmail(
+        ChangeField.parseReviewerByEmailFieldValues(
+            FluentIterable.from(doc.get(REVIEWER_BY_EMAIL_FIELD))
+                .transform(IndexableField::stringValue)));
+  }
+
   private void decodeSubmitRecords(
       ListMultimap<String, IndexableField> doc,
       String field,
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
index 699fd51..89fd819 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
@@ -84,7 +84,7 @@
   IndexConfig getIndexConfig(@GerritServerConfig Config cfg) {
     BooleanQuery.setMaxClauseCount(
         cfg.getInt("index", "maxTerms", BooleanQuery.getMaxClauseCount()));
-    return IndexConfig.fromConfig(cfg);
+    return IndexConfig.fromConfig(cfg).separateChangeSubIndexes(true).build();
   }
 
   private static class MultiVersionModule extends LifecycleModule {
diff --git a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java b/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
index c3210ae..68b28a9d 100644
--- a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
+++ b/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
@@ -32,7 +32,7 @@
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
index e862bac..878f9ee 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
@@ -31,7 +31,7 @@
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
index efe8c5f..a3bf361 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
@@ -26,7 +26,7 @@
 import com.google.gerrit.server.UrlEncoded;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.auth.openid.OpenIdProviderPattern;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.ConfigUtil;
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
index 5352904..9c1541c 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
@@ -75,6 +75,7 @@
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.gerrit.server.plugins.PluginRestApiModule;
+import com.google.gerrit.server.project.DefaultPermissionBackendModule;
 import com.google.gerrit.server.schema.DataSourceProvider;
 import com.google.gerrit.server.schema.H2AccountPatchReviewStore;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
@@ -229,12 +230,9 @@
     try {
       start();
       RuntimeShutdown.add(
-          new Runnable() {
-            @Override
-            public void run() {
-              log.info("caught shutdown, cleaning up");
-              stop();
-            }
+          () -> {
+            log.info("caught shutdown, cleaning up");
+            stop();
           });
 
       log.info("Gerrit Code Review " + myVersion() + " ready");
@@ -370,6 +368,7 @@
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new SearchingChangeCacheImpl.Module(slave));
     modules.add(new InternalAccountDirectory.Module());
+    modules.add(new DefaultPermissionBackendModule());
     modules.add(new DefaultCacheFactory.Module());
     modules.add(cfgInjector.getInstance(MailReceiver.Module.class));
     if (emailModule != null) {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Gsql.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Gsql.java
index b1a50d7..004486b 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Gsql.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Gsql.java
@@ -45,16 +45,13 @@
     manager.add(dbInjector);
     manager.start();
     RuntimeShutdown.add(
-        new Runnable() {
-          @Override
-          public void run() {
-            try {
-              System.in.close();
-            } catch (IOException e) {
-              // Ignored
-            }
-            manager.stop();
+        () -> {
+          try {
+            System.in.close();
+          } catch (IOException e) {
+            // Ignored
           }
+          manager.stop();
         });
     final QueryShell shell = shellFactory().create(System.in, System.out);
     shell.setOutputFormat(format);
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
index 7457f40..4e18ddc 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
@@ -14,16 +14,19 @@
 
 package com.google.gerrit.pgm;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
 import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
 
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIdsBatchUpdate;
+import com.google.gerrit.server.account.externalids.DisabledExternalIdCache;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsBatchUpdate;
 import com.google.gerrit.server.schema.SchemaVersionCheck;
 import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import java.util.Collection;
@@ -37,6 +40,8 @@
 
   @Inject private SchemaFactory<ReviewDb> database;
 
+  @Inject private ExternalIds externalIds;
+
   @Inject private ExternalIdsBatchUpdate externalIdsBatchUpdate;
 
   @Override
@@ -44,10 +49,22 @@
     Injector dbInjector = createDbInjector(MULTI_USER);
     manager.add(dbInjector, dbInjector.createChildInjector(SchemaVersionCheck.module()));
     manager.start();
-    dbInjector.injectMembers(this);
+    dbInjector
+        .createChildInjector(
+            new AbstractModule() {
+              @Override
+              protected void configure() {
+                // The LocalUsernamesToLowerCase program needs to access all external IDs only
+                // once to update them. After the update they are not accessed again. Hence the
+                // LocalUsernamesToLowerCase program doesn't benefit from caching external IDs and
+                // the external ID cache can be disabled.
+                install(DisabledExternalIdCache.module());
+              }
+            })
+        .injectMembers(this);
 
     try (ReviewDb db = database.open()) {
-      Collection<ExternalId> todo = ExternalId.from(db.accountExternalIds().all().toList());
+      Collection<ExternalId> todo = externalIds.all(db);
       monitor.beginTask("Converting local usernames", todo.size());
 
       for (ExternalId extId : todo) {
@@ -56,9 +73,9 @@
       }
 
       externalIdsBatchUpdate.commit(db, "Convert local usernames to lower case");
+      monitor.endTask();
+      manager.stop();
     }
-    monitor.endTask();
-    manager.stop();
     return 0;
   }
 
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtobufImport.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtobufImport.java
index 07e7921..fb524a3 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtobufImport.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtobufImport.java
@@ -85,13 +85,7 @@
     Injector dbInjector = createDbInjector(SINGLE_USER);
     manager.add(dbInjector);
     manager.start();
-    RuntimeShutdown.add(
-        new Runnable() {
-          @Override
-          public void run() {
-            manager.stop();
-          }
-        });
+    RuntimeShutdown.add(manager::stop);
     dbInjector.injectMembers(this);
 
     ProgressMonitor progress = new TextProgressMonitor();
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java
index d77717e..17ce24a 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java
@@ -48,7 +48,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.DummyIndexModule;
-import com.google.gerrit.server.index.change.ReindexAfterUpdate;
+import com.google.gerrit.server.index.change.ReindexAfterRefUpdate;
 import com.google.gerrit.server.notedb.ChangeBundleReader;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager;
 import com.google.gerrit.server.notedb.NotesMigration;
@@ -66,7 +66,6 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
-import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -153,18 +152,15 @@
       List<ListenableFuture<Boolean>> futures = new ArrayList<>();
       List<Project.NameKey> projectNames =
           Ordering.usingToString().sortedCopy(changesByProject.keySet());
-      for (final Project.NameKey project : projectNames) {
+      for (Project.NameKey project : projectNames) {
         ListenableFuture<Boolean> future =
             executor.submit(
-                new Callable<Boolean>() {
-                  @Override
-                  public Boolean call() {
-                    try (ReviewDb db = unwrapDb(schemaFactory.open())) {
-                      return rebuildProject(db, changesByProject, project, allUsersRepo);
-                    } catch (Exception e) {
-                      log.error("Error rebuilding project " + project, e);
-                      return false;
-                    }
+                () -> {
+                  try (ReviewDb db = unwrapDb(schemaFactory.open())) {
+                    return rebuildProject(db, changesByProject, project, allUsersRepo);
+                  } catch (Exception e) {
+                    log.error("Error rebuilding project " + project, e);
+                    return false;
                   }
                 });
         futures.add(future);
@@ -216,7 +212,7 @@
           public void configure() {
             install(dbInjector.getInstance(BatchProgramModule.class));
             DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
-                .to(ReindexAfterUpdate.class);
+                .to(ReindexAfterRefUpdate.class);
             install(new DummyIndexModule());
             factory(ChangeResource.Factory.class);
           }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/AccountsOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/AccountsOnInit.java
new file mode 100644
index 0000000..09626d7
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/AccountsOnInit.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2017 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 com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.pgm.init.api.InitFlags;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdentProvider;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+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;
+
+  @Inject
+  public AccountsOnInit(InitFlags flags, SitePaths site, AllUsersNameOnInitProvider allUsers) {
+    this.flags = flags;
+    this.site = site;
+    this.allUsers = allUsers.get();
+  }
+
+  public void insert(ReviewDb db, Account account) throws OrmException, IOException {
+    db.accounts().insert(ImmutableSet.of(account));
+
+    File path = getPath();
+    if (path != null) {
+      try (Repository repo = new FileRepository(path);
+          ObjectInserter oi = repo.newObjectInserter()) {
+        PersonIdent serverIdent = new GerritPersonIdentProvider(flags.cfg).get();
+        AccountsUpdate.createUserBranch(repo, oi, serverIdent, serverIdent, account);
+      }
+    }
+  }
+
+  private File getPath() {
+    Path basePath = site.resolve(flags.cfg.getString("gerrit", null, "basePath"));
+    checkArgument(basePath != null, "gerrit.basePath must be configured");
+    return FileKey.resolve(basePath.resolve(allUsers).toFile(), FS.DETECTED);
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java
index ae5a598..56b644a 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java
@@ -129,6 +129,7 @@
     init.flags.dev = isDev() && init.site.isNew;
     init.flags.skipPlugins = skipPlugins();
     init.flags.deleteCaches = getDeleteCaches();
+    init.flags.isNew = init.site.isNew;
 
     final SiteRun run;
     try {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
index 86c5f45e..5bedb1b 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
@@ -14,14 +14,14 @@
 
 package com.google.gerrit.pgm.init;
 
-import static com.google.gerrit.server.account.ExternalId.toAccountExternalIds;
+import static com.google.gerrit.server.account.externalids.ExternalId.toAccountExternalIds;
 
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdentProvider;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIds;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdReader;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -61,9 +61,9 @@
       try (Repository repo = new FileRepository(path);
           RevWalk rw = new RevWalk(repo);
           ObjectInserter ins = repo.newObjectInserter()) {
-        ObjectId rev = ExternalIds.readRevision(repo);
+        ObjectId rev = ExternalIdReader.readRevision(repo);
 
-        NoteMap noteMap = ExternalIds.readNoteMap(rw, rev);
+        NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
         for (ExternalId extId : extIds) {
           ExternalIdsUpdate.insert(rw, ins, noteMap, extId);
         }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
index 68b2b96..1e6bfa8 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -29,7 +29,7 @@
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gwtorm.server.SchemaFactory;
@@ -47,6 +47,7 @@
 public class InitAdminUser implements InitStep {
   private final ConsoleUI ui;
   private final InitFlags flags;
+  private final AccountsOnInit accounts;
   private final VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory;
   private final ExternalIdsOnInit externalIds;
   private SchemaFactory<ReviewDb> dbFactory;
@@ -56,10 +57,12 @@
   InitAdminUser(
       InitFlags flags,
       ConsoleUI ui,
+      AccountsOnInit accounts,
       VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory,
       ExternalIdsOnInit externalIds) {
     this.flags = flags;
     this.ui = ui;
+    this.accounts = accounts;
     this.authorizedKeysFactory = authorizedKeysFactory;
     this.externalIds = externalIds;
   }
@@ -106,7 +109,7 @@
           Account a = new Account(id, TimeUtil.nowTs());
           a.setFullName(name);
           a.setPreferredEmail(email);
-          db.accounts().insert(Collections.singleton(a));
+          accounts.insert(db, a);
 
           AccountGroupName adminGroupName =
               db.accountGroupNames().get(new AccountGroup.NameKey("Administrators"));
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitExperimental.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitExperimental.java
new file mode 100644
index 0000000..769cdb4
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitExperimental.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2017 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 com.google.gerrit.server.notedb.ConfigNotesMigration.SECTION_NOTE_DB;
+import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
+
+import com.google.gerrit.extensions.client.UiType;
+import com.google.gerrit.pgm.init.api.ConsoleUI;
+import com.google.gerrit.pgm.init.api.InitFlags;
+import com.google.gerrit.pgm.init.api.InitStep;
+import com.google.gerrit.pgm.init.api.Section;
+import com.google.gerrit.server.notedb.ConfigNotesMigration;
+import com.google.inject.Inject;
+import java.util.Locale;
+import javax.inject.Singleton;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+class InitExperimental implements InitStep {
+  private final ConsoleUI ui;
+  private final InitFlags flags;
+  private final Section noteDbChanges;
+  private final Section gerrit;
+
+  @Inject
+  InitExperimental(ConsoleUI ui, InitFlags flags, Section.Factory sections) {
+    this.ui = ui;
+    this.flags = flags; // Don't grab any flags yet; they aren't initialized until BaseInit#run.
+    this.noteDbChanges = sections.get(SECTION_NOTE_DB, CHANGES.key());
+    this.gerrit = sections.get("gerrit", null);
+  }
+
+  @Override
+  public void run() {
+    ui.header("Experimental features");
+    if (!ui.yesno(false, "Enable any experimental features")) {
+      return;
+    }
+
+    if (flags.isNew) {
+      initNoteDb();
+    }
+    initUis();
+  }
+
+  private void initNoteDb() {
+    ui.message(
+        "Use experimental NoteDb for change metadata?\n"
+            + "  NoteDb is not recommended for production servers."
+            + "  Please familiarize yourself with the documentation:\n"
+            + "  https://gerrit-review.googlesource.com/Documentation/dev-note-db.html\n");
+    if (!ui.yesno(false, "Enable")) {
+      return;
+    }
+
+    Config defaultConfig = ConfigNotesMigration.allEnabledConfig();
+    for (String name : defaultConfig.getNames(SECTION_NOTE_DB, CHANGES.key())) {
+      noteDbChanges.set(name, defaultConfig.getString(SECTION_NOTE_DB, CHANGES.key(), name));
+    }
+  }
+
+  private void initUis() {
+    boolean pg = ui.yesno(true, "Default to PolyGerrit UI");
+    UiType uiType = pg ? UiType.POLYGERRIT : UiType.GWT;
+    gerrit.set("ui", uiType.name().toLowerCase(Locale.US));
+    if (pg) {
+      gerrit.set("enableGwtUi", Boolean.toString(ui.yesno(true, "Enable GWT UI")));
+    }
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java
index f6b7e6a..f75d2dc 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.pgm.init;
 
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.Section;
 import com.google.gerrit.server.config.SitePaths;
@@ -37,7 +36,6 @@
   @Override
   protected void configure() {
     bind(SitePaths.class);
-    bind(InitFlags.class);
     bind(Libraries.class);
     bind(LibraryDownloader.class);
     factory(Section.Factory.class);
@@ -64,6 +62,7 @@
     step().to(InitCache.class);
     step().to(InitPlugins.class);
     step().to(InitDev.class);
+    step().to(InitExperimental.class);
   }
 
   protected LinkedBindingBuilder<InitStep> step() {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitFlags.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitFlags.java
index 691243f..7bb1366 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitFlags.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitFlags.java
@@ -31,6 +31,9 @@
   /** Recursively delete the site path if initialization fails. */
   public boolean deleteOnFailure;
 
+  /** Site is being newly created */
+  public boolean isNew;
+
   /** Run the daemon (and open the web UI in a browser) after initialization. */
   public boolean autoStart;
 
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index aec0731..e625219 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.server.account.GroupCacheImpl;
 import com.google.gerrit.server.account.GroupIncludeCacheImpl;
 import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.account.externalids.ExternalIdModule;
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
 import com.google.gerrit.server.change.ChangeJson;
@@ -61,13 +62,13 @@
 import com.google.gerrit.server.notedb.NoteDbModule;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.patch.PatchListCacheImpl;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.CommentLinkProvider;
+import com.google.gerrit.server.project.DefaultPermissionBackendModule;
 import com.google.gerrit.server.project.ProjectCacheImpl;
-import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SectionSortCache;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryProcessor;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -112,6 +113,8 @@
     bind(new TypeLiteral<List<CommentLinkInfo>>() {})
         .toProvider(CommentLinkProvider.class)
         .in(SINGLETON);
+    bind(new TypeLiteral<DynamicMap<ChangeQueryProcessor.ChangeAttributeFactory>>() {})
+        .toInstance(DynamicMap.<ChangeQueryProcessor.ChangeAttributeFactory>emptyMap());
     bind(String.class)
         .annotatedWith(CanonicalWebUrl.class)
         .toProvider(CanonicalWebUrlProvider.class);
@@ -141,11 +144,11 @@
     bind(new TypeLiteral<Set<AccountGroup.UUID>>() {})
         .annotatedWith(GitReceivePackGroups.class)
         .toInstance(Collections.<AccountGroup.UUID>emptySet());
-    bind(ChangeControl.Factory.class);
-    factory(ProjectControl.AssistedFactory.class);
 
     install(new BatchGitModule());
+    install(new DefaultPermissionBackendModule());
     install(new DefaultCacheFactory.Module());
+    install(new ExternalIdModule());
     install(new GroupModule());
     install(new NoteDbModule(cfg));
     install(new PrologModule());
diff --git a/gerrit-plugin-api/pom.xml b/gerrit-plugin-api/pom.xml
index 2760c5a..a03f991 100644
--- a/gerrit-plugin-api/pom.xml
+++ b/gerrit-plugin-api/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>2.14</version>
+  <version>2.15-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
diff --git a/gerrit-plugin-gwtui/pom.xml b/gerrit-plugin-gwtui/pom.xml
index aabe015..d047206 100644
--- a/gerrit-plugin-gwtui/pom.xml
+++ b/gerrit-plugin-gwtui/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-gwtui</artifactId>
-  <version>2.14</version>
+  <version>2.15-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin GWT UI</name>
   <description>Common Classes for Gerrit GWT UI Plugins</description>
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/Plugin.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/Plugin.java
index 7c478c1..bfcb3d6 100644
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/Plugin.java
+++ b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/Plugin.java
@@ -121,12 +121,15 @@
    *
    * @param extensionPoint the UI extension point for which the panel should be registered.
    * @param entry callback function invoked to create the panel widgets.
+   * @param name the name of the panel which can be used to specify panel ordering via project
+   *     config
    */
-  public void panel(GerritUiExtensionPoint extensionPoint, Panel.EntryPoint entry) {
-    panel(extensionPoint.name(), wrap(entry));
+  public final void panel(
+      GerritUiExtensionPoint extensionPoint, Panel.EntryPoint entry, String name) {
+    panel(extensionPoint.name(), wrap(entry), name);
   }
 
-  private native void panel(String i, JavaScriptObject e) /*-{ this.panel(i, e) }-*/;
+  private native void panel(String i, JavaScriptObject e, String n) /*-{ this.panel(i, e, n) }-*/;
 
   protected Plugin() {}
 
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
index 9655edd..a101ca0 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
@@ -512,6 +512,14 @@
   @Column(id = 19, notNull = false)
   protected Account.Id assignee;
 
+  /** Whether the change is private. */
+  @Column(id = 20)
+  protected boolean isPrivate;
+
+  /** Whether the change is work in progress. */
+  @Column(id = 21)
+  protected boolean workInProgress;
+
   /** @see com.google.gerrit.server.notedb.NoteDbChangeState */
   @Column(id = 101, notNull = false, length = Integer.MAX_VALUE)
   protected String noteDbState;
@@ -548,6 +556,8 @@
     originalSubject = other.originalSubject;
     submissionId = other.submissionId;
     topic = other.topic;
+    isPrivate = other.isPrivate;
+    workInProgress = other.workInProgress;
     noteDbState = other.noteDbState;
   }
 
@@ -694,6 +704,22 @@
     this.topic = topic;
   }
 
+  public boolean isPrivate() {
+    return isPrivate;
+  }
+
+  public void setPrivate(boolean isPrivate) {
+    this.isPrivate = isPrivate;
+  }
+
+  public boolean isWorkInProgress() {
+    return workInProgress;
+  }
+
+  public void setWorkInProgress(boolean workInProgress) {
+    this.workInProgress = workInProgress;
+  }
+
   public String getNoteDbState() {
     return noteDbState;
   }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java
index cadd52c..4b3c652 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.reviewdb.client;
 
 import java.sql.Timestamp;
+import java.util.Comparator;
 import java.util.Objects;
 
 /**
@@ -130,7 +131,13 @@
     }
   }
 
-  public static class Range {
+  public static class Range implements Comparable<Range> {
+    private static final Comparator<Range> RANGE_COMPARATOR =
+        Comparator.<Range>comparingInt(range -> range.startLine)
+            .thenComparingInt(range -> range.startChar)
+            .thenComparingInt(range -> range.endLine)
+            .thenComparingInt(range -> range.endChar);
+
     public int startLine; // 1-based, inclusive
     public int startChar; // 0-based, inclusive
     public int endLine; // 1-based, exclusive
@@ -186,6 +193,11 @@
           .append('}')
           .toString();
     }
+
+    @Override
+    public int compareTo(Range otherRange) {
+      return RANGE_COMPARATOR.compare(this, otherRange);
+    }
   }
 
   public Key key;
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java
index ba83c58..9918317 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java
@@ -99,6 +99,8 @@
 
   protected InheritableBoolean rejectImplicitMerges;
 
+  protected InheritableBoolean enableReviewerByEmail;
+
   protected Project() {}
 
   public Project(Project.NameKey nameKey) {
@@ -112,6 +114,7 @@
     createNewChangeForAllNotInTarget = InheritableBoolean.INHERIT;
     enableSignedPush = InheritableBoolean.INHERIT;
     requireSignedPush = InheritableBoolean.INHERIT;
+    enableReviewerByEmail = InheritableBoolean.INHERIT;
   }
 
   public Project.NameKey getNameKey() {
@@ -154,6 +157,14 @@
     return rejectImplicitMerges;
   }
 
+  public InheritableBoolean getEnableReviewerByEmail() {
+    return enableReviewerByEmail;
+  }
+
+  public void setEnableReviewerByEmail(final InheritableBoolean enable) {
+    enableReviewerByEmail = enable;
+  }
+
   public void setUseContributorAgreements(final InheritableBoolean u) {
     useContributorAgreements = u;
   }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
index b892e3d..8407bc6 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
@@ -105,6 +105,17 @@
     return r.toString();
   }
 
+  public static boolean isNoteDbMetaRef(String ref) {
+    if (ref.startsWith(REFS_CHANGES)
+        && (ref.endsWith(META_SUFFIX) || ref.endsWith(ROBOT_COMMENTS_SUFFIX))) {
+      return true;
+    }
+    if (ref.startsWith(REFS_DRAFT_COMMENTS) || ref.startsWith(REFS_STARRED_CHANGES)) {
+      return true;
+    }
+    return false;
+  }
+
   public static String refsUsers(Account.Id accountId) {
     StringBuilder r = new StringBuilder();
     r.append(REFS_USERS);
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountExternalIdAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountExternalIdAccess.java
index 9124301..e21faaf 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountExternalIdAccess.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountExternalIdAccess.java
@@ -30,6 +30,9 @@
   @Query("WHERE accountId = ?")
   ResultSet<AccountExternalId> byAccount(Account.Id id) throws OrmException;
 
+  @Query("WHERE emailAddress = ?")
+  ResultSet<AccountExternalId> byEmailAddress(String email) throws OrmException;
+
   @Query
   ResultSet<AccountExternalId> all() throws OrmException;
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupNameAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupNameAccess.java
index 82660cb..b8bc9f0 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupNameAccess.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupNameAccess.java
@@ -29,8 +29,4 @@
 
   @Query("ORDER BY name")
   ResultSet<AccountGroupName> all() throws OrmException;
-
-  @Query("WHERE name.name >= ? AND name.name <= ? ORDER BY name LIMIT ?")
-  ResultSet<AccountGroupName> suggestByName(String nameA, String nameB, int limit)
-      throws OrmException;
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDb.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDb.java
index 9b4e1ed..49b9337 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDb.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDb.java
@@ -118,4 +118,8 @@
   @Sequence(startWith = FIRST_CHANGE_ID)
   @Deprecated
   int nextChangeId() throws OrmException;
+
+  default boolean changesTablesEnabled() {
+    return true;
+  }
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java
index 6dd3701..8aeb3ad 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java
@@ -158,6 +158,11 @@
     return delegate.nextChangeId();
   }
 
+  @Override
+  public boolean changesTablesEnabled() {
+    return delegate.changesTablesEnabled();
+  }
+
   public static class ChangeAccessWrapper implements ChangeAccess {
     protected final ChangeAccess delegate;
 
diff --git a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql
index deceab9..2fcf53c 100644
--- a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql
+++ b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql
@@ -21,6 +21,9 @@
 CREATE INDEX account_external_ids_byAccount
 ON account_external_ids (account_id);
 
+--    covers:             byEmailAddress
+CREATE INDEX account_external_ids_byEmail
+ON account_external_ids (email_address);
 
 -- *********************************************************************
 -- AccountGroupMemberAccess
diff --git a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql
index 1ec8ea6..3be3c26 100644
--- a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql
+++ b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql
@@ -25,6 +25,10 @@
 ON account_external_ids (account_id)
 #
 
+--    covers:             byEmailAddress
+CREATE INDEX account_external_ids_byEmail
+ON account_external_ids (email_address)
+#
 
 -- *********************************************************************
 -- AccountGroupMemberAccess
diff --git a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql
index a11c86b..641c613 100644
--- a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql
+++ b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql
@@ -68,6 +68,10 @@
 CREATE INDEX account_external_ids_byAccount
 ON account_external_ids (account_id);
 
+--    covers:             byEmailAddress
+CREATE INDEX account_external_ids_byEmail
+ON account_external_ids (email_address);
+
 
 -- *********************************************************************
 -- AccountGroupMemberAccess
diff --git a/gerrit-server/BUILD b/gerrit-server/BUILD
index adfe7a4..e5ca05e 100644
--- a/gerrit-server/BUILD
+++ b/gerrit-server/BUILD
@@ -138,6 +138,22 @@
     ],
 )
 
+CUSTOM_TRUTH_SUBJECTS = glob([
+    "src/test/java/com/google/gerrit/server/**/*Subject.java",
+])
+
+java_library(
+    name = "custom-truth-subjects",
+    testonly = 1,
+    srcs = CUSTOM_TRUTH_SUBJECTS,
+    deps = [
+        ":server",
+        "//gerrit-extension-api:api",
+        "//gerrit-test-util:test_util",
+        "//lib:truth",
+    ],
+)
+
 PROLOG_TEST_CASE = [
     "src/test/java/com/google/gerrit/rules/PrologTestCase.java",
 ]
@@ -213,11 +229,12 @@
     size = "large",
     srcs = glob(
         ["src/test/java/**/*.java"],
-        exclude = TESTUTIL + PROLOG_TESTS + PROLOG_TEST_CASE + QUERY_TESTS,
+        exclude = TESTUTIL + CUSTOM_TRUTH_SUBJECTS + PROLOG_TESTS + PROLOG_TEST_CASE + QUERY_TESTS,
     ),
     resources = glob(["src/test/resources/com/google/gerrit/server/**/*"]),
     visibility = ["//visibility:public"],
     deps = TESTUTIL_DEPS + [
+        ":custom-truth-subjects",
         ":testutil",
         "//gerrit-antlr:query_exception",
         "//gerrit-patch-jgit:server",
@@ -229,6 +246,7 @@
         "//lib:guava",
         "//lib:guava-retrying",
         "//lib:protobuf",
+        "//lib:truth-java8-extension",
         "//lib/bouncycastle:bcprov",
         "//lib/bouncycastle:bcpkix",
         "//lib/dropwizard:dropwizard-core",
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java b/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java
index 87cfea1..96c70c0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
@@ -28,6 +29,9 @@
 import com.google.gerrit.server.events.ProjectEvent;
 import com.google.gerrit.server.events.RefEvent;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.ProjectState;
@@ -54,6 +58,7 @@
   /** Listeners to receive all changes as they happen. */
   protected final DynamicSet<EventListener> unrestrictedListeners;
 
+  private final PermissionBackend permissionBackend;
   protected final ProjectCache projectCache;
 
   protected final ChangeNotes.Factory notesFactory;
@@ -64,11 +69,13 @@
   public EventBroker(
       DynamicSet<UserScopedEventListener> listeners,
       DynamicSet<EventListener> unrestrictedListeners,
+      PermissionBackend permissionBackend,
       ProjectCache projectCache,
       ChangeNotes.Factory notesFactory,
       Provider<ReviewDb> dbProvider) {
     this.listeners = listeners;
     this.unrestrictedListeners = unrestrictedListeners;
+    this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
     this.notesFactory = notesFactory;
     this.dbProvider = dbProvider;
@@ -137,11 +144,12 @@
   }
 
   protected boolean isVisibleTo(Project.NameKey project, CurrentUser user) {
-    ProjectState pe = projectCache.get(project);
-    if (pe == null) {
+    try {
+      permissionBackend.user(user).project(project).check(ProjectPermission.ACCESS);
+      return true;
+    } catch (AuthException | PermissionBackendException e) {
       return false;
     }
-    return pe.controlFor(user).isVisible();
   }
 
   protected boolean isVisibleTo(Change change, CurrentUser user) throws OrmException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java
index 9773869..880ba24 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java
@@ -79,20 +79,13 @@
    * @param value only value of the metric.
    * @param desc description of the metric.
    */
-  public <V> void newConstantMetric(String name, final V value, Description desc) {
+  public <V> void newConstantMetric(String name, V value, Description desc) {
     desc.setConstant();
 
     @SuppressWarnings("unchecked")
     Class<V> type = (Class<V>) value.getClass();
-    final CallbackMetric0<V> metric = newCallbackMetric(name, type, desc);
-    newTrigger(
-        metric,
-        new Runnable() {
-          @Override
-          public void run() {
-            metric.set(value);
-          }
-        });
+    CallbackMetric0<V> metric = newCallbackMetric(name, type, desc);
+    newTrigger(metric, () -> metric.set(value));
   }
 
   /**
@@ -116,16 +109,9 @@
    * @param trigger function to compute the value of the metric.
    */
   public <V> void newCallbackMetric(
-      String name, Class<V> valueClass, Description desc, final Supplier<V> trigger) {
-    final CallbackMetric0<V> metric = newCallbackMetric(name, valueClass, desc);
-    newTrigger(
-        metric,
-        new Runnable() {
-          @Override
-          public void run() {
-            metric.set(trigger.get());
-          }
-        });
+      String name, Class<V> valueClass, Description desc, Supplier<V> trigger) {
+    CallbackMetric0<V> metric = newCallbackMetric(name, valueClass, desc);
+    newTrigger(metric, () -> metric.set(trigger.get()));
   }
 
   /**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/GetMetric.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
index 52e35c3..f0ae97e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
@@ -17,10 +17,14 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import org.kohsuke.args4j.Option;
 
 class GetMetric implements RestReadView<MetricResource> {
+  private final PermissionBackend permissionBackend;
   private final CurrentUser user;
   private final DropWizardMetricMaker metrics;
 
@@ -28,16 +32,16 @@
   boolean dataOnly;
 
   @Inject
-  GetMetric(CurrentUser user, DropWizardMetricMaker metrics) {
+  GetMetric(PermissionBackend permissionBackend, CurrentUser user, DropWizardMetricMaker metrics) {
+    this.permissionBackend = permissionBackend;
     this.user = user;
     this.metrics = metrics;
   }
 
   @Override
-  public MetricJson apply(MetricResource resource) throws AuthException {
-    if (!user.getCapabilities().canViewCaches()) {
-      throw new AuthException("restricted to viewCaches");
-    }
+  public MetricJson apply(MetricResource resource)
+      throws AuthException, PermissionBackendException {
+    permissionBackend.user(user).check(GlobalPermission.VIEW_CACHES);
     return new MetricJson(
         resource.getMetric(), metrics.getAnnotations(resource.getName()), dataOnly);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
index 891f4ac..59f6b97 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
@@ -19,6 +19,9 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.List;
@@ -28,6 +31,7 @@
 import org.kohsuke.args4j.Option;
 
 class ListMetrics implements RestReadView<ConfigResource> {
+  private final PermissionBackend permissionBackend;
   private final CurrentUser user;
   private final DropWizardMetricMaker metrics;
 
@@ -43,16 +47,17 @@
   List<String> query = new ArrayList<>();
 
   @Inject
-  ListMetrics(CurrentUser user, DropWizardMetricMaker metrics) {
+  ListMetrics(
+      PermissionBackend permissionBackend, CurrentUser user, DropWizardMetricMaker metrics) {
+    this.permissionBackend = permissionBackend;
     this.user = user;
     this.metrics = metrics;
   }
 
   @Override
-  public Map<String, MetricJson> apply(ConfigResource resource) throws AuthException {
-    if (!user.getCapabilities().canViewCaches()) {
-      throw new AuthException("restricted to viewCaches");
-    }
+  public Map<String, MetricJson> apply(ConfigResource resource)
+      throws AuthException, PermissionBackendException {
+    permissionBackend.user(user).check(GlobalPermission.VIEW_CACHES);
 
     SortedMap<String, MetricJson> out = new TreeMap<>();
     List<String> prefixes = new ArrayList<>(query.size());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java
index 2686f1f..6abf17c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java
@@ -23,6 +23,9 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -31,6 +34,7 @@
 class MetricsCollection implements ChildCollection<ConfigResource, MetricResource> {
   private final DynamicMap<RestView<MetricResource>> views;
   private final Provider<ListMetrics> list;
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
   private final DropWizardMetricMaker metrics;
 
@@ -38,10 +42,12 @@
   MetricsCollection(
       DynamicMap<RestView<MetricResource>> views,
       Provider<ListMetrics> list,
+      PermissionBackend permissionBackend,
       Provider<CurrentUser> user,
       DropWizardMetricMaker metrics) {
     this.views = views;
     this.list = list;
+    this.permissionBackend = permissionBackend;
     this.user = user;
     this.metrics = metrics;
   }
@@ -58,10 +64,8 @@
 
   @Override
   public MetricResource parse(ConfigResource parent, IdString id)
-      throws ResourceNotFoundException, AuthException {
-    if (!user.get().getCapabilities().canViewCaches()) {
-      throw new AuthException("restricted to viewCaches");
-    }
+      throws ResourceNotFoundException, AuthException, PermissionBackendException {
+    permissionBackend.user(user).check(GlobalPermission.VIEW_CACHES);
 
     Metric metric = metrics.getMetric(id.get());
     if (metric == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
index 11f8e50..8978e99 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
@@ -103,7 +103,7 @@
   }
 
   private void procJvmMemory(MetricMaker metrics) {
-    final CallbackMetric0<Long> heapCommitted =
+    CallbackMetric0<Long> heapCommitted =
         metrics.newCallbackMetric(
             "proc/jvm/memory/heap_committed",
             Long.class,
@@ -111,7 +111,7 @@
                 .setGauge()
                 .setUnit(Units.BYTES));
 
-    final CallbackMetric0<Long> heapUsed =
+    CallbackMetric0<Long> heapUsed =
         metrics.newCallbackMetric(
             "proc/jvm/memory/heap_used",
             Long.class,
@@ -119,7 +119,7 @@
                 .setGauge()
                 .setUnit(Units.BYTES));
 
-    final CallbackMetric0<Long> nonHeapCommitted =
+    CallbackMetric0<Long> nonHeapCommitted =
         metrics.newCallbackMetric(
             "proc/jvm/memory/non_heap_committed",
             Long.class,
@@ -127,7 +127,7 @@
                 .setGauge()
                 .setUnit(Units.BYTES));
 
-    final CallbackMetric0<Long> nonHeapUsed =
+    CallbackMetric0<Long> nonHeapUsed =
         metrics.newCallbackMetric(
             "proc/jvm/memory/non_heap_used",
             Long.class,
@@ -135,7 +135,7 @@
                 .setGauge()
                 .setUnit(Units.BYTES));
 
-    final CallbackMetric0<Integer> objectPendingFinalizationCount =
+    CallbackMetric0<Integer> objectPendingFinalizationCount =
         metrics.newCallbackMetric(
             "proc/jvm/memory/object_pending_finalization_count",
             Integer.class,
@@ -143,39 +143,36 @@
                 .setGauge()
                 .setUnit("objects"));
 
-    final MemoryMXBean memory = ManagementFactory.getMemoryMXBean();
+    MemoryMXBean memory = ManagementFactory.getMemoryMXBean();
     metrics.newTrigger(
         ImmutableSet.<CallbackMetric<?>>of(
             heapCommitted, heapUsed, nonHeapCommitted, nonHeapUsed, objectPendingFinalizationCount),
-        new Runnable() {
-          @Override
-          public void run() {
-            try {
-              MemoryUsage stats = memory.getHeapMemoryUsage();
-              heapCommitted.set(stats.getCommitted());
-              heapUsed.set(stats.getUsed());
-            } catch (IllegalArgumentException e) {
-              // MXBean may throw due to a bug in Java 7; ignore.
-            }
-
-            MemoryUsage stats = memory.getNonHeapMemoryUsage();
-            nonHeapCommitted.set(stats.getCommitted());
-            nonHeapUsed.set(stats.getUsed());
-
-            objectPendingFinalizationCount.set(memory.getObjectPendingFinalizationCount());
+        () -> {
+          try {
+            MemoryUsage stats = memory.getHeapMemoryUsage();
+            heapCommitted.set(stats.getCommitted());
+            heapUsed.set(stats.getUsed());
+          } catch (IllegalArgumentException e) {
+            // MXBean may throw due to a bug in Java 7; ignore.
           }
+
+          MemoryUsage stats = memory.getNonHeapMemoryUsage();
+          nonHeapCommitted.set(stats.getCommitted());
+          nonHeapUsed.set(stats.getUsed());
+
+          objectPendingFinalizationCount.set(memory.getObjectPendingFinalizationCount());
         });
   }
 
   private void procJvmGc(MetricMaker metrics) {
-    final CallbackMetric1<String, Long> gcCount =
+    CallbackMetric1<String, Long> gcCount =
         metrics.newCallbackMetric(
             "proc/jvm/gc/count",
             Long.class,
             new Description("Number of GCs").setCumulative(),
             Field.ofString("gc_name", "The name of the garbage collector"));
 
-    final CallbackMetric1<String, Long> gcTime =
+    CallbackMetric1<String, Long> gcTime =
         metrics.newCallbackMetric(
             "proc/jvm/gc/time",
             Long.class,
@@ -187,34 +184,26 @@
     metrics.newTrigger(
         gcCount,
         gcTime,
-        new Runnable() {
-          @Override
-          public void run() {
-            for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
-              long count = gc.getCollectionCount();
-              if (count != -1) {
-                gcCount.set(gc.getName(), count);
-              }
-              long time = gc.getCollectionTime();
-              if (time != -1) {
-                gcTime.set(gc.getName(), time);
-              }
+        () -> {
+          for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
+            long count = gc.getCollectionCount();
+            if (count != -1) {
+              gcCount.set(gc.getName(), count);
+            }
+            long time = gc.getCollectionTime();
+            if (time != -1) {
+              gcTime.set(gc.getName(), time);
             }
           }
         });
   }
 
   private void procJvmThread(MetricMaker metrics) {
-    final ThreadMXBean thread = ManagementFactory.getThreadMXBean();
+    ThreadMXBean thread = ManagementFactory.getThreadMXBean();
     metrics.newCallbackMetric(
         "proc/jvm/thread/num_live",
         Integer.class,
         new Description("Current live thread count").setGauge().setUnit("threads"),
-        new Supplier<Integer>() {
-          @Override
-          public Integer get() {
-            return thread.getThreadCount();
-          }
-        });
+        () -> thread.getThreadCount());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/PrologEnvironment.java b/gerrit-server/src/main/java/com/google/gerrit/rules/PrologEnvironment.java
index 9538121a4..23c59f5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/PrologEnvironment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/rules/PrologEnvironment.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -166,6 +167,7 @@
     }
 
     private final ProjectCache projectCache;
+    private final PermissionBackend permissionBackend;
     private final GitRepositoryManager repositoryManager;
     private final PatchListCache patchListCache;
     private final PatchSetInfoFactory patchSetInfoFactory;
@@ -177,6 +179,7 @@
     @Inject
     Args(
         ProjectCache projectCache,
+        PermissionBackend permissionBackend,
         GitRepositoryManager repositoryManager,
         PatchListCache patchListCache,
         PatchSetInfoFactory patchSetInfoFactory,
@@ -184,6 +187,7 @@
         Provider<AnonymousUser> anonymousUser,
         @GerritServerConfig Config config) {
       this.projectCache = projectCache;
+      this.permissionBackend = permissionBackend;
       this.repositoryManager = repositoryManager;
       this.patchListCache = patchListCache;
       this.patchSetInfoFactory = patchSetInfoFactory;
@@ -213,6 +217,10 @@
       return projectCache;
     }
 
+    public PermissionBackend getPermissionBackend() {
+      return permissionBackend;
+    }
+
     public GitRepositoryManager getGitRepositoryManager() {
       return repositoryManager;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java b/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
index 34fcb52..e35171b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
@@ -119,23 +120,26 @@
           GitRepositoryManager gitMgr = env.getArgs().getGitRepositoryManager();
           Change change = getChange(engine);
           Project.NameKey projectKey = change.getProject();
-          final Repository repo;
+          Repository repo;
           try {
             repo = gitMgr.openRepository(projectKey);
           } catch (IOException e) {
             throw new SystemException(e.getMessage());
           }
-          env.addToCleanup(
-              new Runnable() {
-                @Override
-                public void run() {
-                  repo.close();
-                }
-              });
+          env.addToCleanup(repo::close);
           return repo;
         }
       };
 
+  public static final StoredValue<PermissionBackend> PERMISSION_BACKEND =
+      new StoredValue<PermissionBackend>() {
+        @Override
+        protected PermissionBackend createValue(Prolog engine) {
+          PrologEnvironment env = (PrologEnvironment) engine.control;
+          return env.getArgs().getPermissionBackend();
+        }
+      };
+
   public static final StoredValue<AnonymousUser> ANONYMOUS_USER =
       new StoredValue<AnonymousUser>() {
         @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
index cb65ed3..e6dd1db 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
@@ -43,6 +43,7 @@
 import java.util.TreeMap;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
  * Copies approvals between patch sets.
@@ -140,7 +141,8 @@
 
       TreeMap<Integer, PatchSet> patchSets = getPatchSets(cd);
 
-      try (Repository repo = repoManager.openRepository(project.getProject().getNameKey())) {
+      try (Repository repo = repoManager.openRepository(project.getProject().getNameKey());
+          RevWalk rw = new RevWalk(repo)) {
         // Walk patch sets strictly less than current in descending order.
         Collection<PatchSet> allPrior =
             patchSets.descendingMap().tailMap(ps.getId().get(), false).values();
@@ -153,7 +155,8 @@
           ChangeKind kind =
               changeKindCache.getChangeKind(
                   project.getProject().getNameKey(),
-                  repo,
+                  rw,
+                  repo.getConfig(),
                   ObjectId.fromString(priorPs.getRevision().get()),
                   ObjectId.fromString(ps.getRevision().get()));
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
index 910fbc2..1ef284c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
@@ -322,7 +322,7 @@
         accountId,
         ps.getUploader());
     if (approvals.isEmpty()) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
     checkApprovals(approvals, changeCtl);
     List<PatchSetApproval> cells = new ArrayList<>(approvals.size());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java
index 2f3a76f..2d80ceb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.server;
 
+import com.google.common.collect.Sets;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.ChangeTriplet;
+import com.google.gerrit.server.index.IndexConfig;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -29,13 +31,16 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
+import java.util.Set;
 
 @Singleton
 public class ChangeFinder {
+  private final IndexConfig indexConfig;
   private final Provider<InternalChangeQuery> queryProvider;
 
   @Inject
-  ChangeFinder(Provider<InternalChangeQuery> queryProvider) {
+  ChangeFinder(IndexConfig indexConfig, Provider<InternalChangeQuery> queryProvider) {
+    this.indexConfig = indexConfig;
     this.queryProvider = queryProvider;
   }
 
@@ -93,8 +98,24 @@
   private List<ChangeControl> asChangeControls(List<ChangeData> cds, CurrentUser user)
       throws OrmException {
     List<ChangeControl> ctls = new ArrayList<>(cds.size());
+    if (!indexConfig.separateChangeSubIndexes()) {
+      for (ChangeData cd : cds) {
+        ctls.add(cd.changeControl(user));
+      }
+      return ctls;
+    }
+
+    // If an index implementation uses separate non-atomic subindexes, it's possible to temporarily
+    // observe a change as present in both subindexes, if this search is concurrent with a write.
+    // Dedup to avoid confusing the caller. We can choose an arbitrary ChangeData instance because
+    // the index results have no stored fields, so the data is already reloaded. (It's also possible
+    // that a change might appear in zero subindexes, but there's nothing we can do here to help
+    // this case.)
+    Set<Change.Id> seen = Sets.newHashSetWithExpectedSize(cds.size());
     for (ChangeData cd : cds) {
-      ctls.add(cd.changeControl(user));
+      if (seen.add(cd.getId())) {
+        ctls.add(cd.changeControl(user));
+      }
     }
     return ctls;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
index d277bf9..9077d02 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -55,8 +55,13 @@
   public static final String TAG_SET_ASSIGNEE = "autogenerated:gerrit:setAssignee";
   public static final String TAG_SET_DESCRIPTION = "autogenerated:gerrit:setPsDescription";
   public static final String TAG_SET_HASHTAGS = "autogenerated:gerrit:setHashtag";
+  public static final String TAG_SET_PRIVATE = "autogenerated:gerrit:setPrivate";
+  public static final String TAG_SET_READY = "autogenerated:gerrit:setReadyForReview";
   public static final String TAG_SET_TOPIC = "autogenerated:gerrit:setTopic";
+  public static final String TAG_SET_WIP = "autogenerated:gerrit:setWorkInProgress";
+  public static final String TAG_UNSET_PRIVATE = "autogenerated:gerrit:unsetPrivate";
   public static final String TAG_UPLOADED_PATCH_SET = "autogenerated:gerrit:newPatchSet";
+  public static final String TAG_UPLOADED_WIP_PATCH_SET = "autogenerated:gerrit:newWipPatchSet";
 
   public static ChangeMessage newMessage(ChangeContext ctx, String body, @Nullable String tag) {
     return newMessage(ctx.getChange().currentPatchSetId(), ctx.getUser(), ctx.getWhen(), body, tag);
@@ -78,6 +83,10 @@
     return m;
   }
 
+  public static String uploadedPatchSetTag(boolean workInProgress) {
+    return workInProgress ? TAG_UPLOADED_WIP_PATCH_SET : TAG_UPLOADED_PATCH_SET;
+  }
+
   private static List<ChangeMessage> sortChangeMessages(Iterable<ChangeMessage> changeMessage) {
     return ChangeNotes.MESSAGE_BY_TIME.sortedCopy(changeMessage);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java
index 10ae60c..c9b726b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java
@@ -16,16 +16,18 @@
 
 import static java.util.Comparator.comparingInt;
 
+import com.google.common.collect.Maps;
 import com.google.common.collect.Ordering;
 import com.google.common.io.BaseEncoding;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.security.SecureRandom;
 import java.util.Map;
 import java.util.Random;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.Repository;
 
 @Singleton
@@ -47,7 +49,16 @@
     return UUID_ENCODING.encode(buf, 0, 4) + '_' + UUID_ENCODING.encode(buf, 4, 4);
   }
 
-  public static PatchSet.Id nextPatchSetId(Map<String, Ref> allRefs, PatchSet.Id id) {
+  /**
+   * Get the next patch set ID from a previously-read map of all refs.
+   *
+   * @param allRefs map of full ref name to ref, in the same format returned by {@link
+   *     org.eclipse.jgit.lib.RefDatabase#getRefs(String)} when passing {@code ""}.
+   * @param id previous patch set ID.
+   * @return next unused patch set ID for the same change, skipping any IDs whose corresponding ref
+   *     names appear in the {@code allRefs} map.
+   */
+  public static PatchSet.Id nextPatchSetIdFromAllRefsMap(Map<String, Ref> allRefs, PatchSet.Id id) {
     PatchSet.Id next = nextPatchSetId(id);
     while (allRefs.containsKey(next.toRefName())) {
       next = nextPatchSetId(next);
@@ -55,12 +66,55 @@
     return next;
   }
 
+  /**
+   * Get the next patch set ID from a previously-read map of refs below the change prefix.
+   *
+   * @param changeRefs map of ref suffix to SHA-1, where the keys are ref names with the {@code
+   *     refs/changes/CD/ABCD/} prefix stripped. All refs should be under {@code id}'s change ref
+   *     prefix. The keys match the format returned by {@link
+   *     org.eclipse.jgit.lib.RefDatabase#getRefs(String)} when passing the appropriate {@code
+   *     refs/changes/CD/ABCD}.
+   * @param id previous patch set ID.
+   * @return next unused patch set ID for the same change, skipping any IDs whose corresponding ref
+   *     names appear in the {@code changeRefs} map.
+   */
+  public static PatchSet.Id nextPatchSetIdFromChangeRefsMap(
+      Map<String, ObjectId> changeRefs, PatchSet.Id id) {
+    int prefixLen = id.getParentKey().toRefPrefix().length();
+    PatchSet.Id next = nextPatchSetId(id);
+    while (changeRefs.containsKey(next.toRefName().substring(prefixLen))) {
+      next = nextPatchSetId(next);
+    }
+    return next;
+  }
+
+  /**
+   * Get the next patch set ID just looking at a single previous patch set ID.
+   *
+   * <p>This patch set ID may or may not be available in the database; callers that want a
+   * previously-unused ID should use {@link #nextPatchSetIdFromAllRefsMap} or {@link
+   * #nextPatchSetIdFromChangeRefsMap}.
+   *
+   * @param id previous patch set ID.
+   * @return next patch set ID for the same change, incrementing by 1.
+   */
   public static PatchSet.Id nextPatchSetId(PatchSet.Id id) {
     return new PatchSet.Id(id.getParentKey(), id.get() + 1);
   }
 
+  /**
+   * Get the next patch set ID from scanning refs in the repo.
+   *
+   * @param git repository to scan for patch set refs.
+   * @param id previous patch set ID.
+   * @return next unused patch set ID for the same change, skipping any IDs whose corresponding ref
+   *     names appear in the repository.
+   */
   public static PatchSet.Id nextPatchSetId(Repository git, PatchSet.Id id) throws IOException {
-    return nextPatchSetId(git.getRefDatabase().getRefs(RefDatabase.ALL), id);
+    return nextPatchSetIdFromChangeRefsMap(
+        Maps.transformValues(
+            git.getRefDatabase().getRefs(id.getParentKey().toRefPrefix()), Ref::getObjectId),
+        id);
   }
 
   public static String cropSubject(String subject) {
@@ -78,5 +132,9 @@
     return subject;
   }
 
+  public static String status(Change c) {
+    return c != null ? c.getStatus().name().toLowerCase() : "deleted";
+  }
+
   private ChangeUtil() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java
index 8d2289a..6c342c1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
 import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.FluentIterable;
@@ -56,6 +57,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.function.Predicate;
 import java.util.stream.StreamSupport;
@@ -125,6 +127,8 @@
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
   private final NotesMigration migration;
+  private final PatchListCache patchListCache;
+  private final PatchSetUtil psUtil;
   private final String serverId;
 
   @Inject
@@ -132,10 +136,14 @@
       GitRepositoryManager repoManager,
       AllUsersName allUsers,
       NotesMigration migration,
+      PatchListCache patchListCache,
+      PatchSetUtil psUtil,
       @GerritServerId String serverId) {
     this.repoManager = repoManager;
     this.allUsers = allUsers;
     this.migration = migration;
+    this.patchListCache = patchListCache;
+    this.psUtil = psUtil;
     this.serverId = serverId;
   }
 
@@ -500,4 +508,35 @@
     return COMMENT_ORDER.sortedCopy(
         FluentIterable.from(comments).transform(plc -> plc.asComment(serverId)));
   }
+
+  public void publish(
+      ChangeContext ctx, PatchSet.Id psId, Collection<Comment> drafts, @Nullable String tag)
+      throws OrmException {
+    ChangeNotes notes = ctx.getNotes();
+    checkArgument(notes != null);
+    if (drafts.isEmpty()) {
+      return;
+    }
+
+    Map<PatchSet.Id, PatchSet> patchSets =
+        psUtil.getAsMap(
+            ctx.getDb(), notes, drafts.stream().map(d -> psId(notes, d)).collect(toSet()));
+    for (Comment d : drafts) {
+      PatchSet ps = patchSets.get(psId(notes, d));
+      if (ps == null) {
+        throw new OrmException("patch set " + ps + " not found");
+      }
+      d.writtenOn = ctx.getWhen();
+      d.tag = tag;
+      // Draft may have been created by a different real user; copy the current real user. (Only
+      // applies to X-Gerrit-RunAs, since modifying drafts via on_behalf_of is not allowed.)
+      ctx.getUser().updateRealAccountId(d::setRealAuthor);
+      setCommentRevId(d, patchListCache, notes.getChange(), ps);
+    }
+    putComments(ctx.getDb(), ctx.getUpdate(psId), PatchLineComment.Status.PUBLISHED, drafts);
+  }
+
+  private static PatchSet.Id psId(ChangeNotes notes, Comment c) {
+    return new PatchSet.Id(notes.getChangeId(), c.key.patchSetId);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
index 029b54d..7e1be17 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
@@ -17,8 +17,8 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.CapabilityControl;
-import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.inject.servlet.RequestScoped;
 import java.util.function.Consumer;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/DynamicOptions.java b/gerrit-server/src/main/java/com/google/gerrit/server/DynamicOptions.java
new file mode 100644
index 0000000..6267dca
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/DynamicOptions.java
@@ -0,0 +1,173 @@
+// 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 com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.server.plugins.DelegatingClassLoader;
+import com.google.gerrit.util.cli.CmdLineParser;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+
+/** Helper class to define and parse options from plugins on ssh and RestAPI commands. */
+public class DynamicOptions {
+  /**
+   * To provide additional options, bind a DynamicBean. For example:
+   *
+   * <pre>
+   *   bind(com.google.gerrit.server.DynamicOptions.DynamicBean.class)
+   *       .annotatedWith(Exports.named(com.google.gerrit.sshd.commands.Query.class))
+   *       .to(MyOptions.class);
+   * </pre>
+   *
+   * To define the additional options, implement this interface. For example:
+   *
+   * <pre>
+   *   public class MyOptions implements DynamicOptions.DynamicBean {
+   *     {@literal @}Option(name = "--verbose", aliases = {"-v"}
+   *             usage = "Make the operation more talkative")
+   *     public boolean verbose;
+   *   }
+   * </pre>
+   *
+   * The option will be prefixed by the plugin name. In the example above, if the plugin name was
+   * my-plugin, then the --verbose option as used by the caller would be --my-plugin--verbose.
+   */
+  public interface DynamicBean {}
+
+  /**
+   * Implement this if your DynamicBean needs an opportunity to act on the Bean directly before or
+   * after argument parsing.
+   */
+  public interface BeanParseListener extends DynamicBean {
+    void onBeanParseStart(String plugin, Object bean);
+
+    void onBeanParseEnd(String plugin, Object bean);
+  }
+
+  /**
+   * The entity which provided additional options may need a way to receive a reference to the
+   * DynamicBean it provided. To do so, the existing class should implement BeanReceiver (a setter)
+   * and then provide some way for the plugin to request its DynamicBean (a getter.) For example:
+   *
+   * <pre>
+   *   public class Query extends SshCommand implements DynamicOptions.BeanReceiver {
+   *       public void setDynamicBean(String plugin, DynamicOptions.DynamicBean dynamicBean) {
+   *         dynamicBeans.put(plugin, dynamicBean);
+   *       }
+   *
+   *       public DynamicOptions.DynamicBean getDynamicBean(String plugin) {
+   *         return dynamicBeans.get(plugin);
+   *       }
+   *   ...
+   *   }
+   * }
+   * </pre>
+   */
+  public interface BeanReceiver {
+    void setDynamicBean(String plugin, DynamicBean dynamicBean);
+  }
+
+  protected Object bean;
+  protected Map<String, DynamicBean> beansByPlugin;
+  protected Injector injector;
+
+  /**
+   * Internal: For Gerrit to include options from DynamicBeans, setup a DynamicMap and instantiate
+   * this class so the following methods can be called if desired:
+   *
+   * <pre>
+   *    DynamicOptions pluginOptions = new DynamicOptions(bean, injector, dynamicBeans);
+   *    pluginOptions.parseDynamicBeans(clp);
+   *    pluginOptions.setDynamicBeans();
+   *    pluginOptions.onBeanParseStart();
+   *
+   *    // parse arguments here:  clp.parseArgument(argv);
+   *
+   *    pluginOptions.onBeanParseEnd();
+   * </pre>
+   */
+  public DynamicOptions(Object bean, Injector injector, DynamicMap<DynamicBean> dynamicBeans) {
+    this.bean = bean;
+    this.injector = injector;
+    beansByPlugin = new HashMap<>();
+    for (String plugin : dynamicBeans.plugins()) {
+      Provider<DynamicBean> provider =
+          dynamicBeans.byPlugin(plugin).get(bean.getClass().getCanonicalName());
+      if (provider != null) {
+        beansByPlugin.put(plugin, getDynamicBean(bean, provider.get()));
+      }
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  public DynamicBean getDynamicBean(Object bean, DynamicBean dynamicBean) {
+    ClassLoader coreCl = getClass().getClassLoader();
+    ClassLoader beanCl = bean.getClass().getClassLoader();
+    if (beanCl != coreCl) { // bean from a plugin?
+      ClassLoader dynamicBeanCl = dynamicBean.getClass().getClassLoader();
+      if (beanCl != dynamicBeanCl) { // in a different plugin?
+        ClassLoader mergedCL = new DelegatingClassLoader(beanCl, dynamicBeanCl);
+        try {
+          return injector
+              .createChildInjector()
+              .getInstance(
+                  (Class<DynamicOptions.DynamicBean>)
+                      mergedCL.loadClass(dynamicBean.getClass().getCanonicalName()));
+        } catch (ClassNotFoundException e) {
+          throw new RuntimeException(e);
+        }
+      }
+    }
+    return dynamicBean;
+  }
+
+  public void parseDynamicBeans(CmdLineParser clp) {
+    for (Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
+      clp.parseWithPrefix(e.getKey(), e.getValue());
+    }
+  }
+
+  public void setDynamicBeans() {
+    if (bean instanceof BeanReceiver) {
+      BeanReceiver receiver = (BeanReceiver) bean;
+      for (Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
+        receiver.setDynamicBean(e.getKey(), e.getValue());
+      }
+    }
+  }
+
+  public void onBeanParseStart() {
+    for (Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
+      DynamicBean instance = e.getValue();
+      if (instance instanceof BeanParseListener) {
+        BeanParseListener listener = (BeanParseListener) instance;
+        listener.onBeanParseStart(e.getKey(), bean);
+      }
+    }
+  }
+
+  public void onBeanParseEnd() {
+    for (Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
+      DynamicBean instance = e.getValue();
+      if (instance instanceof BeanParseListener) {
+        BeanParseListener listener = (BeanParseListener) instance;
+        listener.onBeanParseEnd(e.getKey(), bean);
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/PatchSetUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/PatchSetUtil.java
index ab942ca..c23d990 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/PatchSetUtil.java
@@ -16,11 +16,17 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static com.google.gerrit.server.ChangeUtil.PS_ID_ORDER;
 import static com.google.gerrit.server.notedb.PatchSetState.DRAFT;
 import static com.google.gerrit.server.notedb.PatchSetState.PUBLISHED;
+import static java.util.function.Function.identity;
 
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Streams;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RevId;
@@ -36,6 +42,7 @@
 import java.sql.Timestamp;
 import java.util.Collections;
 import java.util.List;
+import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevWalk;
 
@@ -63,8 +70,7 @@
   public ImmutableCollection<PatchSet> byChange(ReviewDb db, ChangeNotes notes)
       throws OrmException {
     if (!migration.readChanges()) {
-      return ChangeUtil.PS_ID_ORDER.immutableSortedCopy(
-          db.patchSets().byChange(notes.getChangeId()));
+      return PS_ID_ORDER.immutableSortedCopy(db.patchSets().byChange(notes.getChangeId()));
     }
     return notes.load().getPatchSets().values();
   }
@@ -73,8 +79,7 @@
       throws OrmException {
     if (!migration.readChanges()) {
       ImmutableMap.Builder<PatchSet.Id, PatchSet> result = ImmutableMap.builder();
-      for (PatchSet ps :
-          ChangeUtil.PS_ID_ORDER.sortedCopy(db.patchSets().byChange(notes.getChangeId()))) {
+      for (PatchSet ps : PS_ID_ORDER.sortedCopy(db.patchSets().byChange(notes.getChangeId()))) {
         result.put(ps.getId(), ps);
       }
       return result.build();
@@ -82,6 +87,17 @@
     return notes.load().getPatchSets();
   }
 
+  public ImmutableMap<PatchSet.Id, PatchSet> getAsMap(
+      ReviewDb db, ChangeNotes notes, Set<PatchSet.Id> patchSetIds) throws OrmException {
+    if (!migration.readChanges()) {
+      patchSetIds = Sets.filter(patchSetIds, p -> p.getParentKey().equals(notes.getChangeId()));
+      return Streams.stream(db.patchSets().get(patchSetIds))
+          .sorted(PS_ID_ORDER)
+          .collect(toImmutableMap(PatchSet::getId, identity()));
+    }
+    return ImmutableMap.copyOf(Maps.filterKeys(notes.load().getPatchSets(), patchSetIds::contains));
+  }
+
   public PatchSet insert(
       ReviewDb db,
       RevWalk rw,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerByEmailSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerByEmailSet.java
new file mode 100644
index 0000000..c16c9c8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerByEmailSet.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2017 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.ImmutableSet;
+import com.google.common.collect.ImmutableTable;
+import com.google.common.collect.Table;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import java.sql.Timestamp;
+
+/**
+ * Set of reviewers on a change that do not have a Gerrit account and were added by email instead.
+ *
+ * <p>A given account may appear in multiple states and at different timestamps. No reviewers with
+ * state {@link ReviewerStateInternal#REMOVED} are ever exposed by this interface.
+ */
+public class ReviewerByEmailSet {
+  private static final ReviewerByEmailSet EMPTY = new ReviewerByEmailSet(ImmutableTable.of());
+
+  public static ReviewerByEmailSet fromTable(
+      Table<ReviewerStateInternal, Address, Timestamp> table) {
+    return new ReviewerByEmailSet(table);
+  }
+
+  public static ReviewerByEmailSet empty() {
+    return EMPTY;
+  }
+
+  private final ImmutableTable<ReviewerStateInternal, Address, Timestamp> table;
+  private ImmutableSet<Address> users;
+
+  private ReviewerByEmailSet(Table<ReviewerStateInternal, Address, Timestamp> table) {
+    this.table = ImmutableTable.copyOf(table);
+  }
+
+  public ImmutableSet<Address> all() {
+    if (users == null) {
+      // Idempotent and immutable, don't bother locking.
+      users = ImmutableSet.copyOf(table.columnKeySet());
+    }
+    return users;
+  }
+
+  public ImmutableSet<Address> byState(ReviewerStateInternal state) {
+    return table.row(state).keySet();
+  }
+
+  public ImmutableTable<ReviewerStateInternal, Address, Timestamp> asTable() {
+    return table;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    return (o instanceof ReviewerByEmailSet) && table.equals(((ReviewerByEmailSet) o).table);
+  }
+
+  @Override
+  public int hashCode() {
+    return table.hashCode();
+  }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName() + table;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
index 5ca9e19..cbaae1e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -147,6 +147,7 @@
 
   public static final String DEFAULT_LABEL = "star";
   public static final String IGNORE_LABEL = "ignore";
+  public static final String MUTE_LABEL = "mute";
   public static final ImmutableSortedSet<String> DEFAULT_LABELS =
       ImmutableSortedSet.of(DEFAULT_LABEL);
 
@@ -341,6 +342,48 @@
     }
   }
 
+  public void ignore(Account.Id accountId, Project.NameKey project, Change.Id changeId)
+      throws OrmException {
+    star(accountId, project, changeId, ImmutableSet.of(IGNORE_LABEL), ImmutableSet.of());
+  }
+
+  public void unignore(Account.Id accountId, Project.NameKey project, Change.Id changeId)
+      throws OrmException {
+    star(accountId, project, changeId, ImmutableSet.of(), ImmutableSet.of(IGNORE_LABEL));
+  }
+
+  public boolean isIgnoredBy(Change.Id changeId, Account.Id accountId) throws OrmException {
+    return byChange(changeId, IGNORE_LABEL).contains(accountId);
+  }
+
+  private static String getMuteLabel(Change change) {
+    return MUTE_LABEL + "/" + change.currentPatchSetId().get();
+  }
+
+  public void mute(Account.Id accountId, Project.NameKey project, Change change)
+      throws OrmException {
+    star(
+        accountId,
+        project,
+        change.getId(),
+        ImmutableSet.of(getMuteLabel(change)),
+        ImmutableSet.of());
+  }
+
+  public void unmute(Account.Id accountId, Project.NameKey project, Change change)
+      throws OrmException {
+    star(
+        accountId,
+        project,
+        change.getId(),
+        ImmutableSet.of(),
+        ImmutableSet.of(getMuteLabel(change)));
+  }
+
+  public boolean isMutedBy(Change change, Account.Id accountId) throws OrmException {
+    return byChange(change.getId(), getMuteLabel(change)).contains(accountId);
+  }
+
   private static StarRef readLabels(Repository repo, String refName) throws IOException {
     Ref ref = repo.exactRef(refName);
     if (ref == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java
index 0f9ec8d..e61736d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.mail.send.EmailSender;
 import com.google.inject.Inject;
 import java.util.Collection;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java
index d45ecd8..950eac7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java
@@ -14,13 +14,15 @@
 
 package com.google.gerrit.server.account;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
-import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Streams;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -29,7 +31,6 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.util.Collections;
-import java.util.HashSet;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
 import org.slf4j.Logger;
@@ -78,32 +79,23 @@
 
   static class Loader extends CacheLoader<String, Set<Account.Id>> {
     private final SchemaFactory<ReviewDb> schema;
-    private final Provider<InternalAccountQuery> accountQueryProvider;
+
+    // This must be a provider to prevent a cyclic dependency within Google-internal glue code.
+    private final Provider<ExternalIds> externalIds;
 
     @Inject
-    Loader(SchemaFactory<ReviewDb> schema, Provider<InternalAccountQuery> accountQueryProvider) {
+    Loader(SchemaFactory<ReviewDb> schema, Provider<ExternalIds> externalIds) {
       this.schema = schema;
-      this.accountQueryProvider = accountQueryProvider;
+      this.externalIds = externalIds;
     }
 
     @Override
     public Set<Account.Id> load(String email) throws Exception {
       try (ReviewDb db = schema.open()) {
-        Set<Account.Id> r = new HashSet<>();
-        for (Account a : db.accounts().byPreferredEmail(email)) {
-          r.add(a.getId());
-        }
-        for (AccountState accountState : accountQueryProvider.get().byEmailPrefix(email)) {
-          if (accountState
-              .getExternalIds()
-              .stream()
-              .filter(e -> email.equals(e.email()))
-              .findAny()
-              .isPresent()) {
-            r.add(accountState.getAccount().getId());
-          }
-        }
-        return ImmutableSet.copyOf(r);
+        return Streams.concat(
+                Streams.stream(db.accounts().byPreferredEmail(email)).map(a -> a.getId()),
+                externalIds.get().byEmail(db, email).stream().map(e -> e.accountId()))
+            .collect(toImmutableSet());
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 245a0be..b2f1bae 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
@@ -27,6 +27,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
@@ -149,6 +150,7 @@
     private final GeneralPreferencesLoader loader;
     private final LoadingCache<String, Optional<Account.Id>> byName;
     private final Provider<WatchConfig.Accessor> watchConfig;
+    private final ExternalIds externalIds;
 
     @Inject
     ByIdLoader(
@@ -156,12 +158,14 @@
         GroupCache groupCache,
         GeneralPreferencesLoader loader,
         @Named(BYUSER_NAME) LoadingCache<String, Optional<Account.Id>> byUsername,
-        Provider<WatchConfig.Accessor> watchConfig) {
+        Provider<WatchConfig.Accessor> watchConfig,
+        ExternalIds externalIds) {
       this.schema = sf;
       this.groupCache = groupCache;
       this.loader = loader;
       this.byName = byUsername;
       this.watchConfig = watchConfig;
+      this.externalIds = externalIds;
     }
 
     @Override
@@ -184,9 +188,6 @@
         return missing(who);
       }
 
-      Set<ExternalId> externalIds =
-          ExternalId.from(db.accountExternalIds().byAccount(who).toList());
-
       Set<AccountGroup.UUID> internalGroups = new HashSet<>();
       for (AccountGroupMember g : db.accountGroupMembers().byAccount(who)) {
         final AccountGroup.Id groupId = g.getAccountGroupId();
@@ -205,7 +206,10 @@
       }
 
       return new AccountState(
-          account, internalGroups, externalIds, watchConfig.get().getProjectWatches(who));
+          account,
+          internalGroups,
+          externalIds.byAccount(db, who),
+          watchConfig.get().getProjectWatches(who));
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
index 77d28f9..012ed5c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.account;
 
-import static java.util.stream.Collectors.toSet;
-
 import com.google.common.base.Strings;
 import com.google.gerrit.audit.AuditService;
 import com.google.gerrit.common.TimeUtil;
@@ -29,6 +27,9 @@
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.OrmException;
@@ -51,6 +52,7 @@
   private static final Logger log = LoggerFactory.getLogger(AccountManager.class);
 
   private final SchemaFactory<ReviewDb> schema;
+  private final AccountsUpdate.Server accountsUpdateFactory;
   private final AccountCache byIdCache;
   private final AccountByEmailCache byEmailCache;
   private final Realm realm;
@@ -60,11 +62,13 @@
   private final AtomicBoolean awaitsFirstAccountCheck;
   private final AuditService auditService;
   private final Provider<InternalAccountQuery> accountQueryProvider;
+  private final ExternalIds externalIds;
   private final ExternalIdsUpdate.Server externalIdsUpdateFactory;
 
   @Inject
   AccountManager(
       SchemaFactory<ReviewDb> schema,
+      AccountsUpdate.Server accountsUpdateFactory,
       AccountCache byIdCache,
       AccountByEmailCache byEmailCache,
       Realm accountMapper,
@@ -73,8 +77,10 @@
       ProjectCache projectCache,
       AuditService auditService,
       Provider<InternalAccountQuery> accountQueryProvider,
+      ExternalIds externalIds,
       ExternalIdsUpdate.Server externalIdsUpdateFactory) {
     this.schema = schema;
+    this.accountsUpdateFactory = accountsUpdateFactory;
     this.byIdCache = byIdCache;
     this.byEmailCache = byEmailCache;
     this.realm = accountMapper;
@@ -84,6 +90,7 @@
     this.awaitsFirstAccountCheck = new AtomicBoolean(true);
     this.auditService = auditService;
     this.accountQueryProvider = accountQueryProvider;
+    this.externalIds = externalIds;
     this.externalIdsUpdateFactory = externalIdsUpdateFactory;
   }
 
@@ -225,13 +232,13 @@
         awaitsFirstAccountCheck.getAndSet(false) && db.accounts().anyAccounts().toList().isEmpty();
 
     try {
-      db.accounts().upsert(Collections.singleton(account));
+      AccountsUpdate accountsUpdate = accountsUpdateFactory.create();
+      accountsUpdate.upsert(db, account);
 
-      ExternalId existingExtId =
-          ExternalId.from(db.accountExternalIds().get(extId.key().asAccountExternalIdKey()));
+      ExternalId existingExtId = externalIds.get(db, extId.key());
       if (existingExtId != null && !existingExtId.accountId().equals(extId.accountId())) {
         // external ID is assigned to another account, do not overwrite
-        db.accounts().delete(Collections.singleton(account));
+        accountsUpdate.delete(db, account);
         throw new AccountException(
             "Cannot assign external ID \""
                 + extId.key().get()
@@ -339,7 +346,7 @@
       // such an account cannot be used for uploading changes,
       // this is why the best we can do here is to fail early and cleanup
       // the database
-      db.accounts().delete(Collections.singleton(account));
+      accountsUpdateFactory.create().delete(db, account);
       externalIdsUpdateFactory.create().delete(db, extId);
       throw new AccountUserNameException(errorMessage, e);
     }
@@ -404,10 +411,7 @@
       throws OrmException, AccountException, IOException, ConfigInvalidException {
     try (ReviewDb db = schema.open()) {
       Collection<ExternalId> filteredExtIdsByScheme =
-          ExternalId.from(db.accountExternalIds().byAccount(to).toList())
-              .stream()
-              .filter(e -> e.isScheme(who.getExternalIdKey().scheme()))
-              .collect(toSet());
+          externalIds.byAccount(db, to, who.getExternalIdKey().scheme());
 
       if (!filteredExtIdsByScheme.isEmpty()
           && (filteredExtIdsByScheme.size() > 1
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
index 4b9b0fb..1eaf34f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_MAILTO;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.base.Function;
 import com.google.common.base.Strings;
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.Map;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsUpdate.java
new file mode 100644
index 0000000..de87fc1
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -0,0 +1,251 @@
+// Copyright (C) 2017 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.account;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
+import org.eclipse.jgit.lib.Repository;
+
+/** Updates accounts. */
+@Singleton
+public class AccountsUpdate {
+  /**
+   * Factory to create an AccountsUpdate instance for updating accounts by the Gerrit server.
+   *
+   * <p>The Gerrit server identity will be used as author and committer for all commits that update
+   * the accounts.
+   */
+  @Singleton
+  public static class Server {
+    private final GitRepositoryManager repoManager;
+    private final AllUsersName allUsersName;
+    private final Provider<PersonIdent> serverIdent;
+
+    @Inject
+    public Server(
+        GitRepositoryManager repoManager,
+        AllUsersName allUsersName,
+        @GerritPersonIdent Provider<PersonIdent> serverIdent) {
+      this.repoManager = repoManager;
+      this.allUsersName = allUsersName;
+      this.serverIdent = serverIdent;
+    }
+
+    public AccountsUpdate create() {
+      PersonIdent i = serverIdent.get();
+      return new AccountsUpdate(repoManager, allUsersName, i, i);
+    }
+  }
+
+  /**
+   * Factory to create an AccountsUpdate instance for updating accounts by the current user.
+   *
+   * <p>The identity of the current user will be used as author for all commits that update the
+   * accounts. The Gerrit server identity will be used as committer.
+   */
+  @Singleton
+  public static class User {
+    private final GitRepositoryManager repoManager;
+    private final AllUsersName allUsersName;
+    private final Provider<PersonIdent> serverIdent;
+    private final Provider<IdentifiedUser> identifiedUser;
+
+    @Inject
+    public User(
+        GitRepositoryManager repoManager,
+        AllUsersName allUsersName,
+        @GerritPersonIdent Provider<PersonIdent> serverIdent,
+        Provider<IdentifiedUser> identifiedUser) {
+      this.repoManager = repoManager;
+      this.allUsersName = allUsersName;
+      this.serverIdent = serverIdent;
+      this.identifiedUser = identifiedUser;
+    }
+
+    public AccountsUpdate create() {
+      PersonIdent i = serverIdent.get();
+      return new AccountsUpdate(
+          repoManager, allUsersName, createPersonIdent(i, identifiedUser.get()), i);
+    }
+
+    private PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
+      return user.newCommitterIdent(ident.getWhen(), ident.getTimeZone());
+    }
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final PersonIdent committerIdent;
+  private final PersonIdent authorIdent;
+
+  private AccountsUpdate(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      PersonIdent committerIdent,
+      PersonIdent authorIdent) {
+    this.repoManager = checkNotNull(repoManager, "repoManager");
+    this.allUsersName = checkNotNull(allUsersName, "allUsersName");
+    this.committerIdent = checkNotNull(committerIdent, "committerIdent");
+    this.authorIdent = checkNotNull(authorIdent, "authorIdent");
+  }
+
+  /**
+   * Inserts a new account.
+   *
+   * @throws OrmDuplicateKeyException if the account already exists
+   * @throws IOException if updating the user branch fails
+   */
+  public void insert(ReviewDb db, Account account) throws OrmException, IOException {
+    db.accounts().insert(ImmutableSet.of(account));
+    createUserBranch(account);
+  }
+
+  /**
+   * Inserts or updates an account.
+   *
+   * <p>If the account already exists, it is overwritten, otherwise it is inserted.
+   */
+  public void upsert(ReviewDb db, Account account) throws OrmException, IOException {
+    db.accounts().upsert(ImmutableSet.of(account));
+    createUserBranchIfNeeded(account);
+  }
+
+  /** Deletes the account. */
+  public void delete(ReviewDb db, Account account) throws OrmException, IOException {
+    db.accounts().delete(ImmutableSet.of(account));
+    deleteUserBranch(account.getId());
+  }
+
+  /** Deletes the account. */
+  public void deleteByKey(ReviewDb db, Account.Id accountId) throws OrmException, IOException {
+    db.accounts().deleteKeys(ImmutableSet.of(accountId));
+    deleteUserBranch(accountId);
+  }
+
+  private void createUserBranch(Account account) throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsersName);
+        ObjectInserter oi = repo.newObjectInserter()) {
+      String refName = RefNames.refsUsers(account.getId());
+      if (repo.exactRef(refName) != null) {
+        throw new IOException(
+            String.format(
+                "User branch %s for newly created account %s already exists.",
+                refName, account.getId().get()));
+      }
+      createUserBranch(repo, oi, committerIdent, authorIdent, account);
+    }
+  }
+
+  private void createUserBranchIfNeeded(Account account) throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsersName);
+        ObjectInserter oi = repo.newObjectInserter()) {
+      if (repo.exactRef(RefNames.refsUsers(account.getId())) == null) {
+        createUserBranch(repo, oi, committerIdent, authorIdent, account);
+      }
+    }
+  }
+
+  public static void createUserBranch(
+      Repository repo,
+      ObjectInserter oi,
+      PersonIdent committerIdent,
+      PersonIdent authorIdent,
+      Account account)
+      throws IOException {
+    ObjectId id =
+        createInitialEmptyCommit(oi, committerIdent, authorIdent, account.getRegisteredOn());
+
+    String refName = RefNames.refsUsers(account.getId());
+    RefUpdate ru = repo.updateRef(refName);
+    ru.setExpectedOldObjectId(ObjectId.zeroId());
+    ru.setNewObjectId(id);
+    ru.setForceUpdate(true);
+    ru.setRefLogIdent(committerIdent);
+    ru.setRefLogMessage("Create Account", true);
+    Result result = ru.update();
+    if (result != Result.NEW) {
+      throw new IOException(String.format("Failed to update ref %s: %s", refName, result.name()));
+    }
+  }
+
+  private static ObjectId createInitialEmptyCommit(
+      ObjectInserter oi,
+      PersonIdent committerIdent,
+      PersonIdent authorIdent,
+      Timestamp registrationDate)
+      throws IOException {
+    CommitBuilder cb = new CommitBuilder();
+    cb.setTreeId(emptyTree(oi));
+    cb.setCommitter(new PersonIdent(committerIdent, registrationDate));
+    cb.setAuthor(new PersonIdent(authorIdent, registrationDate));
+    cb.setMessage("Create Account");
+    ObjectId id = oi.insert(cb);
+    oi.flush();
+    return id;
+  }
+
+  private static ObjectId emptyTree(ObjectInserter oi) throws IOException {
+    return oi.insert(Constants.OBJ_TREE, new byte[] {});
+  }
+
+  private void deleteUserBranch(Account.Id accountId) throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      deleteUserBranch(repo, committerIdent, accountId);
+    }
+  }
+
+  public static void deleteUserBranch(
+      Repository repo, PersonIdent refLogIdent, Account.Id accountId) throws IOException {
+    String refName = RefNames.refsUsers(accountId);
+    Ref ref = repo.exactRef(refName);
+    if (ref == null) {
+      return;
+    }
+
+    RefUpdate ru = repo.updateRef(refName);
+    ru.setExpectedOldObjectId(ref.getObjectId());
+    ru.setNewObjectId(ObjectId.zeroId());
+    ru.setForceUpdate(true);
+    ru.setRefLogIdent(refLogIdent);
+    ru.setRefLogMessage("Delete Account", true);
+    Result result = ru.delete();
+    if (result != Result.FORCED) {
+      throw new IOException(String.format("Failed to delete ref %s: %s", refName, result.name()));
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java
index 8c10c73..1c5495f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java
@@ -30,6 +30,9 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AddSshKey.Input;
 import com.google.gerrit.server.mail.send.AddKeySender;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -50,6 +53,7 @@
   }
 
   private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
   private final AddKeySender.Factory addKeyFactory;
@@ -57,10 +61,12 @@
   @Inject
   AddSshKey(
       Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       SshKeyCache sshKeyCache,
       AddKeySender.Factory addKeyFactory) {
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.authorizedKeys = authorizedKeys;
     this.sshKeyCache = sshKeyCache;
     this.addKeyFactory = addKeyFactory;
@@ -68,9 +74,10 @@
 
   @Override
   public Response<SshKeyInfo> apply(AccountResource rsrc, Input input)
-      throws AuthException, BadRequestException, OrmException, IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("not allowed to add SSH keys");
+      throws AuthException, BadRequestException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
     return apply(rsrc.getUser(), input);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
index d1dd4b0..4dd9926 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_EXTERNAL;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_EXTERNAL;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
+
+import com.google.gerrit.server.account.externalids.ExternalId;
 
 /**
  * Information for {@link AccountManager#authenticate(AuthRequest)}.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java
index 4aced52..2b1bc96 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.externalids.ExternalId;
 
 /** Result from {@link AccountManager#authenticate(AuthRequest)}. */
 public class AuthResult {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Capabilities.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Capabilities.java
index d35656c..08eecd7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Capabilities.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Capabilities.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.api.access.PluginPermission;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
@@ -22,7 +23,11 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource.Capability;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -30,15 +35,18 @@
 @Singleton
 class Capabilities implements ChildCollection<AccountResource, AccountResource.Capability> {
   private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
   private final DynamicMap<RestView<AccountResource.Capability>> views;
   private final Provider<GetCapabilities> get;
 
   @Inject
   Capabilities(
       Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
       DynamicMap<RestView<AccountResource.Capability>> views,
       Provider<GetCapabilities> get) {
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.views = views;
     this.get = get;
   }
@@ -50,20 +58,39 @@
 
   @Override
   public Capability parse(AccountResource parent, IdString id)
-      throws ResourceNotFoundException, AuthException {
-    if (self.get() != parent.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("restricted to administrator");
+      throws ResourceNotFoundException, AuthException, PermissionBackendException {
+    IdentifiedUser target = parent.getUser();
+    if (self.get() != target) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
-    String name = id.get();
-    CapabilityControl cap = parent.getUser().getCapabilities();
-    if (cap.canPerform(name)
-        || (cap.canAdministrateServer() && GlobalCapability.isCapability(name))) {
-      return new AccountResource.Capability(parent.getUser(), name);
+    GlobalOrPluginPermission perm = parse(id);
+    if (permissionBackend.user(target).test(perm)) {
+      return new AccountResource.Capability(target, perm.permissionName());
     }
     throw new ResourceNotFoundException(id);
   }
 
+  private GlobalOrPluginPermission parse(IdString id) throws ResourceNotFoundException {
+    String name = id.get();
+    GlobalOrPluginPermission perm = GlobalPermission.byName(name);
+    if (perm != null) {
+      return perm;
+    }
+
+    int dash = name.lastIndexOf('-');
+    if (dash < 0) {
+      throw new ResourceNotFoundException(id);
+    }
+
+    String pluginName = name.substring(0, dash);
+    String capability = name.substring(dash + 1);
+    if (pluginName.isEmpty() || capability.isEmpty()) {
+      throw new ResourceNotFoundException(id);
+    }
+    return new PluginPermission(pluginName, capability);
+  }
+
   @Override
   public DynamicMap<RestView<Capability>> views() {
     return views;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java
index 66d0bf9..01e16d7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java
@@ -22,11 +22,18 @@
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.api.access.PluginPermission;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.git.QueueProvider;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.RefControl;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
@@ -61,8 +68,23 @@
     return user;
   }
 
-  /** @return true if the user can administer this server. */
-  public boolean canAdministrateServer() {
+  /**
+   * <b>Do not use.</b> Determine if the user can administer this server.
+   *
+   * <p>This method is visible only for the benefit of the following transitional classes:
+   *
+   * <ul>
+   *   <li>{@link ProjectControl}
+   *   <li>{@link RefControl}
+   *   <li>{@link ChangeControl}
+   *   <li>{@link GroupControl}
+   * </ul>
+   *
+   * Other callers should not use this method, as it is slated to go away.
+   *
+   * @return true if the user can administer this server.
+   */
+  public boolean isAdmin_DoNotUse() {
     if (canAdministrateServer == null) {
       if (user.getRealUser() != user) {
         canAdministrateServer = false;
@@ -75,21 +97,6 @@
     return canAdministrateServer;
   }
 
-  /** @return true if the user can create an account for another user. */
-  public boolean canCreateAccount() {
-    return canPerform(GlobalCapability.CREATE_ACCOUNT) || canAdministrateServer();
-  }
-
-  /** @return true if the user can create a group. */
-  public boolean canCreateGroup() {
-    return canPerform(GlobalCapability.CREATE_GROUP) || canAdministrateServer();
-  }
-
-  /** @return true if the user can create a project. */
-  public boolean canCreateProject() {
-    return canPerform(GlobalCapability.CREATE_PROJECT) || canAdministrateServer();
-  }
-
   /** @return true if the user can email reviewers. */
   public boolean canEmailReviewers() {
     if (canEmailReviewers == null) {
@@ -100,69 +107,18 @@
     return canEmailReviewers;
   }
 
-  /** @return true if the user can kill any running task. */
-  public boolean canKillTask() {
-    return canPerform(GlobalCapability.KILL_TASK) || canMaintainServer();
-  }
-
-  /** @return true if the user can modify an account for another user. */
-  public boolean canModifyAccount() {
-    return canPerform(GlobalCapability.MODIFY_ACCOUNT) || canAdministrateServer();
-  }
-
   /** @return true if the user can view all accounts. */
   public boolean canViewAllAccounts() {
-    return canPerform(GlobalCapability.VIEW_ALL_ACCOUNTS) || canAdministrateServer();
-  }
-
-  /** @return true if the user can view the server caches. */
-  public boolean canViewCaches() {
-    return canPerform(GlobalCapability.VIEW_CACHES) || canMaintainServer();
-  }
-
-  /** @return true if the user can flush the server's caches. */
-  public boolean canFlushCaches() {
-    return canPerform(GlobalCapability.FLUSH_CACHES) || canMaintainServer();
-  }
-
-  /** @return true if the user can perform basic server maintenance. */
-  public boolean canMaintainServer() {
-    return canPerform(GlobalCapability.MAINTAIN_SERVER) || canAdministrateServer();
-  }
-
-  /** @return true if the user can view open connections. */
-  public boolean canViewConnections() {
-    return canPerform(GlobalCapability.VIEW_CONNECTIONS) || canAdministrateServer();
-  }
-
-  /** @return true if the user can view the installed plugins. */
-  public boolean canViewPlugins() {
-    return canPerform(GlobalCapability.VIEW_PLUGINS) || canAdministrateServer();
-  }
-
-  /** @return true if the user can view the entire queue. */
-  public boolean canViewQueue() {
-    return canPerform(GlobalCapability.VIEW_QUEUE) || canMaintainServer();
+    return canPerform(GlobalCapability.VIEW_ALL_ACCOUNTS) || isAdmin_DoNotUse();
   }
 
   /** @return true if the user can access the database (with gsql). */
   public boolean canAccessDatabase() {
-    return canPerform(GlobalCapability.ACCESS_DATABASE);
-  }
-
-  /** @return true if the user can stream Gerrit events. */
-  public boolean canStreamEvents() {
-    return canPerform(GlobalCapability.STREAM_EVENTS) || canAdministrateServer();
-  }
-
-  /** @return true if the user can run the Git garbage collection. */
-  public boolean canRunGC() {
-    return canPerform(GlobalCapability.RUN_GC) || canMaintainServer();
-  }
-
-  /** @return true if the user can impersonate another user. */
-  public boolean canRunAs() {
-    return canPerform(GlobalCapability.RUN_AS);
+    try {
+      return doCanForDefaultPermissionBackend(GlobalPermission.ACCESS_DATABASE);
+    } catch (PermissionBackendException e) {
+      return false;
+    }
   }
 
   /** @return which priority queue the user's tasks should be submitted to. */
@@ -204,14 +160,16 @@
     return QueueProvider.QueueType.INTERACTIVE;
   }
 
-  /** True if the user has this permission. Works only for non labels. */
-  public boolean canPerform(String permissionName) {
-    if (GlobalCapability.ADMINISTRATE_SERVER.equals(permissionName)) {
-      return canAdministrateServer();
-    }
+  /** @return true if the user has this permission. */
+  private boolean canPerform(String permissionName) {
     return !access(permissionName).isEmpty();
   }
 
+  /** @return true if the user has a permission rule specifying the range. */
+  public boolean hasExplicitRange(String permission) {
+    return GlobalCapability.hasRange(permission) && !access(permission).isEmpty();
+  }
+
   /** The range of permitted values associated with a label permission. */
   public PermissionRange getRange(String permission) {
     if (GlobalCapability.hasRange(permission)) {
@@ -273,4 +231,50 @@
   private static boolean match(GroupMembership groups, PermissionRule rule) {
     return groups.contains(rule.getGroup().getUUID());
   }
+
+  /** Do not use unless inside DefaultPermissionBackend. */
+  public boolean doCanForDefaultPermissionBackend(GlobalOrPluginPermission perm)
+      throws PermissionBackendException {
+    if (perm instanceof GlobalPermission) {
+      return can((GlobalPermission) perm);
+    } else if (perm instanceof PluginPermission) {
+      return canPerform(perm.permissionName()) || isAdmin_DoNotUse();
+    }
+    throw new PermissionBackendException(perm + " unsupported");
+  }
+
+  private boolean can(GlobalPermission perm) throws PermissionBackendException {
+    switch (perm) {
+      case ADMINISTRATE_SERVER:
+        return isAdmin_DoNotUse();
+      case EMAIL_REVIEWERS:
+        return canEmailReviewers();
+      case VIEW_ALL_ACCOUNTS:
+        return canViewAllAccounts();
+
+      case FLUSH_CACHES:
+      case KILL_TASK:
+      case RUN_GC:
+      case VIEW_CACHES:
+      case VIEW_QUEUE:
+        return canPerform(perm.permissionName())
+            || canPerform(GlobalCapability.MAINTAIN_SERVER)
+            || isAdmin_DoNotUse();
+
+      case CREATE_ACCOUNT:
+      case CREATE_GROUP:
+      case CREATE_PROJECT:
+      case MAINTAIN_SERVER:
+      case MODIFY_ACCOUNT:
+      case STREAM_EVENTS:
+      case VIEW_CONNECTIONS:
+      case VIEW_PLUGINS:
+        return canPerform(perm.permissionName()) || isAdmin_DoNotUse();
+
+      case ACCESS_DATABASE:
+      case RUN_AS:
+        return canPerform(perm.permissionName());
+    }
+    throw new PermissionBackendException(perm + " unsupported");
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityUtils.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityUtils.java
deleted file mode 100644
index 21399f4..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityUtils.java
+++ /dev/null
@@ -1,132 +0,0 @@
-// Copyright (C) 2013 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.account;
-
-import com.google.gerrit.extensions.annotations.CapabilityScope;
-import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.CurrentUser;
-import com.google.inject.Provider;
-import java.lang.annotation.Annotation;
-import java.util.Arrays;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class CapabilityUtils {
-  private static final Logger log = LoggerFactory.getLogger(CapabilityUtils.class);
-
-  public static void checkRequiresCapability(
-      Provider<CurrentUser> userProvider, String pluginName, Class<?> clazz) throws AuthException {
-    checkRequiresCapability(userProvider.get(), pluginName, clazz);
-  }
-
-  public static void checkRequiresCapability(CurrentUser user, String pluginName, Class<?> clazz)
-      throws AuthException {
-    RequiresCapability rc = getClassAnnotation(clazz, RequiresCapability.class);
-    RequiresAnyCapability rac = getClassAnnotation(clazz, RequiresAnyCapability.class);
-    if (rc != null && rac != null) {
-      log.error(
-          String.format(
-              "Class %s uses both @%s and @%s",
-              clazz.getName(),
-              RequiresCapability.class.getSimpleName(),
-              RequiresAnyCapability.class.getSimpleName()));
-      throw new AuthException("cannot check capability");
-    }
-    CapabilityControl ctl = user.getCapabilities();
-    if (ctl.canAdministrateServer()) {
-      return;
-    }
-    checkRequiresCapability(ctl, pluginName, clazz, rc);
-    checkRequiresAnyCapability(ctl, pluginName, clazz, rac);
-  }
-
-  private static void checkRequiresCapability(
-      CapabilityControl ctl, String pluginName, Class<?> clazz, RequiresCapability rc)
-      throws AuthException {
-    if (rc == null) {
-      return;
-    }
-    String capability = resolveCapability(pluginName, rc.value(), rc.scope(), clazz);
-    if (!ctl.canPerform(capability)) {
-      throw new AuthException(
-          String.format("Capability %s is required to access this resource", capability));
-    }
-  }
-
-  private static void checkRequiresAnyCapability(
-      CapabilityControl ctl, String pluginName, Class<?> clazz, RequiresAnyCapability rac)
-      throws AuthException {
-    if (rac == null) {
-      return;
-    }
-    if (rac.value().length == 0) {
-      log.error(
-          String.format(
-              "Class %s uses @%s with no capabilities listed",
-              clazz.getName(), RequiresAnyCapability.class.getSimpleName()));
-      throw new AuthException("cannot check capability");
-    }
-    for (String capability : rac.value()) {
-      capability = resolveCapability(pluginName, capability, rac.scope(), clazz);
-      if (ctl.canPerform(capability)) {
-        return;
-      }
-    }
-    throw new AuthException(
-        "One of the following capabilities is required to access this"
-            + " resource: "
-            + Arrays.asList(rac.value()));
-  }
-
-  private static String resolveCapability(
-      String pluginName, String capability, CapabilityScope scope, Class<?> clazz)
-      throws AuthException {
-    if (pluginName != null
-        && !"gerrit".equals(pluginName)
-        && (scope == CapabilityScope.PLUGIN || scope == CapabilityScope.CONTEXT)) {
-      capability = String.format("%s-%s", pluginName, capability);
-    } else if (scope == CapabilityScope.PLUGIN) {
-      log.error(
-          String.format(
-              "Class %s uses @%s(scope=%s), but is not within a plugin",
-              clazz.getName(),
-              RequiresCapability.class.getSimpleName(),
-              CapabilityScope.PLUGIN.name()));
-      throw new AuthException("cannot check capability");
-    }
-    return capability;
-  }
-
-  /**
-   * Find an instance of the specified annotation, walking up the inheritance tree if necessary.
-   *
-   * @param <T> Annotation type to search for
-   * @param clazz root class to search, may be null
-   * @param annotationClass class object of Annotation subclass to search for
-   * @return the requested annotation or null if none
-   */
-  private static <T extends Annotation> T getClassAnnotation(
-      Class<?> clazz, Class<T> annotationClass) {
-    for (; clazz != null; clazz = clazz.getSuperclass()) {
-      T t = clazz.getAnnotation(annotationClass);
-      if (t != null) {
-        return t;
-      }
-    }
-    return null;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
index f60ee45..1a02ea1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
@@ -14,14 +14,16 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
-import static java.util.stream.Collectors.toSet;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.errors.NameAlreadyUsedException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gwtjsonrpc.common.VoidResult;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
@@ -47,6 +49,7 @@
 
   private final AccountCache accountCache;
   private final SshKeyCache sshKeyCache;
+  private final ExternalIds externalIds;
   private final ExternalIdsUpdate.Server externalIdsUpdateFactory;
 
   private final ReviewDb db;
@@ -57,12 +60,14 @@
   ChangeUserName(
       AccountCache accountCache,
       SshKeyCache sshKeyCache,
+      ExternalIds externalIds,
       ExternalIdsUpdate.Server externalIdsUpdateFactory,
       @Assisted ReviewDb db,
       @Assisted IdentifiedUser user,
       @Nullable @Assisted String newUsername) {
     this.accountCache = accountCache;
     this.sshKeyCache = sshKeyCache;
+    this.externalIds = externalIds;
     this.externalIdsUpdateFactory = externalIdsUpdateFactory;
     this.db = db;
     this.user = user;
@@ -73,11 +78,7 @@
   public VoidResult call()
       throws OrmException, NameAlreadyUsedException, InvalidUserNameException, IOException,
           ConfigInvalidException {
-    Collection<ExternalId> old =
-        ExternalId.from(db.accountExternalIds().byAccount(user.getAccountId()).toList())
-            .stream()
-            .filter(e -> e.isScheme(SCHEME_USERNAME))
-            .collect(toSet());
+    Collection<ExternalId> old = externalIds.byAccount(db, user.getAccountId(), SCHEME_USERNAME);
     if (!old.isEmpty()) {
       throw new IllegalStateException(USERNAME_CANNOT_BE_CHANGED);
     }
@@ -100,8 +101,7 @@
       } catch (OrmDuplicateKeyException dupeErr) {
         // If we are using this identity, don't report the exception.
         //
-        ExternalId other =
-            ExternalId.from(db.accountExternalIds().get(key.asAccountExternalIdKey()));
+        ExternalId other = externalIds.get(db, key);
         if (other != null && other.accountId().equals(user.getAccountId())) {
           return VoidResult.INSTANCE;
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
index 9e7e9a4d..2cfd716 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
 
 import com.google.gerrit.audit.AuditService;
 import com.google.gerrit.common.TimeUtil;
@@ -36,6 +36,9 @@
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.api.accounts.AccountExternalIdCreator;
 import com.google.gerrit.server.group.GroupsCollection;
 import com.google.gerrit.server.index.account.AccountIndexer;
@@ -66,12 +69,15 @@
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
   private final AccountCache accountCache;
+  private final AccountsUpdate.User accountsUpdate;
   private final AccountIndexer indexer;
   private final AccountByEmailCache byEmailCache;
   private final AccountLoader.Factory infoLoader;
   private final DynamicSet<AccountExternalIdCreator> externalIdCreators;
   private final AuditService auditService;
+  private final ExternalIds externalIds;
   private final ExternalIdsUpdate.User externalIdsUpdateFactory;
+  private final OutgoingEmailValidator validator;
   private final String username;
 
   @Inject
@@ -82,12 +88,15 @@
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       SshKeyCache sshKeyCache,
       AccountCache accountCache,
+      AccountsUpdate.User accountsUpdate,
       AccountIndexer indexer,
       AccountByEmailCache byEmailCache,
       AccountLoader.Factory infoLoader,
       DynamicSet<AccountExternalIdCreator> externalIdCreators,
       AuditService auditService,
+      ExternalIds externalIds,
       ExternalIdsUpdate.User externalIdsUpdateFactory,
+      OutgoingEmailValidator validator,
       @Assisted String username) {
     this.db = db;
     this.currentUser = currentUser;
@@ -95,12 +104,15 @@
     this.authorizedKeys = authorizedKeys;
     this.sshKeyCache = sshKeyCache;
     this.accountCache = accountCache;
+    this.accountsUpdate = accountsUpdate;
     this.indexer = indexer;
     this.byEmailCache = byEmailCache;
     this.infoLoader = infoLoader;
     this.externalIdCreators = externalIdCreators;
     this.auditService = auditService;
+    this.externalIds = externalIds;
     this.externalIdsUpdateFactory = externalIdsUpdateFactory;
+    this.validator = validator;
     this.username = username;
   }
 
@@ -125,16 +137,14 @@
     Account.Id id = new Account.Id(db.nextAccountId());
 
     ExternalId extUser = ExternalId.createUsername(username, id, input.httpPassword);
-    if (db.accountExternalIds().get(extUser.key().asAccountExternalIdKey()) != null) {
+    if (externalIds.get(db, extUser.key()) != null) {
       throw new ResourceConflictException("username '" + username + "' already exists");
     }
     if (input.email != null) {
-      if (db.accountExternalIds()
-              .get(ExternalId.Key.create(SCHEME_MAILTO, input.email).asAccountExternalIdKey())
-          != null) {
+      if (externalIds.get(db, ExternalId.Key.create(SCHEME_MAILTO, input.email)) != null) {
         throw new UnprocessableEntityException("email '" + input.email + "' already exists");
       }
-      if (!OutgoingEmailValidator.isValid(input.email)) {
+      if (!validator.isValid(input.email)) {
         throw new BadRequestException("invalid email address");
       }
     }
@@ -158,7 +168,7 @@
       } catch (OrmDuplicateKeyException duplicateKey) {
         try {
           externalIdsUpdate.delete(db, extUser);
-        } catch (IOException | ConfigInvalidException | OrmException cleanupError) {
+        } catch (IOException | ConfigInvalidException cleanupError) {
           // Ignored
         }
         throw new UnprocessableEntityException("email '" + input.email + "' already exists");
@@ -168,7 +178,7 @@
     Account a = new Account(id, TimeUtil.nowTs());
     a.setFullName(input.name);
     a.setPreferredEmail(input.email);
-    db.accounts().insert(Collections.singleton(a));
+    accountsUpdate.create().insert(db, a);
 
     for (AccountGroup.Id groupId : groups) {
       AccountGroupMember m = new AccountGroupMember(new AccountGroupMember.Key(id, groupId));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
index b1a5d3b..56e0c60 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
@@ -32,6 +32,9 @@
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
 import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -50,9 +53,11 @@
 
   private final Provider<CurrentUser> self;
   private final Realm realm;
+  private final PermissionBackend permissionBackend;
   private final AccountManager accountManager;
   private final RegisterNewEmailSender.Factory registerNewEmailFactory;
   private final PutPreferred putPreferred;
+  private final OutgoingEmailValidator validator;
   private final String email;
   private final boolean isDevMode;
 
@@ -60,16 +65,20 @@
   CreateEmail(
       Provider<CurrentUser> self,
       Realm realm,
+      PermissionBackend permissionBackend,
       AuthConfig authConfig,
       AccountManager accountManager,
       RegisterNewEmailSender.Factory registerNewEmailFactory,
       PutPreferred putPreferred,
+      OutgoingEmailValidator validator,
       @Assisted String email) {
     this.self = self;
     this.realm = realm;
+    this.permissionBackend = permissionBackend;
     this.accountManager = accountManager;
     this.registerNewEmailFactory = registerNewEmailFactory;
     this.putPreferred = putPreferred;
+    this.validator = validator;
     this.email = email;
     this.isDevMode = authConfig.getAuthType() == DEVELOPMENT_BECOME_ANY_ACCOUNT;
   }
@@ -78,23 +87,19 @@
   public Response<EmailInfo> apply(AccountResource rsrc, EmailInput input)
       throws AuthException, BadRequestException, ResourceConflictException,
           ResourceNotFoundException, OrmException, EmailException, MethodNotAllowedException,
-          IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to add email address");
+          IOException, ConfigInvalidException, PermissionBackendException {
+    if (self.get() != rsrc.getUser() || input.noConfirmation) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     if (input == null) {
       input = new EmailInput();
     }
 
-    if (!OutgoingEmailValidator.isValid(email)) {
+    if (!validator.isValid(email)) {
       throw new BadRequestException("invalid email address");
     }
 
-    if (input.noConfirmation && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to use no_confirmation");
-    }
-
     if (!realm.allowsEdit(AccountFieldName.REGISTER_NEW_EMAIL)) {
       throw new MethodNotAllowedException("realm does not allow adding emails");
     }
@@ -105,7 +110,7 @@
   public Response<EmailInfo> apply(IdentifiedUser user, EmailInput input)
       throws AuthException, BadRequestException, ResourceConflictException,
           ResourceNotFoundException, OrmException, EmailException, MethodNotAllowedException,
-          IOException, ConfigInvalidException {
+          IOException, ConfigInvalidException, PermissionBackendException {
     if (input.email != null && !email.equals(input.email)) {
       throw new BadRequestException("email address must match URL");
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
index 794a2c1..b4e2bdb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
@@ -27,6 +27,11 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.DeleteEmail.Input;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -41,27 +46,34 @@
 
   private final Provider<CurrentUser> self;
   private final Realm realm;
+  private final PermissionBackend permissionBackend;
   private final Provider<ReviewDb> dbProvider;
   private final AccountManager accountManager;
+  private final ExternalIds externalIds;
 
   @Inject
   DeleteEmail(
       Provider<CurrentUser> self,
       Realm realm,
+      PermissionBackend permissionBackend,
       Provider<ReviewDb> dbProvider,
-      AccountManager accountManager) {
+      AccountManager accountManager,
+      ExternalIds externalIds) {
     this.self = self;
     this.realm = realm;
+    this.permissionBackend = permissionBackend;
     this.dbProvider = dbProvider;
     this.accountManager = accountManager;
+    this.externalIds = externalIds;
   }
 
   @Override
   public Response<?> apply(AccountResource.Email rsrc, Input input)
       throws AuthException, ResourceNotFoundException, ResourceConflictException,
-          MethodNotAllowedException, OrmException, IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to delete email address");
+          MethodNotAllowedException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
     return apply(rsrc.getUser(), rsrc.getEmail());
   }
@@ -74,13 +86,9 @@
     }
 
     Set<ExternalId> extIds =
-        dbProvider
-            .get()
-            .accountExternalIds()
-            .byAccount(user.getAccountId())
-            .toList()
+        externalIds
+            .byAccount(dbProvider.get(), user.getAccountId())
             .stream()
-            .map(ExternalId::from)
             .filter(e -> email.equals(e.email()))
             .collect(toSet());
     if (extIds.isEmpty()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java
index 42726dc..27a0038 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 import static java.util.stream.Collectors.toMap;
 
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -24,9 +24,10 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -37,51 +38,43 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 public class DeleteExternalIds implements RestModifyView<AccountResource, List<String>> {
-  private final AccountByEmailCache accountByEmailCache;
-  private final AccountCache accountCache;
-  private final ExternalIdsUpdate.User externalIdsUpdateFactory;
+  private final AccountManager accountManager;
+  private final ExternalIds externalIds;
   private final Provider<CurrentUser> self;
   private final Provider<ReviewDb> dbProvider;
 
   @Inject
   DeleteExternalIds(
-      AccountByEmailCache accountByEmailCache,
-      AccountCache accountCache,
-      ExternalIdsUpdate.User externalIdsUpdateFactory,
+      AccountManager accountManager,
+      ExternalIds externalIds,
       Provider<CurrentUser> self,
       Provider<ReviewDb> dbProvider) {
-    this.accountByEmailCache = accountByEmailCache;
-    this.accountCache = accountCache;
-    this.externalIdsUpdateFactory = externalIdsUpdateFactory;
+    this.accountManager = accountManager;
+    this.externalIds = externalIds;
     this.self = self;
     this.dbProvider = dbProvider;
   }
 
   @Override
-  public Response<?> apply(AccountResource resource, List<String> externalIds)
+  public Response<?> apply(AccountResource resource, List<String> extIds)
       throws RestApiException, IOException, OrmException, ConfigInvalidException {
     if (self.get() != resource.getUser()) {
       throw new AuthException("not allowed to delete external IDs");
     }
 
-    if (externalIds == null || externalIds.size() == 0) {
+    if (extIds == null || extIds.size() == 0) {
       throw new BadRequestException("external IDs are required");
     }
 
-    Account.Id accountId = resource.getUser().getAccountId();
     Map<ExternalId.Key, ExternalId> externalIdMap =
-        dbProvider
-            .get()
-            .accountExternalIds()
-            .byAccount(resource.getUser().getAccountId())
-            .toList()
+        externalIds
+            .byAccount(dbProvider.get(), resource.getUser().getAccountId())
             .stream()
-            .map(ExternalId::from)
             .collect(toMap(i -> i.key(), i -> i));
 
     List<ExternalId> toDelete = new ArrayList<>();
     ExternalId.Key last = resource.getUser().getLastLoginExternalIdKey();
-    for (String externalIdStr : externalIds) {
+    for (String externalIdStr : extIds) {
       ExternalId id = externalIdMap.get(ExternalId.Key.parse(externalIdStr));
 
       if (id == null) {
@@ -98,12 +91,14 @@
       }
     }
 
-    if (!toDelete.isEmpty()) {
-      externalIdsUpdateFactory.create().delete(dbProvider.get(), toDelete);
-      accountCache.evict(accountId);
-      for (ExternalId e : toDelete) {
-        accountByEmailCache.evict(e.email());
+    try {
+      for (ExternalId extId : toDelete) {
+        AuthRequest authRequest = new AuthRequest(extId.key());
+        authRequest.setEmailAddress(extId.email());
+        accountManager.unlink(extId.accountId(), authRequest);
       }
+    } catch (AccountException e) {
+      throw new ResourceConflictException(e.getMessage());
     }
 
     return Response.none();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java
index 3d5d38e..f1ecd29 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java
@@ -19,6 +19,9 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.DeleteSshKey.Input;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -33,15 +36,18 @@
   public static class Input {}
 
   private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
 
   @Inject
   DeleteSshKey(
       Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       SshKeyCache sshKeyCache) {
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.authorizedKeys = authorizedKeys;
     this.sshKeyCache = sshKeyCache;
   }
@@ -49,9 +55,9 @@
   @Override
   public Response<?> apply(AccountResource.SshKey rsrc, Input input)
       throws AuthException, OrmException, RepositoryNotFoundException, IOException,
-          ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("not allowed to delete SSH keys");
+          ConfigInvalidException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
     authorizedKeys.deleteKey(rsrc.getUser().getAccountId(), rsrc.getSshKey().getKey().get());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java
index 97102a2..1666eb1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java
@@ -25,6 +25,9 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -37,13 +40,18 @@
 public class DeleteWatchedProjects
     implements RestModifyView<AccountResource, List<ProjectWatchInfo>> {
   private final Provider<IdentifiedUser> self;
+  private final PermissionBackend permissionBackend;
   private final AccountCache accountCache;
   private final WatchConfig.Accessor watchConfig;
 
   @Inject
   DeleteWatchedProjects(
-      Provider<IdentifiedUser> self, AccountCache accountCache, WatchConfig.Accessor watchConfig) {
+      Provider<IdentifiedUser> self,
+      PermissionBackend permissionBackend,
+      AccountCache accountCache,
+      WatchConfig.Accessor watchConfig) {
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.accountCache = accountCache;
     this.watchConfig = watchConfig;
   }
@@ -51,9 +59,9 @@
   @Override
   public Response<?> apply(AccountResource rsrc, List<ProjectWatchInfo> input)
       throws AuthException, UnprocessableEntityException, OrmException, IOException,
-          ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("It is not allowed to edit project watches of other users");
+          ConfigInvalidException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
     if (input == null) {
       return Response.none();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Emails.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Emails.java
index b894f56..e31f481 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Emails.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Emails.java
@@ -17,12 +17,16 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsCreate;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResource.Email;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -34,6 +38,7 @@
   private final DynamicMap<RestView<AccountResource.Email>> views;
   private final GetEmails list;
   private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
   private final CreateEmail.Factory createEmailFactory;
 
   @Inject
@@ -41,10 +46,12 @@
       DynamicMap<RestView<AccountResource.Email>> views,
       GetEmails list,
       Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
       CreateEmail.Factory createEmailFactory) {
     this.views = views;
     this.list = list;
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.createEmailFactory = createEmailFactory;
   }
 
@@ -55,21 +62,21 @@
 
   @Override
   public AccountResource.Email parse(AccountResource rsrc, IdString id)
-      throws ResourceNotFoundException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new ResourceNotFoundException();
+      throws ResourceNotFoundException, PermissionBackendException, AuthException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
     if ("preferred".equals(id.get())) {
       String email = rsrc.getUser().getAccount().getPreferredEmail();
       if (Strings.isNullOrEmpty(email)) {
-        throw new ResourceNotFoundException();
+        throw new ResourceNotFoundException(id);
       }
       return new AccountResource.Email(rsrc.getUser(), email);
     } else if (rsrc.getUser().hasEmailAddress(id.get())) {
       return new AccountResource.Email(rsrc.getUser(), id.get());
     } else {
-      throw new ResourceNotFoundException();
+      throw new ResourceNotFoundException(id);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIds.java
deleted file mode 100644
index c937935..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIds.java
+++ /dev/null
@@ -1,105 +0,0 @@
-// 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.account;
-
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-/**
- * Class to read external IDs from NoteDb.
- *
- * <p>In NoteDb external IDs are stored in the All-Users repository in a Git Notes branch called
- * refs/meta/external-ids where the sha1 of the external ID is used as note name. Each note content
- * is a git config file that contains an external ID. It has 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>
- */
-@Singleton
-public class ExternalIds {
-  public static final int MAX_NOTE_SZ = 1 << 19;
-
-  public static ObjectId readRevision(Repository repo) throws IOException {
-    Ref ref = repo.exactRef(RefNames.REFS_EXTERNAL_IDS);
-    return ref != null ? ref.getObjectId() : ObjectId.zeroId();
-  }
-
-  public static NoteMap readNoteMap(RevWalk rw, ObjectId rev) throws IOException {
-    if (!rev.equals(ObjectId.zeroId())) {
-      return NoteMap.read(rw.getObjectReader(), rw.parseCommit(rev));
-    }
-    return NoteMap.newEmptyMap();
-  }
-
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-
-  @Inject
-  public ExternalIds(GitRepositoryManager repoManager, AllUsersName allUsersName) {
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-  }
-
-  public ObjectId readRevision() throws IOException {
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return readRevision(repo);
-    }
-  }
-
-  /** Reads and returns the specified external ID. */
-  @Nullable
-  public ExternalId get(ExternalId.Key key) throws IOException, ConfigInvalidException {
-    try (Repository repo = repoManager.openRepository(allUsersName);
-        RevWalk rw = new RevWalk(repo)) {
-      ObjectId rev = readRevision(repo);
-      if (rev.equals(ObjectId.zeroId())) {
-        return null;
-      }
-
-      return parse(key, rw, rev);
-    }
-  }
-
-  private ExternalId parse(ExternalId.Key key, RevWalk rw, ObjectId rev)
-      throws IOException, ConfigInvalidException {
-    NoteMap noteMap = readNoteMap(rw, rev);
-    ObjectId noteId = key.sha1();
-    if (!noteMap.contains(noteId)) {
-      return null;
-    }
-
-    byte[] raw =
-        rw.getObjectReader().open(noteMap.get(noteId), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
-    return ExternalId.parse(noteId.name(), raw);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetCapabilities.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetCapabilities.java
index cd3c0c8..f519b6c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetCapabilities.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetCapabilities.java
@@ -14,27 +14,13 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.common.data.GlobalCapability.ACCESS_DATABASE;
-import static com.google.gerrit.common.data.GlobalCapability.CREATE_ACCOUNT;
-import static com.google.gerrit.common.data.GlobalCapability.CREATE_GROUP;
-import static com.google.gerrit.common.data.GlobalCapability.CREATE_PROJECT;
-import static com.google.gerrit.common.data.GlobalCapability.EMAIL_REVIEWERS;
-import static com.google.gerrit.common.data.GlobalCapability.FLUSH_CACHES;
-import static com.google.gerrit.common.data.GlobalCapability.KILL_TASK;
-import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
-import static com.google.gerrit.common.data.GlobalCapability.MODIFY_ACCOUNT;
 import static com.google.gerrit.common.data.GlobalCapability.PRIORITY;
-import static com.google.gerrit.common.data.GlobalCapability.RUN_GC;
-import static com.google.gerrit.common.data.GlobalCapability.STREAM_EVENTS;
-import static com.google.gerrit.common.data.GlobalCapability.VIEW_ALL_ACCOUNTS;
-import static com.google.gerrit.common.data.GlobalCapability.VIEW_CACHES;
-import static com.google.gerrit.common.data.GlobalCapability.VIEW_CONNECTIONS;
-import static com.google.gerrit.common.data.GlobalCapability.VIEW_PLUGINS;
-import static com.google.gerrit.common.data.GlobalCapability.VIEW_QUEUE;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.PermissionRange;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.api.access.PluginPermission;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -45,12 +31,14 @@
 import com.google.gerrit.server.OutputFormat;
 import com.google.gerrit.server.account.AccountResource.Capability;
 import com.google.gerrit.server.git.QueueProvider;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.HashSet;
-import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.Set;
@@ -67,83 +55,80 @@
 
   private Set<String> query;
 
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> self;
   private final DynamicMap<CapabilityDefinition> pluginCapabilities;
 
   @Inject
-  GetCapabilities(Provider<CurrentUser> self, DynamicMap<CapabilityDefinition> pluginCapabilities) {
+  GetCapabilities(
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> self,
+      DynamicMap<CapabilityDefinition> pluginCapabilities) {
+    this.permissionBackend = permissionBackend;
     this.self = self;
     this.pluginCapabilities = pluginCapabilities;
   }
 
   @Override
-  public Object apply(AccountResource resource) throws AuthException {
-    if (self.get() != resource.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("restricted to administrator");
+  public Object apply(AccountResource rsrc) throws AuthException, PermissionBackendException {
+    PermissionBackend.WithUser perm = permissionBackend.user(self);
+    if (self.get() != rsrc.getUser()) {
+      perm.check(GlobalPermission.ADMINISTRATE_SERVER);
+      perm = permissionBackend.user(rsrc.getUser());
     }
 
-    CapabilityControl cc = resource.getUser().getCapabilities();
     Map<String, Object> have = new LinkedHashMap<>();
-    for (String name : GlobalCapability.getAllNames()) {
-      if (!name.equals(PRIORITY) && want(name) && cc.canPerform(name)) {
-        if (GlobalCapability.hasRange(name)) {
-          have.put(name, new Range(cc.getRange(name)));
-        } else {
-          have.put(name, true);
-        }
-      }
+    for (GlobalOrPluginPermission p : perm.test(permissionsToTest())) {
+      have.put(p.permissionName(), true);
     }
-    for (String pluginName : pluginCapabilities.plugins()) {
-      for (String capability : pluginCapabilities.byPlugin(pluginName).keySet()) {
-        String name = String.format("%s-%s", pluginName, capability);
-        if (want(name) && cc.canPerform(name)) {
-          have.put(name, true);
-        }
-      }
-    }
-
-    have.put(ACCESS_DATABASE, cc.canAccessDatabase());
-    have.put(CREATE_ACCOUNT, cc.canCreateAccount());
-    have.put(CREATE_GROUP, cc.canCreateGroup());
-    have.put(CREATE_PROJECT, cc.canCreateProject());
-    have.put(EMAIL_REVIEWERS, cc.canEmailReviewers());
-    have.put(FLUSH_CACHES, cc.canFlushCaches());
-    have.put(KILL_TASK, cc.canKillTask());
-    have.put(MAINTAIN_SERVER, cc.canMaintainServer());
-    have.put(MODIFY_ACCOUNT, cc.canModifyAccount());
-    have.put(RUN_GC, cc.canRunGC());
-    have.put(STREAM_EVENTS, cc.canStreamEvents());
-    have.put(VIEW_ALL_ACCOUNTS, cc.canViewAllAccounts());
-    have.put(VIEW_CACHES, cc.canViewCaches());
-    have.put(VIEW_CONNECTIONS, cc.canViewConnections());
-    have.put(VIEW_PLUGINS, cc.canViewPlugins());
-    have.put(VIEW_QUEUE, cc.canViewQueue());
-
-    QueueProvider.QueueType queue = cc.getQueueType();
-    if (queue != QueueProvider.QueueType.INTERACTIVE
-        || (query != null && query.contains(PRIORITY))) {
-      have.put(PRIORITY, queue);
-    }
-
-    Iterator<Map.Entry<String, Object>> itr = have.entrySet().iterator();
-    while (itr.hasNext()) {
-      Map.Entry<String, Object> e = itr.next();
-      if (!want(e.getKey())) {
-        itr.remove();
-      } else if (e.getValue() instanceof Boolean && !((Boolean) e.getValue())) {
-        itr.remove();
-      }
-    }
+    addRanges(have, rsrc);
+    addPriority(have, rsrc);
 
     return OutputFormat.JSON
         .newGson()
         .toJsonTree(have, new TypeToken<Map<String, Object>>() {}.getType());
   }
 
+  private Set<GlobalOrPluginPermission> permissionsToTest() {
+    Set<GlobalOrPluginPermission> toTest = new HashSet<>();
+    for (GlobalPermission p : GlobalPermission.values()) {
+      if (want(p.permissionName())) {
+        toTest.add(p);
+      }
+    }
+
+    for (String pluginName : pluginCapabilities.plugins()) {
+      for (String capability : pluginCapabilities.byPlugin(pluginName).keySet()) {
+        PluginPermission p = new PluginPermission(pluginName, capability);
+        if (want(p.permissionName())) {
+          toTest.add(p);
+        }
+      }
+    }
+    return toTest;
+  }
+
   private boolean want(String name) {
     return query == null || query.contains(name.toLowerCase());
   }
 
+  private void addRanges(Map<String, Object> have, AccountResource rsrc) {
+    CapabilityControl cc = rsrc.getUser().getCapabilities();
+    for (String name : GlobalCapability.getRangeNames()) {
+      if (want(name) && cc.hasExplicitRange(name)) {
+        have.put(name, new Range(cc.getRange(name)));
+      }
+    }
+  }
+
+  private void addPriority(Map<String, Object> have, AccountResource rsrc) {
+    QueueProvider.QueueType queue = rsrc.getUser().getCapabilities().getQueueType();
+    if (queue != QueueProvider.QueueType.INTERACTIVE
+        || (query != null && query.contains(PRIORITY))) {
+      have.put(PRIORITY, queue);
+    }
+  }
+
   private static class Range {
     private transient PermissionRange range;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDiffPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDiffPreferences.java
index 0edff4f..8215c6b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDiffPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDiffPreferences.java
@@ -25,6 +25,9 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.UserConfigSections;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -42,23 +45,26 @@
 
   private final Provider<CurrentUser> self;
   private final Provider<AllUsersName> allUsersName;
+  private final PermissionBackend permissionBackend;
   private final GitRepositoryManager gitMgr;
 
   @Inject
   GetDiffPreferences(
       Provider<CurrentUser> self,
       Provider<AllUsersName> allUsersName,
+      PermissionBackend permissionBackend,
       GitRepositoryManager gitMgr) {
     this.self = self;
     this.allUsersName = allUsersName;
+    this.permissionBackend = permissionBackend;
     this.gitMgr = gitMgr;
   }
 
   @Override
   public DiffPreferencesInfo apply(AccountResource rsrc)
-      throws AuthException, ConfigInvalidException, IOException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("restricted to administrator");
+      throws AuthException, ConfigInvalidException, IOException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
     Account.Id id = rsrc.getUser().getAccountId();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEditPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEditPreferences.java
index e385020..bb207f0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEditPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEditPreferences.java
@@ -24,6 +24,9 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.UserConfigSections;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -35,22 +38,27 @@
 @Singleton
 public class GetEditPreferences implements RestReadView<AccountResource> {
   private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
   private final AllUsersName allUsersName;
   private final GitRepositoryManager gitMgr;
 
   @Inject
   GetEditPreferences(
-      Provider<CurrentUser> self, AllUsersName allUsersName, GitRepositoryManager gitMgr) {
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      AllUsersName allUsersName,
+      GitRepositoryManager gitMgr) {
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.allUsersName = allUsersName;
     this.gitMgr = gitMgr;
   }
 
   @Override
   public EditPreferencesInfo apply(AccountResource rsrc)
-      throws AuthException, IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("requires Modify Account capability");
+      throws AuthException, IOException, ConfigInvalidException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     return readFromGit(rsrc.getUser().getAccountId(), gitMgr, allUsersName, null);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java
index 6ea911f..12de82c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
@@ -24,11 +24,14 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gwtorm.server.OrmException;
 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;
@@ -36,26 +39,30 @@
 @Singleton
 public class GetExternalIds implements RestReadView<AccountResource> {
   private final Provider<ReviewDb> db;
+  private final ExternalIds externalIds;
   private final Provider<CurrentUser> self;
   private final AuthConfig authConfig;
 
   @Inject
-  GetExternalIds(Provider<ReviewDb> db, Provider<CurrentUser> self, AuthConfig authConfig) {
+  GetExternalIds(
+      Provider<ReviewDb> db,
+      ExternalIds externalIds,
+      Provider<CurrentUser> self,
+      AuthConfig authConfig) {
     this.db = db;
+    this.externalIds = externalIds;
     this.self = self;
     this.authConfig = authConfig;
   }
 
   @Override
   public List<AccountExternalIdInfo> apply(AccountResource resource)
-      throws RestApiException, OrmException {
+      throws RestApiException, IOException, OrmException {
     if (self.get() != resource.getUser()) {
       throw new AuthException("not allowed to get external IDs");
     }
 
-    Collection<ExternalId> ids =
-        ExternalId.from(
-            db.get().accountExternalIds().byAccount(resource.getUser().getAccountId()).toList());
+    Collection<ExternalId> ids = externalIds.byAccount(db.get(), resource.getUser().getAccountId());
     if (ids.isEmpty()) {
       return ImmutableList.of();
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java
index 77cdbd4..3ebf864 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java
@@ -19,6 +19,9 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -26,18 +29,22 @@
 @Singleton
 public class GetPreferences implements RestReadView<AccountResource> {
   private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
   private final AccountCache accountCache;
 
   @Inject
-  GetPreferences(Provider<CurrentUser> self, AccountCache accountCache) {
+  GetPreferences(
+      Provider<CurrentUser> self, PermissionBackend permissionBackend, AccountCache accountCache) {
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.accountCache = accountCache;
   }
 
   @Override
-  public GeneralPreferencesInfo apply(AccountResource rsrc) throws AuthException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("requires Modify Account capability");
+  public GeneralPreferencesInfo apply(AccountResource rsrc)
+      throws AuthException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     Account.Id id = rsrc.getUser().getAccountId();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java
index 980d880..9f5b9d5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java
@@ -22,6 +22,9 @@
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -35,20 +38,25 @@
 public class GetSshKeys implements RestReadView<AccountResource> {
 
   private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
 
   @Inject
-  GetSshKeys(Provider<CurrentUser> self, VersionedAuthorizedKeys.Accessor authorizedKeys) {
+  GetSshKeys(
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      VersionedAuthorizedKeys.Accessor authorizedKeys) {
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.authorizedKeys = authorizedKeys;
   }
 
   @Override
   public List<SshKeyInfo> apply(AccountResource rsrc)
       throws AuthException, OrmException, RepositoryNotFoundException, IOException,
-          ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to get SSH keys");
+          ConfigInvalidException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
     return apply(rsrc.getUser());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java
index e0aeee0..c2c0547 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java
@@ -23,6 +23,9 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -38,22 +41,28 @@
 
 @Singleton
 public class GetWatchedProjects implements RestReadView<AccountResource> {
-
+  private final PermissionBackend permissionBackend;
   private final Provider<IdentifiedUser> self;
   private final WatchConfig.Accessor watchConfig;
 
   @Inject
-  public GetWatchedProjects(Provider<IdentifiedUser> self, WatchConfig.Accessor watchConfig) {
+  public GetWatchedProjects(
+      PermissionBackend permissionBackend,
+      Provider<IdentifiedUser> self,
+      WatchConfig.Accessor watchConfig) {
+    this.permissionBackend = permissionBackend;
     this.self = self;
     this.watchConfig = watchConfig;
   }
 
   @Override
   public List<ProjectWatchInfo> apply(AccountResource rsrc)
-      throws OrmException, AuthException, IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("It is not allowed to list project watches of other users");
+      throws OrmException, AuthException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
+
     Account.Id accountId = rsrc.getUser().getAccountId();
     List<ProjectWatchInfo> projectWatchInfos = new ArrayList<>();
     for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e :
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupControl.java
index ee788ec..e88e97e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupControl.java
@@ -127,7 +127,7 @@
     return user.isInternalUser()
         || groupBackend.isVisibleToAll(group.getGroupUUID())
         || user.getEffectiveGroups().contains(group.getGroupUUID())
-        || user.getCapabilities().canAdministrateServer()
+        || user.getCapabilities().isAdmin_DoNotUse()
         || isOwner();
   }
 
@@ -139,7 +139,7 @@
       AccountGroup.UUID ownerUUID = accountGroup.getOwnerGroupUUID();
       isOwner =
           getUser().getEffectiveGroups().contains(ownerUUID)
-              || getUser().getCapabilities().canAdministrateServer();
+              || getUser().getCapabilities().isAdmin_DoNotUse();
     }
     return isOwner;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java
index 6943dca..ecc6b8c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java
@@ -19,6 +19,9 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.Index.Input;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -29,18 +32,22 @@
   public static class Input {}
 
   private final AccountCache accountCache;
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> self;
 
   @Inject
-  Index(AccountCache accountCache, Provider<CurrentUser> self) {
+  Index(
+      AccountCache accountCache, PermissionBackend permissionBackend, Provider<CurrentUser> self) {
     this.accountCache = accountCache;
+    this.permissionBackend = permissionBackend;
     this.self = self;
   }
 
   @Override
-  public Response<?> apply(AccountResource rsrc, Input input) throws IOException, AuthException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to index account");
+  public Response<?> apply(AccountResource rsrc, Input input)
+      throws IOException, AuthException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     // evicting the account from the cache, reindexes the account
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java
index 7791a2e..e7ff314 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.avatar.AvatarProvider;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PostWatchedProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PostWatchedProjects.java
index 55ba912..38887f6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PostWatchedProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PostWatchedProjects.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -24,6 +23,9 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectsCollection;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -41,6 +43,7 @@
 public class PostWatchedProjects
     implements RestModifyView<AccountResource, List<ProjectWatchInfo>> {
   private final Provider<IdentifiedUser> self;
+  private final PermissionBackend permissionBackend;
   private final GetWatchedProjects getWatchedProjects;
   private final ProjectsCollection projectsCollection;
   private final AccountCache accountCache;
@@ -49,11 +52,13 @@
   @Inject
   public PostWatchedProjects(
       Provider<IdentifiedUser> self,
+      PermissionBackend permissionBackend,
       GetWatchedProjects getWatchedProjects,
       ProjectsCollection projectsCollection,
       AccountCache accountCache,
       WatchConfig.Accessor watchConfig) {
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.getWatchedProjects = getWatchedProjects;
     this.projectsCollection = projectsCollection;
     this.accountCache = accountCache;
@@ -62,10 +67,12 @@
 
   @Override
   public List<ProjectWatchInfo> apply(AccountResource rsrc, List<ProjectWatchInfo> input)
-      throws OrmException, RestApiException, IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("not allowed to edit project watches");
+      throws OrmException, RestApiException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
+
     Account.Id accountId = rsrc.getUser().getAccountId();
     watchConfig.upsertProjectWatches(accountId, asMap(input));
     accountCache.evict(accountId);
@@ -73,7 +80,8 @@
   }
 
   private Map<ProjectWatchKey, Set<NotifyType>> asMap(List<ProjectWatchInfo> input)
-      throws BadRequestException, UnprocessableEntityException, IOException {
+      throws BadRequestException, UnprocessableEntityException, IOException,
+          PermissionBackendException {
     Map<ProjectWatchKey, Set<NotifyType>> m = new HashMap<>();
     for (ProjectWatchInfo info : input) {
       if (info.project == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java
index 32c5345..4c525c2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java
@@ -28,7 +28,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Collections;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 @RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
@@ -69,7 +68,6 @@
     if (a == null) {
       throw new ResourceNotFoundException("account not found");
     }
-    dbProvider.get().accounts().update(Collections.singleton(a));
     byIdCache.evict(a.getId());
     return alreadyActive.get() ? Response.ok("") : Response.created("");
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
index 435671f..c06e5a3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -26,6 +26,12 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.PutHttpPassword.Input;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -54,25 +60,35 @@
 
   private final Provider<CurrentUser> self;
   private final Provider<ReviewDb> dbProvider;
+  private final PermissionBackend permissionBackend;
   private final AccountCache accountCache;
+  private final ExternalIds externalIds;
   private final ExternalIdsUpdate.User externalIdsUpdate;
 
   @Inject
   PutHttpPassword(
       Provider<CurrentUser> self,
       Provider<ReviewDb> dbProvider,
+      PermissionBackend permissionBackend,
       AccountCache accountCache,
+      ExternalIds externalIds,
       ExternalIdsUpdate.User externalIdsUpdate) {
     this.self = self;
     this.dbProvider = dbProvider;
+    this.permissionBackend = permissionBackend;
     this.accountCache = accountCache;
+    this.externalIds = externalIds;
     this.externalIdsUpdate = externalIdsUpdate;
   }
 
   @Override
   public Response<String> apply(AccountResource rsrc, Input input)
       throws AuthException, ResourceNotFoundException, ResourceConflictException, OrmException,
-          IOException, ConfigInvalidException {
+          IOException, ConfigInvalidException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
+    }
+
     if (input == null) {
       input = new Input();
     }
@@ -80,22 +96,12 @@
 
     String newPassword;
     if (input.generate) {
-      if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-        throw new AuthException("not allowed to generate HTTP password");
-      }
       newPassword = generate();
-
     } else if (input.httpPassword == null) {
-      if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-        throw new AuthException("not allowed to clear HTTP password");
-      }
       newPassword = null;
     } else {
-      if (!self.get().getCapabilities().canAdministrateServer()) {
-        throw new AuthException(
-            "not allowed to set HTTP password directly, "
-                + "requires the Administrate Server permission");
-      }
+      // Only administrators can explicitly set the password.
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
       newPassword = input.httpPassword;
     }
     return apply(rsrc.getUser(), newPassword);
@@ -109,13 +115,8 @@
     }
 
     ExternalId extId =
-        ExternalId.from(
-            dbProvider
-                .get()
-                .accountExternalIds()
-                .get(
-                    ExternalId.Key.create(SCHEME_USERNAME, user.getUserName())
-                        .asAccountExternalIdKey()));
+        externalIds.get(
+            dbProvider.get(), ExternalId.Key.create(SCHEME_USERNAME, user.getUserName()));
     if (extId == null) {
       throw new ResourceNotFoundException();
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
index 443a549..7a2868e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
@@ -27,6 +27,9 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.PutName.Input;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.AtomicUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -42,6 +45,7 @@
 
   private final Provider<CurrentUser> self;
   private final Realm realm;
+  private final PermissionBackend permissionBackend;
   private final Provider<ReviewDb> dbProvider;
   private final AccountCache byIdCache;
 
@@ -49,10 +53,12 @@
   PutName(
       Provider<CurrentUser> self,
       Realm realm,
+      PermissionBackend permissionBackend,
       Provider<ReviewDb> dbProvider,
       AccountCache byIdCache) {
     this.self = self;
     this.realm = realm;
+    this.permissionBackend = permissionBackend;
     this.dbProvider = dbProvider;
     this.byIdCache = byIdCache;
   }
@@ -60,9 +66,9 @@
   @Override
   public Response<String> apply(AccountResource rsrc, Input input)
       throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
-          IOException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to change name");
+          IOException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
     return apply(rsrc.getUser(), input);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java
index ec60fb3..4941cc8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java
@@ -23,13 +23,15 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.PutPreferred.Input;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.AtomicUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Collections;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 @Singleton
@@ -38,20 +40,27 @@
 
   private final Provider<CurrentUser> self;
   private final Provider<ReviewDb> dbProvider;
+  private final PermissionBackend permissionBackend;
   private final AccountCache byIdCache;
 
   @Inject
-  PutPreferred(Provider<CurrentUser> self, Provider<ReviewDb> dbProvider, AccountCache byIdCache) {
+  PutPreferred(
+      Provider<CurrentUser> self,
+      Provider<ReviewDb> dbProvider,
+      PermissionBackend permissionBackend,
+      AccountCache byIdCache) {
     this.self = self;
     this.dbProvider = dbProvider;
+    this.permissionBackend = permissionBackend;
     this.byIdCache = byIdCache;
   }
 
   @Override
   public Response<String> apply(AccountResource.Email rsrc, Input input)
-      throws AuthException, ResourceNotFoundException, OrmException, IOException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to set preferred email address");
+      throws AuthException, ResourceNotFoundException, OrmException, IOException,
+          PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
     return apply(rsrc.getUser(), rsrc.getEmail());
   }
@@ -79,7 +88,6 @@
     if (a == null) {
       throw new ResourceNotFoundException("account not found");
     }
-    dbProvider.get().accounts().update(Collections.singleton(a));
     byIdCache.evict(a.getId());
     return alreadyPreferred.get() ? Response.ok("") : Response.created("");
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutStatus.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutStatus.java
index ff541fd..73a720b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutStatus.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutStatus.java
@@ -25,6 +25,9 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.PutStatus.Input;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.AtomicUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -46,20 +49,27 @@
 
   private final Provider<CurrentUser> self;
   private final Provider<ReviewDb> dbProvider;
+  private final PermissionBackend permissionBackend;
   private final AccountCache byIdCache;
 
   @Inject
-  PutStatus(Provider<CurrentUser> self, Provider<ReviewDb> dbProvider, AccountCache byIdCache) {
+  PutStatus(
+      Provider<CurrentUser> self,
+      Provider<ReviewDb> dbProvider,
+      PermissionBackend permissionBackend,
+      AccountCache byIdCache) {
     this.self = self;
     this.dbProvider = dbProvider;
+    this.permissionBackend = permissionBackend;
     this.byIdCache = byIdCache;
   }
 
   @Override
   public Response<String> apply(AccountResource rsrc, Input input)
-      throws AuthException, ResourceNotFoundException, OrmException, IOException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to set status");
+      throws AuthException, ResourceNotFoundException, OrmException, IOException,
+          PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
     return apply(rsrc.getUser(), input);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
index e3a3c12..57bff65 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
@@ -25,6 +25,9 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.PutUsername.Input;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -40,6 +43,7 @@
 
   private final Provider<CurrentUser> self;
   private final ChangeUserName.Factory changeUserNameFactory;
+  private final PermissionBackend permissionBackend;
   private final Realm realm;
   private final Provider<ReviewDb> db;
 
@@ -47,10 +51,12 @@
   PutUsername(
       Provider<CurrentUser> self,
       ChangeUserName.Factory changeUserNameFactory,
+      PermissionBackend permissionBackend,
       Realm realm,
       Provider<ReviewDb> db) {
     this.self = self;
     this.changeUserNameFactory = changeUserNameFactory;
+    this.permissionBackend = permissionBackend;
     this.realm = realm;
     this.db = db;
   }
@@ -58,9 +64,10 @@
   @Override
   public String apply(AccountResource rsrc, Input input)
       throws AuthException, MethodNotAllowedException, UnprocessableEntityException,
-          ResourceConflictException, OrmException, IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("not allowed to set username");
+          ResourceConflictException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
     if (!realm.allowsEdit(AccountFieldName.USER_NAME)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetDiffPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetDiffPreferences.java
index ac0cc96..88e9e20 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetDiffPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetDiffPreferences.java
@@ -29,6 +29,9 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.UserConfigSections;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -41,6 +44,7 @@
   private final Provider<CurrentUser> self;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final AllUsersName allUsersName;
+  private final PermissionBackend permissionBackend;
   private final GitRepositoryManager gitMgr;
 
   @Inject
@@ -48,19 +52,21 @@
       Provider<CurrentUser> self,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       AllUsersName allUsersName,
+      PermissionBackend permissionBackend,
       GitRepositoryManager gitMgr) {
     this.self = self;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.allUsersName = allUsersName;
+    this.permissionBackend = permissionBackend;
     this.gitMgr = gitMgr;
   }
 
   @Override
   public DiffPreferencesInfo apply(AccountResource rsrc, DiffPreferencesInfo in)
       throws AuthException, BadRequestException, ConfigInvalidException,
-          RepositoryNotFoundException, IOException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("requires Modify Account capability");
+          RepositoryNotFoundException, IOException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     if (in == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetEditPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetEditPreferences.java
index ca981b8..53285db 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetEditPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetEditPreferences.java
@@ -28,6 +28,9 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.UserConfigSections;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -40,6 +43,7 @@
 
   private final Provider<CurrentUser> self;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
+  private final PermissionBackend permissionBackend;
   private final GitRepositoryManager gitMgr;
   private final AllUsersName allUsersName;
 
@@ -47,10 +51,12 @@
   SetEditPreferences(
       Provider<CurrentUser> self,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
+      PermissionBackend permissionBackend,
       GitRepositoryManager gitMgr,
       AllUsersName allUsersName) {
     this.self = self;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.permissionBackend = permissionBackend;
     this.gitMgr = gitMgr;
     this.allUsersName = allUsersName;
   }
@@ -58,9 +64,9 @@
   @Override
   public EditPreferencesInfo apply(AccountResource rsrc, EditPreferencesInfo in)
       throws AuthException, BadRequestException, RepositoryNotFoundException, IOException,
-          ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("requires Modify Account capability");
+          ConfigInvalidException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     if (in == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java
index 91672f7..c033d9d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java
@@ -36,6 +36,9 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.UserConfigSections;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -51,6 +54,7 @@
 public class SetPreferences implements RestModifyView<AccountResource, GeneralPreferencesInfo> {
   private final Provider<CurrentUser> self;
   private final AccountCache cache;
+  private final PermissionBackend permissionBackend;
   private final GeneralPreferencesLoader loader;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final AllUsersName allUsersName;
@@ -60,6 +64,7 @@
   SetPreferences(
       Provider<CurrentUser> self,
       AccountCache cache,
+      PermissionBackend permissionBackend,
       GeneralPreferencesLoader loader,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       AllUsersName allUsersName,
@@ -67,6 +72,7 @@
     this.self = self;
     this.loader = loader;
     this.cache = cache;
+    this.permissionBackend = permissionBackend;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.allUsersName = allUsersName;
     this.downloadSchemes = downloadSchemes;
@@ -74,9 +80,10 @@
 
   @Override
   public GeneralPreferencesInfo apply(AccountResource rsrc, GeneralPreferencesInfo i)
-      throws AuthException, BadRequestException, IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("requires Modify Account capability");
+      throws AuthException, BadRequestException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     checkDownloadScheme(i.downloadScheme);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SshKeys.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SshKeys.java
index 6336e08..70c02a1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SshKeys.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SshKeys.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -22,6 +23,9 @@
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -34,6 +38,7 @@
   private final DynamicMap<RestView<AccountResource.SshKey>> views;
   private final GetSshKeys list;
   private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
 
   @Inject
@@ -41,10 +46,12 @@
       DynamicMap<RestView<AccountResource.SshKey>> views,
       GetSshKeys list,
       Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
       VersionedAuthorizedKeys.Accessor authorizedKeys) {
     this.views = views;
     this.list = list;
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.authorizedKeys = authorizedKeys;
   }
 
@@ -55,9 +62,15 @@
 
   @Override
   public AccountResource.SshKey parse(AccountResource rsrc, IdString id)
-      throws ResourceNotFoundException, OrmException, IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new ResourceNotFoundException();
+      throws ResourceNotFoundException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      try {
+        permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
+      } catch (AuthException e) {
+        // If lacking MODIFY_ACCOUNT claim the resource does not exist.
+        throw new ResourceNotFoundException();
+      }
     }
     return parse(rsrc.getUser(), id);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
new file mode 100644
index 0000000..ab212fb
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2017 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.account.externalids;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+
+public class DisabledExternalIdCache implements ExternalIdCache {
+  public static Module module() {
+    return new AbstractModule() {
+
+      @Override
+      protected void configure() {
+        bind(ExternalIdCache.class).to(DisabledExternalIdCache.class);
+      }
+    };
+  }
+
+  @Override
+  public void onCreate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId) {}
+
+  @Override
+  public void onUpdate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId) {}
+
+  @Override
+  public void onReplace(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Collection<ExternalId> toRemove,
+      Collection<ExternalId> toAdd) {}
+
+  @Override
+  public void onReplaceByKeys(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Collection<ExternalId.Key> toRemove,
+      Collection<ExternalId> toAdd) {}
+
+  @Override
+  public void onReplaceByKeys(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Collection<ExternalId.Key> toRemove,
+      Collection<ExternalId> toAdd) {}
+
+  @Override
+  public void onReplace(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Collection<ExternalId> toRemove,
+      Collection<ExternalId> toAdd) {}
+
+  @Override
+  public void onRemove(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId) {}
+
+  @Override
+  public void onRemoveByKeys(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Collection<ExternalId.Key> extIdKeys) {}
+
+  @Override
+  public void onRemoveByKeys(
+      ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId.Key> extIdKeys) {}
+
+  @Override
+  public Set<ExternalId> byAccount(Account.Id accountId) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public Set<ExternalId> byEmail(String email) throws IOException {
+    throw new UnsupportedOperationException();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalId.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalId.java
similarity index 85%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalId.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalId.java
index cd10b7b..b4fef67 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalId.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalId.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;
+package com.google.gerrit.server.account.externalids;
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -23,11 +23,11 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.hash.Hashing;
-import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.server.account.HashedPassword;
 import java.io.Serializable;
 import java.util.Collection;
 import java.util.Set;
@@ -215,35 +215,27 @@
       throw invalidConfig(
           noteId,
           String.format(
-              "Expected exactly 1 %s section, found %d",
+              "Expected exactly 1 '%s' section, found %d",
               EXTERNAL_ID_SECTION, externalIdKeys.size()));
     }
 
     String externalIdKeyStr = Iterables.getOnlyElement(externalIdKeys);
     Key externalIdKey = Key.parse(externalIdKeyStr);
     if (externalIdKey == null) {
-      throw invalidConfig(noteId, String.format("Invalid external id: %s", externalIdKeyStr));
+      throw invalidConfig(noteId, String.format("External ID %s is invalid", externalIdKeyStr));
     }
 
-    String accountIdStr =
-        externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY);
+    if (!externalIdKey.sha1().getName().equals(noteId)) {
+      throw invalidConfig(
+          noteId,
+          String.format(
+              "SHA1 of external ID '%s' does not match note ID '%s'", externalIdKeyStr, noteId));
+    }
+
     String email = externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, EMAIL_KEY);
     String password =
         externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, PASSWORD_KEY);
-    if (accountIdStr == null) {
-      throw invalidConfig(
-          noteId,
-          String.format(
-              "Missing value for %s.%s.%s", EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
-    }
-    Integer accountId = Ints.tryParse(accountIdStr);
-    if (accountId == null) {
-      throw invalidConfig(
-          noteId,
-          String.format(
-              "Value %s for %s.%s.%s is invalid, expected account ID",
-              accountIdStr, EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
-    }
+    int accountId = readAccountId(noteId, externalIdConfig, externalIdKeyStr);
 
     return new AutoValue_ExternalId(
         externalIdKey,
@@ -252,9 +244,41 @@
         Strings.emptyToNull(password));
   }
 
+  private static int readAccountId(String noteId, Config externalIdConfig, String externalIdKeyStr)
+      throws ConfigInvalidException {
+    String accountIdStr =
+        externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY);
+    if (accountIdStr == null) {
+      throw invalidConfig(
+          noteId,
+          String.format(
+              "Value for '%s.%s.%s' is missing, expected account ID",
+              EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
+    }
+
+    try {
+      int accountId =
+          externalIdConfig.getInt(EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY, -1);
+      if (accountId <= 0) {
+        throw invalidConfig(
+            noteId,
+            String.format(
+                "Value %s for '%s.%s.%s' is invalid, expected account ID",
+                accountIdStr, EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
+      }
+      return accountId;
+    } catch (IllegalArgumentException e) {
+      throw invalidConfig(
+          noteId,
+          String.format(
+              "Value %s for '%s.%s.%s' is invalid, expected account ID",
+              accountIdStr, EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
+    }
+  }
+
   private static ConfigInvalidException invalidConfig(String noteId, String message) {
     return new ConfigInvalidException(
-        String.format("Invalid external id config for note %s: %s", noteId, message));
+        String.format("Invalid external ID config for note '%s': %s", noteId, message));
   }
 
   public static ExternalId from(AccountExternalId externalId) {
@@ -321,7 +345,11 @@
 
   public void writeToConfig(Config c) {
     String externalIdKey = key().get();
-    c.setInt(EXTERNAL_ID_SECTION, externalIdKey, ACCOUNT_ID_KEY, accountId().get());
+    // Do not use c.setInt(...) to write the account ID because c.setInt(...) persists integers
+    // that can be expressed in KiB as a unit strings, e.g. "1024000" is stored as "100k". Using
+    // c.setString(...) ensures that account IDs are human readable.
+    c.setString(
+        EXTERNAL_ID_SECTION, externalIdKey, ACCOUNT_ID_KEY, Integer.toString(accountId().get()));
     if (email() != null) {
       c.setString(EXTERNAL_ID_SECTION, externalIdKey, EMAIL_KEY, email());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
new file mode 100644
index 0000000..53a1cea
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
@@ -0,0 +1,100 @@
+// 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.account.externalids;
+
+import com.google.gerrit.reviewdb.client.Account;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Caches external IDs of all accounts */
+interface ExternalIdCache {
+  void onCreate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId)
+      throws IOException;
+
+  void onUpdate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId)
+      throws IOException;
+
+  void onReplace(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Collection<ExternalId> toRemove,
+      Collection<ExternalId> toAdd)
+      throws IOException;
+
+  void onReplaceByKeys(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Collection<ExternalId.Key> toRemove,
+      Collection<ExternalId> toAdd)
+      throws IOException;
+
+  void onReplaceByKeys(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Collection<ExternalId.Key> toRemove,
+      Collection<ExternalId> toAdd)
+      throws IOException;
+
+  void onReplace(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Collection<ExternalId> toRemove,
+      Collection<ExternalId> toAdd)
+      throws IOException;
+
+  void onRemove(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId)
+      throws IOException;
+
+  void onRemoveByKeys(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Collection<ExternalId.Key> extIdKeys)
+      throws IOException;
+
+  void onRemoveByKeys(
+      ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId.Key> extIdKeys)
+      throws IOException;
+
+  Set<ExternalId> byAccount(Account.Id accountId) throws IOException;
+
+  Set<ExternalId> byEmail(String email) throws IOException;
+
+  default void onCreate(ObjectId oldNotesRev, ObjectId newNotesRev, ExternalId extId)
+      throws IOException {
+    onCreate(oldNotesRev, newNotesRev, Collections.singleton(extId));
+  }
+
+  default void onRemove(ObjectId oldNotesRev, ObjectId newNotesRev, ExternalId extId)
+      throws IOException {
+    onRemove(oldNotesRev, newNotesRev, Collections.singleton(extId));
+  }
+
+  default void onRemoveByKey(
+      ObjectId oldNotesRev, ObjectId newNotesRev, Account.Id accountId, ExternalId.Key extIdKey)
+      throws IOException {
+    onRemoveByKeys(oldNotesRev, newNotesRev, accountId, Collections.singleton(extIdKey));
+  }
+
+  default void onUpdate(ObjectId oldNotesRev, ObjectId newNotesRev, ExternalId updatedExtId)
+      throws IOException {
+    onUpdate(oldNotesRev, newNotesRev, Collections.singleton(updatedExtId));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
new file mode 100644
index 0000000..9db00d5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
@@ -0,0 +1,283 @@
+// 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.account.externalids;
+
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.cache.CacheBuilder;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.Consumer;
+import org.eclipse.jgit.lib.ObjectId;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Caches external IDs of all accounts. The external IDs are always loaded from NoteDb. */
+@Singleton
+class ExternalIdCacheImpl implements ExternalIdCache {
+  private static final Logger log = LoggerFactory.getLogger(ExternalIdCacheImpl.class);
+
+  private final LoadingCache<ObjectId, ImmutableSetMultimap<Account.Id, ExternalId>>
+      extIdsByAccount;
+  private final ExternalIdReader externalIdReader;
+  private final Lock lock;
+
+  @Inject
+  ExternalIdCacheImpl(ExternalIdReader externalIdReader) {
+    this.extIdsByAccount =
+        CacheBuilder.newBuilder()
+            // The cached data is potentially pretty large and we are always only interested
+            // in the latest value, hence the maximum cache size is set to 1.
+            // This can lead to extra cache loads in case of the following race:
+            // 1. thread 1 reads the notes ref at revision A
+            // 2. thread 2 updates the notes ref to revision B and stores the derived value
+            //    for B in the cache
+            // 3. thread 1 attempts to read the data for revision A from the cache, and misses
+            // 4. later threads attempt to read at B
+            // In this race unneeded reloads are done in step 3 (reload from revision A) and
+            // step 4 (reload from revision B, because the value for revision B was lost when the
+            // reload from revision A was done, since the cache can hold only one entry).
+            // These reloads could be avoided by increasing the cache size to 2. However the race
+            // window between reading the ref and looking it up in the cache is small so that
+            // it's rare that this race happens. Therefore it's not worth to double the memory
+            // usage of this cache, just to avoid this.
+            .maximumSize(1)
+            .build(new Loader(externalIdReader));
+    this.externalIdReader = externalIdReader;
+    this.lock = new ReentrantLock(true /* fair */);
+  }
+
+  @Override
+  public void onCreate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extIds)
+      throws IOException {
+    updateCache(
+        oldNotesRev,
+        newNotesRev,
+        m -> {
+          for (ExternalId extId : extIds) {
+            m.put(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public void onRemove(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extIds)
+      throws IOException {
+    updateCache(
+        oldNotesRev,
+        newNotesRev,
+        m -> {
+          for (ExternalId extId : extIds) {
+            m.remove(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public void onRemoveByKeys(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Collection<ExternalId.Key> extIdKeys)
+      throws IOException {
+    updateCache(oldNotesRev, newNotesRev, m -> removeKeys(m.get(accountId), extIdKeys));
+  }
+
+  @Override
+  public void onRemoveByKeys(
+      ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId.Key> extIdKeys)
+      throws IOException {
+    updateCache(oldNotesRev, newNotesRev, m -> removeKeys(m.values(), extIdKeys));
+  }
+
+  @Override
+  public void onUpdate(
+      ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> updatedExtIds)
+      throws IOException {
+    updateCache(
+        oldNotesRev,
+        newNotesRev,
+        m -> {
+          removeKeys(m.values(), updatedExtIds.stream().map(e -> e.key()).collect(toSet()));
+          for (ExternalId updatedExtId : updatedExtIds) {
+            m.put(updatedExtId.accountId(), updatedExtId);
+          }
+        });
+  }
+
+  @Override
+  public void onReplace(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Collection<ExternalId> toRemove,
+      Collection<ExternalId> toAdd)
+      throws IOException {
+    ExternalIdsUpdate.checkSameAccount(Iterables.concat(toRemove, toAdd), accountId);
+
+    updateCache(
+        oldNotesRev,
+        newNotesRev,
+        m -> {
+          for (ExternalId extId : toRemove) {
+            m.remove(extId.accountId(), extId);
+          }
+          for (ExternalId extId : toAdd) {
+            m.put(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public void onReplaceByKeys(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Collection<ExternalId.Key> toRemove,
+      Collection<ExternalId> toAdd)
+      throws IOException {
+    ExternalIdsUpdate.checkSameAccount(toAdd, accountId);
+
+    updateCache(
+        oldNotesRev,
+        newNotesRev,
+        m -> {
+          removeKeys(m.get(accountId), toRemove);
+          for (ExternalId extId : toAdd) {
+            m.put(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public void onReplaceByKeys(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Collection<ExternalId.Key> toRemove,
+      Collection<ExternalId> toAdd)
+      throws IOException {
+    updateCache(
+        oldNotesRev,
+        newNotesRev,
+        m -> {
+          removeKeys(m.values(), toRemove);
+          for (ExternalId extId : toAdd) {
+            m.put(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public void onReplace(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Collection<ExternalId> toRemove,
+      Collection<ExternalId> toAdd)
+      throws IOException {
+    updateCache(
+        oldNotesRev,
+        newNotesRev,
+        m -> {
+          for (ExternalId extId : toRemove) {
+            m.remove(extId.accountId(), extId);
+          }
+          for (ExternalId extId : toAdd) {
+            m.put(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public Set<ExternalId> byAccount(Account.Id accountId) throws IOException {
+    try {
+      return extIdsByAccount.get(externalIdReader.readRevision()).get(accountId);
+    } catch (ExecutionException e) {
+      throw new IOException("Cannot list external ids by account", e);
+    }
+  }
+
+  @Override
+  public Set<ExternalId> byEmail(String email) throws IOException {
+    try {
+      return extIdsByAccount
+          .get(externalIdReader.readRevision())
+          .values()
+          .stream()
+          .filter(e -> email.equals(e.email()))
+          .collect(toSet());
+    } catch (ExecutionException e) {
+      throw new IOException("Cannot list external ids by email", e);
+    }
+  }
+
+  private void updateCache(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Consumer<Multimap<Account.Id, ExternalId>> update) {
+    lock.lock();
+    try {
+      ListMultimap<Account.Id, ExternalId> m;
+      if (!ObjectId.zeroId().equals(oldNotesRev)) {
+        m = MultimapBuilder.hashKeys().arrayListValues().build(extIdsByAccount.get(oldNotesRev));
+      } else {
+        m = MultimapBuilder.hashKeys().arrayListValues().build();
+      }
+      update.accept(m);
+      extIdsByAccount.put(newNotesRev, ImmutableSetMultimap.copyOf(m));
+    } catch (ExecutionException e) {
+      log.warn("Cannot update external IDs", e);
+    } finally {
+      lock.unlock();
+    }
+  }
+
+  private static void removeKeys(Collection<ExternalId> ids, Collection<ExternalId.Key> toRemove) {
+    Collections2.transform(ids, e -> e.key()).removeAll(toRemove);
+  }
+
+  private static class Loader
+      extends CacheLoader<ObjectId, ImmutableSetMultimap<Account.Id, ExternalId>> {
+    private final ExternalIdReader externalIdReader;
+
+    Loader(ExternalIdReader externalIdReader) {
+      this.externalIdReader = externalIdReader;
+    }
+
+    @Override
+    public ImmutableSetMultimap<Account.Id, ExternalId> load(ObjectId notesRev) throws Exception {
+      Multimap<Account.Id, ExternalId> extIdsByAccount =
+          MultimapBuilder.hashKeys().arrayListValues().build();
+      for (ExternalId extId : externalIdReader.all(notesRev)) {
+        extIdsByAccount.put(extId.accountId(), extId);
+      }
+      return ImmutableSetMultimap.copyOf(extIdsByAccount);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
new file mode 100644
index 0000000..8c97144
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2017 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.account.externalids;
+
+import com.google.inject.AbstractModule;
+
+public class ExternalIdModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    bind(ExternalIdCacheImpl.class);
+    bind(ExternalIdCache.class).to(ExternalIdCacheImpl.class);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
new file mode 100644
index 0000000..e25c36f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
@@ -0,0 +1,220 @@
+// Copyright (C) 2017 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.account.externalids;
+
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.Note;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Class to read external IDs from ReviewDb or NoteDb.
+ *
+ * <p>In NoteDb external IDs are stored in the All-Users repository in a Git Notes branch called
+ * refs/meta/external-ids where the sha1 of the external ID is used as note name. Each note content
+ * is a git config file that contains an external ID. It has 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>
+ */
+@Singleton
+public class ExternalIdReader {
+  private static final Logger log = LoggerFactory.getLogger(ExternalIdReader.class);
+
+  public static final int MAX_NOTE_SZ = 1 << 19;
+
+  public static ObjectId readRevision(Repository repo) throws IOException {
+    Ref ref = repo.exactRef(RefNames.REFS_EXTERNAL_IDS);
+    return ref != null ? ref.getObjectId() : ObjectId.zeroId();
+  }
+
+  public static NoteMap readNoteMap(RevWalk rw, ObjectId rev) throws IOException {
+    if (!rev.equals(ObjectId.zeroId())) {
+      return NoteMap.read(rw.getObjectReader(), rw.parseCommit(rev));
+    }
+    return NoteMap.newEmptyMap();
+  }
+
+  private final boolean readFromGit;
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private boolean failOnLoad = false;
+  private final Timer0 readAllLatency;
+
+  @Inject
+  ExternalIdReader(
+      @GerritServerConfig Config cfg,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      MetricMaker metricMaker) {
+    this.readFromGit = cfg.getBoolean("user", null, "readExternalIdsFromGit", false);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.readAllLatency =
+        metricMaker.newTimer(
+            "notedb/read_all_external_ids_latency",
+            new Description("Latency for reading all external IDs from NoteDb.")
+                .setCumulative()
+                .setUnit(Units.MILLISECONDS));
+  }
+
+  @VisibleForTesting
+  public void setFailOnLoad(boolean failOnLoad) {
+    this.failOnLoad = failOnLoad;
+  }
+
+  boolean readFromGit() {
+    return readFromGit;
+  }
+
+  ObjectId readRevision() throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return readRevision(repo);
+    }
+  }
+
+  /** Reads and returns all external IDs. */
+  Set<ExternalId> all(ReviewDb db) throws IOException, OrmException {
+    checkReadEnabled();
+
+    if (readFromGit) {
+      try (Repository repo = repoManager.openRepository(allUsersName)) {
+        return all(repo, readRevision(repo));
+      }
+    }
+
+    return ExternalId.from(db.accountExternalIds().all().toList());
+  }
+
+  /**
+   * Reads and returns all external IDs from the specified revision of the refs/meta/external-ids
+   * branch.
+   */
+  Set<ExternalId> all(ObjectId rev) throws IOException {
+    checkReadEnabled();
+
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return all(repo, rev);
+    }
+  }
+
+  /** Reads and returns all external IDs. */
+  private Set<ExternalId> all(Repository repo, ObjectId rev) throws IOException {
+    if (rev.equals(ObjectId.zeroId())) {
+      return ImmutableSet.of();
+    }
+
+    try (Timer0.Context ctx = readAllLatency.start();
+        RevWalk rw = new RevWalk(repo)) {
+      NoteMap noteMap = readNoteMap(rw, rev);
+      Set<ExternalId> extIds = new HashSet<>();
+      for (Note note : noteMap) {
+        byte[] raw =
+            rw.getObjectReader().open(note.getData(), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
+        try {
+          extIds.add(ExternalId.parse(note.getName(), raw));
+        } catch (Exception e) {
+          log.error(String.format("Ignoring invalid external ID note %s", note.getName()), e);
+        }
+      }
+      return extIds;
+    }
+  }
+
+  /** Reads and returns the specified external ID. */
+  @Nullable
+  ExternalId get(ReviewDb db, ExternalId.Key key)
+      throws IOException, ConfigInvalidException, OrmException {
+    checkReadEnabled();
+
+    if (readFromGit) {
+      try (Repository repo = repoManager.openRepository(allUsersName);
+          RevWalk rw = new RevWalk(repo)) {
+        ObjectId rev = readRevision(repo);
+        if (rev.equals(ObjectId.zeroId())) {
+          return null;
+        }
+
+        return parse(key, rw, rev);
+      }
+    }
+    return ExternalId.from(db.accountExternalIds().get(key.asAccountExternalIdKey()));
+  }
+
+  /** Reads and returns the specified external ID from the given revision. */
+  @Nullable
+  ExternalId get(ExternalId.Key key, ObjectId rev) throws IOException, ConfigInvalidException {
+    checkReadEnabled();
+
+    if (rev.equals(ObjectId.zeroId())) {
+      return null;
+    }
+
+    try (Repository repo = repoManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(repo)) {
+      return parse(key, rw, rev);
+    }
+  }
+
+  private static ExternalId parse(ExternalId.Key key, RevWalk rw, ObjectId rev)
+      throws IOException, ConfigInvalidException {
+    NoteMap noteMap = readNoteMap(rw, rev);
+    ObjectId noteId = key.sha1();
+    if (!noteMap.contains(noteId)) {
+      return null;
+    }
+
+    byte[] raw =
+        rw.getObjectReader().open(noteMap.get(noteId), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
+    return ExternalId.parse(noteId.name(), raw);
+  }
+
+  private void checkReadEnabled() throws IOException {
+    if (failOnLoad) {
+      throw new IOException("Reading from external IDs is disabled");
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIds.java
new file mode 100644
index 0000000..b77fed8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIds.java
@@ -0,0 +1,93 @@
+// 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.account.externalids;
+
+import static java.util.stream.Collectors.toSet;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Set;
+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;
+
+  @Inject
+  public ExternalIds(ExternalIdReader externalIdReader, ExternalIdCache externalIdCache) {
+    this.externalIdReader = externalIdReader;
+    this.externalIdCache = externalIdCache;
+  }
+
+  /** Returns all external IDs. */
+  public Set<ExternalId> all(ReviewDb db) throws IOException, OrmException {
+    return externalIdReader.all(db);
+  }
+
+  /** Returns all external IDs from the specified revision of the refs/meta/external-ids branch. */
+  public Set<ExternalId> all(ObjectId rev) throws IOException {
+    return externalIdReader.all(rev);
+  }
+
+  /** Returns the specified external ID. */
+  @Nullable
+  public ExternalId get(ReviewDb db, ExternalId.Key key)
+      throws IOException, ConfigInvalidException, OrmException {
+    return externalIdReader.get(db, key);
+  }
+
+  /** Returns the specified external ID from the given revision. */
+  @Nullable
+  public ExternalId get(ExternalId.Key key, ObjectId rev)
+      throws IOException, ConfigInvalidException {
+    return externalIdReader.get(key, rev);
+  }
+
+  /** Returns the external IDs of the specified account. */
+  public Set<ExternalId> byAccount(ReviewDb db, Account.Id accountId)
+      throws IOException, OrmException {
+    if (externalIdReader.readFromGit()) {
+      return externalIdCache.byAccount(accountId);
+    }
+
+    return ExternalId.from(db.accountExternalIds().byAccount(accountId).toList());
+  }
+
+  /** Returns the external IDs of the specified account that have the given scheme. */
+  public Set<ExternalId> byAccount(ReviewDb db, Account.Id accountId, String scheme)
+      throws IOException, OrmException {
+    return byAccount(db, accountId).stream().filter(e -> e.key().isScheme(scheme)).collect(toSet());
+  }
+
+  public Set<ExternalId> byEmail(ReviewDb db, String email) throws IOException, OrmException {
+    if (externalIdReader.readFromGit()) {
+      return externalIdCache.byEmail(email);
+    }
+
+    return ExternalId.from(db.accountExternalIds().byEmailAddress(email).toList());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsBatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsBatchUpdate.java
similarity index 85%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsBatchUpdate.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsBatchUpdate.java
index 531e562..492866d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsBatchUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsBatchUpdate.java
@@ -12,9 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.account;
+package com.google.gerrit.server.account.externalids;
 
-import static com.google.gerrit.server.account.ExternalId.toAccountExternalIds;
+import static com.google.gerrit.server.account.externalids.ExternalId.toAccountExternalIds;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -46,6 +46,7 @@
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsersName;
   private final PersonIdent serverIdent;
+  private final ExternalIdCache externalIdCache;
   private final Set<ExternalId> toAdd = new HashSet<>();
   private final Set<ExternalId> toDelete = new HashSet<>();
 
@@ -53,10 +54,12 @@
   public ExternalIdsBatchUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
-      @GerritPersonIdent PersonIdent serverIdent) {
+      @GerritPersonIdent PersonIdent serverIdent,
+      ExternalIdCache externalIdCache) {
     this.repoManager = repoManager;
     this.allUsersName = allUsersName;
     this.serverIdent = serverIdent;
+    this.externalIdCache = externalIdCache;
   }
 
   /**
@@ -94,9 +97,9 @@
     try (Repository repo = repoManager.openRepository(allUsersName);
         RevWalk rw = new RevWalk(repo);
         ObjectInserter ins = repo.newObjectInserter()) {
-      ObjectId rev = ExternalIds.readRevision(repo);
+      ObjectId rev = ExternalIdReader.readRevision(repo);
 
-      NoteMap noteMap = ExternalIds.readNoteMap(rw, rev);
+      NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
 
       for (ExternalId extId : toDelete) {
         ExternalIdsUpdate.remove(rw, noteMap, extId);
@@ -106,8 +109,10 @@
         ExternalIdsUpdate.insert(rw, ins, noteMap, extId);
       }
 
-      ExternalIdsUpdate.commit(
-          repo, rw, ins, rev, noteMap, commitMessage, serverIdent, serverIdent);
+      ObjectId newRev =
+          ExternalIdsUpdate.commit(
+              repo, rw, ins, rev, noteMap, commitMessage, serverIdent, serverIdent);
+      externalIdCache.onReplace(rev, newRev, toDelete, toAdd);
     }
 
     toAdd.clear();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
new file mode 100644
index 0000000..928349d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
@@ -0,0 +1,155 @@
+// Copyright (C) 2017 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.account.externalids;
+
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static java.util.stream.Collectors.joining;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+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.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.apache.commons.codec.DecoderException;
+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;
+
+  @Inject
+  ExternalIdsConsistencyChecker(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsers,
+      AccountCache accountCache,
+      OutgoingEmailValidator validator) {
+    this.repoManager = repoManager;
+    this.allUsers = allUsers;
+    this.accountCache = accountCache;
+    this.validator = validator;
+  }
+
+  public List<ConsistencyProblemInfo> check() throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return check(repo, ExternalIdReader.readRevision(repo));
+    }
+  }
+
+  public List<ConsistencyProblemInfo> check(ObjectId rev) throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return check(repo, rev);
+    }
+  }
+
+  private List<ConsistencyProblemInfo> check(Repository repo, ObjectId commit) throws IOException {
+    List<ConsistencyProblemInfo> problems = new ArrayList<>();
+
+    ListMultimap<String, ExternalId.Key> emails =
+        MultimapBuilder.hashKeys().arrayListValues().build();
+
+    try (RevWalk rw = new RevWalk(repo)) {
+      NoteMap noteMap = ExternalIdReader.readNoteMap(rw, commit);
+      for (Note note : noteMap) {
+        byte[] raw =
+            rw.getObjectReader()
+                .open(note.getData(), OBJ_BLOB)
+                .getCachedBytes(ExternalIdReader.MAX_NOTE_SZ);
+        try {
+          ExternalId extId = ExternalId.parse(note.getName(), raw);
+          problems.addAll(validateExternalId(extId));
+
+          if (extId.email() != null) {
+            emails.put(extId.email(), extId.key());
+          }
+        } 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.get() + "'")
+                            .sorted()
+                            .collect(joining(", "))),
+                    problems));
+
+    return problems;
+  }
+
+  private List<ConsistencyProblemInfo> validateExternalId(ExternalId extId) {
+    List<ConsistencyProblemInfo> problems = new ArrayList<>();
+
+    if (accountCache.getIfPresent(extId.accountId()) == null) {
+      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 (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));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsUpdate.java
similarity index 66%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsUpdate.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsUpdate.java
index a596a8e..003928f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsUpdate.java
@@ -12,15 +12,15 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.account;
+package com.google.gerrit.server.account.externalids;
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.account.ExternalId.Key.toAccountExternalIdKeys;
-import static com.google.gerrit.server.account.ExternalId.toAccountExternalIds;
-import static com.google.gerrit.server.account.ExternalIds.MAX_NOTE_SZ;
-import static com.google.gerrit.server.account.ExternalIds.readNoteMap;
-import static com.google.gerrit.server.account.ExternalIds.readRevision;
+import static com.google.gerrit.server.account.externalids.ExternalId.Key.toAccountExternalIdKeys;
+import static com.google.gerrit.server.account.externalids.ExternalId.toAccountExternalIds;
+import static com.google.gerrit.server.account.externalids.ExternalIdReader.MAX_NOTE_SZ;
+import static com.google.gerrit.server.account.externalids.ExternalIdReader.readNoteMap;
+import static com.google.gerrit.server.account.externalids.ExternalIdReader.readRevision;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toSet;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
@@ -37,6 +37,9 @@
 import com.google.common.collect.Iterables;
 import com.google.common.util.concurrent.Runnables;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -53,7 +56,6 @@
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -98,21 +100,31 @@
   public static class Server {
     private final GitRepositoryManager repoManager;
     private final AllUsersName allUsersName;
+    private final MetricMaker metricMaker;
+    private final ExternalIds externalIds;
+    private final ExternalIdCache externalIdCache;
     private final Provider<PersonIdent> serverIdent;
 
     @Inject
     public Server(
         GitRepositoryManager repoManager,
         AllUsersName allUsersName,
+        MetricMaker metricMaker,
+        ExternalIds externalIds,
+        ExternalIdCache externalIdCache,
         @GerritPersonIdent Provider<PersonIdent> serverIdent) {
       this.repoManager = repoManager;
       this.allUsersName = allUsersName;
+      this.metricMaker = metricMaker;
+      this.externalIds = externalIds;
+      this.externalIdCache = externalIdCache;
       this.serverIdent = serverIdent;
     }
 
     public ExternalIdsUpdate create() {
       PersonIdent i = serverIdent.get();
-      return new ExternalIdsUpdate(repoManager, allUsersName, i, i);
+      return new ExternalIdsUpdate(
+          repoManager, allUsersName, metricMaker, externalIds, externalIdCache, i, i);
     }
   }
 
@@ -126,6 +138,9 @@
   public static class User {
     private final GitRepositoryManager repoManager;
     private final AllUsersName allUsersName;
+    private final MetricMaker metricMaker;
+    private final ExternalIds externalIds;
+    private final ExternalIdCache externalIdCache;
     private final Provider<PersonIdent> serverIdent;
     private final Provider<IdentifiedUser> identifiedUser;
 
@@ -133,10 +148,16 @@
     public User(
         GitRepositoryManager repoManager,
         AllUsersName allUsersName,
+        MetricMaker metricMaker,
+        ExternalIds externalIds,
+        ExternalIdCache externalIdCache,
         @GerritPersonIdent Provider<PersonIdent> serverIdent,
         Provider<IdentifiedUser> identifiedUser) {
       this.repoManager = repoManager;
       this.allUsersName = allUsersName;
+      this.metricMaker = metricMaker;
+      this.externalIds = externalIds;
+      this.externalIdCache = externalIdCache;
       this.serverIdent = serverIdent;
       this.identifiedUser = identifiedUser;
     }
@@ -144,7 +165,13 @@
     public ExternalIdsUpdate create() {
       PersonIdent i = serverIdent.get();
       return new ExternalIdsUpdate(
-          repoManager, allUsersName, createPersonIdent(i, identifiedUser.get()), i);
+          repoManager,
+          allUsersName,
+          metricMaker,
+          externalIds,
+          externalIdCache,
+          createPersonIdent(i, identifiedUser.get()),
+          i);
     }
 
     private PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
@@ -153,8 +180,8 @@
   }
 
   @VisibleForTesting
-  public static RetryerBuilder<Void> retryerBuilder() {
-    return RetryerBuilder.<Void>newBuilder()
+  public static RetryerBuilder<RefsMetaExternalIdsUpdate> retryerBuilder() {
+    return RetryerBuilder.<RefsMetaExternalIdsUpdate>newBuilder()
         .retryIfException(e -> e instanceof LockFailureException)
         .withWaitStrategy(
             WaitStrategies.join(
@@ -163,37 +190,61 @@
         .withStopStrategy(StopStrategies.stopAfterDelay(10, TimeUnit.SECONDS));
   }
 
-  private static final Retryer<Void> RETRYER = retryerBuilder().build();
+  private static final Retryer<RefsMetaExternalIdsUpdate> RETRYER = retryerBuilder().build();
 
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsersName;
+  private final ExternalIds externalIds;
+  private final ExternalIdCache externalIdCache;
   private final PersonIdent committerIdent;
   private final PersonIdent authorIdent;
   private final Runnable afterReadRevision;
-  private final Retryer<Void> retryer;
+  private final Retryer<RefsMetaExternalIdsUpdate> retryer;
+  private final Counter0 updateCount;
 
   private ExternalIdsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
+      MetricMaker metricMaker,
+      ExternalIds externalIds,
+      ExternalIdCache externalIdCache,
       PersonIdent committerIdent,
       PersonIdent authorIdent) {
-    this(repoManager, allUsersName, committerIdent, authorIdent, Runnables.doNothing(), RETRYER);
+    this(
+        repoManager,
+        allUsersName,
+        metricMaker,
+        externalIds,
+        externalIdCache,
+        committerIdent,
+        authorIdent,
+        Runnables.doNothing(),
+        RETRYER);
   }
 
   @VisibleForTesting
   public ExternalIdsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
+      MetricMaker metricMaker,
+      ExternalIds externalIds,
+      ExternalIdCache externalIdCache,
       PersonIdent committerIdent,
       PersonIdent authorIdent,
       Runnable afterReadRevision,
-      Retryer<Void> retryer) {
+      Retryer<RefsMetaExternalIdsUpdate> retryer) {
     this.repoManager = checkNotNull(repoManager, "repoManager");
     this.allUsersName = checkNotNull(allUsersName, "allUsersName");
     this.committerIdent = checkNotNull(committerIdent, "committerIdent");
+    this.externalIds = checkNotNull(externalIds, "externalIds");
+    this.externalIdCache = checkNotNull(externalIdCache, "externalIdCache");
     this.authorIdent = checkNotNull(authorIdent, "authorIdent");
     this.afterReadRevision = checkNotNull(afterReadRevision, "afterReadRevision");
     this.retryer = checkNotNull(retryer, "retryer");
+    this.updateCount =
+        metricMaker.newCounter(
+            "notedb/external_id_update_count",
+            new Description("Total number of external ID updates.").setRate().setUnit("updates"));
   }
 
   /**
@@ -216,12 +267,14 @@
       throws IOException, ConfigInvalidException, OrmException {
     db.accountExternalIds().insert(toAccountExternalIds(extIds));
 
-    updateNoteMap(
-        o -> {
-          for (ExternalId extId : extIds) {
-            insert(o.rw(), o.ins(), o.noteMap(), extId);
-          }
-        });
+    RefsMetaExternalIdsUpdate u =
+        updateNoteMap(
+            o -> {
+              for (ExternalId extId : extIds) {
+                insert(o.rw(), o.ins(), o.noteMap(), extId);
+              }
+            });
+    externalIdCache.onCreate(u.oldRev(), u.newRev(), extIds);
   }
 
   /**
@@ -243,19 +296,21 @@
       throws IOException, ConfigInvalidException, OrmException {
     db.accountExternalIds().upsert(toAccountExternalIds(extIds));
 
-    updateNoteMap(
-        o -> {
-          for (ExternalId extId : extIds) {
-            upsert(o.rw(), o.ins(), o.noteMap(), extId);
-          }
-        });
+    RefsMetaExternalIdsUpdate u =
+        updateNoteMap(
+            o -> {
+              for (ExternalId extId : extIds) {
+                upsert(o.rw(), o.ins(), o.noteMap(), extId);
+              }
+            });
+    externalIdCache.onUpdate(u.oldRev(), u.newRev(), extIds);
   }
 
   /**
    * Deletes an external ID.
    *
-   * <p>The deletion fails with {@link IllegalStateException} if there is an existing external ID
-   * that has the same key, but otherwise doesn't match the specified external ID.
+   * @throws IllegalStateException is thrown if there is an existing external ID that has the same
+   *     key, but otherwise doesn't match the specified external ID.
    */
   public void delete(ReviewDb db, ExternalId extId)
       throws IOException, ConfigInvalidException, OrmException {
@@ -265,27 +320,29 @@
   /**
    * Deletes external IDs.
    *
-   * <p>The deletion fails with {@link IllegalStateException} if there is an existing external ID
-   * that has the same key as any of the external IDs that should be deleted, but otherwise doesn't
-   * match the that external ID.
+   * @throws IllegalStateException is thrown if there is an existing external ID that has the same
+   *     key as any of the external IDs that should be deleted, but otherwise doesn't match the that
+   *     external ID.
    */
   public void delete(ReviewDb db, Collection<ExternalId> extIds)
       throws IOException, ConfigInvalidException, OrmException {
     db.accountExternalIds().delete(toAccountExternalIds(extIds));
 
-    updateNoteMap(
-        o -> {
-          for (ExternalId extId : extIds) {
-            remove(o.rw(), o.noteMap(), extId);
-          }
-        });
+    RefsMetaExternalIdsUpdate u =
+        updateNoteMap(
+            o -> {
+              for (ExternalId extId : extIds) {
+                remove(o.rw(), o.noteMap(), extId);
+              }
+            });
+    externalIdCache.onRemove(u.oldRev(), u.newRev(), extIds);
   }
 
   /**
    * Delete an external ID by key.
    *
-   * <p>The external ID is only deleted if it belongs to the specified account. If it belongs to
-   * another account the deletion fails with {@link IllegalStateException}.
+   * @throws IllegalStateException is thrown if the external ID does not belong to the specified
+   *     account.
    */
   public void delete(ReviewDb db, Account.Id accountId, ExternalId.Key extIdKey)
       throws IOException, ConfigInvalidException, OrmException {
@@ -295,25 +352,46 @@
   /**
    * Delete external IDs by external ID key.
    *
-   * <p>The external IDs are only deleted if they belongs to the specified account. If any of the
-   * external IDs belongs to another account the deletion fails with {@link IllegalStateException}.
+   * @throws IllegalStateException is thrown if any of the external IDs does not belong to the
+   *     specified account.
    */
   public void delete(ReviewDb db, Account.Id accountId, Collection<ExternalId.Key> extIdKeys)
       throws IOException, ConfigInvalidException, OrmException {
     db.accountExternalIds().deleteKeys(toAccountExternalIdKeys(extIdKeys));
 
-    updateNoteMap(
-        o -> {
-          for (ExternalId.Key extIdKey : extIdKeys) {
-            remove(o.rw(), o.noteMap(), accountId, extIdKey);
-          }
-        });
+    RefsMetaExternalIdsUpdate u =
+        updateNoteMap(
+            o -> {
+              for (ExternalId.Key extIdKey : extIdKeys) {
+                remove(o.rw(), o.noteMap(), extIdKey, accountId);
+              }
+            });
+    externalIdCache.onRemoveByKeys(u.oldRev(), u.newRev(), accountId, extIdKeys);
+  }
+
+  /**
+   * Delete external IDs by external ID key.
+   *
+   * <p>The external IDs are deleted regardless of which account they belong to.
+   */
+  public void deleteByKeys(ReviewDb db, Collection<ExternalId.Key> extIdKeys)
+      throws IOException, ConfigInvalidException, OrmException {
+    db.accountExternalIds().deleteKeys(toAccountExternalIdKeys(extIdKeys));
+
+    RefsMetaExternalIdsUpdate u =
+        updateNoteMap(
+            o -> {
+              for (ExternalId.Key extIdKey : extIdKeys) {
+                remove(o.rw(), o.noteMap(), extIdKey, null);
+              }
+            });
+    externalIdCache.onRemoveByKeys(u.oldRev(), u.newRev(), extIdKeys);
   }
 
   /** Deletes all external IDs of the specified account. */
   public void deleteAll(ReviewDb db, Account.Id accountId)
       throws IOException, ConfigInvalidException, OrmException {
-    delete(db, ExternalId.from(db.accountExternalIds().byAccount(accountId).toList()));
+    delete(db, externalIds.byAccount(db, accountId));
   }
 
   /**
@@ -324,8 +402,8 @@
    * be added, the old external ID with that key is deleted first and then the new external ID is
    * added (so the external ID for that key is replaced).
    *
-   * <p>If any of the specified external IDs belongs to another account the replacement fails with
-   * {@link IllegalStateException}.
+   * @throws IllegalStateException is thrown if any of the specified external IDs does not belong to
+   *     the specified account.
    */
   public void replace(
       ReviewDb db,
@@ -338,23 +416,55 @@
     db.accountExternalIds().deleteKeys(toAccountExternalIdKeys(toDelete));
     db.accountExternalIds().insert(toAccountExternalIds(toAdd));
 
-    updateNoteMap(
-        o -> {
-          for (ExternalId.Key extIdKey : toDelete) {
-            remove(o.rw(), o.noteMap(), accountId, extIdKey);
-          }
+    RefsMetaExternalIdsUpdate u =
+        updateNoteMap(
+            o -> {
+              for (ExternalId.Key extIdKey : toDelete) {
+                remove(o.rw(), o.noteMap(), extIdKey, accountId);
+              }
 
-          for (ExternalId extId : toAdd) {
-            insert(o.rw(), o.ins(), o.noteMap(), extId);
-          }
-        });
+              for (ExternalId extId : toAdd) {
+                insert(o.rw(), o.ins(), o.noteMap(), extId);
+              }
+            });
+    externalIdCache.onReplaceByKeys(u.oldRev(), u.newRev(), accountId, toDelete, toAdd);
+  }
+
+  /**
+   * Replaces external IDs for an account by external ID keys.
+   *
+   * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
+   * external ID key is specified for deletion and an external ID with the same key is specified to
+   * be added, the old external ID with that key is deleted first and then the new external ID is
+   * added (so the external ID for that key is replaced).
+   *
+   * <p>The external IDs are replaced regardless of which account they belong to.
+   */
+  public void replaceByKeys(
+      ReviewDb db, Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd)
+      throws IOException, ConfigInvalidException, OrmException {
+    db.accountExternalIds().deleteKeys(toAccountExternalIdKeys(toDelete));
+    db.accountExternalIds().insert(toAccountExternalIds(toAdd));
+
+    RefsMetaExternalIdsUpdate u =
+        updateNoteMap(
+            o -> {
+              for (ExternalId.Key extIdKey : toDelete) {
+                remove(o.rw(), o.noteMap(), extIdKey, null);
+              }
+
+              for (ExternalId extId : toAdd) {
+                insert(o.rw(), o.ins(), o.noteMap(), extId);
+              }
+            });
+    externalIdCache.onReplaceByKeys(u.oldRev(), u.newRev(), toDelete, toAdd);
   }
 
   /**
    * Replaces an external ID.
    *
-   * <p>If the specified external IDs belongs to different accounts the replacement fails with
-   * {@link IllegalStateException}.
+   * @throws IllegalStateException is thrown if the specified external IDs belong to different
+   *     accounts.
    */
   public void replace(ReviewDb db, ExternalId toDelete, ExternalId toAdd)
       throws IOException, ConfigInvalidException, OrmException {
@@ -369,8 +479,8 @@
    * added, the old external ID with that key is deleted first and then the new external ID is added
    * (so the external ID for that key is replaced).
    *
-   * <p>If the specified external IDs belong to different accounts the replacement fails with {@link
-   * IllegalStateException}.
+   * @throws IllegalStateException is thrown if the specified external IDs belong to different
+   *     accounts.
    */
   public void replace(ReviewDb db, Collection<ExternalId> toDelete, Collection<ExternalId> toAdd)
       throws IOException, ConfigInvalidException, OrmException {
@@ -457,8 +567,8 @@
   /**
    * Removes an external ID from the note map.
    *
-   * <p>The removal fails with {@link IllegalStateException} if there is an existing external ID
-   * that has the same key, but otherwise doesn't match the specified external ID.
+   * @throws IllegalStateException is thrown if there is an existing external ID that has the same
+   *     key, but otherwise doesn't match the specified external ID.
    */
   public static void remove(RevWalk rw, NoteMap noteMap, ExternalId extId)
       throws IOException, ConfigInvalidException {
@@ -481,11 +591,11 @@
   /**
    * Removes an external ID from the note map by external ID key.
    *
-   * <p>The external ID is only deleted if it belongs to the specified account. If the external IDs
-   * belongs to another account the deletion fails with {@link IllegalStateException}.
+   * @throws IllegalStateException is thrown if an expected account ID is provided and an external
+   *     ID with the specified key exists, but belongs to another account.
    */
   private static void remove(
-      RevWalk rw, NoteMap noteMap, Account.Id accountId, ExternalId.Key extIdKey)
+      RevWalk rw, NoteMap noteMap, ExternalId.Key extIdKey, Account.Id expectedAccountId)
       throws IOException, ConfigInvalidException {
     ObjectId noteId = extIdKey.sha1();
     if (!noteMap.contains(noteId)) {
@@ -495,22 +605,34 @@
     byte[] raw =
         rw.getObjectReader().open(noteMap.get(noteId), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
     ExternalId extId = ExternalId.parse(noteId.name(), raw);
-    checkState(
-        accountId.equals(extId.accountId()),
-        "external id %s should be removed for account %s,"
-            + " but external id belongs to account %s",
-        extIdKey.get(),
-        accountId.get(),
-        extId.accountId().get());
+    if (expectedAccountId != null) {
+      checkState(
+          expectedAccountId.equals(extId.accountId()),
+          "external id %s should be removed for account %s,"
+              + " but external id belongs to account %s",
+          extIdKey.get(),
+          expectedAccountId.get(),
+          extId.accountId().get());
+    }
     noteMap.remove(noteId);
   }
 
-  private void updateNoteMap(MyConsumer<OpenRepo> update)
+  private RefsMetaExternalIdsUpdate updateNoteMap(MyConsumer<OpenRepo> update)
       throws IOException, ConfigInvalidException, OrmException {
     try (Repository repo = repoManager.openRepository(allUsersName);
         RevWalk rw = new RevWalk(repo);
         ObjectInserter ins = repo.newObjectInserter()) {
-      retryer.call(new TryNoteMapUpdate(repo, rw, ins, update));
+      return retryer.call(
+          () -> {
+            ObjectId rev = readRevision(repo);
+
+            afterReadRevision.run();
+
+            NoteMap noteMap = readNoteMap(rw, rev);
+            update.accept(OpenRepo.create(repo, rw, ins, noteMap));
+
+            return commit(repo, rw, ins, rev, noteMap);
+          });
     } catch (ExecutionException | RetryException e) {
       if (e.getCause() != null) {
         Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
@@ -521,14 +643,16 @@
     }
   }
 
-  private void commit(
+  private RefsMetaExternalIdsUpdate commit(
       Repository repo, RevWalk rw, ObjectInserter ins, ObjectId rev, NoteMap noteMap)
       throws IOException {
-    commit(repo, rw, ins, rev, noteMap, COMMIT_MSG, committerIdent, authorIdent);
+    ObjectId newRev = commit(repo, rw, ins, rev, noteMap, COMMIT_MSG, committerIdent, authorIdent);
+    updateCount.increment();
+    return RefsMetaExternalIdsUpdate.create(rev, newRev);
   }
 
   /** Commits updates to the external IDs. */
-  public static void commit(
+  public static ObjectId commit(
       Repository repo,
       RevWalk rw,
       ObjectInserter ins,
@@ -581,12 +705,14 @@
       default:
         throw new IOException("Updating external IDs failed with " + res);
     }
+    return rw.parseCommit(commitId);
   }
 
   private static ObjectId emptyTree(ObjectInserter ins) throws IOException {
     return ins.insert(OBJ_TREE, new byte[] {});
   }
 
+  @FunctionalInterface
   private static interface MyConsumer<T> {
     void accept(T t) throws IOException, ConfigInvalidException, OrmException;
   }
@@ -606,31 +732,15 @@
     abstract NoteMap noteMap();
   }
 
-  private class TryNoteMapUpdate implements Callable<Void> {
-    private final Repository repo;
-    private final RevWalk rw;
-    private final ObjectInserter ins;
-    private final MyConsumer<OpenRepo> update;
-
-    private TryNoteMapUpdate(
-        Repository repo, RevWalk rw, ObjectInserter ins, MyConsumer<OpenRepo> update) {
-      this.repo = repo;
-      this.rw = rw;
-      this.ins = ins;
-      this.update = update;
+  @VisibleForTesting
+  @AutoValue
+  public abstract static class RefsMetaExternalIdsUpdate {
+    static RefsMetaExternalIdsUpdate create(ObjectId oldRev, ObjectId newRev) {
+      return new AutoValue_ExternalIdsUpdate_RefsMetaExternalIdsUpdate(oldRev, newRev);
     }
 
-    @Override
-    public Void call() throws Exception {
-      ObjectId rev = readRevision(repo);
+    abstract ObjectId oldRev();
 
-      afterReadRevision.run();
-
-      NoteMap noteMap = readNoteMap(rw, rev);
-      update.accept(OpenRepo.create(repo, rw, ins, noteMap));
-
-      commit(repo, rw, ins, rev, noteMap);
-      return null;
-    }
+    abstract ObjectId newRev();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 430b6b7..6604496 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -71,6 +71,7 @@
 import com.google.gerrit.server.account.Stars;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ChangesCollection;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -234,14 +235,18 @@
 
   @Override
   public GeneralPreferencesInfo getPreferences() throws RestApiException {
-    return getPreferences.apply(account);
+    try {
+      return getPreferences.apply(account);
+    } catch (PermissionBackendException e) {
+      throw new RestApiException("Cannot get preferences", e);
+    }
   }
 
   @Override
   public GeneralPreferencesInfo setPreferences(GeneralPreferencesInfo in) throws RestApiException {
     try {
       return setPreferences.apply(account, in);
-    } catch (IOException | ConfigInvalidException e) {
+    } catch (IOException | ConfigInvalidException | PermissionBackendException e) {
       throw new RestApiException("Cannot set preferences", e);
     }
   }
@@ -250,7 +255,7 @@
   public DiffPreferencesInfo getDiffPreferences() throws RestApiException {
     try {
       return getDiffPreferences.apply(account);
-    } catch (IOException | ConfigInvalidException e) {
+    } catch (IOException | ConfigInvalidException | PermissionBackendException e) {
       throw new RestApiException("Cannot query diff preferences", e);
     }
   }
@@ -259,7 +264,7 @@
   public DiffPreferencesInfo setDiffPreferences(DiffPreferencesInfo in) throws RestApiException {
     try {
       return setDiffPreferences.apply(account, in);
-    } catch (IOException | ConfigInvalidException e) {
+    } catch (IOException | ConfigInvalidException | PermissionBackendException e) {
       throw new RestApiException("Cannot set diff preferences", e);
     }
   }
@@ -268,7 +273,7 @@
   public EditPreferencesInfo getEditPreferences() throws RestApiException {
     try {
       return getEditPreferences.apply(account);
-    } catch (IOException | ConfigInvalidException e) {
+    } catch (IOException | ConfigInvalidException | PermissionBackendException e) {
       throw new RestApiException("Cannot query edit preferences", e);
     }
   }
@@ -277,7 +282,7 @@
   public EditPreferencesInfo setEditPreferences(EditPreferencesInfo in) throws RestApiException {
     try {
       return setEditPreferences.apply(account, in);
-    } catch (IOException | ConfigInvalidException e) {
+    } catch (IOException | ConfigInvalidException | PermissionBackendException e) {
       throw new RestApiException("Cannot set edit preferences", e);
     }
   }
@@ -286,7 +291,7 @@
   public List<ProjectWatchInfo> getWatchedProjects() throws RestApiException {
     try {
       return getWatchedProjects.apply(account);
-    } catch (OrmException | IOException | ConfigInvalidException e) {
+    } catch (OrmException | IOException | ConfigInvalidException | PermissionBackendException e) {
       throw new RestApiException("Cannot get watched projects", e);
     }
   }
@@ -296,7 +301,7 @@
       throws RestApiException {
     try {
       return postWatchedProjects.apply(account, in);
-    } catch (OrmException | IOException | ConfigInvalidException e) {
+    } catch (OrmException | IOException | ConfigInvalidException | PermissionBackendException e) {
       throw new RestApiException("Cannot update watched projects", e);
     }
   }
@@ -305,7 +310,7 @@
   public void deleteWatchedProjects(List<ProjectWatchInfo> in) throws RestApiException {
     try {
       deleteWatchedProjects.apply(account, in);
-    } catch (OrmException | IOException | ConfigInvalidException e) {
+    } catch (OrmException | IOException | ConfigInvalidException | PermissionBackendException e) {
       throw new RestApiException("Cannot delete watched projects", e);
     }
   }
@@ -372,7 +377,11 @@
     AccountResource.Email rsrc = new AccountResource.Email(account.getUser(), input.email);
     try {
       createEmailFactory.create(input.email).apply(rsrc, input);
-    } catch (EmailException | OrmException | IOException | ConfigInvalidException e) {
+    } catch (EmailException
+        | OrmException
+        | IOException
+        | ConfigInvalidException
+        | PermissionBackendException e) {
       throw new RestApiException("Cannot add email", e);
     }
   }
@@ -382,7 +391,7 @@
     AccountResource.Email rsrc = new AccountResource.Email(account.getUser(), email);
     try {
       deleteEmail.apply(rsrc, null);
-    } catch (OrmException | IOException | ConfigInvalidException e) {
+    } catch (OrmException | IOException | ConfigInvalidException | PermissionBackendException e) {
       throw new RestApiException("Cannot delete email", e);
     }
   }
@@ -392,7 +401,7 @@
     PutStatus.Input in = new PutStatus.Input(status);
     try {
       putStatus.apply(account, in);
-    } catch (OrmException | IOException e) {
+    } catch (OrmException | IOException | PermissionBackendException e) {
       throw new RestApiException("Cannot set status", e);
     }
   }
@@ -401,7 +410,7 @@
   public List<SshKeyInfo> listSshKeys() throws RestApiException {
     try {
       return getSshKeys.apply(account);
-    } catch (OrmException | IOException | ConfigInvalidException e) {
+    } catch (OrmException | IOException | ConfigInvalidException | PermissionBackendException e) {
       throw new RestApiException("Cannot list SSH keys", e);
     }
   }
@@ -412,7 +421,7 @@
     in.raw = RawInputUtil.create(key);
     try {
       return addSshKey.apply(account, in).value();
-    } catch (OrmException | IOException | ConfigInvalidException e) {
+    } catch (OrmException | IOException | ConfigInvalidException | PermissionBackendException e) {
       throw new RestApiException("Cannot add SSH key", e);
     }
   }
@@ -423,7 +432,7 @@
       AccountResource.SshKey sshKeyRes =
           sshKeys.parse(account, IdString.fromDecoded(Integer.toString(seq)));
       deleteSshKey.apply(sshKeyRes, null);
-    } catch (OrmException | IOException | ConfigInvalidException e) {
+    } catch (OrmException | IOException | ConfigInvalidException | PermissionBackendException e) {
       throw new RestApiException("Cannot delete SSH key", e);
     }
   }
@@ -476,7 +485,7 @@
   public void index() throws RestApiException {
     try {
       index.apply(account, new Index.Input());
-    } catch (IOException e) {
+    } catch (IOException | PermissionBackendException e) {
       throw new RestApiException("Cannot index account", e);
     }
   }
@@ -485,7 +494,7 @@
   public List<AccountExternalIdInfo> getExternalIds() throws RestApiException {
     try {
       return getExternalIds.apply(account);
-    } catch (OrmException e) {
+    } catch (IOException | OrmException e) {
       throw new RestApiException("Cannot get external IDs", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java
index 2d90853..2f8dee6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.api.accounts;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import java.util.List;
 
 public interface AccountExternalIdCreator {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
index 498b720..bade8ce 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.api.accounts;
 
 import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.server.account.CapabilityUtils.checkRequiresCapability;
 
 import com.google.gerrit.extensions.api.accounts.AccountApi;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
@@ -32,6 +31,9 @@
 import com.google.gerrit.server.account.AccountsCollection;
 import com.google.gerrit.server.account.CreateAccount;
 import com.google.gerrit.server.account.QueryAccounts;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -44,6 +46,7 @@
 public class AccountsImpl implements Accounts {
   private final AccountsCollection accounts;
   private final AccountApiImpl.Factory api;
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> self;
   private final CreateAccount.Factory createAccount;
   private final Provider<QueryAccounts> queryAccountsProvider;
@@ -52,11 +55,13 @@
   AccountsImpl(
       AccountsCollection accounts,
       AccountApiImpl.Factory api,
+      PermissionBackend permissionBackend,
       Provider<CurrentUser> self,
       CreateAccount.Factory createAccount,
       Provider<QueryAccounts> queryAccountsProvider) {
     this.accounts = accounts;
     this.api = api;
+    this.permissionBackend = permissionBackend;
     this.self = self;
     this.createAccount = createAccount;
     this.queryAccountsProvider = queryAccountsProvider;
@@ -96,12 +101,12 @@
     if (checkNotNull(in, "AccountInput").username == null) {
       throw new BadRequestException("AccountInput must specify username");
     }
-    checkRequiresCapability(self, null, CreateAccount.class);
     try {
-      AccountInfo info =
-          createAccount.create(in.username).apply(TopLevelResource.INSTANCE, in).value();
+      CreateAccount impl = createAccount.create(in.username);
+      permissionBackend.user(self).checkAny(GlobalPermission.fromAnnotation(impl.getClass()));
+      AccountInfo info = impl.apply(TopLevelResource.INSTANCE, in).value();
       return id(info._accountId);
-    } catch (OrmException | IOException | ConfigInvalidException e) {
+    } catch (OrmException | IOException | ConfigInvalidException | PermissionBackendException e) {
       throw new RestApiException("Cannot create account " + in.username, e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index a0babe1..f75adbc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.ChangeEditApi;
@@ -51,27 +52,37 @@
 import com.google.gerrit.server.change.CreateMergePatchSet;
 import com.google.gerrit.server.change.DeleteAssignee;
 import com.google.gerrit.server.change.DeleteChange;
+import com.google.gerrit.server.change.DeletePrivate;
 import com.google.gerrit.server.change.GetAssignee;
 import com.google.gerrit.server.change.GetHashtags;
 import com.google.gerrit.server.change.GetPastAssignees;
 import com.google.gerrit.server.change.GetTopic;
+import com.google.gerrit.server.change.Ignore;
 import com.google.gerrit.server.change.Index;
 import com.google.gerrit.server.change.ListChangeComments;
 import com.google.gerrit.server.change.ListChangeDrafts;
 import com.google.gerrit.server.change.ListChangeRobotComments;
 import com.google.gerrit.server.change.Move;
+import com.google.gerrit.server.change.Mute;
 import com.google.gerrit.server.change.PostHashtags;
 import com.google.gerrit.server.change.PostReviewers;
 import com.google.gerrit.server.change.PublishDraftPatchSet;
 import com.google.gerrit.server.change.PutAssignee;
+import com.google.gerrit.server.change.PutPrivate;
 import com.google.gerrit.server.change.PutTopic;
 import com.google.gerrit.server.change.Rebase;
 import com.google.gerrit.server.change.Restore;
 import com.google.gerrit.server.change.Revert;
 import com.google.gerrit.server.change.Reviewers;
 import com.google.gerrit.server.change.Revisions;
+import com.google.gerrit.server.change.SetReadyForReview;
+import com.google.gerrit.server.change.SetWorkInProgress;
 import com.google.gerrit.server.change.SubmittedTogether;
 import com.google.gerrit.server.change.SuggestChangeReviewers;
+import com.google.gerrit.server.change.Unignore;
+import com.google.gerrit.server.change.Unmute;
+import com.google.gerrit.server.change.WorkInProgressOp;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
@@ -122,6 +133,14 @@
   private final Check check;
   private final Index index;
   private final Move move;
+  private final PutPrivate putPrivate;
+  private final DeletePrivate deletePrivate;
+  private final Ignore ignore;
+  private final Unignore unignore;
+  private final Mute mute;
+  private final Unmute unmute;
+  private final SetWorkInProgress setWip;
+  private final SetReadyForReview setReady;
 
   @Inject
   ChangeApiImpl(
@@ -157,6 +176,14 @@
       Check check,
       Index index,
       Move move,
+      PutPrivate putPrivate,
+      DeletePrivate deletePrivate,
+      Ignore ignore,
+      Unignore unignore,
+      Mute mute,
+      Unmute unmute,
+      SetWorkInProgress setWip,
+      SetReadyForReview setReady,
       @Assisted ChangeResource change) {
     this.changeApi = changeApi;
     this.revert = revert;
@@ -190,6 +217,14 @@
     this.check = check;
     this.index = index;
     this.move = move;
+    this.putPrivate = putPrivate;
+    this.deletePrivate = deletePrivate;
+    this.ignore = ignore;
+    this.unignore = unignore;
+    this.mute = mute;
+    this.unmute = unmute;
+    this.setWip = setWip;
+    this.setReady = setReady;
     this.change = change;
   }
 
@@ -235,7 +270,7 @@
   public void abandon(AbandonInput in) throws RestApiException {
     try {
       abandon.apply(change, in);
-    } catch (OrmException | UpdateException e) {
+    } catch (OrmException | UpdateException | PermissionBackendException e) {
       throw new RestApiException("Cannot abandon change", e);
     }
   }
@@ -249,7 +284,7 @@
   public void restore(RestoreInput in) throws RestApiException {
     try {
       restore.apply(change, in);
-    } catch (OrmException | UpdateException e) {
+    } catch (OrmException | UpdateException | PermissionBackendException e) {
       throw new RestApiException("Cannot restore change", e);
     }
   }
@@ -271,6 +306,37 @@
   }
 
   @Override
+  public void setPrivate(boolean value) throws RestApiException {
+    try {
+      if (value) {
+        putPrivate.apply(change, null);
+      } else {
+        deletePrivate.apply(change, null);
+      }
+    } catch (UpdateException e) {
+      throw new RestApiException("Cannot change private status", e);
+    }
+  }
+
+  @Override
+  public void setWorkInProgress(String message) throws RestApiException {
+    try {
+      setWip.apply(change, new WorkInProgressOp.Input(message));
+    } catch (UpdateException e) {
+      throw new RestApiException("Cannot set work in progress state", e);
+    }
+  }
+
+  @Override
+  public void setReadyForReview(String message) throws RestApiException {
+    try {
+      setReady.apply(change, new WorkInProgressOp.Input(message));
+    } catch (UpdateException e) {
+      throw new RestApiException("Cannot set ready for review state", e);
+    }
+  }
+
+  @Override
   public ChangeApi revert() throws RestApiException {
     return revert(new RevertInput());
   }
@@ -340,7 +406,11 @@
   public void rebase(RebaseInput in) throws RestApiException {
     try {
       rebase.apply(change, in);
-    } catch (EmailException | OrmException | UpdateException | IOException e) {
+    } catch (EmailException
+        | OrmException
+        | UpdateException
+        | IOException
+        | PermissionBackendException e) {
       throw new RestApiException("Cannot rebase change", e);
     }
   }
@@ -349,7 +419,7 @@
   public void delete() throws RestApiException {
     try {
       deleteChange.apply(change, null);
-    } catch (UpdateException e) {
+    } catch (UpdateException | PermissionBackendException e) {
       throw new RestApiException("Cannot delete change", e);
     }
   }
@@ -365,7 +435,7 @@
     in.topic = topic;
     try {
       putTopic.apply(change, in);
-    } catch (UpdateException e) {
+    } catch (UpdateException | PermissionBackendException e) {
       throw new RestApiException("Cannot set topic", e);
     }
   }
@@ -380,17 +450,17 @@
   }
 
   @Override
-  public void addReviewer(String reviewer) throws RestApiException {
+  public AddReviewerResult addReviewer(String reviewer) throws RestApiException {
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = reviewer;
-    addReviewer(in);
+    return addReviewer(in);
   }
 
   @Override
-  public void addReviewer(AddReviewerInput in) throws RestApiException {
+  public AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException {
     try {
-      postReviewers.apply(change, in);
-    } catch (OrmException | IOException | UpdateException e) {
+      return postReviewers.apply(change, in);
+    } catch (OrmException | IOException | UpdateException | PermissionBackendException e) {
       throw new RestApiException("Cannot add change reviewer", e);
     }
   }
@@ -454,7 +524,7 @@
   public void setHashtags(HashtagsInput input) throws RestApiException {
     try {
       postHashtags.apply(change, input);
-    } catch (UpdateException e) {
+    } catch (UpdateException | PermissionBackendException e) {
       throw new RestApiException("Cannot post hashtags", e);
     }
   }
@@ -471,8 +541,8 @@
   @Override
   public AccountInfo setAssignee(AssigneeInput input) throws RestApiException {
     try {
-      return putAssignee.apply(change, input).value();
-    } catch (UpdateException | IOException | OrmException e) {
+      return putAssignee.apply(change, input);
+    } catch (UpdateException | IOException | OrmException | PermissionBackendException e) {
       throw new RestApiException("Cannot set assignee", e);
     }
   }
@@ -501,7 +571,7 @@
     try {
       Response<AccountInfo> r = deleteAssignee.apply(change, null);
       return r.isNone() ? null : r.value();
-    } catch (UpdateException | OrmException e) {
+    } catch (UpdateException | OrmException | PermissionBackendException e) {
       throw new RestApiException("Cannot delete assignee", e);
     }
   }
@@ -546,7 +616,7 @@
   public ChangeInfo check(FixInput fix) throws RestApiException {
     try {
       return check.apply(change, fix).value();
-    } catch (OrmException e) {
+    } catch (OrmException | PermissionBackendException e) {
       throw new RestApiException("Cannot check change", e);
     }
   }
@@ -555,8 +625,26 @@
   public void index() throws RestApiException {
     try {
       index.apply(change, new Index.Input());
-    } catch (IOException | OrmException e) {
+    } catch (IOException | OrmException | PermissionBackendException e) {
       throw new RestApiException("Cannot index change", e);
     }
   }
+
+  @Override
+  public void ignore(boolean ignore) throws RestApiException {
+    if (ignore) {
+      this.ignore.apply(change, new Ignore.Input());
+    } else {
+      unignore.apply(change, new Unignore.Input());
+    }
+  }
+
+  @Override
+  public void mute(boolean mute) throws RestApiException {
+    if (mute) {
+      this.mute.apply(change, new Mute.Input());
+    } else {
+      unmute.apply(change, new Unmute.Input());
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java
index c77f86f..9a89d48 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.ChangesCollection;
 import com.google.gerrit.server.change.CreateChange;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.query.change.QueryChanges;
 import com.google.gerrit.server.update.UpdateException;
@@ -87,7 +88,11 @@
     try {
       ChangeInfo out = createChange.apply(TopLevelResource.INSTANCE, in).value();
       return api.create(changes.parse(new Change.Id(out._number)));
-    } catch (OrmException | IOException | InvalidChangeOperationException | UpdateException e) {
+    } catch (OrmException
+        | IOException
+        | InvalidChangeOperationException
+        | UpdateException
+        | PermissionBackendException e) {
       throw new RestApiException("Cannot create change", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 43be8df..6440509 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.common.MergeableInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
@@ -41,6 +42,7 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.change.ApplyFix;
 import com.google.gerrit.server.change.CherryPick;
 import com.google.gerrit.server.change.Comments;
 import com.google.gerrit.server.change.CreateDraftComment;
@@ -48,6 +50,7 @@
 import com.google.gerrit.server.change.DraftComments;
 import com.google.gerrit.server.change.FileResource;
 import com.google.gerrit.server.change.Files;
+import com.google.gerrit.server.change.Fixes;
 import com.google.gerrit.server.change.GetDescription;
 import com.google.gerrit.server.change.GetMergeList;
 import com.google.gerrit.server.change.GetPatch;
@@ -70,6 +73,7 @@
 import com.google.gerrit.server.change.TestSubmitType;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -109,6 +113,8 @@
   private final FileApiImpl.Factory fileApi;
   private final ListRevisionComments listComments;
   private final ListRobotComments listRobotComments;
+  private final ApplyFix applyFix;
+  private final Fixes fixes;
   private final ListRevisionDrafts listDrafts;
   private final CreateDraftComment createDraft;
   private final DraftComments drafts;
@@ -147,6 +153,8 @@
       FileApiImpl.Factory fileApi,
       ListRevisionComments listComments,
       ListRobotComments listRobotComments,
+      ApplyFix applyFix,
+      Fixes fixes,
       ListRevisionDrafts listDrafts,
       CreateDraftComment createDraft,
       DraftComments drafts,
@@ -184,6 +192,8 @@
     this.listComments = listComments;
     this.robotComments = robotComments;
     this.listRobotComments = listRobotComments;
+    this.applyFix = applyFix;
+    this.fixes = fixes;
     this.listDrafts = listDrafts;
     this.createDraft = createDraft;
     this.drafts = drafts;
@@ -204,7 +214,7 @@
   public void review(ReviewInput in) throws RestApiException {
     try {
       review.apply(revision, in);
-    } catch (OrmException | UpdateException | IOException e) {
+    } catch (OrmException | UpdateException | IOException | PermissionBackendException e) {
       throw new RestApiException("Cannot post review", e);
     }
   }
@@ -219,7 +229,7 @@
   public void submit(SubmitInput in) throws RestApiException {
     try {
       submit.apply(revision, in);
-    } catch (OrmException | IOException e) {
+    } catch (OrmException | IOException | PermissionBackendException e) {
       throw new RestApiException("Cannot submit change", e);
     }
   }
@@ -252,7 +262,7 @@
   public void delete() throws RestApiException {
     try {
       deleteDraft.apply(revision, null);
-    } catch (UpdateException e) {
+    } catch (UpdateException | OrmException | PermissionBackendException e) {
       throw new RestApiException("Cannot delete draft ps", e);
     }
   }
@@ -267,7 +277,11 @@
   public ChangeApi rebase(RebaseInput in) throws RestApiException {
     try {
       return changes.id(rebase.apply(revision, in)._number);
-    } catch (OrmException | EmailException | UpdateException | IOException e) {
+    } catch (OrmException
+        | EmailException
+        | UpdateException
+        | IOException
+        | PermissionBackendException e) {
       throw new RestApiException("Cannot rebase ps", e);
     }
   }
@@ -427,6 +441,15 @@
   }
 
   @Override
+  public EditInfo applyFix(String fixId) throws RestApiException {
+    try {
+      return applyFix.apply(fixes.parse(revision, IdString.fromDecoded(fixId)), null).value();
+    } catch (OrmException | IOException e) {
+      throw new RestApiException("Cannot apply fix", e);
+    }
+  }
+
+  @Override
   public List<CommentInfo> draftsAsList() throws RestApiException {
     try {
       return listDrafts.getComments(revision);
@@ -544,7 +567,7 @@
     in.description = description;
     try {
       putDescription.apply(revision, in);
-    } catch (UpdateException e) {
+    } catch (UpdateException | PermissionBackendException e) {
       throw new RestApiException("Cannot set description", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java
index 9b6ead0..21b42dd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java
@@ -15,18 +15,27 @@
 package com.google.gerrit.server.api.config;
 
 import com.google.gerrit.common.Version;
+import com.google.gerrit.extensions.api.config.AccessCheckInfo;
+import com.google.gerrit.extensions.api.config.AccessCheckInput;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
 import com.google.gerrit.extensions.api.config.Server;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.config.CheckAccess;
+import com.google.gerrit.server.config.CheckConsistency;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.config.GetDiffPreferences;
 import com.google.gerrit.server.config.GetPreferences;
 import com.google.gerrit.server.config.GetServerInfo;
 import com.google.gerrit.server.config.SetDiffPreferences;
 import com.google.gerrit.server.config.SetPreferences;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -38,6 +47,8 @@
   private final GetDiffPreferences getDiffPreferences;
   private final SetDiffPreferences setDiffPreferences;
   private final GetServerInfo getServerInfo;
+  private final Provider<CheckConsistency> checkConsistency;
+  private final Provider<CheckAccess> checkAccess;
 
   @Inject
   ServerImpl(
@@ -45,12 +56,16 @@
       SetPreferences setPreferences,
       GetDiffPreferences getDiffPreferences,
       SetDiffPreferences setDiffPreferences,
-      GetServerInfo getServerInfo) {
+      GetServerInfo getServerInfo,
+      Provider<CheckConsistency> checkConsistency,
+      Provider<CheckAccess> checkAccess) {
     this.getPreferences = getPreferences;
     this.setPreferences = setPreferences;
     this.getDiffPreferences = getDiffPreferences;
     this.setDiffPreferences = setDiffPreferences;
     this.getServerInfo = getServerInfo;
+    this.checkConsistency = checkConsistency;
+    this.checkAccess = checkAccess;
   }
 
   @Override
@@ -104,4 +119,22 @@
       throw new RestApiException("Cannot set default diff preferences", e);
     }
   }
+
+  @Override
+  public ConsistencyCheckInfo checkConsistency(ConsistencyCheckInput in) throws RestApiException {
+    try {
+      return checkConsistency.get().apply(new ConfigResource(), in);
+    } catch (IOException e) {
+      throw new RestApiException("Cannot check consistency", e);
+    }
+  }
+
+  @Override
+  public AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException {
+    try {
+      return checkAccess.get().apply(new ConfigResource(), in);
+    } catch (OrmException | IOException | PermissionBackendException e) {
+      throw new RestApiException("Cannot check access", e);
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
index 15120d2..2c1ee3e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
@@ -42,8 +42,8 @@
 import com.google.gerrit.server.group.PutOptions;
 import com.google.gerrit.server.group.PutOwner;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.List;
@@ -73,7 +73,7 @@
   private final GroupResource rsrc;
   private final Index index;
 
-  @AssistedInject
+  @Inject
   GroupApiImpl(
       GetGroup getGroup,
       GetDetail getDetail,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java
index 1d725a8..ecbde59 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.api.groups;
 
 import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.server.account.CapabilityUtils.checkRequiresCapability;
 
 import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.api.groups.GroupInput;
@@ -32,6 +31,9 @@
 import com.google.gerrit.server.group.GroupsCollection;
 import com.google.gerrit.server.group.ListGroups;
 import com.google.gerrit.server.group.QueryGroups;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectsCollection;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -49,6 +51,7 @@
   private final Provider<ListGroups> listGroups;
   private final Provider<QueryGroups> queryGroups;
   private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
   private final CreateGroup.Factory createGroup;
   private final GroupApiImpl.Factory api;
 
@@ -60,6 +63,7 @@
       Provider<ListGroups> listGroups,
       Provider<QueryGroups> queryGroups,
       Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
       CreateGroup.Factory createGroup,
       GroupApiImpl.Factory api) {
     this.accounts = accounts;
@@ -68,6 +72,7 @@
     this.listGroups = listGroups;
     this.queryGroups = queryGroups;
     this.user = user;
+    this.permissionBackend = permissionBackend;
     this.createGroup = createGroup;
     this.api = api;
   }
@@ -89,11 +94,12 @@
     if (checkNotNull(in, "GroupInput").name == null) {
       throw new BadRequestException("GroupInput must specify name");
     }
-    checkRequiresCapability(user, null, CreateGroup.class);
     try {
-      GroupInfo info = createGroup.create(in.name).apply(TopLevelResource.INSTANCE, in);
+      CreateGroup impl = createGroup.create(in.name);
+      permissionBackend.user(user).checkAny(GlobalPermission.fromAnnotation(impl.getClass()));
+      GroupInfo info = impl.apply(TopLevelResource.INSTANCE, in);
       return id(info.id);
-    } catch (OrmException | IOException e) {
+    } catch (OrmException | IOException | PermissionBackendException e) {
       throw new RestApiException("Cannot create group " + in.name, e);
     }
   }
@@ -116,7 +122,7 @@
     for (String project : req.getProjects()) {
       try {
         list.addProject(projects.parse(tlr, IdString.fromDecoded(project)).getControl());
-      } catch (IOException e) {
+      } catch (IOException | PermissionBackendException e) {
         throw new RestApiException("Error looking up project " + project, e);
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
index 925b647..1595682 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
@@ -19,8 +19,8 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.project.ChildProjectResource;
 import com.google.gerrit.server.project.GetChildProject;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 
 public class ChildProjectApiImpl implements ChildProjectApi {
   interface Factory {
@@ -30,7 +30,7 @@
   private final GetChildProject getChildProject;
   private final ChildProjectResource rsrc;
 
-  @AssistedInject
+  @Inject
   ChildProjectApiImpl(GetChildProject getChildProject, @Assisted ChildProjectResource rsrc) {
     this.getChildProject = getChildProject;
     this.rsrc = rsrc;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/CommitApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
new file mode 100644
index 0000000..9e17498
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2017 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.api.projects;
+
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.Changes;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.projects.CommitApi;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.change.CherryPickCommit;
+import com.google.gerrit.server.project.CommitResource;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+
+public class CommitApiImpl implements CommitApi {
+  public interface Factory {
+    CommitApiImpl create(CommitResource r);
+  }
+
+  private final Changes changes;
+  private final CherryPickCommit cherryPickCommit;
+  private final CommitResource commitResource;
+
+  @Inject
+  CommitApiImpl(
+      Changes changes, CherryPickCommit cherryPickCommit, @Assisted CommitResource commitResource) {
+    this.changes = changes;
+    this.cherryPickCommit = cherryPickCommit;
+    this.commitResource = commitResource;
+  }
+
+  @Override
+  public ChangeApi cherryPick(CherryPickInput input) throws RestApiException {
+    try {
+      return changes.id(cherryPickCommit.apply(commitResource, input)._number);
+    } catch (OrmException | IOException | UpdateException e) {
+      throw new RestApiException("Cannot cherry pick", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/Module.java
index 975e6c1..a4fe39b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/Module.java
@@ -26,5 +26,6 @@
     factory(TagApiImpl.Factory.class);
     factory(ProjectApiImpl.Factory.class);
     factory(ChildProjectApiImpl.Factory.class);
+    factory(CommitApiImpl.Factory.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index e29d633..1aa203c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -14,13 +14,12 @@
 
 package com.google.gerrit.server.api.projects;
 
-import static com.google.gerrit.server.account.CapabilityUtils.checkRequiresCapability;
-
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ChildProjectApi;
+import com.google.gerrit.extensions.api.projects.CommitApi;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
@@ -38,7 +37,11 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChildProjectsCollection;
+import com.google.gerrit.server.project.CommitsCollection;
 import com.google.gerrit.server.project.CreateProject;
 import com.google.gerrit.server.project.DeleteBranches;
 import com.google.gerrit.server.project.DeleteTags;
@@ -69,6 +72,7 @@
   }
 
   private final CurrentUser user;
+  private final PermissionBackend permissionBackend;
   private final CreateProject.Factory createProjectFactory;
   private final ProjectApiImpl.Factory projectApi;
   private final ProjectsCollection projects;
@@ -89,10 +93,13 @@
   private final ListTags listTags;
   private final DeleteBranches deleteBranches;
   private final DeleteTags deleteTags;
+  private final CommitsCollection commitsCollection;
+  private final CommitApiImpl.Factory commitApi;
 
   @AssistedInject
   ProjectApiImpl(
       CurrentUser user,
+      PermissionBackend permissionBackend,
       CreateProject.Factory createProjectFactory,
       ProjectApiImpl.Factory projectApi,
       ProjectsCollection projects,
@@ -111,9 +118,12 @@
       ListTags listTags,
       DeleteBranches deleteBranches,
       DeleteTags deleteTags,
+      CommitsCollection commitsCollection,
+      CommitApiImpl.Factory commitApi,
       @Assisted ProjectResource project) {
     this(
         user,
+        permissionBackend,
         createProjectFactory,
         projectApi,
         projects,
@@ -133,12 +143,15 @@
         deleteBranches,
         deleteTags,
         project,
+        commitsCollection,
+        commitApi,
         null);
   }
 
   @AssistedInject
   ProjectApiImpl(
       CurrentUser user,
+      PermissionBackend permissionBackend,
       CreateProject.Factory createProjectFactory,
       ProjectApiImpl.Factory projectApi,
       ProjectsCollection projects,
@@ -157,9 +170,12 @@
       ListTags listTags,
       DeleteBranches deleteBranches,
       DeleteTags deleteTags,
+      CommitsCollection commitsCollection,
+      CommitApiImpl.Factory commitApi,
       @Assisted String name) {
     this(
         user,
+        permissionBackend,
         createProjectFactory,
         projectApi,
         projects,
@@ -179,11 +195,14 @@
         deleteBranches,
         deleteTags,
         null,
+        commitsCollection,
+        commitApi,
         name);
   }
 
   private ProjectApiImpl(
       CurrentUser user,
+      PermissionBackend permissionBackend,
       CreateProject.Factory createProjectFactory,
       ProjectApiImpl.Factory projectApi,
       ProjectsCollection projects,
@@ -203,8 +222,11 @@
       DeleteBranches deleteBranches,
       DeleteTags deleteTags,
       ProjectResource project,
+      CommitsCollection commitsCollection,
+      CommitApiImpl.Factory commitApi,
       String name) {
     this.user = user;
+    this.permissionBackend = permissionBackend;
     this.createProjectFactory = createProjectFactory;
     this.projectApi = projectApi;
     this.projects = projects;
@@ -225,6 +247,8 @@
     this.listTags = listTags;
     this.deleteBranches = deleteBranches;
     this.deleteTags = deleteTags;
+    this.commitsCollection = commitsCollection;
+    this.commitApi = commitApi;
   }
 
   @Override
@@ -241,10 +265,11 @@
       if (in.name != null && !name.equals(in.name)) {
         throw new BadRequestException("name must match input.name");
       }
-      checkRequiresCapability(user, null, CreateProject.class);
-      createProjectFactory.create(name).apply(TopLevelResource.INSTANCE, in);
+      CreateProject impl = createProjectFactory.create(name);
+      permissionBackend.user(user).checkAny(GlobalPermission.fromAnnotation(impl.getClass()));
+      impl.apply(TopLevelResource.INSTANCE, in);
       return projectApi.create(projects.parse(name));
-    } catch (IOException | ConfigInvalidException e) {
+    } catch (IOException | ConfigInvalidException | PermissionBackendException e) {
       throw new RestApiException("Cannot create project: " + e.getMessage(), e);
     }
   }
@@ -275,7 +300,7 @@
   public ProjectAccessInfo access(ProjectAccessInput p) throws RestApiException {
     try {
       return setAccess.apply(checkExists(), p);
-    } catch (IOException e) {
+    } catch (IOException | PermissionBackendException e) {
       throw new RestApiException("Cannot put access rights", e);
     }
   }
@@ -353,14 +378,18 @@
   public List<ProjectInfo> children(boolean recursive) throws RestApiException {
     ListChildProjects list = children.list();
     list.setRecursive(recursive);
-    return list.apply(checkExists());
+    try {
+      return list.apply(checkExists());
+    } catch (PermissionBackendException e) {
+      throw new RestApiException("Cannot list children", e);
+    }
   }
 
   @Override
   public ChildProjectApi child(String name) throws RestApiException {
     try {
       return childApi.create(children.parse(checkExists(), IdString.fromDecoded(name)));
-    } catch (IOException e) {
+    } catch (IOException | PermissionBackendException e) {
       throw new RestApiException("Cannot parse child project", e);
     }
   }
@@ -393,6 +422,15 @@
     }
   }
 
+  @Override
+  public CommitApi commit(String commit) throws RestApiException {
+    try {
+      return commitApi.create(commitsCollection.parse(checkExists(), IdString.fromDecoded(commit)));
+    } catch (IOException e) {
+      throw new RestApiException("Cannot parse commit", e);
+    }
+  }
+
   private ProjectResource checkExists() throws ResourceNotFoundException {
     if (project == null) {
       throw new ResourceNotFoundException(name);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
index 9483508..dc19f71 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ListProjects;
 import com.google.gerrit.server.project.ListProjects.FilterType;
 import com.google.gerrit.server.project.ProjectsCollection;
@@ -52,8 +53,8 @@
       return api.create(projects.parse(name));
     } catch (UnprocessableEntityException e) {
       return api.create(name);
-    } catch (IOException e) {
-      throw new RestApiException("Cannot retrieve project");
+    } catch (IOException | PermissionBackendException e) {
+      throw new RestApiException("Cannot retrieve project", e);
     }
   }
 
@@ -77,12 +78,17 @@
     return new ListRequest() {
       @Override
       public SortedMap<String, ProjectInfo> getAsMap() throws RestApiException {
-        return list(this);
+        try {
+          return list(this);
+        } catch (PermissionBackendException e) {
+          throw new RestApiException("project list unavailable", e);
+        }
       }
     };
   }
 
-  private SortedMap<String, ProjectInfo> list(ListRequest request) throws RestApiException {
+  private SortedMap<String, ProjectInfo> list(ListRequest request)
+      throws RestApiException, PermissionBackendException {
     ListProjects lp = listProvider.get();
     lp.setShowDescription(request.getDescription());
     lp.setLimit(request.getLimit());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ProjectControlHandler.java b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ProjectControlHandler.java
index 02e907f..bd0cdcd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ProjectControlHandler.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ProjectControlHandler.java
@@ -15,8 +15,12 @@
 package com.google.gerrit.server.args4j;
 
 import com.google.gerrit.common.ProjectUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.inject.Inject;
@@ -34,18 +38,22 @@
 
 public class ProjectControlHandler extends OptionHandler<ProjectControl> {
   private static final Logger log = LoggerFactory.getLogger(ProjectControlHandler.class);
+
   private final ProjectControl.GenericFactory projectControlFactory;
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
 
   @Inject
   public ProjectControlHandler(
-      final ProjectControl.GenericFactory projectControlFactory,
+      ProjectControl.GenericFactory projectControlFactory,
+      PermissionBackend permissionBackend,
       Provider<CurrentUser> user,
       @Assisted final CmdLineParser parser,
       @Assisted final OptionDef option,
       @Assisted final Setter<ProjectControl> setter) {
     super(parser, option, setter);
     this.projectControlFactory = projectControlFactory;
+    this.permissionBackend = permissionBackend;
     this.user = user;
   }
 
@@ -69,14 +77,15 @@
     String nameWithoutSuffix = ProjectUtil.stripGitSuffix(projectName);
     Project.NameKey nameKey = new Project.NameKey(nameWithoutSuffix);
 
-    final ProjectControl control;
+    ProjectControl control;
     try {
-      control =
-          projectControlFactory.validateFor(
-              nameKey, ProjectControl.OWNER | ProjectControl.VISIBLE, user.get());
+      control = projectControlFactory.controlFor(nameKey, user.get());
+      permissionBackend.user(user).project(nameKey).check(ProjectPermission.ACCESS);
+    } catch (AuthException e) {
+      throw new CmdLineException(owner, new NoSuchProjectException(nameKey).getMessage());
     } catch (NoSuchProjectException e) {
       throw new CmdLineException(owner, e.getMessage());
-    } catch (IOException e) {
+    } catch (PermissionBackendException | IOException e) {
       log.warn("Cannot load project " + nameWithoutSuffix, e);
       throw new CmdLineException(owner, new NoSuchProjectException(nameKey).getMessage());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
index 7feb745..1a8d916 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.auth.ldap;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
 import static com.google.gerrit.server.account.GroupBackends.GROUP_REF_NAME_COMPARATOR;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
 import static com.google.gerrit.server.auth.ldap.Helper.LDAP_UUID;
 import static com.google.gerrit.server.auth.ldap.LdapModule.GROUP_CACHE;
 import static com.google.gerrit.server.auth.ldap.LdapModule.GROUP_EXIST_CACHE;
@@ -29,9 +29,9 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.auth.ldap.Helper.LdapSchema;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectControl;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
index 66b279f..9bcf3d6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.auth.ldap;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
 
 import com.google.common.base.Strings;
 import com.google.common.cache.CacheLoader;
@@ -30,8 +30,9 @@
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.EmailExpander;
-import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.GroupBackends;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.auth.AuthenticationUnavailableException;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -319,21 +320,19 @@
 
   static class UserLoader extends CacheLoader<String, Optional<Account.Id>> {
     private final SchemaFactory<ReviewDb> schema;
+    private final ExternalIds externalIds;
 
     @Inject
-    UserLoader(SchemaFactory<ReviewDb> schema) {
+    UserLoader(SchemaFactory<ReviewDb> schema, ExternalIds externalIds) {
       this.schema = schema;
+      this.externalIds = externalIds;
     }
 
     @Override
     public Optional<Account.Id> load(String username) throws Exception {
       try (ReviewDb db = schema.open()) {
         return Optional.ofNullable(
-                ExternalId.from(
-                    db.accountExternalIds()
-                        .get(
-                            ExternalId.Key.create(SCHEME_GERRIT, username)
-                                .asAccountExternalIdKey())))
+                externalIds.get(db, ExternalId.Key.create(SCHEME_GERRIT, username)))
             .map(ExternalId::accountId);
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java
index d30e667..75f4213 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.auth.openid;
 
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 
 public class OpenIdProviderPattern {
   public static OpenIdProviderPattern create(String pattern) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java
index 862f4e8..11f2034 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java
@@ -30,65 +30,60 @@
 @Singleton
 public class CacheMetrics {
   @Inject
-  public CacheMetrics(MetricMaker metrics, final DynamicMap<Cache<?, ?>> cacheMap) {
+  public CacheMetrics(MetricMaker metrics, DynamicMap<Cache<?, ?>> cacheMap) {
     Field<String> F_NAME = Field.ofString("cache_name");
 
-    final CallbackMetric1<String, Long> memEnt =
+    CallbackMetric1<String, Long> memEnt =
         metrics.newCallbackMetric(
             "caches/memory_cached",
             Long.class,
             new Description("Memory entries").setGauge().setUnit("entries"),
             F_NAME);
-    final CallbackMetric1<String, Double> memHit =
+    CallbackMetric1<String, Double> memHit =
         metrics.newCallbackMetric(
             "caches/memory_hit_ratio",
             Double.class,
             new Description("Memory hit ratio").setGauge().setUnit("percent"),
             F_NAME);
-    final CallbackMetric1<String, Long> memEvict =
+    CallbackMetric1<String, Long> memEvict =
         metrics.newCallbackMetric(
             "caches/memory_eviction_count",
             Long.class,
             new Description("Memory eviction count").setGauge().setUnit("evicted entries"),
             F_NAME);
-    final CallbackMetric1<String, Long> perDiskEnt =
+    CallbackMetric1<String, Long> perDiskEnt =
         metrics.newCallbackMetric(
             "caches/disk_cached",
             Long.class,
             new Description("Disk entries used by persistent cache").setGauge().setUnit("entries"),
             F_NAME);
-    final CallbackMetric1<String, Double> perDiskHit =
+    CallbackMetric1<String, Double> perDiskHit =
         metrics.newCallbackMetric(
             "caches/disk_hit_ratio",
             Double.class,
             new Description("Disk hit ratio for persistent cache").setGauge().setUnit("percent"),
             F_NAME);
 
-    final Set<CallbackMetric<?>> cacheMetrics =
+    Set<CallbackMetric<?>> cacheMetrics =
         ImmutableSet.<CallbackMetric<?>>of(memEnt, memHit, memEvict, perDiskEnt, perDiskHit);
 
     metrics.newTrigger(
         cacheMetrics,
-        new Runnable() {
-          @Override
-          public void run() {
-            for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
-              Cache<?, ?> c = e.getProvider().get();
-              String name = metricNameOf(e);
-              CacheStats cstats = c.stats();
-              memEnt.set(name, c.size());
-              memHit.set(name, cstats.hitRate() * 100);
-              memEvict.set(name, cstats.evictionCount());
-              if (c instanceof PersistentCache) {
-                PersistentCache.DiskStats d = ((PersistentCache) c).diskStats();
-                perDiskEnt.set(name, d.size());
-                perDiskHit.set(name, hitRatio(d));
-              }
-            }
-            for (CallbackMetric<?> cbm : cacheMetrics) {
-              cbm.prune();
+        () -> {
+          for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
+            Cache<?, ?> c = e.getProvider().get();
+            String name = metricNameOf(e);
+            CacheStats cstats = c.stats();
+            memEnt.set(name, c.size());
+            memHit.set(name, cstats.hitRate() * 100);
+            memEvict.set(name, cstats.evictionCount());
+            if (c instanceof PersistentCache) {
+              PersistentCache.DiskStats d = ((PersistentCache) c).diskStats();
+              perDiskEnt.set(name, d.size());
+              perDiskHit.set(name, hitRatio(d));
             }
           }
+          cacheMetrics.forEach(CallbackMetric::prune);
         });
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
index 0cafe6d..95e1f2f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -32,6 +31,8 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.AbandonOp;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
@@ -40,14 +41,10 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.Collection;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 @Singleton
 public class Abandon
     implements RestModifyView<ChangeResource, AbandonInput>, UiAction<ChangeResource> {
-  private static final Logger log = LoggerFactory.getLogger(Abandon.class);
-
   private final Provider<ReviewDb> dbProvider;
   private final ChangeJson.Factory json;
   private final BatchUpdate.Factory batchUpdateFactory;
@@ -70,14 +67,15 @@
 
   @Override
   public ChangeInfo apply(ChangeResource req, AbandonInput input)
-      throws RestApiException, UpdateException, OrmException {
-    ChangeControl control = req.getControl();
-    if (!control.canAbandon(dbProvider.get())) {
-      throw new AuthException("abandon not permitted");
-    }
+      throws RestApiException, UpdateException, OrmException, PermissionBackendException {
+    req.permissions().database(dbProvider).check(ChangePermission.ABANDON);
+
     Change change =
         abandon(
-            control, input.message, input.notify, notifyUtil.resolveAccounts(input.notifyDetails));
+            req.getControl(),
+            input.message,
+            input.notify,
+            notifyUtil.resolveAccounts(input.notifyDetails));
     return json.noOptions().format(change);
   }
 
@@ -159,19 +157,14 @@
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource resource) {
-    boolean canAbandon = false;
-    try {
-      canAbandon = resource.getControl().canAbandon(dbProvider.get());
-    } catch (OrmException e) {
-      log.error("Cannot check canAbandon status. Assuming false.", e);
-    }
+  public UiAction.Description getDescription(ChangeResource rsrc) {
+    Change change = rsrc.getChange();
     return new UiAction.Description()
         .setLabel("Abandon")
         .setTitle("Abandon the change")
         .setVisible(
-            resource.getChange().getStatus().isOpen()
-                && resource.getChange().getStatus() != Change.Status.DRAFT
-                && canAbandon);
+            change.getStatus().isOpen()
+                && change.getStatus() != Change.Status.DRAFT
+                && rsrc.permissions().database(dbProvider).testOrFalse(ChangePermission.ABANDON));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java
index 519a4bc..19fdcfb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java
@@ -30,14 +30,11 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.webui.PrivateInternals_UiActionDescription;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import com.google.inject.util.Providers;
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -48,6 +45,7 @@
   private final Revisions revisions;
   private final ChangeJson.Factory changeJsonFactory;
   private final ChangeResource.Factory changeResourceFactory;
+  private final UiActions uiActions;
   private final DynamicMap<RestView<ChangeResource>> changeViews;
   private final DynamicSet<ActionVisitor> visitorSet;
 
@@ -56,11 +54,13 @@
       Revisions revisions,
       ChangeJson.Factory changeJsonFactory,
       ChangeResource.Factory changeResourceFactory,
+      UiActions uiActions,
       DynamicMap<RestView<ChangeResource>> changeViews,
       DynamicSet<ActionVisitor> visitorSet) {
     this.revisions = revisions;
     this.changeJsonFactory = changeJsonFactory;
     this.changeResourceFactory = changeResourceFactory;
+    this.uiActions = uiActions;
     this.changeViews = changeViews;
     this.visitorSet = visitorSet;
   }
@@ -122,6 +122,7 @@
     copy.mergeable = changeInfo.mergeable;
     copy.insertions = changeInfo.insertions;
     copy.deletions = changeInfo.deletions;
+    copy.isPrivate = changeInfo.isPrivate;
     copy.subject = changeInfo.subject;
     copy.status = changeInfo.status;
     copy.owner = changeInfo.owner;
@@ -161,9 +162,9 @@
       return out;
     }
 
-    Provider<CurrentUser> userProvider = Providers.of(ctl.getUser());
     FluentIterable<UiAction.Description> descs =
-        UiActions.from(changeViews, changeResourceFactory.create(ctl), userProvider);
+        uiActions.from(changeViews, changeResourceFactory.create(ctl));
+
     // The followup action is a client-side only operation that does not
     // have a server side handler. It must be manually registered into the
     // resulting action map.
@@ -197,10 +198,10 @@
     if (!rsrc.getControl().getUser().isIdentifiedUser()) {
       return ImmutableMap.of();
     }
+
     Map<String, ActionInfo> out = new LinkedHashMap<>();
-    Provider<CurrentUser> userProvider = Providers.of(rsrc.getControl().getUser());
     ACTION:
-    for (UiAction.Description d : UiActions.from(revisions, rsrc, userProvider)) {
+    for (UiAction.Description d : uiActions.from(revisions, rsrc)) {
       ActionInfo actionInfo = new ActionInfo(d);
       for (ActionVisitor visitor : visitors) {
         if (!visitor.visit(d.getId(), actionInfo, changeInfo, revisionInfo)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ApplyFix.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ApplyFix.java
new file mode 100644
index 0000000..77eff90
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ApplyFix.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2017 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.change;
+
+import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.edit.ChangeEdit;
+import com.google.gerrit.server.edit.ChangeEditJson;
+import com.google.gerrit.server.edit.ChangeEditModifier;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import com.google.gerrit.server.fixes.FixReplacementInterpreter;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+
+@Singleton
+public class ApplyFix implements RestModifyView<FixResource, Void> {
+
+  private final GitRepositoryManager gitRepositoryManager;
+  private final FixReplacementInterpreter fixReplacementInterpreter;
+  private final ChangeEditModifier changeEditModifier;
+  private final ChangeEditJson changeEditJson;
+
+  @Inject
+  public ApplyFix(
+      GitRepositoryManager gitRepositoryManager,
+      FixReplacementInterpreter fixReplacementInterpreter,
+      ChangeEditModifier changeEditModifier,
+      ChangeEditJson changeEditJson) {
+    this.gitRepositoryManager = gitRepositoryManager;
+    this.fixReplacementInterpreter = fixReplacementInterpreter;
+    this.changeEditModifier = changeEditModifier;
+    this.changeEditJson = changeEditJson;
+  }
+
+  @Override
+  public Response<EditInfo> apply(FixResource fixResource, Void nothing)
+      throws AuthException, OrmException, ResourceConflictException, IOException,
+          ResourceNotFoundException {
+    RevisionResource revisionResource = fixResource.getRevisionResource();
+    Project.NameKey project = revisionResource.getProject();
+    ProjectState projectState = revisionResource.getControl().getProjectControl().getProjectState();
+    PatchSet patchSet = revisionResource.getPatchSet();
+    ObjectId patchSetCommitId = ObjectId.fromString(patchSet.getRevision().get());
+
+    try (Repository repository = gitRepositoryManager.openRepository(project)) {
+      List<TreeModification> treeModifications =
+          fixReplacementInterpreter.toTreeModifications(
+              repository, projectState, patchSetCommitId, fixResource.getFixReplacements());
+      ChangeEdit changeEdit =
+          changeEditModifier.combineWithModifiedPatchSetTree(
+              repository, revisionResource.getControl(), patchSet, treeModifications);
+      EditInfo editInfo = changeEditJson.toEditInfo(changeEdit, false);
+      return Response.ok(editInfo);
+    } catch (InvalidChangeOperationException e) {
+      throw new ResourceConflictException(e.getMessage());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEditResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEditResource.java
index 108e180..1695d0c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEditResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEditResource.java
@@ -16,8 +16,6 @@
 
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.inject.TypeLiteral;
@@ -67,12 +65,4 @@
   public String getPath() {
     return path;
   }
-
-  Account.Id getAccountId() {
-    return getUser().getAccountId();
-  }
-
-  IdentifiedUser getUser() {
-    return edit.getUser();
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
index fbb2115..332c3c6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
@@ -229,7 +229,8 @@
         }
         try {
           editInfo.files =
-              fileInfoJson.toFileInfoMap(rsrc.getChange(), edit.get().getRevision(), basePatchSet);
+              fileInfoJson.toFileInfoMap(
+                  rsrc.getChange(), edit.get().getEditCommit(), basePatchSet);
         } catch (PatchListNotAvailableException e) {
           throw new ResourceNotFoundException(e.getMessage());
         }
@@ -395,7 +396,7 @@
                 rsrc.getControl().getProjectControl().getProjectState(),
                 base
                     ? ObjectId.fromString(edit.getBasePatchSet().getRevision().get())
-                    : ObjectId.fromString(edit.getRevision().get()),
+                    : edit.getEditCommit(),
                 rsrc.getPath()));
       } catch (ResourceNotFoundException rnfe) {
         return Response.none();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
index da34064..e2d9eb1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -75,6 +75,7 @@
 import java.util.concurrent.Future;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.eclipse.jgit.util.ChangeIdUtil;
 import org.slf4j.Logger;
@@ -82,7 +83,7 @@
 
 public class ChangeInserter implements InsertChangeOp {
   public interface Factory {
-    ChangeInserter create(Change.Id cid, RevCommit rc, String refName);
+    ChangeInserter create(Change.Id cid, ObjectId commitId, String refName);
   }
 
   private static final Logger log = LoggerFactory.getLogger(ChangeInserter.class);
@@ -102,7 +103,7 @@
 
   private final Change.Id changeId;
   private final PatchSet.Id psId;
-  private final RevCommit commit;
+  private final ObjectId commitId;
   private final String refName;
 
   // Fields exposed as setters.
@@ -110,8 +111,10 @@
   private String topic;
   private String message;
   private String patchSetDescription;
+  private boolean isPrivate;
+  private boolean workInProgress;
   private List<String> groups = Collections.emptyList();
-  private CommitValidators.Policy validatePolicy = CommitValidators.Policy.GERRIT;
+  private boolean validate = true;
   private NotifyHandling notify = NotifyHandling.ALL;
   private ListMultimap<RecipientType, Account.Id> accountsToNotify = ImmutableListMultimap.of();
   private Set<Account.Id> reviewers;
@@ -145,7 +148,7 @@
       CommentAdded commentAdded,
       RevisionCreated revisionCreated,
       @Assisted Change.Id changeId,
-      @Assisted RevCommit commit,
+      @Assisted ObjectId commitId,
       @Assisted String refName) {
     this.projectControlFactory = projectControlFactory;
     this.userFactory = userFactory;
@@ -162,7 +165,7 @@
 
     this.changeId = changeId;
     this.psId = new PatchSet.Id(changeId, INITIAL_PATCH_SET_ID);
-    this.commit = commit;
+    this.commitId = commitId.copy();
     this.refName = refName;
     this.reviewers = Collections.emptySet();
     this.extraCC = Collections.emptySet();
@@ -174,42 +177,46 @@
   }
 
   @Override
-  public Change createChange(Context ctx) {
+  public Change createChange(Context ctx) throws IOException {
     change =
         new Change(
-            getChangeKey(commit),
+            getChangeKey(ctx.getRevWalk(), commitId),
             changeId,
             ctx.getAccountId(),
             new Branch.NameKey(ctx.getProject(), refName),
             ctx.getWhen());
     change.setStatus(MoreObjects.firstNonNull(status, Change.Status.NEW));
     change.setTopic(topic);
+    change.setPrivate(isPrivate);
+    change.setWorkInProgress(workInProgress);
     return change;
   }
 
-  private static Change.Key getChangeKey(RevCommit commit) {
+  private static Change.Key getChangeKey(RevWalk rw, ObjectId id) throws IOException {
+    RevCommit commit = rw.parseCommit(id);
+    rw.parseBody(commit);
     List<String> idList = commit.getFooterLines(FooterConstants.CHANGE_ID);
     if (!idList.isEmpty()) {
       return new Change.Key(idList.get(idList.size() - 1).trim());
     }
-    ObjectId id =
+    ObjectId changeId =
         ChangeIdUtil.computeChangeId(
             commit.getTree(),
             commit,
             commit.getAuthorIdent(),
             commit.getCommitterIdent(),
             commit.getShortMessage());
-    StringBuilder changeId = new StringBuilder();
-    changeId.append("I").append(ObjectId.toString(id));
-    return new Change.Key(changeId.toString());
+    StringBuilder changeIdStr = new StringBuilder();
+    changeIdStr.append("I").append(ObjectId.toString(changeId));
+    return new Change.Key(changeIdStr.toString());
   }
 
   public PatchSet.Id getPatchSetId() {
     return psId;
   }
 
-  public RevCommit getCommit() {
-    return commit;
+  public ObjectId getCommitId() {
+    return commitId;
   }
 
   public Change getChange() {
@@ -233,8 +240,8 @@
     return this;
   }
 
-  public ChangeInserter setValidatePolicy(CommitValidators.Policy validate) {
-    this.validatePolicy = checkNotNull(validate);
+  public ChangeInserter setValidate(boolean validate) {
+    this.validate = validate;
     return this;
   }
 
@@ -259,11 +266,22 @@
     return this;
   }
 
+  public ChangeInserter setPrivate(boolean isPrivate) {
+    checkState(change == null, "setPrivate(boolean) only valid before creating change");
+    this.isPrivate = isPrivate;
+    return this;
+  }
+
   public ChangeInserter setDraft(boolean draft) {
     checkState(change == null, "setDraft(boolean) only valid before creating change");
     return setStatus(draft ? Change.Status.DRAFT : Change.Status.NEW);
   }
 
+  public ChangeInserter setWorkInProgress(boolean workInProgress) {
+    this.workInProgress = workInProgress;
+    return this;
+  }
+
   public ChangeInserter setStatus(Change.Status status) {
     checkState(change == null, "setStatus(Change.Status) only valid before creating change");
     this.status = status;
@@ -330,7 +348,7 @@
       return;
     }
     if (updateRefCommand == null) {
-      ctx.addRefUpdate(new ReceiveCommand(ObjectId.zeroId(), commit, psId.toRefName()));
+      ctx.addRefUpdate(ObjectId.zeroId(), commitId, psId.toRefName());
     } else {
       ctx.addRefUpdate(updateRefCommand);
     }
@@ -342,7 +360,8 @@
     change = ctx.getChange(); // Use defensive copy created by ChangeControl.
     ReviewDb db = ctx.getDb();
     ChangeControl ctl = ctx.getControl();
-    patchSetInfo = patchSetInfoFactory.get(ctx.getRevWalk(), commit, psId);
+    patchSetInfo =
+        patchSetInfoFactory.get(ctx.getRevWalk(), ctx.getRevWalk().parseCommit(commitId), psId);
     ctx.getChange().setCurrentPatchSet(patchSetInfo);
 
     ChangeUpdate update = ctx.getUpdate(psId);
@@ -351,11 +370,13 @@
     update.setBranch(change.getDest().get());
     update.setTopic(change.getTopic());
     update.setPsDescription(patchSetDescription);
+    update.setPrivate(isPrivate);
+    update.setWorkInProgress(workInProgress);
 
     boolean draft = status == Change.Status.DRAFT;
     List<String> newGroups = groups;
     if (newGroups.isEmpty()) {
-      newGroups = GroupCollector.getDefaultGroups(commit);
+      newGroups = GroupCollector.getDefaultGroups(commitId);
     }
     patchSet =
         psUtil.insert(
@@ -363,7 +384,7 @@
             ctx.getRevWalk(),
             update,
             psId,
-            commit,
+            commitId,
             draft,
             newGroups,
             pushCert,
@@ -404,7 +425,7 @@
               ctx.getUser(),
               patchSet.getCreatedOn(),
               message,
-              ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
+              ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
       cmUtil.addChangeMessage(db, update, changeMessage);
     }
     return true;
@@ -497,7 +518,7 @@
   }
 
   private void validate(RepoContext ctx) throws IOException, ResourceConflictException {
-    if (validatePolicy == CommitValidators.Policy.NONE) {
+    if (!validate) {
       return;
     }
 
@@ -505,16 +526,18 @@
       RefControl refControl =
           projectControlFactory.controlFor(ctx.getProject(), ctx.getUser()).controlForRef(refName);
       String refName = psId.toRefName();
-      CommitReceivedEvent event =
+      try (CommitReceivedEvent event =
           new CommitReceivedEvent(
-              new ReceiveCommand(ObjectId.zeroId(), commit.getId(), refName),
+              new ReceiveCommand(ObjectId.zeroId(), commitId, refName),
               refControl.getProjectControl().getProject(),
               change.getDest().get(),
-              commit,
-              ctx.getIdentifiedUser());
-      commitValidatorsFactory
-          .create(validatePolicy, refControl, new NoSshInfo(), ctx.getRepository())
-          .validate(event);
+              ctx.getRevWalk().getObjectReader(),
+              commitId,
+              ctx.getIdentifiedUser())) {
+        commitValidatorsFactory
+            .forGerritCommits(refControl, new NoSshInfo(), ctx.getRevWalk())
+            .validate(event);
+      }
     } catch (CommitValidationException e) {
       throw new ResourceConflictException(e.getFullMessage());
     } catch (NoSuchProjectException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
index 41d101b..d197a2c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
@@ -59,8 +59,6 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.extensions.api.changes.FixInput;
@@ -104,25 +102,28 @@
 import com.google.gerrit.server.api.accounts.AccountInfoComparator;
 import com.google.gerrit.server.api.accounts.GpgApiAdapter;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.LabelNormalizer;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.QueryResult;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeData.ChangedLines;
+import com.google.gerrit.server.query.change.PluginDefinedAttributesFactory;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -186,9 +187,9 @@
   }
 
   private final Provider<ReviewDb> db;
-  private final LabelNormalizer labelNormalizer;
   private final Provider<CurrentUser> userProvider;
   private final AnonymousUser anonymous;
+  private final PermissionBackend permissionBackend;
   private final GitRepositoryManager repoManager;
   private final ProjectCache projectCache;
   private final MergeUtil.Factory mergeUtilFactory;
@@ -213,13 +214,14 @@
   private boolean lazyLoad = true;
   private AccountLoader accountLoader;
   private FixInput fix;
+  private PluginDefinedAttributesFactory pluginDefinedAttributesFactory;
 
-  @AssistedInject
+  @Inject
   ChangeJson(
       Provider<ReviewDb> db,
-      LabelNormalizer ln,
       Provider<CurrentUser> user,
       AnonymousUser au,
+      PermissionBackend permissionBackend,
       GitRepositoryManager repoManager,
       ProjectCache projectCache,
       MergeUtil.Factory mergeUtilFactory,
@@ -241,10 +243,10 @@
       ApprovalsUtil approvalsUtil,
       @Assisted Iterable<ListChangesOption> options) {
     this.db = db;
-    this.labelNormalizer = ln;
     this.userProvider = user;
     this.anonymous = au;
     this.changeDataFactory = cdf;
+    this.permissionBackend = permissionBackend;
     this.repoManager = repoManager;
     this.userFactory = uf;
     this.projectCache = projectCache;
@@ -276,6 +278,10 @@
     return this;
   }
 
+  public void setPluginDefinedAttributesFactory(PluginDefinedAttributesFactory pluginsFactory) {
+    this.pluginDefinedAttributesFactory = pluginsFactory;
+  }
+
   public ChangeInfo format(ChangeResource rsrc) throws OrmException {
     return format(changeDataFactory.create(db.get(), rsrc.getControl()));
   }
@@ -316,6 +322,7 @@
         | GpgException
         | OrmException
         | IOException
+        | PermissionBackendException
         | RuntimeException e) {
       if (!has(CHECK)) {
         Throwables.throwIfInstanceOf(e, OrmException.class);
@@ -393,6 +400,7 @@
             | GpgException
             | OrmException
             | IOException
+            | PermissionBackendException
             | RuntimeException e) {
           if (has(CHECK)) {
             i = checkOnly(cd);
@@ -439,6 +447,8 @@
       info.updated = c.getLastUpdatedOn();
       info._number = c.getId().get();
       info.problems = result.problems();
+      info.isPrivate = c.isPrivate();
+      info.workInProgress = c.isWorkInProgress();
       finish(info);
     } else {
       info = new ChangeInfo();
@@ -449,7 +459,8 @@
   }
 
   private ChangeInfo toChangeInfo(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
-      throws PatchListNotAvailableException, GpgException, OrmException, IOException {
+      throws PatchListNotAvailableException, GpgException, OrmException, IOException,
+          PermissionBackendException {
     ChangeInfo out = new ChangeInfo();
     CurrentUser user = userProvider.get();
     ChangeControl ctl = cd.changeControl().forUser(user);
@@ -465,6 +476,7 @@
       }
     }
 
+    PermissionBackend.ForChange perm = permissionBackend.user(user).database(db).change(cd);
     Change in = cd.change();
     out.project = in.getProject().get();
     out.branch = in.getDest().getShortName();
@@ -491,6 +503,8 @@
       out.insertions = changedLines.get().insertions;
       out.deletions = changedLines.get().deletions;
     }
+    out.isPrivate = in.isPrivate();
+    out.workInProgress = in.isWorkInProgress();
     out.subject = in.getSubject();
     out.status = in.getStatus().asChangeStatus();
     out.owner = accountLoader.get(in.getOwner());
@@ -502,6 +516,10 @@
     if (user.isIdentifiedUser()) {
       Collection<String> stars = cd.stars(user.getAccountId());
       out.starred = stars.contains(StarredChangesUtil.DEFAULT_LABEL) ? true : null;
+      out.muted =
+          stars.contains(StarredChangesUtil.MUTE_LABEL + "/" + cd.currentPatchSet().getPatchSetId())
+              ? true
+              : null;
       if (!stars.isEmpty()) {
         out.stars = stars;
       }
@@ -509,26 +527,39 @@
 
     if (in.getStatus().isOpen() && has(REVIEWED) && user.isIdentifiedUser()) {
       Account.Id accountId = user.getAccountId();
-      out.reviewed = cd.reviewedBy().contains(accountId) ? true : null;
+      if (out.muted != null) {
+        out.reviewed = true;
+      } else {
+        out.reviewed = cd.reviewedBy().contains(accountId) ? true : null;
+      }
     }
 
-    out.labels = labelsFor(ctl, cd, has(LABELS), has(DETAILED_LABELS));
+    out.labels = labelsFor(perm, ctl, cd, has(LABELS), has(DETAILED_LABELS));
     out.submitted = getSubmittedOn(cd);
+    out.plugins =
+        pluginDefinedAttributesFactory != null ? pluginDefinedAttributesFactory.create(cd) : null;
 
     if (out.labels != null && has(DETAILED_LABELS)) {
       // If limited to specific patch sets but not the current patch set, don't
       // list permitted labels, since users can't vote on those patch sets.
-      if (!limitToPsId.isPresent() || limitToPsId.get().equals(in.currentPatchSetId())) {
+      if (user.isIdentifiedUser()
+          && (!limitToPsId.isPresent() || limitToPsId.get().equals(in.currentPatchSetId()))) {
         out.permittedLabels =
             cd.change().getStatus() != Change.Status.ABANDONED
-                ? permittedLabels(ctl, cd)
+                ? permittedLabels(perm, cd)
                 : ImmutableMap.of();
       }
 
       out.reviewers = new HashMap<>();
-      for (Map.Entry<ReviewerStateInternal, Map<Account.Id, Timestamp>> e :
-          cd.reviewers().asTable().rowMap().entrySet()) {
-        out.reviewers.put(e.getKey().asReviewerState(), toAccountInfo(e.getValue().keySet()));
+      for (ReviewerStateInternal state : ReviewerStateInternal.values()) {
+        if (state == ReviewerStateInternal.REMOVED) {
+          continue;
+        }
+        Collection<AccountInfo> reviewers = toAccountInfo(cd.reviewers().byState(state));
+        reviewers.addAll(toAccountInfoByEmail(cd.reviewersByEmail().byState(state)));
+        if (!reviewers.isEmpty()) {
+          out.reviewers.put(state.asReviewerState(), reviewers);
+        }
       }
 
       out.removableReviewers = removableReviewers(ctl, out);
@@ -595,7 +626,12 @@
   }
 
   private Map<String, LabelInfo> labelsFor(
-      ChangeControl ctl, ChangeData cd, boolean standard, boolean detailed) throws OrmException {
+      PermissionBackend.ForChange perm,
+      ChangeControl ctl,
+      ChangeData cd,
+      boolean standard,
+      boolean detailed)
+      throws OrmException, PermissionBackendException {
     if (!standard && !detailed) {
       return null;
     }
@@ -604,20 +640,24 @@
       return null;
     }
 
-    LabelTypes labelTypes = ctl.getLabelTypes();
+    LabelTypes labelTypes = cd.getLabelTypes();
     Map<String, LabelWithStatus> withStatus =
         cd.change().getStatus().isOpen()
-            ? labelsForOpenChange(ctl, cd, labelTypes, standard, detailed)
-            : labelsForClosedChange(ctl, cd, labelTypes, standard, detailed);
+            ? labelsForOpenChange(perm, cd, labelTypes, standard, detailed)
+            : labelsForClosedChange(perm, cd, labelTypes, standard, detailed);
     return ImmutableMap.copyOf(Maps.transformValues(withStatus, LabelWithStatus::label));
   }
 
   private Map<String, LabelWithStatus> labelsForOpenChange(
-      ChangeControl ctl, ChangeData cd, LabelTypes labelTypes, boolean standard, boolean detailed)
-      throws OrmException {
+      PermissionBackend.ForChange perm,
+      ChangeData cd,
+      LabelTypes labelTypes,
+      boolean standard,
+      boolean detailed)
+      throws OrmException, PermissionBackendException {
     Map<String, LabelWithStatus> labels = initLabels(cd, labelTypes, standard);
     if (detailed) {
-      setAllApprovals(ctl, cd, labels);
+      setAllApprovals(perm, cd, labels);
     }
     for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
       LabelType type = labelTypes.byLabel(e.getKey());
@@ -704,8 +744,8 @@
   }
 
   private void setAllApprovals(
-      ChangeControl baseCtrl, ChangeData cd, Map<String, LabelWithStatus> labels)
-      throws OrmException {
+      PermissionBackend.ForChange basePerm, ChangeData cd, Map<String, LabelWithStatus> labels)
+      throws OrmException, PermissionBackendException {
     Change.Status status = cd.change().getStatus();
     checkState(status.isOpen(), "should not call setAllApprovals on %s change", status);
 
@@ -719,17 +759,17 @@
     }
 
     Table<Account.Id, String, PatchSetApproval> current =
-        HashBasedTable.create(allUsers.size(), baseCtrl.getLabelTypes().getLabelTypes().size());
+        HashBasedTable.create(allUsers.size(), cd.getLabelTypes().getLabelTypes().size());
     for (PatchSetApproval psa : cd.currentApprovals()) {
       current.put(psa.getAccountId(), psa.getLabel(), psa);
     }
 
+    LabelTypes labelTypes = cd.getLabelTypes();
     for (Account.Id accountId : allUsers) {
-      IdentifiedUser user = userFactory.create(accountId);
-      ChangeControl ctl = baseCtrl.forUser(user);
-      Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(ctl, cd));
+      PermissionBackend.ForChange perm = basePerm.user(userFactory.create(accountId));
+      Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(perm, cd));
       for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
-        LabelType lt = ctl.getLabelTypes().byLabel(e.getKey());
+        LabelType lt = labelTypes.byLabel(e.getKey());
         if (lt == null) {
           // Ignore submit record for undefined label; likely the submit rule
           // author didn't intend for the label to show up in the table.
@@ -746,7 +786,7 @@
             // This may be a dummy approval that was inserted when the reviewer
             // was added. Explicitly check whether the user can vote on this
             // label.
-            value = labelNormalizer.canVote(ctl, lt, accountId) ? 0 : null;
+            value = perm.test(new LabelPermission(lt)) ? 0 : null;
           }
           tag = psa.getTag();
           date = psa.getGranted();
@@ -757,7 +797,7 @@
           // Either the user cannot vote on this label, or they were added as a
           // reviewer but have not responded yet. Explicitly check whether the
           // user can vote on this label.
-          value = labelNormalizer.canVote(ctl, lt, accountId) ? 0 : null;
+          value = perm.test(new LabelPermission(lt)) ? 0 : null;
         }
         addApproval(
             e.getValue().label(), approvalInfo(accountId, value, permittedVotingRange, tag, date));
@@ -805,12 +845,12 @@
   }
 
   private Map<String, LabelWithStatus> labelsForClosedChange(
-      ChangeControl baseCtrl,
+      PermissionBackend.ForChange basePerm,
       ChangeData cd,
       LabelTypes labelTypes,
       boolean standard,
       boolean detailed)
-      throws OrmException {
+      throws OrmException, PermissionBackendException {
     Set<Account.Id> allUsers = new HashSet<>();
     if (detailed) {
       // Users expect to see all reviewers on closed changes, even if they
@@ -878,8 +918,8 @@
       Map<String, ApprovalInfo> byLabel = Maps.newHashMapWithExpectedSize(labels.size());
       Map<String, VotingRangeInfo> pvr = Collections.emptyMap();
       if (detailed) {
-        ChangeControl ctl = baseCtrl.forUser(userFactory.create(accountId));
-        pvr = getPermittedVotingRanges(permittedLabels(ctl, cd));
+        PermissionBackend.ForChange perm = basePerm.user(userFactory.create(accountId));
+        pvr = getPermittedVotingRanges(permittedLabels(perm, cd));
         for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
           ApprovalInfo ai = approvalInfo(accountId, 0, null, null, null);
           byLabel.put(entry.getKey(), ai);
@@ -953,15 +993,25 @@
     }
   }
 
-  private Map<String, Collection<String>> permittedLabels(ChangeControl ctl, ChangeData cd)
-      throws OrmException {
-    if (ctl == null || !ctl.getUser().isIdentifiedUser()) {
-      return null;
+  private Map<String, Collection<String>> permittedLabels(
+      PermissionBackend.ForChange perm, ChangeData cd)
+      throws OrmException, PermissionBackendException {
+    boolean isMerged = cd.change().getStatus() == Change.Status.MERGED;
+    LabelTypes labelTypes = cd.getLabelTypes();
+    Map<String, LabelType> toCheck = new HashMap<>();
+    for (SubmitRecord rec : submitRecords(cd)) {
+      if (rec.labels != null) {
+        for (SubmitRecord.Label r : rec.labels) {
+          LabelType type = labelTypes.byLabel(r.label);
+          if (type != null && (!isMerged || type.allowPostSubmit())) {
+            toCheck.put(type.getName(), type);
+          }
+        }
+      }
     }
 
     Map<String, Short> labels = null;
-    boolean isMerged = ctl.getChange().getStatus() == Change.Status.MERGED;
-    LabelTypes labelTypes = ctl.getLabelTypes();
+    Set<LabelPermission.WithValue> can = perm.testLabels(toCheck.values());
     SetMultimap<String, String> permitted = LinkedHashMultimap.create();
     for (SubmitRecord rec : submitRecords(cd)) {
       if (rec.labels == null) {
@@ -972,12 +1022,12 @@
         if (type == null || (isMerged && !type.allowPostSubmit())) {
           continue;
         }
-        PermissionRange range = ctl.getRange(Permission.forLabel(r.label));
+
         for (LabelValue v : type.getValues()) {
-          boolean ok = range.contains(v.getValue());
+          boolean ok = can.contains(new LabelPermission.WithValue(type, v));
           if (isMerged) {
             if (labels == null) {
-              labels = currentLabels(ctl);
+              labels = currentLabels(perm, cd);
             }
             short prev = labels.getOrDefault(type.getName(), (short) 0);
             ok &= v.getValue() >= prev;
@@ -988,6 +1038,7 @@
         }
       }
     }
+
     List<String> toClear = Lists.newArrayListWithCapacity(permitted.keySet().size());
     for (Map.Entry<String, Collection<String>> e : permitted.asMap().entrySet()) {
       if (isOnlyZero(e.getValue())) {
@@ -1000,11 +1051,14 @@
     return permitted.asMap();
   }
 
-  private Map<String, Short> currentLabels(ChangeControl ctl) throws OrmException {
+  private Map<String, Short> currentLabels(PermissionBackend.ForChange perm, ChangeData cd)
+      throws OrmException {
+    IdentifiedUser user = perm.user().asIdentifiedUser();
+    ChangeControl ctl = cd.changeControl().forUser(user);
     Map<String, Short> result = new HashMap<>();
     for (PatchSetApproval psa :
         approvalsUtil.byPatchSetUser(
-            db.get(), ctl, ctl.getChange().currentPatchSetId(), ctl.getUser().getAccountId())) {
+            db.get(), ctl, cd.change().currentPatchSetId(), user.getAccountId())) {
       result.put(psa.getLabel(), psa.getValue());
     }
     return result;
@@ -1029,6 +1083,10 @@
         cmi.message = message.getMessage();
         cmi.tag = message.getTag();
         cmi._revisionNumber = patchNum != null ? patchNum.get() : null;
+        Account.Id realAuthor = message.getRealAuthor();
+        if (realAuthor != null) {
+          cmi.realAuthor = accountLoader.get(realAuthor);
+        }
         result.add(cmi);
       }
     }
@@ -1070,9 +1128,11 @@
     Collection<AccountInfo> ccs = out.reviewers.get(ReviewerState.CC);
     if (ccs != null) {
       for (AccountInfo ai : ccs) {
-        Account.Id id = new Account.Id(ai._accountId);
-        if (ctl.canRemoveReviewer(id, 0)) {
-          removable.add(id);
+        if (ai._accountId != null) {
+          Account.Id id = new Account.Id(ai._accountId);
+          if (ctl.canRemoveReviewer(id, 0)) {
+            removable.add(id);
+          }
         }
       }
     }
@@ -1086,6 +1146,14 @@
     for (Account.Id id : removable) {
       result.add(accountLoader.get(id));
     }
+    // Reviewers added by email are always removable
+    for (Collection<AccountInfo> infos : out.reviewers.values()) {
+      for (AccountInfo info : infos) {
+        if (info._accountId == null) {
+          result.add(info);
+        }
+      }
+    }
     return result;
   }
 
@@ -1097,6 +1165,14 @@
         .collect(toList());
   }
 
+  private Collection<AccountInfo> toAccountInfoByEmail(Collection<Address> addresses) {
+    return addresses
+        .stream()
+        .map(a -> new AccountInfo(a.getName(), a.getEmail()))
+        .sorted(AccountInfoComparator.ORDER_NULLS_FIRST)
+        .collect(toList());
+  }
+
   @Nullable
   private Repository openRepoIfNecessary(ChangeControl ctl) throws IOException {
     if (has(ALL_COMMITS) || has(CURRENT_COMMIT) || has(COMMIT_FOOTERS)) {
@@ -1105,15 +1181,21 @@
     return null;
   }
 
+  @Nullable
+  private RevWalk newRevWalk(@Nullable Repository repo) {
+    return repo != null ? new RevWalk(repo) : null;
+  }
+
   private Map<String, RevisionInfo> revisions(
       ChangeControl ctl, ChangeData cd, Map<PatchSet.Id, PatchSet> map, ChangeInfo changeInfo)
       throws PatchListNotAvailableException, GpgException, OrmException, IOException {
     Map<String, RevisionInfo> res = new LinkedHashMap<>();
-    try (Repository repo = openRepoIfNecessary(ctl)) {
+    try (Repository repo = openRepoIfNecessary(ctl);
+        RevWalk rw = newRevWalk(repo)) {
       for (PatchSet in : map.values()) {
         if ((has(ALL_REVISIONS) || in.getId().equals(ctl.getChange().currentPatchSetId()))
             && ctl.isPatchVisible(in, db.get())) {
-          res.put(in.getRevision().get(), toRevisionInfo(ctl, cd, in, repo, false, changeInfo));
+          res.put(in.getRevision().get(), toRevisionInfo(ctl, cd, in, repo, rw, false, changeInfo));
         }
       }
       return res;
@@ -1150,9 +1232,10 @@
   public RevisionInfo getRevisionInfo(ChangeControl ctl, PatchSet in)
       throws PatchListNotAvailableException, GpgException, OrmException, IOException {
     accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
-    try (Repository repo = openRepoIfNecessary(ctl)) {
+    try (Repository repo = openRepoIfNecessary(ctl);
+        RevWalk rw = newRevWalk(repo)) {
       RevisionInfo rev =
-          toRevisionInfo(ctl, changeDataFactory.create(db.get(), ctl), in, repo, true, null);
+          toRevisionInfo(ctl, changeDataFactory.create(db.get(), ctl), in, repo, rw, true, null);
       accountLoader.fill();
       return rev;
     }
@@ -1163,6 +1246,7 @@
       ChangeData cd,
       PatchSet in,
       @Nullable Repository repo,
+      @Nullable RevWalk rw,
       boolean fillCommit,
       @Nullable ChangeInfo changeInfo)
       throws PatchListNotAvailableException, GpgException, OrmException, IOException {
@@ -1175,32 +1259,32 @@
     out.uploader = accountLoader.get(in.getUploader());
     out.draft = in.isDraft() ? true : null;
     out.fetch = makeFetchMap(ctl, in);
-    out.kind = changeKindCache.getChangeKind(repo, cd, in);
+    out.kind = changeKindCache.getChangeKind(rw, repo != null ? repo.getConfig() : null, cd, in);
     out.description = in.getDescription();
 
     boolean setCommit = has(ALL_COMMITS) || (out.isCurrent && has(CURRENT_COMMIT));
     boolean addFooters = out.isCurrent && has(COMMIT_FOOTERS);
     if (setCommit || addFooters) {
+      checkState(rw != null);
+      checkState(repo != null);
       Project.NameKey project = c.getProject();
-      try (RevWalk rw = new RevWalk(repo)) {
-        String rev = in.getRevision().get();
-        RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
-        rw.parseBody(commit);
-        if (setCommit) {
-          out.commit = toCommit(ctl, rw, commit, has(WEB_LINKS), fillCommit);
+      String rev = in.getRevision().get();
+      RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
+      rw.parseBody(commit);
+      if (setCommit) {
+        out.commit = toCommit(ctl, rw, commit, has(WEB_LINKS), fillCommit);
+      }
+      if (addFooters) {
+        Ref ref = repo.exactRef(ctl.getChange().getDest().get());
+        RevCommit mergeTip = null;
+        if (ref != null) {
+          mergeTip = rw.parseCommit(ref.getObjectId());
+          rw.parseBody(mergeTip);
         }
-        if (addFooters) {
-          Ref ref = repo.exactRef(ctl.getChange().getDest().get());
-          RevCommit mergeTip = null;
-          if (ref != null) {
-            mergeTip = rw.parseCommit(ref.getObjectId());
-            rw.parseBody(mergeTip);
-          }
-          out.commitWithFooters =
-              mergeUtilFactory
-                  .create(projectCache.get(project))
-                  .createCommitMessageOnSubmit(commit, mergeTip, ctl, in.getId());
-        }
+        out.commitWithFooters =
+            mergeUtilFactory
+                .create(projectCache.get(project))
+                .createCommitMessageOnSubmit(commit, mergeTip, ctl, in.getId());
       }
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java
index aa47827..6baeefc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java
@@ -21,8 +21,9 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.query.change.ChangeData;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
  * Cache of {@link ChangeKind} per commit.
@@ -32,9 +33,14 @@
  */
 public interface ChangeKindCache {
   ChangeKind getChangeKind(
-      Project.NameKey project, @Nullable Repository repo, ObjectId prior, ObjectId next);
+      Project.NameKey project,
+      @Nullable RevWalk rw,
+      @Nullable Config repoConfig,
+      ObjectId prior,
+      ObjectId next);
 
   ChangeKind getChangeKind(ReviewDb db, Change change, PatchSet patch);
 
-  ChangeKind getChangeKind(@Nullable Repository repo, ChangeData cd, PatchSet patch);
+  ChangeKind getChangeKind(
+      @Nullable RevWalk rw, @Nullable Config repoConfig, ChangeData cd, PatchSet patch);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
index 030ddd2..7a6c209 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static org.eclipse.jgit.lib.ObjectIdSerialization.readNotNull;
 import static org.eclipse.jgit.lib.ObjectIdSerialization.writeNotNull;
@@ -94,10 +95,14 @@
 
     @Override
     public ChangeKind getChangeKind(
-        Project.NameKey project, @Nullable Repository repo, ObjectId prior, ObjectId next) {
+        Project.NameKey project,
+        @Nullable RevWalk rw,
+        @Nullable Config repoConfig,
+        ObjectId prior,
+        ObjectId next) {
       try {
         Key key = new Key(prior, next, useRecursiveMerge);
-        return new Loader(key, repoManager, project, repo).call();
+        return new Loader(key, repoManager, project, rw, repoConfig).call();
       } catch (IOException e) {
         log.warn(
             "Cannot check trivial rebase of new patch set " + next.name() + " in " + project, e);
@@ -111,8 +116,9 @@
     }
 
     @Override
-    public ChangeKind getChangeKind(@Nullable Repository repo, ChangeData cd, PatchSet patch) {
-      return getChangeKindInternal(this, repo, cd, patch);
+    public ChangeKind getChangeKind(
+        @Nullable RevWalk rw, @Nullable Config repoConfig, ChangeData cd, PatchSet patch) {
+      return getChangeKindInternal(this, rw, repoConfig, cd, patch);
     }
   }
 
@@ -182,36 +188,47 @@
     private final Key key;
     private final GitRepositoryManager repoManager;
     private final Project.NameKey projectName;
-    private final Repository alreadyOpenRepo;
+    private final RevWalk alreadyOpenRw;
+    private final Config repoConfig;
 
     private Loader(
         Key key,
         GitRepositoryManager repoManager,
         Project.NameKey projectName,
-        @Nullable Repository alreadyOpenRepo) {
+        @Nullable RevWalk rw,
+        @Nullable Config repoConfig) {
+      checkArgument(
+          (rw == null && repoConfig == null) || (rw != null && repoConfig != null),
+          "must either provide both revwalk/config, or neither; got %s/%s",
+          rw,
+          repoConfig);
       this.key = key;
       this.repoManager = repoManager;
       this.projectName = projectName;
-      this.alreadyOpenRepo = alreadyOpenRepo;
+      this.alreadyOpenRw = rw;
+      this.repoConfig = repoConfig;
     }
 
+    @SuppressWarnings("resource") // Resources are manually managed.
     @Override
     public ChangeKind call() throws IOException {
       if (Objects.equals(key.prior, key.next)) {
         return ChangeKind.NO_CODE_CHANGE;
       }
 
-      Repository repo = alreadyOpenRepo;
-      boolean close = false;
-      if (repo == null) {
+      RevWalk rw = alreadyOpenRw;
+      Config config = repoConfig;
+      Repository repo = null;
+      if (alreadyOpenRw == null) {
         repo = repoManager.openRepository(projectName);
-        close = true;
+        rw = new RevWalk(repo);
+        config = repo.getConfig();
       }
-      try (RevWalk walk = new RevWalk(repo)) {
-        RevCommit prior = walk.parseCommit(key.prior);
-        walk.parseBody(prior);
-        RevCommit next = walk.parseCommit(key.next);
-        walk.parseBody(next);
+      try {
+        RevCommit prior = rw.parseCommit(key.prior);
+        rw.parseBody(prior);
+        RevCommit next = rw.parseCommit(key.next);
+        rw.parseBody(next);
 
         if (!next.getFullMessage().equals(prior.getFullMessage())) {
           if (isSameDeltaAndTree(prior, next)) {
@@ -233,8 +250,8 @@
         // A trivial rebase can be detected by looking for the next commit
         // having the same tree as would exist when the prior commit is
         // cherry-picked onto the next commit's new first parent.
-        try (ObjectInserter ins = new InMemoryInserter(repo)) {
-          ThreeWayMerger merger = MergeUtil.newThreeWayMerger(repo, ins, key.strategyName);
+        try (ObjectInserter ins = new InMemoryInserter(rw.getObjectReader())) {
+          ThreeWayMerger merger = MergeUtil.newThreeWayMerger(ins, config, key.strategyName);
           merger.setBase(prior.getParent(0));
           if (merger.merge(next.getParent(0), prior)
               && merger.getResultTreeId().equals(next.getTree())) {
@@ -249,7 +266,8 @@
         }
         return ChangeKind.REWORK;
       } finally {
-        if (close) {
+        if (repo != null) {
+          rw.close();
           repo.close();
         }
       }
@@ -327,10 +345,14 @@
 
   @Override
   public ChangeKind getChangeKind(
-      Project.NameKey project, @Nullable Repository repo, ObjectId prior, ObjectId next) {
+      Project.NameKey project,
+      @Nullable RevWalk rw,
+      @Nullable Config repoConfig,
+      ObjectId prior,
+      ObjectId next) {
     try {
       Key key = new Key(prior, next, useRecursiveMerge);
-      return cache.get(key, new Loader(key, repoManager, project, repo));
+      return cache.get(key, new Loader(key, repoManager, project, rw, repoConfig));
     } catch (ExecutionException e) {
       log.warn("Cannot check trivial rebase of new patch set " + next.name() + " in " + project, e);
       return ChangeKind.REWORK;
@@ -343,12 +365,17 @@
   }
 
   @Override
-  public ChangeKind getChangeKind(@Nullable Repository repo, ChangeData cd, PatchSet patch) {
-    return getChangeKindInternal(this, repo, cd, patch);
+  public ChangeKind getChangeKind(
+      @Nullable RevWalk rw, @Nullable Config repoConfig, ChangeData cd, PatchSet patch) {
+    return getChangeKindInternal(this, rw, repoConfig, cd, patch);
   }
 
   private static ChangeKind getChangeKindInternal(
-      ChangeKindCache cache, @Nullable Repository repo, ChangeData change, PatchSet patch) {
+      ChangeKindCache cache,
+      @Nullable RevWalk rw,
+      @Nullable Config repoConfig,
+      ChangeData change,
+      PatchSet patch) {
     ChangeKind kind = ChangeKind.REWORK;
     // Trivial case: if we're on the first patch, we don't need to use
     // the repository.
@@ -372,7 +399,8 @@
           kind =
               cache.getChangeKind(
                   change.project(),
-                  repo,
+                  rw,
+                  repoConfig,
                   ObjectId.fromString(priorPs.getRevision().get()),
                   ObjectId.fromString(patch.getRevision().get()));
         }
@@ -401,8 +429,11 @@
     // Trivial case: if we're on the first patch, we don't need to open
     // the repository.
     if (patch.getId().get() > 1) {
-      try (Repository repo = repoManager.openRepository(change.getProject())) {
-        kind = getChangeKindInternal(cache, repo, changeDataFactory.create(db, change), patch);
+      try (Repository repo = repoManager.openRepository(change.getProject());
+          RevWalk rw = new RevWalk(repo)) {
+        kind =
+            getChangeKindInternal(
+                cache, rw, repo.getConfig(), changeDataFactory.create(db, change), patch);
       } catch (IOException e) {
         // Do nothing; assume we have a complex change
         log.warn(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeMessages.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeMessages.java
index 92b4150..41b6855 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeMessages.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeMessages.java
@@ -23,6 +23,10 @@
   }
 
   public String revertChangeDefaultMessage;
+
+  public String reviewerCantSeeChange;
+  public String reviewerInactive;
+  public String reviewerInvalid;
   public String reviewerNotFoundUser;
   public String reviewerNotFoundUserOrGroup;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java
index b06f05f..1bec3d1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestResource.HasETag;
 import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
@@ -29,12 +30,13 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import org.eclipse.jgit.lib.ObjectId;
 
 public class ChangeResource implements RestResource, HasETag {
@@ -53,15 +55,24 @@
     ChangeResource create(ChangeControl ctl);
   }
 
+  private final PermissionBackend permissionBackend;
   private final StarredChangesUtil starredChangesUtil;
   private final ChangeControl control;
 
-  @AssistedInject
-  ChangeResource(StarredChangesUtil starredChangesUtil, @Assisted ChangeControl control) {
+  @Inject
+  ChangeResource(
+      PermissionBackend permissionBackend,
+      StarredChangesUtil starredChangesUtil,
+      @Assisted ChangeControl control) {
+    this.permissionBackend = permissionBackend;
     this.starredChangesUtil = starredChangesUtil;
     this.control = control;
   }
 
+  public PermissionBackend.ForChange permissions() {
+    return permissionBackend.user(getControl().getUser()).change(getNotes());
+  }
+
   public ChangeControl getControl() {
     return control;
   }
@@ -74,6 +85,13 @@
     return getControl().getId();
   }
 
+  /** @return true if {@link #getUser()} is the change's owner. */
+  public boolean isUserOwner() {
+    CurrentUser user = getControl().getUser();
+    Account.Id owner = getChange().getOwner();
+    return user.isIdentifiedUser() && user.asIdentifiedUser().getAccountId().equals(owner);
+  }
+
   public Change getChange() {
     return getControl().getChange();
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java
index 3b67930..a36a1d3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java
@@ -17,21 +17,28 @@
 import com.google.gerrit.extensions.api.changes.FixInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 
 public class Check
     implements RestReadView<ChangeResource>, RestModifyView<ChangeResource, FixInput> {
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
   private final ChangeJson.Factory jsonFactory;
 
   @Inject
-  Check(ChangeJson.Factory json) {
+  Check(PermissionBackend permissionBackend, Provider<CurrentUser> user, ChangeJson.Factory json) {
+    this.permissionBackend = permissionBackend;
+    this.user = user;
     this.jsonFactory = json;
   }
 
@@ -42,12 +49,9 @@
 
   @Override
   public Response<ChangeInfo> apply(ChangeResource rsrc, FixInput input)
-      throws RestApiException, OrmException {
-    ChangeControl ctl = rsrc.getControl();
-    if (!ctl.isOwner()
-        && !ctl.getProjectControl().isOwner()
-        && !ctl.getUser().getCapabilities().canMaintainServer()) {
-      throw new AuthException("Cannot fix change");
+      throws RestApiException, OrmException, PermissionBackendException {
+    if (!rsrc.isUserOwner() && !rsrc.getControl().getProjectControl().isOwner()) {
+      permissionBackend.user(user).check(GlobalPermission.MAINTAIN_SERVER);
     }
     return Response.withMustRevalidate(newChangeJson().fix(input).format(rsrc));
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java
index e5a4d0f..18d2fc1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java
@@ -66,9 +66,7 @@
       throw new BadRequestException("destination must be non-empty");
     }
 
-    @SuppressWarnings("resource")
-    ReviewDb db = dbProvider.get();
-    if (!control.isVisible(db)) {
+    if (!control.isVisible(dbProvider.get())) {
       throw new AuthException("Cherry pick not permitted");
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
index b2455f1..ac092b5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
@@ -39,10 +40,8 @@
 import com.google.gerrit.server.git.IntegrationException;
 import com.google.gerrit.server.git.MergeIdenticalTreeException;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.RefControl;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -59,9 +58,6 @@
 import java.sql.Timestamp;
 import java.util.List;
 import java.util.TimeZone;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -115,23 +111,42 @@
   }
 
   public Change.Id cherryPick(
-      Change change,
-      PatchSet patch,
-      final String message,
-      final String ref,
-      final RefControl refControl,
-      int parent)
-      throws NoSuchChangeException, OrmException, MissingObjectException,
-          IncorrectObjectTypeException, IOException, InvalidChangeOperationException,
-          IntegrationException, UpdateException, RestApiException {
+      Change change, PatchSet patch, String message, String ref, RefControl refControl, int parent)
+      throws OrmException, IOException, InvalidChangeOperationException, IntegrationException,
+          UpdateException, RestApiException {
+    return cherryPick(
+        change.getId(),
+        patch.getId(),
+        change.getDest(),
+        change.getTopic(),
+        change.getProject(),
+        ObjectId.fromString(patch.getRevision().get()),
+        message,
+        ref,
+        refControl,
+        parent);
+  }
 
-    if (Strings.isNullOrEmpty(ref)) {
+  public Change.Id cherryPick(
+      @Nullable Change.Id sourceChangeId,
+      @Nullable PatchSet.Id sourcePatchId,
+      @Nullable Branch.NameKey sourceBranch,
+      @Nullable String sourceChangeTopic,
+      Project.NameKey project,
+      ObjectId sourceCommit,
+      String message,
+      String targetRef,
+      RefControl targetRefControl,
+      int parent)
+      throws OrmException, IOException, InvalidChangeOperationException, IntegrationException,
+          UpdateException, RestApiException {
+
+    if (Strings.isNullOrEmpty(targetRef)) {
       throw new InvalidChangeOperationException(
           "Cherry Pick: Destination branch cannot be null or empty");
     }
 
-    Project.NameKey project = change.getProject();
-    String destinationBranch = RefNames.shortName(ref);
+    String destinationBranch = RefNames.shortName(targetRef);
     IdentifiedUser identifiedUser = user.get();
     try (Repository git = gitManager.openRepository(project);
         // This inserter and revwalk *must* be passed to any BatchUpdates
@@ -140,7 +155,7 @@
         ObjectInserter oi = git.newObjectInserter();
         ObjectReader reader = oi.newReader();
         CodeReviewRevWalk revWalk = CodeReviewCommit.newRevWalk(reader)) {
-      Ref destRef = git.getRefDatabase().exactRef(ref);
+      Ref destRef = git.getRefDatabase().exactRef(targetRef);
       if (destRef == null) {
         throw new InvalidChangeOperationException(
             String.format("Branch %s does not exist.", destinationBranch));
@@ -148,8 +163,7 @@
 
       CodeReviewCommit mergeTip = revWalk.parseCommit(destRef.getObjectId());
 
-      CodeReviewCommit commitToCherryPick =
-          revWalk.parseCommit(ObjectId.fromString(patch.getRevision().get()));
+      CodeReviewCommit commitToCherryPick = revWalk.parseCommit(sourceCommit);
 
       if (parent <= 0 || parent > commitToCherryPick.getParentCount()) {
         throw new InvalidChangeOperationException(
@@ -173,13 +187,13 @@
 
       CodeReviewCommit cherryPickCommit;
       try {
-        ProjectState projectState = refControl.getProjectControl().getProjectState();
+        ProjectState projectState = targetRefControl.getProjectControl().getProjectState();
         cherryPickCommit =
             mergeUtilFactory
                 .create(projectState)
                 .createCherryPickFromCommit(
-                    git,
                     oi,
+                    git.getConfig(),
                     mergeTip,
                     commitToCherryPick,
                     committerIdent,
@@ -197,7 +211,7 @@
           changeKey = new Change.Key("I" + computedChangeId.name());
         }
 
-        Branch.NameKey newDest = new Branch.NameKey(change.getProject(), destRef.getName());
+        Branch.NameKey newDest = new Branch.NameKey(project, destRef.getName());
         List<ChangeData> destChanges =
             queryProvider.get().setLimit(2).byBranchKey(newDest, changeKey);
         if (destChanges.size() > 1) {
@@ -207,32 +221,37 @@
                   + " reside on the same branch. "
                   + "Cannot create a new patch set.");
         }
-        try (BatchUpdate bu =
-            batchUpdateFactory.create(
-                db.get(), change.getDest().getParentKey(), identifiedUser, now)) {
+        try (BatchUpdate bu = batchUpdateFactory.create(db.get(), project, identifiedUser, now)) {
           bu.setRepository(git, revWalk, oi);
           Change.Id result;
           if (destChanges.size() == 1) {
             // The change key exists on the destination branch. The cherry pick
             // will be added as a new patch set.
             ChangeControl destCtl =
-                refControl.getProjectControl().controlFor(destChanges.get(0).notes());
+                targetRefControl.getProjectControl().controlFor(destChanges.get(0).notes());
             result = insertPatchSet(bu, git, destCtl, cherryPickCommit);
           } else {
             // Change key not found on destination branch. We can create a new
             // change.
             String newTopic = null;
-            if (!Strings.isNullOrEmpty(change.getTopic())) {
-              newTopic = change.getTopic() + "-" + newDest.getShortName();
+            if (!Strings.isNullOrEmpty(sourceChangeTopic)) {
+              newTopic = sourceChangeTopic + "-" + newDest.getShortName();
             }
             result =
                 createNewChange(
-                    bu, cherryPickCommit, refControl.getRefName(), newTopic, change.getDest());
+                    bu,
+                    cherryPickCommit,
+                    targetRefControl.getRefName(),
+                    newTopic,
+                    sourceBranch,
+                    sourceCommit);
 
-            bu.addOp(
-                change.getId(),
-                new AddMessageToSourceChangeOp(
-                    changeMessagesUtil, patch.getId(), destinationBranch, cherryPickCommit));
+            if (sourceChangeId != null && sourcePatchId != null) {
+              bu.addOp(
+                  sourceChangeId,
+                  new AddMessageToSourceChangeOp(
+                      changeMessagesUtil, sourcePatchId, destinationBranch, cherryPickCommit));
+            }
           }
           bu.execute();
           return result;
@@ -240,8 +259,6 @@
       } catch (MergeIdenticalTreeException | MergeConflictException e) {
         throw new IntegrationException("Cherry pick failed: " + e.getMessage());
       }
-    } catch (RepositoryNotFoundException e) {
-      throw new NoSuchChangeException(change.getId(), e);
     }
   }
 
@@ -268,16 +285,13 @@
       CodeReviewCommit cherryPickCommit,
       String refName,
       String topic,
-      Branch.NameKey sourceBranch)
-      throws OrmException {
+      Branch.NameKey sourceBranch,
+      ObjectId sourceCommit)
+      throws OrmException, IOException {
     Change.Id changeId = new Change.Id(seq.nextChangeId());
     ChangeInserter ins =
-        changeInserterFactory
-            .create(changeId, cherryPickCommit, refName)
-            .setValidatePolicy(CommitValidators.Policy.GERRIT)
-            .setTopic(topic);
-
-    ins.setMessage(messageForDestinationChange(ins.getPatchSetId(), sourceBranch));
+        changeInserterFactory.create(changeId, cherryPickCommit, refName).setTopic(topic);
+    ins.setMessage(messageForDestinationChange(ins.getPatchSetId(), sourceBranch, sourceCommit));
     bu.insertChange(ins);
     return changeId;
   }
@@ -319,12 +333,16 @@
     }
   }
 
-  private String messageForDestinationChange(PatchSet.Id patchSetId, Branch.NameKey sourceBranch) {
-    return new StringBuilder("Patch Set ")
-        .append(patchSetId.get())
-        .append(": Cherry Picked from branch ")
-        .append(sourceBranch.getShortName())
-        .append(".")
-        .toString();
+  private String messageForDestinationChange(
+      PatchSet.Id patchSetId, Branch.NameKey sourceBranch, ObjectId sourceCommit) {
+    StringBuilder stringBuilder = new StringBuilder("Patch Set ").append(patchSetId.get());
+
+    if (sourceBranch != null) {
+      stringBuilder.append(": Cherry Picked from branch ").append(sourceBranch.getShortName());
+    } else {
+      stringBuilder.append(": Cherry Picked from commit ").append(sourceCommit.getName());
+    }
+
+    return stringBuilder.append(".").toString();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickCommit.java
new file mode 100644
index 0000000..cf99d37
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickCommit.java
@@ -0,0 +1,98 @@
+// Copyright (C) 2017 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.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.data.Capable;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.project.CommitResource;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.RefControl;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+@Singleton
+public class CherryPickCommit implements RestModifyView<CommitResource, CherryPickInput> {
+
+  private final CherryPickChange cherryPickChange;
+  private final ChangeJson.Factory json;
+
+  @Inject
+  CherryPickCommit(CherryPickChange cherryPickChange, ChangeJson.Factory json) {
+    this.cherryPickChange = cherryPickChange;
+    this.json = json;
+  }
+
+  @Override
+  public ChangeInfo apply(CommitResource rsrc, CherryPickInput input)
+      throws OrmException, IOException, UpdateException, RestApiException {
+    String message = Strings.nullToEmpty(input.message).trim();
+    String destination = Strings.nullToEmpty(input.destination).trim();
+    int parent = input.parent == null ? 1 : input.parent;
+
+    if (destination.isEmpty()) {
+      throw new BadRequestException("destination must be non-empty");
+    }
+
+    ProjectControl projectControl = rsrc.getProject();
+    Capable capable = projectControl.canPushToAtLeastOneRef();
+    if (capable != Capable.OK) {
+      throw new AuthException(capable.getMessage());
+    }
+
+    RevCommit commit = rsrc.getCommit();
+    String refName = RefNames.fullName(destination);
+    RefControl refControl = projectControl.controlForRef(refName);
+    if (!refControl.canUpload()) {
+      throw new AuthException("Not allowed to cherry pick " + commit + " to " + destination);
+    }
+
+    Project.NameKey project = projectControl.getProject().getNameKey();
+    try {
+      Change.Id cherryPickedChangeId =
+          cherryPickChange.cherryPick(
+              null,
+              null,
+              null,
+              null,
+              project,
+              commit,
+              message.isEmpty() ? commit.getFullMessage() : message,
+              refName,
+              refControl,
+              parent);
+      return json.noOptions().format(project, cherryPickedChangeId);
+    } catch (InvalidChangeOperationException e) {
+      throw new BadRequestException(e.getMessage());
+    } catch (IntegrationException e) {
+      throw new ResourceConflictException(e.getMessage());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
index 41845e3..49d7bae 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -44,7 +44,6 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.PatchSetState;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
@@ -77,7 +76,6 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -119,6 +117,7 @@
   private ChangeControl ctl;
   private Repository repo;
   private RevWalk rw;
+  private ObjectInserter oi;
 
   private RevCommit tip;
   private SetMultimap<ObjectId, PatchSet> patchSetsBySha;
@@ -175,7 +174,9 @@
       return result();
     } finally {
       if (rw != null) {
+        rw.getObjectReader().close();
         rw.close();
+        oi.close();
       }
       if (repo != null) {
         repo.close();
@@ -223,7 +224,8 @@
     Project.NameKey project = change().getDest().getParentKey();
     try {
       repo = repoManager.openRepository(project);
-      rw = new RevWalk(repo);
+      oi = repo.newObjectInserter();
+      rw = new RevWalk(oi.newReader());
       return true;
     } catch (RepositoryNotFoundException e) {
       return error("Destination repository not found: " + project, e);
@@ -490,8 +492,7 @@
               ? psIdToDelete
               : ChangeUtil.nextPatchSetId(repo, change().currentPatchSetId());
       PatchSetInserter inserter = patchSetInserterFactory.create(ctl, psId, commit);
-      try (BatchUpdate bu = newBatchUpdate();
-          ObjectInserter oi = repo.newObjectInserter()) {
+      try (BatchUpdate bu = newBatchUpdate()) {
         bu.setRepository(repo, rw, oi);
 
         if (psIdToDelete != null) {
@@ -502,8 +503,7 @@
               new BatchUpdateOp() {
                 @Override
                 public void updateRepo(RepoContext ctx) throws IOException {
-                  ctx.addRefUpdate(
-                      new ReceiveCommand(commit, ObjectId.zeroId(), psIdToDelete.toRefName()));
+                  ctx.addRefUpdate(commit, ObjectId.zeroId(), psIdToDelete.toRefName());
                 }
               });
           if (!reuseOldPsId) {
@@ -516,7 +516,7 @@
         bu.addOp(
             ctl.getId(),
             inserter
-                .setValidatePolicy(CommitValidators.Policy.NONE)
+                .setValidate(false)
                 .setFireRevisionCreated(false)
                 .setNotify(NotifyHandling.NONE)
                 .setAllowClosed(true)
@@ -555,8 +555,7 @@
   }
 
   private void fixMerged(ProblemInfo p) {
-    try (BatchUpdate bu = newBatchUpdate();
-        ObjectInserter oi = repo.newObjectInserter()) {
+    try (BatchUpdate bu = newBatchUpdate()) {
       bu.setRepository(repo, rw, oi);
       bu.addOp(ctl.getId(), new FixMergedOp(p));
       bu.execute();
@@ -607,8 +606,7 @@
   }
 
   private void deletePatchSets(List<DeletePatchSetFromDbOp> ops) {
-    try (BatchUpdate bu = newBatchUpdate();
-        ObjectInserter oi = repo.newObjectInserter()) {
+    try (BatchUpdate bu = newBatchUpdate()) {
       bu.setRepository(repo, rw, oi);
       for (DeletePatchSetFromDbOp op : ops) {
         checkArgument(op.psId.getParentKey().equals(ctl.getId()));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java
index 656c9d1..e0cb2e1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java
@@ -53,7 +53,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.ProjectControl;
@@ -146,7 +146,7 @@
   @Override
   public Response<ChangeInfo> apply(TopLevelResource parent, ChangeInput input)
       throws OrmException, IOException, InvalidChangeOperationException, RestApiException,
-          UpdateException {
+          UpdateException, PermissionBackendException {
     if (Strings.isNullOrEmpty(input.project)) {
       throw new BadRequestException("project must be non-empty");
     }
@@ -252,10 +252,7 @@
       }
 
       Change.Id changeId = new Change.Id(seq.nextChangeId());
-      ChangeInserter ins =
-          changeInserterFactory
-              .create(changeId, c, refName)
-              .setValidatePolicy(CommitValidators.Policy.GERRIT);
+      ChangeInserter ins = changeInserterFactory.create(changeId, c, refName);
       ins.setMessage(String.format("Uploaded patch set %s.", ins.getPatchSetId().get()));
       String topic = input.topic;
       if (topic != null) {
@@ -324,7 +321,14 @@
             Strings.emptyToNull(merge.strategy), mergeUtil.mergeStrategyName());
 
     return MergeUtil.createMergeCommit(
-        repo, oi, mergeTip, sourceCommit, mergeStrategy, authorIdent, commitMessage, rw);
+        oi,
+        repo.getConfig(),
+        mergeTip,
+        sourceCommit,
+        mergeStrategy,
+        authorIdent,
+        commitMessage,
+        rw);
   }
 
   private static ObjectId insert(ObjectInserter inserter, CommitBuilder commit)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
index 5032e57..6536550f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
@@ -122,7 +122,7 @@
 
       commentsUtil.putComments(
           ctx.getDb(), ctx.getUpdate(psId), Status.DRAFT, Collections.singleton(comment));
-      ctx.bumpLastUpdatedOn(false);
+      ctx.dontBumpLastUpdatedOn();
       return true;
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java
index 5bd651d..42bfb8a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java
@@ -216,6 +216,6 @@
             mergeUtilFactory.create(projectControl.getProjectState()).mergeStrategyName());
 
     return MergeUtil.createMergeCommit(
-        git, oi, mergeTip, sourceCommit, mergeStrategy, author, commitMsg, rw);
+        oi, git.getConfig(), mergeTip, sourceCommit, mergeStrategy, author, commitMsg, rw);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java
index b8556d6..1d5a916 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -30,6 +29,8 @@
 import com.google.gerrit.server.change.DeleteAssignee.Input;
 import com.google.gerrit.server.extensions.events.AssigneeChanged;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -69,7 +70,9 @@
 
   @Override
   public Response<AccountInfo> apply(ChangeResource rsrc, Input input)
-      throws RestApiException, UpdateException, OrmException {
+      throws RestApiException, UpdateException, OrmException, PermissionBackendException {
+    rsrc.permissions().check(ChangePermission.EDIT_ASSIGNEE);
+
     try (BatchUpdate bu =
         batchUpdateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       Op op = new Op();
@@ -88,9 +91,6 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx) throws RestApiException, OrmException {
-      if (!ctx.getControl().canEditAssignee()) {
-        throw new AuthException("Delete Assignee not permitted");
-      }
       change = ctx.getChange();
       ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
       Account.Id currentAssigneeId = change.getAssignee();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChange.java
index ad823d4..151ffa1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChange.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChange.java
@@ -15,20 +15,24 @@
 package com.google.gerrit.server.change;
 
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.DeleteChange.Input;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.Order;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -42,6 +46,8 @@
   private final Provider<ReviewDb> db;
   private final BatchUpdate.Factory updateFactory;
   private final Provider<DeleteChangeOp> opProvider;
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
   private final boolean allowDrafts;
 
   @Inject
@@ -49,16 +55,33 @@
       Provider<ReviewDb> db,
       BatchUpdate.Factory updateFactory,
       Provider<DeleteChangeOp> opProvider,
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
       @GerritServerConfig Config cfg) {
     this.db = db;
     this.updateFactory = updateFactory;
     this.opProvider = opProvider;
+    this.user = user;
+    this.permissionBackend = permissionBackend;
     this.allowDrafts = DeleteChangeOp.allowDrafts(cfg);
   }
 
   @Override
   public Response<?> apply(ChangeResource rsrc, Input input)
-      throws RestApiException, UpdateException {
+      throws RestApiException, UpdateException, PermissionBackendException {
+    if (rsrc.getChange().getStatus() == Change.Status.MERGED) {
+      throw new MethodNotAllowedException("delete not permitted");
+    } else if (!allowDrafts && rsrc.getChange().getStatus() == Change.Status.DRAFT) {
+      // If drafts are disabled, only an administrator can delete a draft.
+      try {
+        permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+      } catch (AuthException e) {
+        throw new MethodNotAllowedException("Draft workflow is disabled");
+      }
+    } else {
+      rsrc.permissions().database(db).check(ChangePermission.DELETE);
+    }
+
     try (BatchUpdate bu =
         updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       Change.Id id = rsrc.getChange().getId();
@@ -71,21 +94,33 @@
 
   @Override
   public UiAction.Description getDescription(ChangeResource rsrc) {
-    try {
-      Change.Status status = rsrc.getChange().getStatus();
-      ChangeControl changeControl = rsrc.getControl();
-      boolean visible =
-          isActionAllowed(changeControl, status) && changeControl.canDelete(db.get(), status);
-      return new UiAction.Description()
-          .setLabel("Delete")
-          .setTitle("Delete change " + rsrc.getId())
-          .setVisible(visible);
-    } catch (OrmException e) {
-      throw new IllegalStateException(e);
-    }
+    Change.Status status = rsrc.getChange().getStatus();
+    PermissionBackend.ForChange perm = rsrc.permissions().database(db);
+    return new UiAction.Description()
+        .setLabel("Delete")
+        .setTitle("Delete change " + rsrc.getId())
+        .setVisible(couldDeleteWhenIn(status) && perm.testOrFalse(ChangePermission.DELETE));
   }
 
-  private boolean isActionAllowed(ChangeControl changeControl, Status status) {
-    return status != Status.DRAFT || allowDrafts || changeControl.isAdmin();
+  private boolean couldDeleteWhenIn(Change.Status status) {
+    switch (status) {
+      case NEW:
+      case ABANDONED:
+        // New or abandoned changes can be deleted with the right permissions.
+        return true;
+
+      case MERGED:
+        // Merged changes should never be deleted.
+        return false;
+
+      case DRAFT:
+        if (allowDrafts) {
+          // Drafts can only be deleted if the server has drafts enabled.
+          return true;
+        }
+        // If drafts are disabled, only administrators may delete.
+        return permissionBackend.user(user).testOrFalse(GlobalPermission.ADMINISTRATE_SERVER);
+    }
+    return false;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java
index 9d819b1..8ab1d2a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -27,7 +26,6 @@
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateReviewDb;
@@ -38,14 +36,11 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.Collection;
-import java.util.Collections;
+import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
 
 class DeleteChangeOp implements BatchUpdateOp {
   static boolean allowDrafts(Config cfg) {
@@ -65,7 +60,6 @@
   private final PatchSetUtil psUtil;
   private final StarredChangesUtil starredChangesUtil;
   private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
-  private final boolean allowDrafts;
 
   private Change.Id id;
 
@@ -73,12 +67,10 @@
   DeleteChangeOp(
       PatchSetUtil psUtil,
       StarredChangesUtil starredChangesUtil,
-      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
-      @GerritServerConfig Config cfg) {
+      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
     this.psUtil = psUtil;
     this.starredChangesUtil = starredChangesUtil;
     this.accountPatchReviewStore = accountPatchReviewStore;
-    this.allowDrafts = allowDrafts(cfg);
   }
 
   @Override
@@ -102,8 +94,7 @@
   }
 
   private void ensureDeletable(ChangeContext ctx, Change.Id id, Collection<PatchSet> patchSets)
-      throws ResourceConflictException, MethodNotAllowedException, OrmException, AuthException,
-          IOException {
+      throws ResourceConflictException, MethodNotAllowedException, IOException {
     Change.Status status = ctx.getChange().getStatus();
     if (status == Change.Status.MERGED) {
       throw new MethodNotAllowedException("Deleting merged change " + id + " is not allowed");
@@ -117,14 +108,7 @@
       }
     }
 
-    if (!ctx.getControl().canDelete(ctx.getDb(), status)) {
-      throw new AuthException("Deleting change " + id + " is not permitted");
-    }
-
     if (status == Change.Status.DRAFT) {
-      if (!allowDrafts && !ctx.getControl().isAdmin()) {
-        throw new MethodNotAllowedException("Draft workflow is disabled");
-      }
       for (PatchSet ps : patchSets) {
         if (!ps.isDraft()) {
           throw new ResourceConflictException(
@@ -139,17 +123,14 @@
   }
 
   private boolean isPatchSetMerged(ChangeContext ctx, PatchSet patchSet) throws IOException {
-    Repository repository = ctx.getRepository();
-    Ref destinationRef = repository.exactRef(ctx.getChange().getDest().get());
-    if (destinationRef == null) {
+    Optional<ObjectId> destId = ctx.getRepoView().getRef(ctx.getChange().getDest().get());
+    if (!destId.isPresent()) {
       return false;
     }
 
     RevWalk revWalk = ctx.getRevWalk();
     ObjectId objectId = ObjectId.fromString(patchSet.getRevision().get());
-    RevCommit revCommit = revWalk.parseCommit(objectId);
-    return IncludedInResolver.includedInOne(
-        repository, revWalk, revCommit, Collections.singletonList(destinationRef));
+    return revWalk.isMergedInto(revWalk.parseCommit(objectId), revWalk.parseCommit(destId.get()));
   }
 
   private void deleteChangeElementsFromDb(ChangeContext ctx, Change.Id id) throws OrmException {
@@ -177,8 +158,8 @@
   public void updateRepo(RepoContext ctx) throws IOException {
     String prefix = new PatchSet.Id(id, 1).toRefName();
     prefix = prefix.substring(0, prefix.length() - 1);
-    for (Ref ref : ctx.getRepository().getRefDatabase().getRefs(prefix).values()) {
-      ctx.addRefUpdate(new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), ref.getName()));
+    for (Map.Entry<String, ObjectId> e : ctx.getRepoView().getRefs(prefix).entrySet()) {
+      ctx.addRefUpdate(e.getValue(), ObjectId.zeroId(), prefix + e.getKey());
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
index 7787260..d1b26ec 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
@@ -101,7 +101,7 @@
       Comment c = maybeComment.get();
       setCommentRevId(c, patchListCache, ctx.getChange(), ps);
       commentsUtil.deleteComments(ctx.getDb(), ctx.getUpdate(psId), Collections.singleton(c));
-      ctx.bumpLastUpdatedOn(false);
+      ctx.dontBumpLastUpdatedOn();
       return true;
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
index 222230b..583bc58 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.common.collect.Iterables;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -32,6 +33,8 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -47,7 +50,6 @@
 import java.util.Collection;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.transport.ReceiveCommand;
 
 @Singleton
 public class DeleteDraftPatchSet
@@ -82,7 +84,12 @@
 
   @Override
   public Response<?> apply(RevisionResource rsrc, Input input)
-      throws RestApiException, UpdateException {
+      throws RestApiException, UpdateException, OrmException, PermissionBackendException {
+    if (isDeletingOnlyPatchSet(rsrc)) {
+      // A change cannot have zero patch sets; the change is deleted instead.
+      rsrc.permissions().database(db).check(ChangePermission.DELETE);
+    }
+
     try (BatchUpdate bu =
         updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       bu.setOrder(Order.DB_BEFORE_REPO);
@@ -92,6 +99,12 @@
     return Response.none();
   }
 
+  private boolean isDeletingOnlyPatchSet(RevisionResource rsrc) throws OrmException {
+    Collection<PatchSet> patchSets = psUtil.byChange(db.get(), rsrc.getNotes());
+    return patchSets.size() == 1
+        && Iterables.getOnlyElement(patchSets).getId().equals(rsrc.getPatchSet().getId());
+  }
+
   private class Op implements BatchUpdateOp {
     private final PatchSet.Id psId;
 
@@ -133,10 +146,9 @@
         return;
       }
       ctx.addRefUpdate(
-          new ReceiveCommand(
-              ObjectId.fromString(patchSet.getRevision().get()),
-              ObjectId.zeroId(),
-              patchSet.getRefName()));
+          ObjectId.fromString(patchSet.getRevision().get()),
+          ObjectId.zeroId(),
+          patchSet.getRefName());
     }
 
     private void deleteDraftPatchSet(PatchSet patchSet, ChangeContext ctx) throws OrmException {
@@ -185,15 +197,14 @@
   @Override
   public UiAction.Description getDescription(RevisionResource rsrc) {
     try {
-      int psCount = psUtil.byChange(db.get(), rsrc.getNotes()).size();
       return new UiAction.Description()
           .setLabel("Delete")
           .setTitle(String.format("Delete draft revision %d", rsrc.getPatchSet().getPatchSetId()))
           .setVisible(
               allowDrafts
                   && rsrc.getPatchSet().isDraft()
-                  && rsrc.getControl().canDelete(db.get(), Change.Status.DRAFT)
-                  && psCount > 1);
+                  && psUtil.byChange(db.get(), rsrc.getNotes()).size() > 1
+                  && rsrc.getControl().canDelete(db.get(), Change.Status.DRAFT));
     } catch (OrmException e) {
       throw new IllegalStateException(e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeletePrivate.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeletePrivate.java
new file mode 100644
index 0000000..fc1c3f1
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeletePrivate.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2017 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.change;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class DeletePrivate
+    implements RestModifyView<ChangeResource, DeletePrivate.Input>, UiAction<ChangeResource> {
+  public static class Input {}
+
+  private final ChangeMessagesUtil cmUtil;
+  private final Provider<ReviewDb> dbProvider;
+  private final BatchUpdate.Factory batchUpdateFactory;
+
+  @Inject
+  DeletePrivate(
+      Provider<ReviewDb> dbProvider,
+      BatchUpdate.Factory batchUpdateFactory,
+      ChangeMessagesUtil cmUtil) {
+    this.dbProvider = dbProvider;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.cmUtil = cmUtil;
+  }
+
+  @Override
+  public Response<String> apply(ChangeResource rsrc, DeletePrivate.Input input)
+      throws RestApiException, UpdateException {
+    if (!rsrc.isUserOwner()) {
+      throw new AuthException("not allowed to unmark private");
+    }
+
+    if (!rsrc.getChange().isPrivate()) {
+      throw new ResourceConflictException("change is not private");
+    }
+
+    ChangeControl control = rsrc.getControl();
+    SetPrivateOp op = new SetPrivateOp(cmUtil, false);
+    try (BatchUpdate u =
+        batchUpdateFactory.create(
+            dbProvider.get(),
+            control.getProject().getNameKey(),
+            control.getUser(),
+            TimeUtil.nowTs())) {
+      u.addOp(control.getId(), op).execute();
+    }
+
+    return Response.none();
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Unmark private")
+        .setTitle("Unmark change as private")
+        .setVisible(rsrc.getChange().isPrivate() && rsrc.isUserOwner());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
index 1485d03..4822478 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
@@ -14,92 +14,38 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.collect.Iterables;
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.extensions.events.ReviewerDeleted;
-import com.google.gerrit.server.mail.send.DeleteReviewerSender;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.BatchUpdateReviewDb;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 @Singleton
 public class DeleteReviewer implements RestModifyView<ReviewerResource, DeleteReviewerInput> {
-  private static final Logger log = LoggerFactory.getLogger(DeleteReviewer.class);
 
   private final Provider<ReviewDb> dbProvider;
-  private final ApprovalsUtil approvalsUtil;
-  private final PatchSetUtil psUtil;
-  private final ChangeMessagesUtil cmUtil;
   private final BatchUpdate.Factory batchUpdateFactory;
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final ReviewerDeleted reviewerDeleted;
-  private final Provider<IdentifiedUser> user;
-  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
-  private final NotesMigration migration;
-  private final NotifyUtil notifyUtil;
+  private final DeleteReviewerOp.Factory deleteReviewerOpFactory;
+  private final DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory;
 
   @Inject
   DeleteReviewer(
       Provider<ReviewDb> dbProvider,
-      ApprovalsUtil approvalsUtil,
-      PatchSetUtil psUtil,
-      ChangeMessagesUtil cmUtil,
       BatchUpdate.Factory batchUpdateFactory,
-      IdentifiedUser.GenericFactory userFactory,
-      ReviewerDeleted reviewerDeleted,
-      Provider<IdentifiedUser> user,
-      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
-      NotesMigration migration,
-      NotifyUtil notifyUtil) {
+      DeleteReviewerOp.Factory deleteReviewerOpFactory,
+      DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory) {
     this.dbProvider = dbProvider;
-    this.approvalsUtil = approvalsUtil;
-    this.psUtil = psUtil;
-    this.cmUtil = cmUtil;
     this.batchUpdateFactory = batchUpdateFactory;
-    this.userFactory = userFactory;
-    this.reviewerDeleted = reviewerDeleted;
-    this.user = user;
-    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
-    this.migration = migration;
-    this.notifyUtil = notifyUtil;
+    this.deleteReviewerOpFactory = deleteReviewerOpFactory;
+    this.deleteReviewerByEmailOpFactory = deleteReviewerByEmailOpFactory;
   }
 
   @Override
@@ -118,151 +64,15 @@
             rsrc.getChangeResource().getProject(),
             rsrc.getChangeResource().getUser(),
             TimeUtil.nowTs())) {
-      Op op = new Op(rsrc.getReviewerUser().getAccount(), input);
+      BatchUpdateOp op;
+      if (rsrc.isByEmail()) {
+        op = deleteReviewerByEmailOpFactory.create(rsrc.getReviewerByEmail(), input);
+      } else {
+        op = deleteReviewerOpFactory.create(rsrc.getReviewerUser().getAccount(), input);
+      }
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
     }
-
     return Response.none();
   }
-
-  private class Op implements BatchUpdateOp {
-    private final Account reviewer;
-    private final DeleteReviewerInput input;
-    ChangeMessage changeMessage;
-    Change currChange;
-    PatchSet currPs;
-    Map<String, Short> newApprovals = new HashMap<>();
-    Map<String, Short> oldApprovals = new HashMap<>();
-
-    Op(Account reviewerAccount, DeleteReviewerInput input) {
-      this.reviewer = reviewerAccount;
-      this.input = input;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws AuthException, ResourceNotFoundException, OrmException {
-      Account.Id reviewerId = reviewer.getId();
-      if (!approvalsUtil.getReviewers(ctx.getDb(), ctx.getNotes()).all().contains(reviewerId)) {
-        throw new ResourceNotFoundException();
-      }
-      currChange = ctx.getChange();
-      currPs = psUtil.current(ctx.getDb(), ctx.getNotes());
-
-      LabelTypes labelTypes = ctx.getControl().getLabelTypes();
-      // removing a reviewer will remove all her votes
-      for (LabelType lt : labelTypes.getLabelTypes()) {
-        newApprovals.put(lt.getName(), (short) 0);
-      }
-
-      StringBuilder msg = new StringBuilder();
-      msg.append("Removed reviewer " + reviewer.getFullName());
-      StringBuilder removedVotesMsg = new StringBuilder();
-      removedVotesMsg.append(" with the following votes:\n\n");
-      List<PatchSetApproval> del = new ArrayList<>();
-      boolean votesRemoved = false;
-      for (PatchSetApproval a : approvals(ctx, reviewerId)) {
-        if (ctx.getControl().canRemoveReviewer(a)) {
-          del.add(a);
-          if (a.getPatchSetId().equals(currPs.getId()) && a.getValue() != 0) {
-            oldApprovals.put(a.getLabel(), a.getValue());
-            removedVotesMsg
-                .append("* ")
-                .append(a.getLabel())
-                .append(formatLabelValue(a.getValue()))
-                .append(" by ")
-                .append(userFactory.create(a.getAccountId()).getNameEmail())
-                .append("\n");
-            votesRemoved = true;
-          }
-        } else {
-          throw new AuthException("delete reviewer not permitted");
-        }
-      }
-
-      if (votesRemoved) {
-        msg.append(removedVotesMsg);
-      } else {
-        msg.append(".");
-      }
-      ctx.getDb().patchSetApprovals().delete(del);
-      ChangeUpdate update = ctx.getUpdate(currPs.getId());
-      update.removeReviewer(reviewerId);
-
-      changeMessage =
-          ChangeMessagesUtil.newMessage(
-              ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_REVIEWER);
-      cmUtil.addChangeMessage(ctx.getDb(), update, changeMessage);
-
-      return true;
-    }
-
-    @Override
-    public void postUpdate(Context ctx) {
-      if (NotifyUtil.shouldNotify(input.notify, input.notifyDetails)) {
-        emailReviewers(ctx.getProject(), currChange, changeMessage);
-      }
-      reviewerDeleted.fire(
-          currChange,
-          currPs,
-          reviewer,
-          ctx.getAccount(),
-          changeMessage.getMessage(),
-          newApprovals,
-          oldApprovals,
-          input.notify,
-          ctx.getWhen());
-    }
-
-    private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId)
-        throws OrmException {
-      Change.Id changeId = ctx.getNotes().getChangeId();
-      Iterable<PatchSetApproval> approvals;
-      PrimaryStorage r = PrimaryStorage.of(ctx.getChange());
-
-      if (migration.readChanges() && r == PrimaryStorage.REVIEW_DB) {
-        // Because NoteDb and ReviewDb have different semantics for zero-value
-        // approvals, we must fall back to ReviewDb as the source of truth here.
-        ReviewDb db = ctx.getDb();
-
-        if (db instanceof BatchUpdateReviewDb) {
-          db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
-        }
-        db = ReviewDbUtil.unwrapDb(db);
-        approvals = db.patchSetApprovals().byChange(changeId);
-      } else {
-        approvals = approvalsUtil.byChange(ctx.getDb(), ctx.getNotes()).values();
-      }
-
-      return Iterables.filter(approvals, psa -> accountId.equals(psa.getAccountId()));
-    }
-
-    private String formatLabelValue(short value) {
-      if (value > 0) {
-        return "+" + value;
-      }
-      return Short.toString(value);
-    }
-
-    private void emailReviewers(
-        Project.NameKey projectName, Change change, ChangeMessage changeMessage) {
-      Account.Id userId = user.get().getAccountId();
-      if (userId.equals(reviewer.getId())) {
-        // The user knows they removed themselves, don't bother emailing them.
-        return;
-      }
-      try {
-        DeleteReviewerSender cm = deleteReviewerSenderFactory.create(projectName, change.getId());
-        cm.setFrom(userId);
-        cm.addReviewers(Collections.singleton(reviewer.getId()));
-        cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
-        cm.setNotify(input.notify);
-        cm.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
-        cm.send();
-      } catch (Exception err) {
-        log.error("Cannot email update for change " + change.getId(), err);
-      }
-    }
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
new file mode 100644
index 0000000..adfe3f5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2017 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.change;
+
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Collections;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class DeleteReviewerByEmailOp implements BatchUpdateOp {
+  private static final Logger log = LoggerFactory.getLogger(DeleteReviewer.class);
+
+  public interface Factory {
+    DeleteReviewerByEmailOp create(Address reviewer, DeleteReviewerInput input);
+  }
+
+  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
+  private final NotifyUtil notifyUtil;
+  private final Address reviewer;
+  private final DeleteReviewerInput input;
+
+  private ChangeMessage changeMessage;
+  private Change.Id changeId;
+
+  @Inject
+  DeleteReviewerByEmailOp(
+      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
+      NotifyUtil notifyUtil,
+      @Assisted Address reviewer,
+      @Assisted DeleteReviewerInput input) {
+    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
+    this.notifyUtil = notifyUtil;
+    this.reviewer = reviewer;
+    this.input = input;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws OrmException {
+    changeId = ctx.getChange().getId();
+    PatchSet.Id psId = ctx.getChange().currentPatchSetId();
+    String msg = "Removed reviewer " + reviewer;
+    changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(changeId, ChangeUtil.messageUuid()),
+            ctx.getAccountId(),
+            ctx.getWhen(),
+            psId);
+    changeMessage.setMessage(msg);
+
+    ctx.getUpdate(psId).setChangeMessage(msg);
+    ctx.getUpdate(psId).removeReviewerByEmail(reviewer);
+    return true;
+  }
+
+  @Override
+  public void postUpdate(Context ctx) {
+    if (!NotifyUtil.shouldNotify(input.notify, input.notifyDetails)) {
+      return;
+    }
+    try {
+      DeleteReviewerSender cm = deleteReviewerSenderFactory.create(ctx.getProject(), changeId);
+      cm.setFrom(ctx.getAccountId());
+      cm.addReviewersByEmail(Collections.singleton(reviewer));
+      cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
+      cm.setNotify(input.notify);
+      cm.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
+      cm.send();
+    } catch (Exception err) {
+      log.error("Cannot email update for change " + changeId, err);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerOp.java
new file mode 100644
index 0000000..a255f79
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -0,0 +1,232 @@
+// Copyright (C) 2017 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.change;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.extensions.events.ReviewerDeleted;
+import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.BatchUpdateReviewDb;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class DeleteReviewerOp implements BatchUpdateOp {
+  private static final Logger log = LoggerFactory.getLogger(DeleteReviewer.class);
+
+  public interface Factory {
+    DeleteReviewerOp create(Account reviewerAccount, DeleteReviewerInput input);
+  }
+
+  private final ApprovalsUtil approvalsUtil;
+  private final PatchSetUtil psUtil;
+  private final ChangeMessagesUtil cmUtil;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final ReviewerDeleted reviewerDeleted;
+  private final Provider<IdentifiedUser> user;
+  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
+  private final NotesMigration migration;
+  private final NotifyUtil notifyUtil;
+
+  private final Account reviewer;
+  private final DeleteReviewerInput input;
+
+  ChangeMessage changeMessage;
+  Change currChange;
+  PatchSet currPs;
+  Map<String, Short> newApprovals = new HashMap<>();
+  Map<String, Short> oldApprovals = new HashMap<>();
+
+  @Inject
+  DeleteReviewerOp(
+      ApprovalsUtil approvalsUtil,
+      PatchSetUtil psUtil,
+      ChangeMessagesUtil cmUtil,
+      IdentifiedUser.GenericFactory userFactory,
+      ReviewerDeleted reviewerDeleted,
+      Provider<IdentifiedUser> user,
+      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
+      NotesMigration migration,
+      NotifyUtil notifyUtil,
+      @Assisted Account reviewerAccount,
+      @Assisted DeleteReviewerInput input) {
+    this.approvalsUtil = approvalsUtil;
+    this.psUtil = psUtil;
+    this.cmUtil = cmUtil;
+    this.userFactory = userFactory;
+    this.reviewerDeleted = reviewerDeleted;
+    this.user = user;
+    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
+    this.migration = migration;
+    this.notifyUtil = notifyUtil;
+
+    this.reviewer = reviewerAccount;
+    this.input = input;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws AuthException, ResourceNotFoundException, OrmException {
+    Account.Id reviewerId = reviewer.getId();
+    if (!approvalsUtil.getReviewers(ctx.getDb(), ctx.getNotes()).all().contains(reviewerId)) {
+      throw new ResourceNotFoundException();
+    }
+    currChange = ctx.getChange();
+    currPs = psUtil.current(ctx.getDb(), ctx.getNotes());
+
+    LabelTypes labelTypes = ctx.getControl().getLabelTypes();
+    // removing a reviewer will remove all her votes
+    for (LabelType lt : labelTypes.getLabelTypes()) {
+      newApprovals.put(lt.getName(), (short) 0);
+    }
+
+    StringBuilder msg = new StringBuilder();
+    msg.append("Removed reviewer " + reviewer.getFullName());
+    StringBuilder removedVotesMsg = new StringBuilder();
+    removedVotesMsg.append(" with the following votes:\n\n");
+    List<PatchSetApproval> del = new ArrayList<>();
+    boolean votesRemoved = false;
+    for (PatchSetApproval a : approvals(ctx, reviewerId)) {
+      if (ctx.getControl().canRemoveReviewer(a)) {
+        del.add(a);
+        if (a.getPatchSetId().equals(currPs.getId()) && a.getValue() != 0) {
+          oldApprovals.put(a.getLabel(), a.getValue());
+          removedVotesMsg
+              .append("* ")
+              .append(a.getLabel())
+              .append(formatLabelValue(a.getValue()))
+              .append(" by ")
+              .append(userFactory.create(a.getAccountId()).getNameEmail())
+              .append("\n");
+          votesRemoved = true;
+        }
+      } else {
+        throw new AuthException("delete reviewer not permitted");
+      }
+    }
+
+    if (votesRemoved) {
+      msg.append(removedVotesMsg);
+    } else {
+      msg.append(".");
+    }
+    ctx.getDb().patchSetApprovals().delete(del);
+    ChangeUpdate update = ctx.getUpdate(currPs.getId());
+    update.removeReviewer(reviewerId);
+
+    changeMessage =
+        ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_REVIEWER);
+    cmUtil.addChangeMessage(ctx.getDb(), update, changeMessage);
+
+    return true;
+  }
+
+  @Override
+  public void postUpdate(Context ctx) {
+    if (NotifyUtil.shouldNotify(input.notify, input.notifyDetails)) {
+      emailReviewers(ctx.getProject(), currChange, changeMessage);
+    }
+    reviewerDeleted.fire(
+        currChange,
+        currPs,
+        reviewer,
+        ctx.getAccount(),
+        changeMessage.getMessage(),
+        newApprovals,
+        oldApprovals,
+        input.notify,
+        ctx.getWhen());
+  }
+
+  private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId)
+      throws OrmException {
+    Change.Id changeId = ctx.getNotes().getChangeId();
+    Iterable<PatchSetApproval> approvals;
+    PrimaryStorage r = PrimaryStorage.of(ctx.getChange());
+
+    if (migration.readChanges() && r == PrimaryStorage.REVIEW_DB) {
+      // Because NoteDb and ReviewDb have different semantics for zero-value
+      // approvals, we must fall back to ReviewDb as the source of truth here.
+      ReviewDb db = ctx.getDb();
+
+      if (db instanceof BatchUpdateReviewDb) {
+        db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
+      }
+      db = ReviewDbUtil.unwrapDb(db);
+      approvals = db.patchSetApprovals().byChange(changeId);
+    } else {
+      approvals = approvalsUtil.byChange(ctx.getDb(), ctx.getNotes()).values();
+    }
+
+    return Iterables.filter(approvals, psa -> accountId.equals(psa.getAccountId()));
+  }
+
+  private String formatLabelValue(short value) {
+    if (value > 0) {
+      return "+" + value;
+    }
+    return Short.toString(value);
+  }
+
+  private void emailReviewers(
+      Project.NameKey projectName, Change change, ChangeMessage changeMessage) {
+    Account.Id userId = user.get().getAccountId();
+    if (userId.equals(reviewer.getId())) {
+      // The user knows they removed themselves, don't bother emailing them.
+      return;
+    }
+    try {
+      DeleteReviewerSender cm = deleteReviewerSenderFactory.create(projectName, change.getId());
+      cm.setFrom(userId);
+      cm.addReviewers(Collections.singleton(reviewer.getId()));
+      cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
+      cm.setNotify(input.notify);
+      cm.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
+      cm.send();
+    } catch (Exception err) {
+      log.error("Cannot email update for change " + change.getId(), err);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java
index 732848e..166197e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java
@@ -69,8 +69,15 @@
 
   public BinaryResult getContent(ProjectState project, ObjectId revstr, String path)
       throws ResourceNotFoundException, IOException {
-    try (Repository repo = openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
+    try (Repository repo = openRepository(project)) {
+      return getContent(repo, project, revstr, path);
+    }
+  }
+
+  public BinaryResult getContent(
+      Repository repo, ProjectState project, ObjectId revstr, String path)
+      throws IOException, ResourceNotFoundException {
+    try (RevWalk rw = new RevWalk(repo)) {
       RevCommit commit = rw.parseCommit(revstr);
       ObjectReader reader = rw.getObjectReader();
       TreeWalk tw = TreeWalk.forPath(reader, path, commit.getTree());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java
index 60a4daf..b25b588 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java
@@ -48,9 +48,14 @@
 
   Map<String, FileInfo> toFileInfoMap(Change change, RevId revision, @Nullable PatchSet base)
       throws PatchListNotAvailableException {
+    ObjectId objectId = ObjectId.fromString(revision.get());
+    return toFileInfoMap(change, objectId, base);
+  }
+
+  Map<String, FileInfo> toFileInfoMap(Change change, ObjectId objectId, @Nullable PatchSet base)
+      throws PatchListNotAvailableException {
     ObjectId a = (base == null) ? null : ObjectId.fromString(base.getRevision().get());
-    ObjectId b = ObjectId.fromString(revision.get());
-    return toFileInfoMap(change, new PatchListKey(a, b, Whitespace.IGNORE_NONE));
+    return toFileInfoMap(change, new PatchListKey(a, objectId, Whitespace.IGNORE_NONE));
   }
 
   Map<String, FileInfo> toFileInfoMap(Change change, RevId revision, int parent)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/FixResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/FixResource.java
new file mode 100644
index 0000000..08e2785
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/FixResource.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2017 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.change;
+
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.FixReplacement;
+import com.google.inject.TypeLiteral;
+import java.util.List;
+
+public class FixResource implements RestResource {
+  public static final TypeLiteral<RestView<FixResource>> FIX_KIND =
+      new TypeLiteral<RestView<FixResource>>() {};
+
+  private final List<FixReplacement> fixReplacements;
+  private final RevisionResource revisionResource;
+
+  public FixResource(RevisionResource revisionResource, List<FixReplacement> fixReplacements) {
+    this.fixReplacements = fixReplacements;
+    this.revisionResource = revisionResource;
+  }
+
+  public List<FixReplacement> getFixReplacements() {
+    return fixReplacements;
+  }
+
+  public RevisionResource getRevisionResource() {
+    return revisionResource;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Fixes.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Fixes.java
new file mode 100644
index 0000000..af9f60a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Fixes.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2017 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.change;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.FixSuggestion;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.List;
+import java.util.Objects;
+
+@Singleton
+public class Fixes implements ChildCollection<RevisionResource, FixResource> {
+
+  private final DynamicMap<RestView<FixResource>> views;
+  private final CommentsUtil commentsUtil;
+
+  @Inject
+  Fixes(DynamicMap<RestView<FixResource>> views, CommentsUtil commentsUtil) {
+    this.views = views;
+    this.commentsUtil = commentsUtil;
+  }
+
+  @Override
+  public RestView<RevisionResource> list() throws ResourceNotFoundException {
+    throw new ResourceNotFoundException();
+  }
+
+  @Override
+  public FixResource parse(RevisionResource revisionResource, IdString id)
+      throws ResourceNotFoundException, OrmException {
+    String fixId = id.get();
+    ChangeNotes changeNotes = revisionResource.getNotes();
+
+    List<RobotComment> robotComments =
+        commentsUtil.robotCommentsByPatchSet(changeNotes, revisionResource.getPatchSet().getId());
+    for (RobotComment robotComment : robotComments) {
+      for (FixSuggestion fixSuggestion : robotComment.fixSuggestions) {
+        if (Objects.equals(fixId, fixSuggestion.fixId)) {
+          return new FixResource(revisionResource, fixSuggestion.replacements);
+        }
+      }
+    }
+    throw new ResourceNotFoundException(id);
+  }
+
+  @Override
+  public DynamicMap<RestView<FixResource>> views() {
+    return views;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReviewer.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReviewer.java
index aa0b339..db9af1d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReviewer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReviewer.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -31,7 +32,8 @@
   }
 
   @Override
-  public List<ReviewerInfo> apply(ReviewerResource rsrc) throws OrmException {
+  public List<ReviewerInfo> apply(ReviewerResource rsrc)
+      throws OrmException, PermissionBackendException {
     return json.format(rsrc);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Ignore.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Ignore.java
new file mode 100644
index 0000000..83ab811
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Ignore.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2017 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.change;
+
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class Ignore
+    implements RestModifyView<ChangeResource, Ignore.Input>, UiAction<ChangeResource> {
+  private static final Logger log = LoggerFactory.getLogger(Ignore.class);
+
+  public static class Input {}
+
+  private final Provider<IdentifiedUser> self;
+  private final StarredChangesUtil stars;
+
+  @Inject
+  Ignore(Provider<IdentifiedUser> self, StarredChangesUtil stars) {
+    this.self = self;
+    this.stars = stars;
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Ignore")
+        .setTitle("Ignore the change")
+        .setVisible(!rsrc.isUserOwner() && !isIgnored(rsrc));
+  }
+
+  @Override
+  public Response<String> apply(ChangeResource rsrc, Input input) throws RestApiException {
+    try {
+      if (rsrc.isUserOwner() || isIgnored(rsrc)) {
+        // early exit for own changes and already ignored changes
+        return Response.ok("");
+      }
+      stars.ignore(self.get().getAccountId(), rsrc.getProject(), rsrc.getChange().getId());
+    } catch (OrmException e) {
+      throw new RestApiException("failed to ignore change", e);
+    }
+    return Response.ok("");
+  }
+
+  private boolean isIgnored(ChangeResource rsrc) {
+    try {
+      return stars.isIgnoredBy(rsrc.getChange().getId(), self.get().getAccountId());
+    } catch (OrmException e) {
+      log.error("failed to check ignored star", e);
+    }
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java
index 9257445..13a94d3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java
@@ -18,9 +18,12 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.Index.Input;
 import com.google.gerrit.server.index.change.ChangeIndexer;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -32,21 +35,26 @@
   public static class Input {}
 
   private final Provider<ReviewDb> db;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
   private final ChangeIndexer indexer;
 
   @Inject
-  Index(Provider<ReviewDb> db, ChangeIndexer indexer) {
+  Index(
+      Provider<ReviewDb> db,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      ChangeIndexer indexer) {
     this.db = db;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
     this.indexer = indexer;
   }
 
   @Override
   public Response<?> apply(ChangeResource rsrc, Input input)
-      throws IOException, AuthException, OrmException {
-    ChangeControl ctl = rsrc.getControl();
-    if (!ctl.isOwner() && !ctl.getUser().getCapabilities().canMaintainServer()) {
-      throw new AuthException("Only change owner or server maintainer can reindex");
-    }
+      throws IOException, AuthException, OrmException, PermissionBackendException {
+    permissionBackend.user(user).check(GlobalPermission.MAINTAIN_SERVER);
     indexer.index(db.get(), rsrc.getChange());
     return Response.none();
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java
index 27ec89d..ba2a10b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java
@@ -19,6 +19,8 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -47,12 +49,18 @@
   }
 
   @Override
-  public List<ReviewerInfo> apply(ChangeResource rsrc) throws OrmException {
-    Map<Account.Id, ReviewerResource> reviewers = new LinkedHashMap<>();
+  public List<ReviewerInfo> apply(ChangeResource rsrc)
+      throws OrmException, PermissionBackendException {
+    Map<String, ReviewerResource> reviewers = new LinkedHashMap<>();
     ReviewDb db = dbProvider.get();
     for (Account.Id accountId : approvalsUtil.getReviewers(db, rsrc.getNotes()).all()) {
-      if (!reviewers.containsKey(accountId)) {
-        reviewers.put(accountId, resourceFactory.create(rsrc, accountId));
+      if (!reviewers.containsKey(accountId.toString())) {
+        reviewers.put(accountId.toString(), resourceFactory.create(rsrc, accountId));
+      }
+    }
+    for (Address adr : rsrc.getNotes().getReviewersByEmail().all()) {
+      if (!reviewers.containsKey(adr.toString())) {
+        reviewers.put(adr.toString(), new ReviewerResource(rsrc, adr));
       }
     }
     return json.format(reviewers.values());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionReviewers.java
index d0c8ca0..6d9dc79 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionReviewers.java
@@ -20,6 +20,8 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -49,16 +51,21 @@
 
   @Override
   public List<ReviewerInfo> apply(RevisionResource rsrc)
-      throws OrmException, MethodNotAllowedException {
+      throws OrmException, MethodNotAllowedException, PermissionBackendException {
     if (!rsrc.isCurrent()) {
       throw new MethodNotAllowedException("Cannot list reviewers on non-current patch set");
     }
 
-    Map<Account.Id, ReviewerResource> reviewers = new LinkedHashMap<>();
+    Map<String, ReviewerResource> reviewers = new LinkedHashMap<>();
     ReviewDb db = dbProvider.get();
     for (Account.Id accountId : approvalsUtil.getReviewers(db, rsrc.getNotes()).all()) {
-      if (!reviewers.containsKey(accountId)) {
-        reviewers.put(accountId, resourceFactory.create(rsrc, accountId));
+      if (!reviewers.containsKey(accountId.toString())) {
+        reviewers.put(accountId.toString(), resourceFactory.create(rsrc, accountId));
+      }
+    }
+    for (Address address : rsrc.getNotes().getReviewersByEmail().all()) {
+      if (!reviewers.containsKey(address.toString())) {
+        reviewers.put(address.toString(), new ReviewerResource(rsrc, address));
       }
     }
     return json.format(reviewers.values());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
index d67f8ce..119051e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
@@ -31,9 +31,7 @@
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
-import com.google.gerrit.server.git.IntegrationException;
 import com.google.gerrit.server.git.strategy.SubmitDryRun;
-import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
@@ -45,7 +43,6 @@
 import java.util.Arrays;
 import java.util.Objects;
 import java.util.Set;
-import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -173,31 +170,6 @@
     }
   }
 
-  private class Loader implements Callable<Boolean> {
-    private final EntryKey key;
-    private final Branch.NameKey dest;
-    private final Repository repo;
-
-    Loader(EntryKey key, Branch.NameKey dest, Repository repo) {
-      this.key = key;
-      this.dest = dest;
-      this.repo = repo;
-    }
-
-    @Override
-    public Boolean call() throws NoSuchProjectException, IntegrationException, IOException {
-      if (key.into.equals(ObjectId.zeroId())) {
-        return true; // Assume yes on new branch.
-      }
-      try (CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
-        Set<RevCommit> accepted = SubmitDryRun.getAlreadyAccepted(repo, rw);
-        accepted.add(rw.parseCommit(key.into));
-        accepted.addAll(Arrays.asList(rw.parseCommit(key.commit).getParents()));
-        return submitDryRun.run(key.submitType, repo, rw, dest, key.into, key.commit, accepted);
-      }
-    }
-  }
-
   public static class MergeabilityWeigher implements Weigher<EntryKey, Boolean> {
     @Override
     public int weigh(EntryKey k, Boolean v) {
@@ -229,7 +201,20 @@
     ObjectId into = intoRef != null ? intoRef.getObjectId() : ObjectId.zeroId();
     EntryKey key = new EntryKey(commit, into, submitType, mergeStrategy);
     try {
-      return cache.get(key, new Loader(key, dest, repo));
+      return cache.get(
+          key,
+          () -> {
+            if (key.into.equals(ObjectId.zeroId())) {
+              return true; // Assume yes on new branch.
+            }
+            try (CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
+              Set<RevCommit> accepted = SubmitDryRun.getAlreadyAccepted(repo, rw);
+              accepted.add(rw.parseCommit(key.into));
+              accepted.addAll(Arrays.asList(rw.parseCommit(key.commit).getParents()));
+              return submitDryRun.run(
+                  key.submitType, repo, rw, dest, key.into, key.commit, accepted);
+            }
+          });
     } catch (ExecutionException | UncheckedExecutionException e) {
       log.error(
           String.format(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java
index e5f9352..69f26b2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.git.BranchOrderSection;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
@@ -98,7 +99,7 @@
     MergeableInfo result = new MergeableInfo();
 
     if (!change.getStatus().isOpen()) {
-      throw new ResourceConflictException("change is " + Submit.status(change));
+      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     } else if (!ps.getId().equals(change.currentPatchSetId())) {
       // Only the current revision is mergeable. Others always fail.
       return result;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
index aca6ef1..5ddf9e9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.server.change.CommentResource.COMMENT_KIND;
 import static com.google.gerrit.server.change.DraftCommentResource.DRAFT_COMMENT_KIND;
 import static com.google.gerrit.server.change.FileResource.FILE_KIND;
+import static com.google.gerrit.server.change.FixResource.FIX_KIND;
 import static com.google.gerrit.server.change.ReviewerResource.REVIEWER_KIND;
 import static com.google.gerrit.server.change.RevisionResource.REVISION_KIND;
 import static com.google.gerrit.server.change.RobotCommentResource.ROBOT_COMMENT_KIND;
@@ -40,12 +41,14 @@
     bind(DraftComments.class);
     bind(Comments.class);
     bind(RobotComments.class);
+    bind(Fixes.class);
     bind(Files.class);
     bind(Votes.class);
 
     DynamicMap.mapOf(binder(), CHANGE_KIND);
     DynamicMap.mapOf(binder(), COMMENT_KIND);
     DynamicMap.mapOf(binder(), ROBOT_COMMENT_KIND);
+    DynamicMap.mapOf(binder(), FIX_KIND);
     DynamicMap.mapOf(binder(), DRAFT_COMMENT_KIND);
     DynamicMap.mapOf(binder(), FILE_KIND);
     DynamicMap.mapOf(binder(), REVIEWER_KIND);
@@ -82,6 +85,14 @@
     post(CHANGE_KIND, "index").to(Index.class);
     post(CHANGE_KIND, "rebuild.notedb").to(Rebuild.class);
     post(CHANGE_KIND, "move").to(Move.class);
+    put(CHANGE_KIND, "private").to(PutPrivate.class);
+    delete(CHANGE_KIND, "private").to(DeletePrivate.class);
+    put(CHANGE_KIND, "ignore").to(Ignore.class);
+    put(CHANGE_KIND, "unignore").to(Unignore.class);
+    put(CHANGE_KIND, "mute").to(Mute.class);
+    put(CHANGE_KIND, "unmute").to(Unmute.class);
+    post(CHANGE_KIND, "wip").to(SetWorkInProgress.class);
+    post(CHANGE_KIND, "ready").to(SetReadyForReview.class);
 
     post(CHANGE_KIND, "reviewers").to(PostReviewers.class);
     get(CHANGE_KIND, "suggest_reviewers").to(SuggestChangeReviewers.class);
@@ -128,6 +139,8 @@
 
     child(REVISION_KIND, "robotcomments").to(RobotComments.class);
     get(ROBOT_COMMENT_KIND).to(GetRobotComment.class);
+    child(REVISION_KIND, "fixes").to(Fixes.class);
+    post(FIX_KIND, "apply").to(ApplyFix.class);
 
     child(REVISION_KIND, "files").to(Files.class);
     put(FILE_KIND, "reviewed").to(PutReviewed.class);
@@ -159,5 +172,8 @@
     factory(SetAssigneeOp.Factory.class);
     factory(SetHashtagsOp.Factory.class);
     factory(ChangeResource.Factory.class);
+    factory(DeleteReviewerOp.Factory.class);
+    factory(DeleteReviewerByEmailOp.Factory.class);
+    factory(PostReviewersOp.Factory.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java
index fb6ae0b..ec494ce 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -115,7 +116,7 @@
         throws OrmException, ResourceConflictException, RepositoryNotFoundException, IOException {
       change = ctx.getChange();
       if (change.getStatus() != Status.NEW && change.getStatus() != Status.DRAFT) {
-        throw new ResourceConflictException("Change is " + status(change));
+        throw new ResourceConflictException("Change is " + ChangeUtil.status(change));
       }
 
       Project.NameKey projectKey = change.getProject();
@@ -182,8 +183,4 @@
       return true;
     }
   }
-
-  private static String status(Change change) {
-    return change != null ? change.getStatus().name().toLowerCase() : "deleted";
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Mute.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Mute.java
new file mode 100644
index 0000000..d14fec8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Mute.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2017 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.change;
+
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class Mute implements RestModifyView<ChangeResource, Mute.Input>, UiAction<ChangeResource> {
+  private static final Logger log = LoggerFactory.getLogger(Mute.class);
+
+  public static class Input {}
+
+  private final Provider<IdentifiedUser> self;
+  private final StarredChangesUtil stars;
+
+  @Inject
+  Mute(Provider<IdentifiedUser> self, StarredChangesUtil stars) {
+    this.self = self;
+    this.stars = stars;
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Mute")
+        .setTitle("Mute the change to unhighlight it in the dashboard")
+        .setVisible(!rsrc.isUserOwner() && isMuteable(rsrc.getChange()));
+  }
+
+  @Override
+  public Response<String> apply(ChangeResource rsrc, Input input) throws RestApiException {
+    try {
+      if (rsrc.isUserOwner() || isMuted(rsrc.getChange())) {
+        // early exit for own changes and already muted changes
+        return Response.ok("");
+      }
+      stars.mute(self.get().getAccountId(), rsrc.getProject(), rsrc.getChange());
+    } catch (OrmException e) {
+      throw new RestApiException("failed to mute change", e);
+    }
+    return Response.ok("");
+  }
+
+  private boolean isMuted(Change change) {
+    try {
+      return stars.isMutedBy(change, self.get().getAccountId());
+    } catch (OrmException e) {
+      log.error("failed to check muted star", e);
+    }
+    return false;
+  }
+
+  private boolean isMuteable(Change change) {
+    try {
+      return !isMuted(change) && !stars.isIgnoredBy(change.getId(), self.get().getAccountId());
+    } catch (OrmException e) {
+      log.error("failed to check ignored star", e);
+    }
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
index 7cf62a0..5933b19 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.server.ApprovalCopier;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.events.CommitReceivedEvent;
@@ -50,13 +51,12 @@
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.Collections;
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -65,7 +65,7 @@
   private static final Logger log = LoggerFactory.getLogger(PatchSetInserter.class);
 
   public interface Factory {
-    PatchSetInserter create(ChangeControl ctl, PatchSet.Id psId, RevCommit commit);
+    PatchSetInserter create(ChangeControl ctl, PatchSet.Id psId, ObjectId commitId);
   }
 
   // Injected fields.
@@ -80,7 +80,7 @@
 
   // Assisted-injected fields.
   private final PatchSet.Id psId;
-  private final RevCommit commit;
+  private final ObjectId commitId;
   // Read prior to running the batch update, so must only be used during
   // updateRepo; updateChange and later must use the control from the
   // ChangeContext.
@@ -89,7 +89,7 @@
   // Fields exposed as setters.
   private String message;
   private String description;
-  private CommitValidators.Policy validatePolicy = CommitValidators.Policy.GERRIT;
+  private boolean validate = true;
   private boolean checkAddPatchSetPermission = true;
   private boolean draft;
   private List<String> groups = Collections.emptyList();
@@ -106,7 +106,7 @@
   private ChangeMessage changeMessage;
   private ReviewerSet oldReviewers;
 
-  @AssistedInject
+  @Inject
   public PatchSetInserter(
       ApprovalsUtil approvalsUtil,
       ApprovalCopier approvalCopier,
@@ -118,7 +118,7 @@
       RevisionCreated revisionCreated,
       @Assisted ChangeControl ctl,
       @Assisted PatchSet.Id psId,
-      @Assisted RevCommit commit) {
+      @Assisted ObjectId commitId) {
     this.approvalsUtil = approvalsUtil;
     this.approvalCopier = approvalCopier;
     this.cmUtil = cmUtil;
@@ -130,7 +130,7 @@
 
     this.origCtl = ctl;
     this.psId = psId;
-    this.commit = commit;
+    this.commitId = commitId.copy();
   }
 
   public PatchSet.Id getPatchSetId() {
@@ -147,8 +147,8 @@
     return this;
   }
 
-  public PatchSetInserter setValidatePolicy(CommitValidators.Policy validate) {
-    this.validatePolicy = checkNotNull(validate);
+  public PatchSetInserter setValidate(boolean validate) {
+    this.validate = validate;
     return this;
   }
 
@@ -208,9 +208,7 @@
   public void updateRepo(RepoContext ctx)
       throws AuthException, ResourceConflictException, IOException, OrmException {
     validate(ctx);
-    ctx.addRefUpdate(
-        new ReceiveCommand(
-            ObjectId.zeroId(), commit, getPatchSetId().toRefName(), ReceiveCommand.Type.CREATE));
+    ctx.addRefUpdate(ObjectId.zeroId(), commitId, getPatchSetId().toRefName());
   }
 
   @Override
@@ -227,7 +225,7 @@
       throw new ResourceConflictException(
           String.format(
               "Cannot create new patch set of change %s because it is %s",
-              change.getId(), change.getStatus().name().toLowerCase()));
+              change.getId(), ChangeUtil.status(change)));
     }
 
     List<String> newGroups = groups;
@@ -243,7 +241,7 @@
             ctx.getRevWalk(),
             ctx.getUpdate(psId),
             psId,
-            commit,
+            commitId,
             draft,
             newGroups,
             null,
@@ -264,7 +262,8 @@
       changeMessage.setMessage(message);
     }
 
-    patchSetInfo = patchSetInfoFactory.get(ctx.getRevWalk(), commit, psId);
+    patchSetInfo =
+        patchSetInfoFactory.get(ctx.getRevWalk(), ctx.getRevWalk().parseCommit(commitId), psId);
     if (change.getStatus() != Change.Status.DRAFT && !allowClosed) {
       change.setStatus(Change.Status.NEW);
     }
@@ -306,25 +305,24 @@
     if (checkAddPatchSetPermission && !origCtl.canAddPatchSet(ctx.getDb())) {
       throw new AuthException("cannot add patch set");
     }
-    if (validatePolicy == CommitValidators.Policy.NONE) {
+    if (!validate) {
       return;
     }
 
     String refName = getPatchSetId().toRefName();
-    CommitReceivedEvent event =
+    try (CommitReceivedEvent event =
         new CommitReceivedEvent(
             new ReceiveCommand(
                 ObjectId.zeroId(),
-                commit.getId(),
+                commitId,
                 refName.substring(0, refName.lastIndexOf('/') + 1) + "new"),
             origCtl.getProjectControl().getProject(),
             origCtl.getRefControl().getRefName(),
-            commit,
-            ctx.getIdentifiedUser());
-
-    try {
+            ctx.getRevWalk().getObjectReader(),
+            commitId,
+            ctx.getIdentifiedUser())) {
       commitValidatorsFactory
-          .create(validatePolicy, origCtl.getRefControl(), new NoSshInfo(), ctx.getRepository())
+          .forGerritCommits(origCtl.getRefControl(), new NoSshInfo(), ctx.getRevWalk())
           .validate(event);
     } catch (CommitValidationException e) {
       throw new ResourceConflictException(e.getFullMessage());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java
index 5aa41b1..ebe8f7e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java
@@ -22,6 +22,8 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
@@ -47,7 +49,9 @@
 
   @Override
   public Response<ImmutableSortedSet<String>> apply(ChangeResource req, HashtagsInput input)
-      throws RestApiException, UpdateException {
+      throws RestApiException, UpdateException, PermissionBackendException {
+    req.permissions().check(ChangePermission.EDIT_HASHTAGS);
+
     try (BatchUpdate bu =
         batchUpdateFactory.create(
             db.get(), req.getChange().getProject(), req.getControl().getUser(), TimeUtil.nowTs())) {
@@ -59,9 +63,9 @@
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource resource) {
+  public UiAction.Description getDescription(ChangeResource rsrc) {
     return new UiAction.Description()
         .setLabel("Edit Hashtags")
-        .setVisible(resource.getControl().canEditHashtags());
+        .setVisible(rsrc.permissions().testOrFalse(ChangePermission.EDIT_HASHTAGS));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
index 76cc7e8..69aa19d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
@@ -18,7 +18,9 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.groupingBy;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
@@ -27,7 +29,6 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
@@ -38,8 +39,6 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -50,6 +49,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -81,28 +81,39 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.OutputFormat;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.account.AccountsCollection;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdate.Factory;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.LabelVote;
+import com.google.gson.Gson;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -113,13 +124,17 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.OptionalInt;
 import java.util.Set;
+import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 @Singleton
 public class PostReview implements RestModifyView<RevisionResource, ReviewInput> {
   private static final Logger log = LoggerFactory.getLogger(PostReview.class);
+  private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
+  private static final int DEFAULT_ROBOT_COMMENT_SIZE_LIMIT_IN_BYTES = 1024 * 1024;
 
   private final Provider<ReviewDb> db;
   private final BatchUpdate.Factory batchUpdateFactory;
@@ -136,11 +151,12 @@
   private final PostReviewers postReviewers;
   private final NotesMigration migration;
   private final NotifyUtil notifyUtil;
+  private final Config gerritConfig;
 
   @Inject
   PostReview(
       Provider<ReviewDb> db,
-      BatchUpdate.Factory batchUpdateFactory,
+      Factory batchUpdateFactory,
       ChangesCollection changes,
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
@@ -153,7 +169,8 @@
       CommentAdded commentAdded,
       PostReviewers postReviewers,
       NotesMigration migration,
-      NotifyUtil notifyUtil) {
+      NotifyUtil notifyUtil,
+      @GerritServerConfig Config gerritConfig) {
     this.db = db;
     this.batchUpdateFactory = batchUpdateFactory;
     this.changes = changes;
@@ -169,16 +186,19 @@
     this.postReviewers = postReviewers;
     this.migration = migration;
     this.notifyUtil = notifyUtil;
+    this.gerritConfig = gerritConfig;
   }
 
   @Override
   public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input)
-      throws RestApiException, UpdateException, OrmException, IOException {
+      throws RestApiException, UpdateException, OrmException, IOException,
+          PermissionBackendException {
     return apply(revision, input, TimeUtil.nowTs());
   }
 
   public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input, Timestamp ts)
-      throws RestApiException, UpdateException, OrmException, IOException {
+      throws RestApiException, UpdateException, OrmException, IOException,
+          PermissionBackendException {
     // Respect timestamp, but truncate at change created-on time.
     ts = Ordering.natural().max(ts, revision.getChange().getCreatedOn());
     if (revision.getEdit().isPresent()) {
@@ -293,7 +313,7 @@
 
       bu.addOp(
           revision.getChange().getId(),
-          new Op(revision.getPatchSet().getId(), input, accountsToNotify, reviewerResults));
+          new Op(revision.getPatchSet().getId(), input, accountsToNotify));
       bu.execute();
 
       for (PostReviewers.Addition reviewerResult : reviewerResults) {
@@ -309,22 +329,32 @@
   private void emailReviewers(
       Change change,
       List<PostReviewers.Addition> reviewerAdditions,
-      NotifyHandling notify,
+      @Nullable NotifyHandling notify,
       ListMultimap<RecipientType, Account.Id> accountsToNotify) {
     List<Account.Id> to = new ArrayList<>();
     List<Account.Id> cc = new ArrayList<>();
+    List<Address> toByEmail = new ArrayList<>();
+    List<Address> ccByEmail = new ArrayList<>();
     for (PostReviewers.Addition addition : reviewerAdditions) {
-      if (addition.op.state == ReviewerState.REVIEWER) {
-        to.addAll(addition.op.reviewers.keySet());
-      } else if (addition.op.state == ReviewerState.CC) {
-        cc.addAll(addition.op.reviewers.keySet());
+      if (addition.state == ReviewerState.REVIEWER) {
+        to.addAll(addition.reviewers);
+        toByEmail.addAll(addition.reviewersByEmail);
+      } else if (addition.state == ReviewerState.CC) {
+        cc.addAll(addition.reviewers);
+        ccByEmail.addAll(addition.reviewersByEmail);
       }
     }
-    postReviewers.emailReviewers(change, to, cc, notify, accountsToNotify);
+    if (reviewerAdditions.size() > 0) {
+      reviewerAdditions
+          .get(0)
+          .op
+          .emailReviewers(change, to, cc, toByEmail, ccByEmail, notify, accountsToNotify);
+    }
   }
 
   private RevisionResource onBehalfOf(RevisionResource rev, ReviewInput in)
-      throws BadRequestException, AuthException, UnprocessableEntityException, OrmException {
+      throws BadRequestException, AuthException, UnprocessableEntityException, OrmException,
+          PermissionBackendException {
     if (in.labels == null || in.labels.isEmpty()) {
       throw new AuthException(
           String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf));
@@ -336,11 +366,13 @@
       throw new AuthException("not allowed to modify other user's drafts");
     }
 
-    ChangeControl caller = rev.getControl();
+    CurrentUser caller = rev.getUser();
+    PermissionBackend.ForChange perm = rev.permissions().database(db);
+    LabelTypes labelTypes = rev.getControl().getLabelTypes();
     Iterator<Map.Entry<String, Short>> itr = in.labels.entrySet().iterator();
     while (itr.hasNext()) {
       Map.Entry<String, Short> ent = itr.next();
-      LabelType type = caller.getLabelTypes().byLabel(ent.getKey());
+      LabelType type = labelTypes.byLabel(ent.getKey());
       if (type == null && in.strictLabels) {
         throw new BadRequestException(
             String.format("label \"%s\" is not a configured label", ent.getKey()));
@@ -349,16 +381,15 @@
         continue;
       }
 
-      if (caller.getUser().isInternalUser()) {
-        continue;
-      }
-
-      PermissionRange r = caller.getRange(Permission.forLabelAs(type.getName()));
-      if (r == null || r.isEmpty() || !r.contains(ent.getValue())) {
-        throw new AuthException(
-            String.format(
-                "not permitted to modify label \"%s\" on behalf of \"%s\"",
-                ent.getKey(), in.onBehalfOf));
+      if (!caller.isInternalUser()) {
+        try {
+          perm.check(new LabelPermission.WithValue(ON_BEHALF_OF, type, ent.getValue()));
+        } catch (AuthException e) {
+          throw new AuthException(
+              String.format(
+                  "not permitted to modify label \"%s\" on behalf of \"%s\"",
+                  type.getName(), in.onBehalfOf));
+        }
       }
     }
     if (in.labels.isEmpty()) {
@@ -366,25 +397,26 @@
           String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf));
     }
 
-    ChangeControl target =
-        caller.forUser(accounts.parseOnBehalfOf(caller.getUser(), in.onBehalfOf));
-    if (!target.getRefControl().isVisible()) {
+    IdentifiedUser reviewer = accounts.parseOnBehalfOf(caller, in.onBehalfOf);
+    try {
+      perm.user(reviewer).check(ChangePermission.READ);
+    } catch (AuthException e) {
       throw new UnprocessableEntityException(
-          String.format(
-              "on_behalf_of account %s cannot see destination ref",
-              target.getUser().getAccountId()));
+          String.format("on_behalf_of account %s cannot see change", reviewer.getAccountId()));
     }
-    return new RevisionResource(changes.parse(target), rev.getPatchSet());
+
+    ChangeControl ctl = rev.getControl().forUser(reviewer);
+    return new RevisionResource(changes.parse(ctl), rev.getPatchSet());
   }
 
-  private void checkLabels(RevisionResource revision, boolean strict, Map<String, Short> labels)
-      throws BadRequestException, AuthException {
-    ChangeControl ctl = revision.getControl();
+  private void checkLabels(RevisionResource rsrc, boolean strict, Map<String, Short> labels)
+      throws BadRequestException, AuthException, PermissionBackendException {
+    LabelTypes types = rsrc.getControl().getLabelTypes();
+    PermissionBackend.ForChange perm = rsrc.permissions();
     Iterator<Map.Entry<String, Short>> itr = labels.entrySet().iterator();
     while (itr.hasNext()) {
       Map.Entry<String, Short> ent = itr.next();
-
-      LabelType lt = revision.getControl().getLabelTypes().byLabel(ent.getKey());
+      LabelType lt = types.byLabel(ent.getKey());
       if (lt == null) {
         if (strict) {
           throw new BadRequestException(
@@ -409,23 +441,21 @@
         continue;
       }
 
-      String name = lt.getName();
-      PermissionRange range = ctl.getRange(Permission.forLabel(name));
-      if (range == null || !range.contains(ent.getValue())) {
+      short val = ent.getValue();
+      try {
+        perm.check(new LabelPermission.WithValue(lt, val));
+      } catch (AuthException e) {
         if (strict) {
           throw new AuthException(
-              String.format(
-                  "Applying label \"%s\": %d is restricted", ent.getKey(), ent.getValue()));
-        } else if (range == null || range.isEmpty()) {
-          ent.setValue((short) 0);
-        } else {
-          ent.setValue((short) range.squash(ent.getValue()));
+              String.format("Applying label \"%s\": %d is restricted", lt.getName(), val));
         }
+        ent.setValue(perm.squashThenCheck(lt, val));
       }
     }
   }
 
-  private <T extends CommentInput> void cleanUpComments(Map<String, List<T>> commentsPerPath) {
+  private static <T extends CommentInput> void cleanUpComments(
+      Map<String, List<T>> commentsPerPath) {
     Iterator<List<T>> mapValueIterator = commentsPerPath.values().iterator();
     while (mapValueIterator.hasNext()) {
       List<T> comments = mapValueIterator.next();
@@ -441,7 +471,7 @@
     }
   }
 
-  private <T extends CommentInput> void cleanUpComments(List<T> comments) {
+  private static <T extends CommentInput> void cleanUpComments(List<T> comments) {
     Iterator<T> commentsIterator = comments.iterator();
     while (commentsIterator.hasNext()) {
       T comment = commentsIterator.next();
@@ -480,7 +510,7 @@
     return new HashSet<>(changeData.filePaths(revision.getPatchSet()));
   }
 
-  private void ensurePathRefersToAvailableOrMagicFile(
+  private static void ensurePathRefersToAvailableOrMagicFile(
       String path, Set<String> availableFilePaths, PatchSet.Id patchSetId)
       throws BadRequestException {
     if (!availableFilePaths.contains(path) && !Patch.isMagic(path)) {
@@ -489,14 +519,15 @@
     }
   }
 
-  private void ensureLineIsNonNegative(Integer line, String path) throws BadRequestException {
+  private static void ensureLineIsNonNegative(Integer line, String path)
+      throws BadRequestException {
     if (line != null && line < 0) {
       throw new BadRequestException(
           String.format("negative line number %d not allowed on %s", line, path));
     }
   }
 
-  private <T extends CommentInput> void ensureCommentNotOnMagicFilesOfAutoMerge(
+  private static <T extends CommentInput> void ensureCommentNotOnMagicFilesOfAutoMerge(
       String path, T comment) throws BadRequestException {
     if (Patch.isMagic(path) && comment.side == Side.PARENT && comment.parent == null) {
       throw new BadRequestException(String.format("cannot comment on %s on auto-merge", path));
@@ -510,6 +541,7 @@
     for (Map.Entry<String, List<RobotCommentInput>> e : in.entrySet()) {
       String commentPath = e.getKey();
       for (RobotCommentInput c : e.getValue()) {
+        ensureSizeOfJsonInputIsWithinBounds(c);
         ensureRobotIdIsSet(c.robotId, commentPath);
         ensureRobotRunIdIsSet(c.robotRunId, commentPath);
         ensureFixSuggestionsAreAddable(c.fixSuggestions, commentPath);
@@ -518,14 +550,41 @@
     checkComments(revision, in);
   }
 
-  private void ensureRobotIdIsSet(String robotId, String commentPath) throws BadRequestException {
+  private void ensureSizeOfJsonInputIsWithinBounds(RobotCommentInput robotCommentInput)
+      throws BadRequestException {
+    OptionalInt robotCommentSizeLimit = getRobotCommentSizeLimit();
+    if (robotCommentSizeLimit.isPresent()) {
+      int sizeLimit = robotCommentSizeLimit.getAsInt();
+      byte[] robotCommentBytes = GSON.toJson(robotCommentInput).getBytes(StandardCharsets.UTF_8);
+      int robotCommentSize = robotCommentBytes.length;
+      if (robotCommentSize > sizeLimit) {
+        throw new BadRequestException(
+            String.format(
+                "Size %d (bytes) of robot comment is greater than limit %d (bytes)",
+                robotCommentSize, sizeLimit));
+      }
+    }
+  }
+
+  private OptionalInt getRobotCommentSizeLimit() {
+    int robotCommentSizeLimit =
+        gerritConfig.getInt(
+            "change", "robotCommentSizeLimit", DEFAULT_ROBOT_COMMENT_SIZE_LIMIT_IN_BYTES);
+    if (robotCommentSizeLimit <= 0) {
+      return OptionalInt.empty();
+    }
+    return OptionalInt.of(robotCommentSizeLimit);
+  }
+
+  private static void ensureRobotIdIsSet(String robotId, String commentPath)
+      throws BadRequestException {
     if (robotId == null) {
       throw new BadRequestException(
           String.format("robotId is missing for robot comment on %s", commentPath));
     }
   }
 
-  private void ensureRobotRunIdIsSet(String robotRunId, String commentPath)
+  private static void ensureRobotRunIdIsSet(String robotRunId, String commentPath)
       throws BadRequestException {
     if (robotRunId == null) {
       throw new BadRequestException(
@@ -533,7 +592,7 @@
     }
   }
 
-  private void ensureFixSuggestionsAreAddable(
+  private static void ensureFixSuggestionsAreAddable(
       List<FixSuggestionInfo> fixSuggestionInfos, String commentPath) throws BadRequestException {
     if (fixSuggestionInfos == null) {
       return;
@@ -545,7 +604,7 @@
     }
   }
 
-  private void ensureDescriptionIsSet(String commentPath, String description)
+  private static void ensureDescriptionIsSet(String commentPath, String description)
       throws BadRequestException {
     if (description == null) {
       throw new BadRequestException(
@@ -555,20 +614,25 @@
     }
   }
 
-  private void ensureFixReplacementsAreAddable(
+  private static void ensureFixReplacementsAreAddable(
       String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
     ensureReplacementsArePresent(commentPath, fixReplacementInfos);
 
     for (FixReplacementInfo fixReplacementInfo : fixReplacementInfos) {
       ensureReplacementPathIsSet(commentPath, fixReplacementInfo.path);
-      ensureReplacementPathRefersToFileOfComment(commentPath, fixReplacementInfo.path);
       ensureRangeIsSet(commentPath, fixReplacementInfo.range);
       ensureRangeIsValid(commentPath, fixReplacementInfo.range);
       ensureReplacementStringIsSet(commentPath, fixReplacementInfo.replacement);
     }
+
+    Map<String, List<FixReplacementInfo>> replacementsPerFilePath =
+        fixReplacementInfos.stream().collect(groupingBy(fixReplacement -> fixReplacement.path));
+    for (List<FixReplacementInfo> sameFileReplacements : replacementsPerFilePath.values()) {
+      ensureRangesDoNotOverlap(commentPath, sameFileReplacements);
+    }
   }
 
-  private void ensureReplacementsArePresent(
+  private static void ensureReplacementsArePresent(
       String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
     if (fixReplacementInfos == null || fixReplacementInfos.isEmpty()) {
       throw new BadRequestException(
@@ -579,7 +643,7 @@
     }
   }
 
-  private void ensureReplacementPathIsSet(String commentPath, String replacementPath)
+  private static void ensureReplacementPathIsSet(String commentPath, String replacementPath)
       throws BadRequestException {
     if (replacementPath == null) {
       throw new BadRequestException(
@@ -589,20 +653,7 @@
     }
   }
 
-  private void ensureReplacementPathRefersToFileOfComment(
-      String commentPath, String replacementPath) throws BadRequestException {
-    if (!Objects.equals(commentPath, replacementPath)) {
-      throw new BadRequestException(
-          String.format(
-              "Replacements may only be "
-                  + "specified for the file %s on which the robot comment was added",
-              commentPath));
-    }
-  }
-
-  private void ensureRangeIsSet(
-      String commentPath, com.google.gerrit.extensions.client.Comment.Range range)
-      throws BadRequestException {
+  private static void ensureRangeIsSet(String commentPath, Range range) throws BadRequestException {
     if (range == null) {
       throw new BadRequestException(
           String.format(
@@ -610,8 +661,7 @@
     }
   }
 
-  private void ensureRangeIsValid(
-      String commentPath, com.google.gerrit.extensions.client.Comment.Range range)
+  private static void ensureRangeIsValid(String commentPath, Range range)
       throws BadRequestException {
     if (range == null) {
       return;
@@ -628,7 +678,7 @@
     }
   }
 
-  private void ensureReplacementStringIsSet(String commentPath, String replacement)
+  private static void ensureReplacementStringIsSet(String commentPath, String replacement)
       throws BadRequestException {
     if (replacement == null) {
       throw new BadRequestException(
@@ -639,6 +689,28 @@
     }
   }
 
+  private static void ensureRangesDoNotOverlap(
+      String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
+    List<Range> sortedRanges =
+        fixReplacementInfos
+            .stream()
+            .map(fixReplacementInfo -> fixReplacementInfo.range)
+            .sorted()
+            .collect(toList());
+
+    int previousEndLine = 0;
+    int previousOffset = -1;
+    for (Range range : sortedRanges) {
+      if (range.startLine < previousEndLine
+          || (range.startLine == previousEndLine && range.startCharacter < previousOffset)) {
+        throw new BadRequestException(
+            String.format("Replacements overlap for the robot comment on %s", commentPath));
+      }
+      previousEndLine = range.endLine;
+      previousOffset = range.endCharacter;
+    }
+  }
+
   /** Used to compare Comments with CommentInput comments. */
   @AutoValue
   abstract static class CommentSetEntry {
@@ -682,7 +754,6 @@
     private final PatchSet.Id psId;
     private final ReviewInput in;
     private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
-    private final List<PostReviewers.Addition> reviewerResults;
 
     private IdentifiedUser user;
     private ChangeNotes notes;
@@ -696,12 +767,10 @@
     private Op(
         PatchSet.Id psId,
         ReviewInput in,
-        ListMultimap<RecipientType, Account.Id> accountsToNotify,
-        List<PostReviewers.Addition> reviewerResults) {
+        ListMultimap<RecipientType, Account.Id> accountsToNotify) {
       this.psId = psId;
       this.in = in;
       this.accountsToNotify = checkNotNull(accountsToNotify);
-      this.reviewerResults = reviewerResults;
     }
 
     @Override
@@ -801,12 +870,9 @@
           toDel.addAll(drafts.values());
           break;
         case PUBLISH:
-          for (Comment e : drafts.values()) {
-            toPublish.add(publishComment(ctx, e, ps));
-          }
-          break;
         case PUBLISH_ALL_REVISIONS:
-          publishAllRevisions(ctx, drafts, toPublish);
+          commentsUtil.publish(ctx, psId, drafts.values(), in.tag);
+          comments.addAll(drafts.values());
           break;
       }
       ChangeUpdate u = ctx.getUpdate(psId);
@@ -939,37 +1005,6 @@
       return labels;
     }
 
-    private Comment publishComment(ChangeContext ctx, Comment c, PatchSet ps) throws OrmException {
-      c.writtenOn = ctx.getWhen();
-      c.tag = in.tag;
-      // Draft may have been created by a different real user; copy the current
-      // real user. (Only applies to X-Gerrit-RunAs, since modifying drafts via
-      // on_behalf_of is not allowed.)
-      ctx.getUser().updateRealAccountId(c::setRealAuthor);
-      setCommentRevId(c, patchListCache, ctx.getChange(), checkNotNull(ps));
-      return c;
-    }
-
-    private void publishAllRevisions(
-        ChangeContext ctx, Map<String, Comment> drafts, List<Comment> ups) throws OrmException {
-      boolean needOtherPatchSets = false;
-      for (Comment c : drafts.values()) {
-        if (c.key.patchSetId != psId.get()) {
-          needOtherPatchSets = true;
-          break;
-        }
-      }
-      Map<PatchSet.Id, PatchSet> patchSets =
-          needOtherPatchSets
-              ? psUtil.byChangeAsMap(ctx.getDb(), ctx.getNotes())
-              : ImmutableMap.of(psId, ps);
-      for (Comment e : drafts.values()) {
-        ups.add(
-            publishComment(
-                ctx, e, patchSets.get(new PatchSet.Id(ctx.getChange().getId(), e.key.patchSetId))));
-      }
-    }
-
     private Map<String, Short> getAllApprovals(
         LabelTypes labelTypes, Map<String, Short> current, Map<String, Short> input) {
       Map<String, Short> allApprovals = new HashMap<>();
@@ -1005,16 +1040,6 @@
       if (ctx.getAccountId().equals(ctx.getChange().getOwner())) {
         return true;
       }
-      for (PostReviewers.Addition addition : reviewerResults) {
-        if (addition.op.addedReviewers == null) {
-          continue;
-        }
-        for (PatchSetApproval psa : addition.op.addedReviewers) {
-          if (psa.getAccountId().equals(ctx.getAccountId())) {
-            return true;
-          }
-        }
-      }
       ChangeData cd = changeDataFactory.create(db.get(), ctx.getControl());
       ReviewerSet reviewers = cd.reviewers();
       if (reviewers.byState(REVIEWER).contains(ctx.getAccountId())) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
index 7031d51..13d5271 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
@@ -14,15 +14,16 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.errors.NoSuchGroupException;
@@ -32,6 +33,8 @@
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -39,29 +42,29 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.AccountsCollection;
 import com.google.gerrit.server.account.GroupMembers;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.extensions.events.ReviewerAdded;
 import com.google.gerrit.server.group.GroupsCollection;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.mail.send.AddReviewerSender;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -69,85 +72,81 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.text.MessageFormat;
-import java.util.ArrayList;
 import java.util.Collection;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
+import java.util.HashSet;
 import java.util.Set;
 import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 @Singleton
 public class PostReviewers implements RestModifyView<ChangeResource, AddReviewerInput> {
-  private static final Logger log = LoggerFactory.getLogger(PostReviewers.class);
 
   public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
   public static final int DEFAULT_MAX_REVIEWERS = 20;
 
   private final AccountsCollection accounts;
   private final ReviewerResource.Factory reviewerFactory;
-  private final ApprovalsUtil approvalsUtil;
-  private final PatchSetUtil psUtil;
-  private final AddReviewerSender.Factory addReviewerSenderFactory;
+  private final PermissionBackend permissionBackend;
+
   private final GroupsCollection groupsCollection;
   private final GroupMembers.Factory groupMembersFactory;
   private final AccountLoader.Factory accountLoaderFactory;
   private final Provider<ReviewDb> dbProvider;
+  private final ChangeData.Factory changeDataFactory;
   private final BatchUpdate.Factory batchUpdateFactory;
-  private final Provider<IdentifiedUser> user;
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final Config cfg;
   private final ReviewerJson json;
-  private final ReviewerAdded reviewerAdded;
   private final NotesMigration migration;
-  private final AccountCache accountCache;
   private final NotifyUtil notifyUtil;
+  private final ProjectCache projectCache;
+  private final Provider<AnonymousUser> anonymousProvider;
+  private final PostReviewersOp.Factory postReviewersOpFactory;
+  private final OutgoingEmailValidator validator;
 
   @Inject
   PostReviewers(
       AccountsCollection accounts,
       ReviewerResource.Factory reviewerFactory,
-      ApprovalsUtil approvalsUtil,
-      PatchSetUtil psUtil,
-      AddReviewerSender.Factory addReviewerSenderFactory,
+      PermissionBackend permissionBackend,
       GroupsCollection groupsCollection,
       GroupMembers.Factory groupMembersFactory,
       AccountLoader.Factory accountLoaderFactory,
       Provider<ReviewDb> db,
+      ChangeData.Factory changeDataFactory,
       BatchUpdate.Factory batchUpdateFactory,
-      Provider<IdentifiedUser> user,
       IdentifiedUser.GenericFactory identifiedUserFactory,
       @GerritServerConfig Config cfg,
       ReviewerJson json,
-      ReviewerAdded reviewerAdded,
       NotesMigration migration,
-      AccountCache accountCache,
-      NotifyUtil notifyUtil) {
+      NotifyUtil notifyUtil,
+      ProjectCache projectCache,
+      Provider<AnonymousUser> anonymousProvider,
+      PostReviewersOp.Factory postReviewersOpFactory,
+      OutgoingEmailValidator validator) {
     this.accounts = accounts;
     this.reviewerFactory = reviewerFactory;
-    this.approvalsUtil = approvalsUtil;
-    this.psUtil = psUtil;
-    this.addReviewerSenderFactory = addReviewerSenderFactory;
+    this.permissionBackend = permissionBackend;
     this.groupsCollection = groupsCollection;
     this.groupMembersFactory = groupMembersFactory;
     this.accountLoaderFactory = accountLoaderFactory;
     this.dbProvider = db;
+    this.changeDataFactory = changeDataFactory;
     this.batchUpdateFactory = batchUpdateFactory;
-    this.user = user;
     this.identifiedUserFactory = identifiedUserFactory;
     this.cfg = cfg;
     this.json = json;
-    this.reviewerAdded = reviewerAdded;
     this.migration = migration;
-    this.accountCache = accountCache;
     this.notifyUtil = notifyUtil;
+    this.projectCache = projectCache;
+    this.anonymousProvider = anonymousProvider;
+    this.postReviewersOpFactory = postReviewersOpFactory;
+    this.validator = validator;
   }
 
   @Override
   public AddReviewerResult apply(ChangeResource rsrc, AddReviewerInput input)
-      throws IOException, OrmException, RestApiException, UpdateException {
+      throws IOException, OrmException, RestApiException, UpdateException,
+          PermissionBackendException {
     if (input.reviewer == null) {
       throw new BadRequestException("missing reviewer field");
     }
@@ -169,75 +168,120 @@
 
   public Addition prepareApplication(
       ChangeResource rsrc, AddReviewerInput input, boolean allowGroup)
-      throws OrmException, RestApiException, IOException {
-    Account.Id accountId;
+      throws OrmException, IOException, PermissionBackendException {
+    String reviewer = input.reviewer;
+    ReviewerState state = input.state();
+    NotifyHandling notify = input.notify;
+    ListMultimap<RecipientType, Account.Id> accountsToNotify = null;
     try {
-      accountId = accounts.parse(input.reviewer).getAccountId();
-    } catch (UnprocessableEntityException e) {
-      if (allowGroup) {
-        try {
-          return putGroup(rsrc, input);
-        } catch (UnprocessableEntityException e2) {
-          throw new UnprocessableEntityException(
-              MessageFormat.format(
-                  ChangeMessages.get().reviewerNotFoundUserOrGroup, input.reviewer));
-        }
-      }
-      throw new UnprocessableEntityException(
-          MessageFormat.format(ChangeMessages.get().reviewerNotFoundUser, input.reviewer));
+      accountsToNotify = notifyUtil.resolveAccounts(input.notifyDetails);
+    } catch (BadRequestException e) {
+      return fail(reviewer, e.getMessage());
     }
-    return putAccount(
-        input.reviewer,
-        reviewerFactory.create(rsrc, accountId),
-        input.state(),
-        input.notify,
-        notifyUtil.resolveAccounts(input.notifyDetails));
+    boolean confirmed = input.confirmed();
+    boolean allowByEmail = projectCache.checkedGet(rsrc.getProject()).isEnableReviewerByEmail();
+
+    Addition byAccountId =
+        addByAccountId(reviewer, rsrc, state, notify, accountsToNotify, allowGroup, allowByEmail);
+    if (byAccountId != null) {
+      return byAccountId;
+    }
+
+    Addition wholeGroup =
+        addWholeGroup(
+            reviewer, rsrc, state, notify, accountsToNotify, confirmed, allowGroup, allowByEmail);
+    if (wholeGroup != null) {
+      return wholeGroup;
+    }
+
+    return addByEmail(reviewer, rsrc, state, notify, accountsToNotify);
   }
 
   Addition ccCurrentUser(CurrentUser user, RevisionResource revision) {
     return new Addition(
         user.getUserName(),
         revision.getChangeResource(),
-        ImmutableMap.of(user.getAccountId(), revision.getControl()),
+        ImmutableSet.of(user.getAccountId()),
+        null,
         CC,
         NotifyHandling.NONE,
         ImmutableListMultimap.of());
   }
 
-  private Addition putAccount(
+  @Nullable
+  private Addition addByAccountId(
       String reviewer,
-      ReviewerResource rsrc,
+      ChangeResource rsrc,
       ReviewerState state,
       NotifyHandling notify,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify)
-      throws UnprocessableEntityException {
-    Account member = rsrc.getReviewerUser().getAccount();
-    ChangeControl control = rsrc.getReviewerControl();
-    if (isValidReviewer(member, control)) {
+      ListMultimap<RecipientType, Account.Id> accountsToNotify,
+      boolean allowGroup,
+      boolean allowByEmail)
+      throws OrmException, PermissionBackendException {
+    Account.Id accountId = null;
+    try {
+      accountId = accounts.parse(reviewer).getAccountId();
+    } catch (UnprocessableEntityException | AuthException e) {
+      // AuthException won't occur since the user is authenticated at this point.
+      if (!allowGroup && !allowByEmail) {
+        // Only return failure if we aren't going to try other interpretations.
+        return fail(
+            reviewer, MessageFormat.format(ChangeMessages.get().reviewerNotFoundUser, reviewer));
+      }
+      return null;
+    }
+
+    ReviewerResource rrsrc = reviewerFactory.create(rsrc, accountId);
+    Account member = rrsrc.getReviewerUser().getAccount();
+    PermissionBackend.ForRef perm =
+        permissionBackend.user(rrsrc.getReviewerUser()).ref(rrsrc.getChange().getDest());
+    if (isValidReviewer(member, perm)) {
       return new Addition(
-          reviewer,
-          rsrc.getChangeResource(),
-          ImmutableMap.of(member.getId(), control),
-          state,
-          notify,
-          accountsToNotify);
+          reviewer, rsrc, ImmutableSet.of(member.getId()), null, state, notify, accountsToNotify);
     }
-    if (member.isActive()) {
-      throw new UnprocessableEntityException(String.format("Change not visible to %s", reviewer));
+    if (!member.isActive()) {
+      if (allowByEmail && state == CC) {
+        return null;
+      }
+      return fail(reviewer, MessageFormat.format(ChangeMessages.get().reviewerInactive, reviewer));
     }
-    throw new UnprocessableEntityException(String.format("Account of %s is inactive.", reviewer));
+    return fail(
+        reviewer, MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, reviewer));
   }
 
-  private Addition putGroup(ChangeResource rsrc, AddReviewerInput input)
-      throws RestApiException, OrmException, IOException {
-    GroupDescription.Basic group = groupsCollection.parseInternal(input.reviewer);
-    if (!isLegalReviewerGroup(group.getGroupUUID())) {
-      return fail(
-          input.reviewer,
-          MessageFormat.format(ChangeMessages.get().groupIsNotAllowed, group.getName()));
+  @Nullable
+  private Addition addWholeGroup(
+      String reviewer,
+      ChangeResource rsrc,
+      ReviewerState state,
+      NotifyHandling notify,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify,
+      boolean confirmed,
+      boolean allowGroup,
+      boolean allowByEmail)
+      throws OrmException, IOException, PermissionBackendException {
+    if (!allowGroup) {
+      return null;
     }
 
-    Map<Account.Id, ChangeControl> reviewers = new HashMap<>();
+    GroupDescription.Basic group = null;
+    try {
+      group = groupsCollection.parseInternal(reviewer);
+    } catch (UnprocessableEntityException e) {
+      if (!allowByEmail) {
+        return fail(
+            reviewer,
+            MessageFormat.format(ChangeMessages.get().reviewerNotFoundUserOrGroup, reviewer));
+      }
+      return null;
+    }
+
+    if (!isLegalReviewerGroup(group.getGroupUUID())) {
+      return fail(
+          reviewer, MessageFormat.format(ChangeMessages.get().groupIsNotAllowed, group.getName()));
+    }
+
+    Set<Account.Id> reviewers = new HashSet<>();
     ChangeControl control = rsrc.getControl();
     Set<Account> members;
     try {
@@ -246,9 +290,11 @@
               .create(control.getUser())
               .listAccounts(group.getGroupUUID(), control.getProject().getNameKey());
     } catch (NoSuchGroupException e) {
-      throw new UnprocessableEntityException(e.getMessage());
+      return fail(
+          reviewer,
+          MessageFormat.format(ChangeMessages.get().reviewerNotFoundUserOrGroup, group.getName()));
     } catch (NoSuchProjectException e) {
-      throw new BadRequestException(e.getMessage());
+      return fail(reviewer, e.getMessage());
     }
 
     // if maxAllowed is set to 0, it is allowed to add any number of
@@ -256,44 +302,69 @@
     int maxAllowed = cfg.getInt("addreviewer", "maxAllowed", DEFAULT_MAX_REVIEWERS);
     if (maxAllowed > 0 && members.size() > maxAllowed) {
       return fail(
-          input.reviewer,
+          reviewer,
           MessageFormat.format(ChangeMessages.get().groupHasTooManyMembers, group.getName()));
     }
 
     // if maxWithoutCheck is set to 0, we never ask for confirmation
     int maxWithoutConfirmation =
         cfg.getInt("addreviewer", "maxWithoutConfirmation", DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
-    if (!input.confirmed()
-        && maxWithoutConfirmation > 0
-        && members.size() > maxWithoutConfirmation) {
+    if (!confirmed && maxWithoutConfirmation > 0 && members.size() > maxWithoutConfirmation) {
       return fail(
-          input.reviewer,
+          reviewer,
           true,
           MessageFormat.format(
               ChangeMessages.get().groupManyMembersConfirmation, group.getName(), members.size()));
     }
 
+    PermissionBackend.ForRef perm =
+        permissionBackend.user(rsrc.getUser()).ref(rsrc.getChange().getDest());
     for (Account member : members) {
-      if (isValidReviewer(member, control)) {
-        reviewers.put(member.getId(), control);
+      if (isValidReviewer(member, perm)) {
+        reviewers.add(member.getId());
       }
     }
 
-    return new Addition(
-        input.reviewer,
-        rsrc,
-        reviewers,
-        input.state(),
-        input.notify,
-        notifyUtil.resolveAccounts(input.notifyDetails));
+    return new Addition(reviewer, rsrc, reviewers, null, state, notify, accountsToNotify);
   }
 
-  private boolean isValidReviewer(Account member, ChangeControl control) {
+  @Nullable
+  private Addition addByEmail(
+      String reviewer,
+      ChangeResource rsrc,
+      ReviewerState state,
+      NotifyHandling notify,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify)
+      throws OrmException {
+    if (!rsrc.getControl().forUser(anonymousProvider.get()).isVisible(dbProvider.get())) {
+      return fail(
+          reviewer, MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, reviewer));
+    }
+    if (!migration.readChanges()) {
+      // addByEmail depends on NoteDb.
+      return fail(
+          reviewer, MessageFormat.format(ChangeMessages.get().reviewerNotFoundUser, reviewer));
+    }
+    Address adr = Address.tryParse(reviewer);
+    if (adr == null || !validator.isValid(adr.getEmail())) {
+      return fail(reviewer, MessageFormat.format(ChangeMessages.get().reviewerInvalid, reviewer));
+    }
+    return new Addition(
+        reviewer, rsrc, null, ImmutableList.of(adr), state, notify, accountsToNotify);
+  }
+
+  private boolean isValidReviewer(Account member, PermissionBackend.ForRef perm)
+      throws PermissionBackendException {
     if (member.isActive()) {
       IdentifiedUser user = identifiedUserFactory.create(member.getId());
       // Does not account for draft status as a user might want to let a
       // reviewer see a draft.
-      return control.forUser(user).isRefVisible();
+      try {
+        perm.user(user).check(RefPermission.READ);
+        return true;
+      } catch (AuthException e) {
+        return false;
+      }
     }
     return false;
   }
@@ -311,177 +382,87 @@
 
   public class Addition {
     final AddReviewerResult result;
-    final Op op;
+    final PostReviewersOp op;
+    final Set<Account.Id> reviewers;
+    final Collection<Address> reviewersByEmail;
+    final ReviewerState state;
+    final ChangeNotes notes;
+    final IdentifiedUser caller;
 
-    private final Map<Account.Id, ChangeControl> reviewers;
-
-    protected Addition(String reviewer) {
-      this(reviewer, null, null, REVIEWER, null, ImmutableListMultimap.of());
+    Addition(String reviewer) {
+      result = new AddReviewerResult(reviewer);
+      op = null;
+      reviewers = ImmutableSet.of();
+      reviewersByEmail = ImmutableSet.of();
+      state = REVIEWER;
+      notes = null;
+      caller = null;
     }
 
     protected Addition(
         String reviewer,
         ChangeResource rsrc,
-        Map<Account.Id, ChangeControl> reviewers,
+        @Nullable Set<Account.Id> reviewers,
+        @Nullable Collection<Address> reviewersByEmail,
         ReviewerState state,
-        NotifyHandling notify,
+        @Nullable NotifyHandling notify,
         ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+      checkArgument(
+          reviewers != null || reviewersByEmail != null,
+          "must have either reviewers or reviewersByEmail");
+
       result = new AddReviewerResult(reviewer);
-      if (reviewers == null) {
-        this.reviewers = ImmutableMap.of();
-        op = null;
-        return;
-      }
-      this.reviewers = reviewers;
-      op = new Op(rsrc, reviewers, state, notify, accountsToNotify);
+      this.reviewers = reviewers == null ? ImmutableSet.of() : reviewers;
+      this.reviewersByEmail = reviewersByEmail == null ? ImmutableList.of() : reviewersByEmail;
+      this.state = state;
+      notes = rsrc.getNotes();
+      caller = rsrc.getUser();
+      op =
+          postReviewersOpFactory.create(
+              rsrc, this.reviewers, this.reviewersByEmail, state, notify, accountsToNotify);
     }
 
-    void gatherResults() throws OrmException {
+    void gatherResults() throws OrmException, PermissionBackendException {
+      if (notes == null || caller == null) {
+        // When notes or caller is missing this is likely just carrying an error message
+        // in the contained AddReviewerResult.
+        return;
+      }
+
+      ChangeData cd = changeDataFactory.create(dbProvider.get(), notes);
+      PermissionBackend.ForChange perm =
+          permissionBackend.user(caller).database(dbProvider).change(cd);
+
       // Generate result details and fill AccountLoader. This occurs outside
       // the Op because the accounts are in a different table.
-      if (migration.readChanges() && op.state == CC) {
-        result.ccs = Lists.newArrayListWithCapacity(op.addedCCs.size());
-        for (Account.Id accountId : op.addedCCs) {
-          result.ccs.add(json.format(new ReviewerInfo(accountId.get()), reviewers.get(accountId)));
+      PostReviewersOp.Result opResult = op.getResult();
+      if (migration.readChanges() && state == CC) {
+        result.ccs = Lists.newArrayListWithCapacity(opResult.addedCCs().size());
+        for (Account.Id accountId : opResult.addedCCs()) {
+          IdentifiedUser u = identifiedUserFactory.create(accountId);
+          result.ccs.add(json.format(new ReviewerInfo(accountId.get()), perm.user(u), cd));
         }
         accountLoaderFactory.create(true).fill(result.ccs);
+        for (Address a : reviewersByEmail) {
+          result.ccs.add(new AccountInfo(a.getName(), a.getEmail()));
+        }
       } else {
-        result.reviewers = Lists.newArrayListWithCapacity(op.addedReviewers.size());
-        for (PatchSetApproval psa : op.addedReviewers) {
+        result.reviewers = Lists.newArrayListWithCapacity(opResult.addedReviewers().size());
+        for (PatchSetApproval psa : opResult.addedReviewers()) {
           // New reviewers have value 0, don't bother normalizing.
+          IdentifiedUser u = identifiedUserFactory.create(psa.getAccountId());
           result.reviewers.add(
               json.format(
                   new ReviewerInfo(psa.getAccountId().get()),
-                  reviewers.get(psa.getAccountId()),
+                  perm.user(u),
+                  cd,
                   ImmutableList.of(psa)));
         }
         accountLoaderFactory.create(true).fill(result.reviewers);
-      }
-    }
-  }
-
-  public class Op implements BatchUpdateOp {
-    final Map<Account.Id, ChangeControl> reviewers;
-    final ReviewerState state;
-    final NotifyHandling notify;
-    final ListMultimap<RecipientType, Account.Id> accountsToNotify;
-    List<PatchSetApproval> addedReviewers;
-    Collection<Account.Id> addedCCs;
-
-    private final ChangeResource rsrc;
-    private PatchSet patchSet;
-
-    Op(
-        ChangeResource rsrc,
-        Map<Account.Id, ChangeControl> reviewers,
-        ReviewerState state,
-        NotifyHandling notify,
-        ListMultimap<RecipientType, Account.Id> accountsToNotify) {
-      this.rsrc = rsrc;
-      this.reviewers = reviewers;
-      this.state = state;
-      this.notify = notify;
-      this.accountsToNotify = checkNotNull(accountsToNotify);
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws RestApiException, OrmException, IOException {
-      if (migration.readChanges() && state == CC) {
-        addedCCs =
-            approvalsUtil.addCcs(
-                ctx.getNotes(),
-                ctx.getUpdate(ctx.getChange().currentPatchSetId()),
-                reviewers.keySet());
-        if (addedCCs.isEmpty()) {
-          return false;
-        }
-      } else {
-        addedReviewers =
-            approvalsUtil.addReviewers(
-                ctx.getDb(),
-                ctx.getNotes(),
-                ctx.getUpdate(ctx.getChange().currentPatchSetId()),
-                rsrc.getControl().getLabelTypes(),
-                rsrc.getChange(),
-                reviewers.keySet());
-        if (addedReviewers.isEmpty()) {
-          return false;
+        for (Address a : reviewersByEmail) {
+          result.reviewers.add(ReviewerInfo.byEmail(a.getName(), a.getEmail()));
         }
       }
-
-      patchSet = psUtil.current(dbProvider.get(), rsrc.getNotes());
-      return true;
-    }
-
-    @Override
-    public void postUpdate(Context ctx) throws Exception {
-      if (addedReviewers != null || addedCCs != null) {
-        if (addedReviewers == null) {
-          addedReviewers = new ArrayList<>();
-        }
-        if (addedCCs == null) {
-          addedCCs = new ArrayList<>();
-        }
-        emailReviewers(
-            rsrc.getChange(),
-            Lists.transform(addedReviewers, r -> r.getAccountId()),
-            addedCCs,
-            notify,
-            accountsToNotify);
-        if (!addedReviewers.isEmpty()) {
-          List<Account> reviewers =
-              Lists.transform(
-                  addedReviewers, psa -> accountCache.get(psa.getAccountId()).getAccount());
-          reviewerAdded.fire(
-              rsrc.getChange(), patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
-        }
-      }
-    }
-  }
-
-  public void emailReviewers(
-      Change change,
-      Collection<Account.Id> added,
-      Collection<Account.Id> copied,
-      NotifyHandling notify,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify) {
-    if (added.isEmpty() && copied.isEmpty()) {
-      return;
-    }
-
-    // Email the reviewers
-    //
-    // The user knows they added themselves, don't bother emailing them.
-    List<Account.Id> toMail = Lists.newArrayListWithCapacity(added.size());
-    Account.Id userId = user.get().getAccountId();
-    for (Account.Id id : added) {
-      if (!id.equals(userId)) {
-        toMail.add(id);
-      }
-    }
-    List<Account.Id> toCopy = Lists.newArrayListWithCapacity(copied.size());
-    for (Account.Id id : copied) {
-      if (!id.equals(userId)) {
-        toCopy.add(id);
-      }
-    }
-    if (toMail.isEmpty() && toCopy.isEmpty()) {
-      return;
-    }
-
-    try {
-      AddReviewerSender cm = addReviewerSenderFactory.create(change.getProject(), change.getId());
-      if (notify != null) {
-        cm.setNotify(notify);
-      }
-      cm.setAccountsToNotify(accountsToNotify);
-      cm.setFrom(userId);
-      cm.addReviewers(toMail);
-      cm.addExtraCC(toCopy);
-      cm.send();
-    } catch (Exception err) {
-      log.error("Cannot send email to new reviewers of change " + change.getId(), err);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewersOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewersOp.java
new file mode 100644
index 0000000..2e80122
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewersOp.java
@@ -0,0 +1,258 @@
+// Copyright (C) 2017 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.change;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.extensions.client.ReviewerState.CC;
+import static java.util.stream.Collectors.toList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.extensions.events.ReviewerAdded;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.send.AddReviewerSender;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class PostReviewersOp implements BatchUpdateOp {
+  private static final Logger log = LoggerFactory.getLogger(PostReviewersOp.class);
+
+  public interface Factory {
+    PostReviewersOp create(
+        ChangeResource rsrc,
+        Set<Account.Id> reviewers,
+        Collection<Address> reviewersByEmail,
+        ReviewerState state,
+        @Nullable NotifyHandling notify,
+        ListMultimap<RecipientType, Account.Id> accountsToNotify);
+  }
+
+  @AutoValue
+  public abstract static class Result {
+    public abstract ImmutableList<PatchSetApproval> addedReviewers();
+
+    public abstract ImmutableList<Account.Id> addedCCs();
+
+    static Builder builder() {
+      return new AutoValue_PostReviewersOp_Result.Builder();
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+      abstract Builder setAddedReviewers(ImmutableList<PatchSetApproval> addedReviewers);
+
+      abstract Builder setAddedCCs(ImmutableList<Account.Id> addedCCs);
+
+      abstract Result build();
+    }
+  }
+
+  private final ApprovalsUtil approvalsUtil;
+  private final PatchSetUtil psUtil;
+  private final ReviewerAdded reviewerAdded;
+  private final AccountCache accountCache;
+  private final AddReviewerSender.Factory addReviewerSenderFactory;
+  private final NotesMigration migration;
+  private final Provider<IdentifiedUser> user;
+  private final Provider<ReviewDb> dbProvider;
+  private final ChangeResource rsrc;
+  private final Set<Account.Id> reviewers;
+  private final Collection<Address> reviewersByEmail;
+  private final ReviewerState state;
+  private final NotifyHandling notify;
+  private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
+
+  private List<PatchSetApproval> addedReviewers = new ArrayList<>();
+  private Collection<Account.Id> addedCCs = new ArrayList<>();
+  private Collection<Address> addedCCsByEmail = new ArrayList<>();
+  private PatchSet patchSet;
+  private Result opResult;
+
+  @Inject
+  PostReviewersOp(
+      ApprovalsUtil approvalsUtil,
+      PatchSetUtil psUtil,
+      ReviewerAdded reviewerAdded,
+      AccountCache accountCache,
+      AddReviewerSender.Factory addReviewerSenderFactory,
+      NotesMigration migration,
+      Provider<IdentifiedUser> user,
+      Provider<ReviewDb> dbProvider,
+      @Assisted ChangeResource rsrc,
+      @Assisted Set<Account.Id> reviewers,
+      @Assisted Collection<Address> reviewersByEmail,
+      @Assisted ReviewerState state,
+      @Assisted @Nullable NotifyHandling notify,
+      @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+    this.approvalsUtil = approvalsUtil;
+    this.psUtil = psUtil;
+    this.reviewerAdded = reviewerAdded;
+    this.accountCache = accountCache;
+    this.addReviewerSenderFactory = addReviewerSenderFactory;
+    this.migration = migration;
+    this.user = user;
+    this.dbProvider = dbProvider;
+
+    this.rsrc = rsrc;
+    this.reviewers = reviewers;
+    this.reviewersByEmail = reviewersByEmail;
+    this.state = state;
+    this.notify = notify;
+    this.accountsToNotify = accountsToNotify;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws RestApiException, OrmException, IOException {
+    if (!reviewers.isEmpty()) {
+      if (migration.readChanges() && state == CC) {
+        addedCCs =
+            approvalsUtil.addCcs(
+                ctx.getNotes(), ctx.getUpdate(ctx.getChange().currentPatchSetId()), reviewers);
+        if (addedCCs.isEmpty()) {
+          return false;
+        }
+      } else {
+        addedReviewers =
+            approvalsUtil.addReviewers(
+                ctx.getDb(),
+                ctx.getNotes(),
+                ctx.getUpdate(ctx.getChange().currentPatchSetId()),
+                rsrc.getControl().getLabelTypes(),
+                rsrc.getChange(),
+                reviewers);
+        if (addedReviewers.isEmpty()) {
+          return false;
+        }
+      }
+    }
+
+    for (Address a : reviewersByEmail) {
+      ctx.getUpdate(ctx.getChange().currentPatchSetId())
+          .putReviewerByEmail(a, ReviewerStateInternal.fromReviewerState(state));
+    }
+
+    patchSet = psUtil.current(dbProvider.get(), rsrc.getNotes());
+    return true;
+  }
+
+  @Override
+  public void postUpdate(Context ctx) throws Exception {
+    opResult =
+        Result.builder()
+            .setAddedReviewers(ImmutableList.copyOf(addedReviewers))
+            .setAddedCCs(ImmutableList.copyOf(addedCCs))
+            .build();
+    emailReviewers(
+        rsrc.getChange(),
+        Lists.transform(addedReviewers, r -> r.getAccountId()),
+        addedCCs == null ? ImmutableList.of() : addedCCs,
+        reviewersByEmail,
+        addedCCsByEmail,
+        notify,
+        accountsToNotify);
+    if (!addedReviewers.isEmpty()) {
+      List<Account> reviewers =
+          addedReviewers
+              .stream()
+              .map(r -> accountCache.get(r.getAccountId()).getAccount())
+              .collect(toList());
+      reviewerAdded.fire(rsrc.getChange(), patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
+    }
+  }
+
+  public void emailReviewers(
+      Change change,
+      Collection<Account.Id> added,
+      Collection<Account.Id> copied,
+      Collection<Address> addedByEmail,
+      Collection<Address> copiedByEmail,
+      NotifyHandling notify,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+    if (added.isEmpty() && copied.isEmpty() && addedByEmail.isEmpty() && copiedByEmail.isEmpty()) {
+      return;
+    }
+
+    // Email the reviewers
+    //
+    // The user knows they added themselves, don't bother emailing them.
+    List<Account.Id> toMail = Lists.newArrayListWithCapacity(added.size());
+    Account.Id userId = user.get().getAccountId();
+    for (Account.Id id : added) {
+      if (!id.equals(userId)) {
+        toMail.add(id);
+      }
+    }
+    List<Account.Id> toCopy = Lists.newArrayListWithCapacity(copied.size());
+    for (Account.Id id : copied) {
+      if (!id.equals(userId)) {
+        toCopy.add(id);
+      }
+    }
+    if (toMail.isEmpty() && toCopy.isEmpty() && addedByEmail.isEmpty() && copiedByEmail.isEmpty()) {
+      return;
+    }
+
+    try {
+      AddReviewerSender cm = addReviewerSenderFactory.create(change.getProject(), change.getId());
+      cm.setNotify(MoreObjects.firstNonNull(notify, NotifyHandling.ALL));
+      cm.setAccountsToNotify(accountsToNotify);
+      cm.setFrom(userId);
+      cm.addReviewers(toMail);
+      cm.addReviewersByEmail(addedByEmail);
+      cm.addExtraCC(toCopy);
+      cm.addExtraCCByEmail(copiedByEmail);
+      cm.send();
+    } catch (Exception err) {
+      log.error("Cannot send email to new reviewers of change " + change.getId(), err);
+    }
+  }
+
+  public Result getResult() {
+    checkState(opResult != null, "Batch update wasn't executed yet");
+    return opResult;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java
index f4356db..a074818 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java
@@ -25,7 +25,9 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.LimitedByteArrayOutputStream.LimitExceededException;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -45,6 +47,7 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.storage.pack.PackConfig;
 import org.eclipse.jgit.transport.BundleWriter;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.kohsuke.args4j.Option;
@@ -94,7 +97,7 @@
 
     Change change = rsrc.getChange();
     if (!change.getStatus().isOpen()) {
-      throw new PreconditionFailedException("change is " + Submit.status(change));
+      throw new PreconditionFailedException("change is " + ChangeUtil.status(change));
     }
     ChangeControl control = rsrc.getControl();
     if (!control.getUser().isIdentifiedUser()) {
@@ -144,14 +147,16 @@
         MergeOpRepoManager orm = mergeOp.getMergeOpRepoManager();
         for (Project.NameKey p : mergeOp.getAllProjects()) {
           OpenRepo or = orm.getRepo(p);
-          BundleWriter bw = new BundleWriter(or.getRepo());
+          BundleWriter bw = new BundleWriter(or.getCodeReviewRevWalk().getObjectReader());
           bw.setObjectCountCallback(null);
-          bw.setPackConfig(null);
-          Collection<ReceiveCommand> refs = or.getUpdate().getRefUpdates();
+          bw.setPackConfig(new PackConfig(or.getRepo()));
+          Collection<ReceiveCommand> refs = or.getUpdate().getRefUpdates().values();
           for (ReceiveCommand r : refs) {
             bw.include(r.getRefName(), r.getNewId());
             ObjectId oldId = r.getOldId();
-            if (!oldId.equals(ObjectId.zeroId())) {
+            if (!oldId.equals(ObjectId.zeroId())
+                // Probably the client doesn't already have NoteDb data.
+                && !RefNames.isNoteDbMetaRef(r.getRefName())) {
               bw.assume(or.getCodeReviewRevWalk().parseCommit(oldId));
             }
           }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java
index 0e72979..3acb93b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java
@@ -96,7 +96,8 @@
       if (in == null) {
         in = new PublishChangeEditInput();
       }
-      editUtil.publish(edit.get(), in.notify, notifyUtil.resolveAccounts(in.notifyDetails));
+      editUtil.publish(
+          rsrc.getControl(), edit.get(), in.notify, notifyUtil.resolveAccounts(in.notifyDetails));
       return Response.none();
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutAssignee.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutAssignee.java
index e64abaa..735107f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutAssignee.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutAssignee.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
@@ -22,13 +23,17 @@
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountsCollection;
 import com.google.gerrit.server.change.PostReviewers.Addition;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
@@ -41,6 +46,7 @@
 public class PutAssignee
     implements RestModifyView<ChangeResource, AssigneeInput>, UiAction<ChangeResource> {
 
+  private final AccountsCollection accounts;
   private final SetAssigneeOp.Factory assigneeFactory;
   private final BatchUpdate.Factory batchUpdateFactory;
   private final Provider<ReviewDb> db;
@@ -49,11 +55,13 @@
 
   @Inject
   PutAssignee(
+      AccountsCollection accounts,
       SetAssigneeOp.Factory assigneeFactory,
       BatchUpdate.Factory batchUpdateFactory,
       Provider<ReviewDb> db,
       PostReviewers postReviewers,
       AccountLoader.Factory accountLoaderFactory) {
+    this.accounts = accounts;
     this.assigneeFactory = assigneeFactory;
     this.batchUpdateFactory = batchUpdateFactory;
     this.db = db;
@@ -62,34 +70,45 @@
   }
 
   @Override
-  public Response<AccountInfo> apply(ChangeResource rsrc, AssigneeInput input)
-      throws RestApiException, UpdateException, OrmException, IOException {
-    if (!rsrc.getControl().canEditAssignee()) {
-      throw new AuthException("Changing Assignee not permitted");
-    }
-    if (input.assignee == null || input.assignee.trim().isEmpty()) {
+  public AccountInfo apply(ChangeResource rsrc, AssigneeInput input)
+      throws RestApiException, UpdateException, OrmException, IOException,
+          PermissionBackendException {
+    rsrc.permissions().check(ChangePermission.EDIT_ASSIGNEE);
+
+    input.assignee = Strings.nullToEmpty(input.assignee).trim();
+    if (input.assignee.isEmpty()) {
       throw new BadRequestException("missing assignee field");
     }
 
+    IdentifiedUser assignee = accounts.parse(input.assignee);
+    if (!assignee.getAccount().isActive()) {
+      throw new UnprocessableEntityException(input.assignee + " is not active");
+    }
+    try {
+      rsrc.permissions().database(db).user(assignee).check(ChangePermission.READ);
+    } catch (AuthException e) {
+      throw new AuthException("read not permitted for " + input.assignee);
+    }
+
     try (BatchUpdate bu =
         batchUpdateFactory.create(
             db.get(),
             rsrc.getChange().getProject(),
             rsrc.getControl().getUser(),
             TimeUtil.nowTs())) {
-      SetAssigneeOp op = assigneeFactory.create(input.assignee);
+      SetAssigneeOp op = assigneeFactory.create(assignee);
       bu.addOp(rsrc.getId(), op);
 
       PostReviewers.Addition reviewersAddition = addAssigneeAsCC(rsrc, input.assignee);
       bu.addOp(rsrc.getId(), reviewersAddition.op);
 
       bu.execute();
-      return Response.ok(accountLoaderFactory.create(true).fillOne(op.getNewAssignee()));
+      return accountLoaderFactory.create(true).fillOne(assignee.getAccountId());
     }
   }
 
   private Addition addAssigneeAsCC(ChangeResource rsrc, String assignee)
-      throws OrmException, RestApiException, IOException {
+      throws OrmException, IOException, PermissionBackendException {
     AddReviewerInput reviewerInput = new AddReviewerInput();
     reviewerInput.reviewer = assignee;
     reviewerInput.state = ReviewerState.CC;
@@ -99,9 +118,9 @@
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource resource) {
+  public UiAction.Description getDescription(ChangeResource rsrc) {
     return new UiAction.Description()
         .setLabel("Edit Assignee")
-        .setVisible(resource.getControl().canEditAssignee());
+        .setVisible(rsrc.permissions().testOrFalse(ChangePermission.EDIT_ASSIGNEE));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java
index 3c2633e..e872206 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java
@@ -16,7 +16,6 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -28,6 +27,8 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -65,11 +66,10 @@
 
   @Override
   public Response<String> apply(RevisionResource rsrc, Input input)
-      throws UpdateException, RestApiException {
+      throws UpdateException, RestApiException, PermissionBackendException {
+    rsrc.permissions().check(ChangePermission.EDIT_DESCRIPTION);
+
     ChangeControl ctl = rsrc.getControl();
-    if (!ctl.canEditDescription()) {
-      throw new AuthException("changing description not permitted");
-    }
     Op op = new Op(input != null ? input : new Input(), rsrc.getPatchSet().getId());
     try (BatchUpdate u =
         batchUpdateFactory.create(
@@ -129,6 +129,6 @@
   public UiAction.Description getDescription(RevisionResource rsrc) {
     return new UiAction.Description()
         .setLabel("Edit Description")
-        .setVisible(rsrc.getControl().canEditDescription());
+        .setVisible(rsrc.permissions().testOrFalse(ChangePermission.EDIT_DESCRIPTION));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
index b289da8..ecdb382 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
@@ -146,7 +146,7 @@
           update,
           Status.DRAFT,
           Collections.singleton(update(comment, in, ctx.getWhen())));
-      ctx.bumpLastUpdatedOn(false);
+      ctx.dontBumpLastUpdatedOn();
       return true;
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutPrivate.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutPrivate.java
new file mode 100644
index 0000000..d9105f1
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutPrivate.java
@@ -0,0 +1,88 @@
+// Copyright (C) 2017 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.change;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class PutPrivate
+    implements RestModifyView<ChangeResource, PutPrivate.Input>, UiAction<ChangeResource> {
+  public static class Input {}
+
+  private final ChangeMessagesUtil cmUtil;
+  private final Provider<ReviewDb> dbProvider;
+  private final BatchUpdate.Factory batchUpdateFactory;
+
+  @Inject
+  PutPrivate(
+      Provider<ReviewDb> dbProvider,
+      BatchUpdate.Factory batchUpdateFactory,
+      ChangeMessagesUtil cmUtil) {
+    this.dbProvider = dbProvider;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.cmUtil = cmUtil;
+  }
+
+  @Override
+  public Response<String> apply(ChangeResource rsrc, Input input)
+      throws RestApiException, UpdateException {
+    if (!rsrc.isUserOwner()) {
+      throw new AuthException("not allowed to mark private");
+    }
+
+    if (rsrc.getChange().isPrivate()) {
+      return Response.ok("");
+    }
+
+    ChangeControl control = rsrc.getControl();
+    SetPrivateOp op = new SetPrivateOp(cmUtil, true);
+    try (BatchUpdate u =
+        batchUpdateFactory.create(
+            dbProvider.get(),
+            control.getProject().getNameKey(),
+            control.getUser(),
+            TimeUtil.nowTs())) {
+      u.addOp(control.getId(), op).execute();
+    }
+
+    return Response.created("");
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    Change change = rsrc.getChange();
+    return new UiAction.Description()
+        .setLabel("Mark private")
+        .setTitle("Mark change as private")
+        .setVisible(
+            !change.isPrivate()
+                && change.getStatus() != Change.Status.MERGED
+                && rsrc.isUserOwner());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
index 783ab9d..b1e351b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
@@ -16,7 +16,6 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -29,7 +28,8 @@
 import com.google.gerrit.server.change.PutTopic.Input;
 import com.google.gerrit.server.extensions.events.TopicEdited;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -65,16 +65,13 @@
 
   @Override
   public Response<String> apply(ChangeResource req, Input input)
-      throws UpdateException, RestApiException {
-    ChangeControl ctl = req.getControl();
-    if (!ctl.canEditTopicName()) {
-      throw new AuthException("changing topic not permitted");
-    }
+      throws UpdateException, RestApiException, PermissionBackendException {
+    req.permissions().check(ChangePermission.EDIT_TOPIC_NAME);
 
     Op op = new Op(input != null ? input : new Input());
     try (BatchUpdate u =
         batchUpdateFactory.create(
-            dbProvider.get(), req.getChange().getProject(), ctl.getUser(), TimeUtil.nowTs())) {
+            dbProvider.get(), req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
       u.addOp(req.getId(), op);
       u.execute();
     }
@@ -129,9 +126,9 @@
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource resource) {
+  public UiAction.Description getDescription(ChangeResource rsrc) {
     return new UiAction.Description()
         .setLabel("Edit Topic")
-        .setVisible(resource.getControl().canEditTopicName());
+        .setVisible(rsrc.permissions().testOrFalse(ChangePermission.EDIT_TOPIC_NAME));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java
index 93e1e4e..b866489 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java
@@ -31,10 +31,12 @@
 import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RebaseUtil.Base;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -46,6 +48,8 @@
 import java.io.IOException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -85,19 +89,20 @@
   @Override
   public ChangeInfo apply(RevisionResource rsrc, RebaseInput input)
       throws EmailException, OrmException, UpdateException, RestApiException, IOException,
-          NoSuchChangeException {
+          NoSuchChangeException, PermissionBackendException {
+    rsrc.permissions().database(dbProvider).check(ChangePermission.REBASE);
+
     ChangeControl control = rsrc.getControl();
     Change change = rsrc.getChange();
     try (Repository repo = repoManager.openRepository(change.getProject());
-        RevWalk rw = new RevWalk(repo);
         ObjectInserter oi = repo.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk rw = new RevWalk(reader);
         BatchUpdate bu =
             updateFactory.create(
                 dbProvider.get(), change.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      if (!control.canRebase(dbProvider.get())) {
-        throw new AuthException("rebase not permitted");
-      } else if (!change.getStatus().isOpen()) {
-        throw new ResourceConflictException("change is " + change.getStatus().name().toLowerCase());
+      if (!change.getStatus().isOpen()) {
+        throw new ResourceConflictException("change is " + ChangeUtil.status(change));
       } else if (!hasOneParent(rw, rsrc.getPatchSet())) {
         throw new ResourceConflictException(
             "cannot rebase merge commits or commit with no ancestor");
@@ -106,27 +111,32 @@
       bu.addOp(
           change.getId(),
           rebaseFactory
-              .create(control, rsrc.getPatchSet(), findBaseRev(rw, rsrc, input))
+              .create(control, rsrc.getPatchSet(), findBaseRev(repo, rw, rsrc, input))
               .setForceContentMerge(true)
-              .setFireRevisionCreated(true)
-              .setValidatePolicy(CommitValidators.Policy.GERRIT));
+              .setFireRevisionCreated(true));
       bu.execute();
     }
     return json.create(OPTIONS).format(change.getProject(), change.getId());
   }
 
-  private String findBaseRev(RevWalk rw, RevisionResource rsrc, RebaseInput input)
-      throws AuthException, ResourceConflictException, OrmException, IOException,
-          NoSuchChangeException {
+  private ObjectId findBaseRev(
+      Repository repo, RevWalk rw, RevisionResource rsrc, RebaseInput input)
+      throws RestApiException, OrmException, IOException, NoSuchChangeException {
+    Branch.NameKey destRefKey = rsrc.getChange().getDest();
     if (input == null || input.base == null) {
-      return null;
+      return rebaseUtil.findBaseRevision(rsrc.getPatchSet(), destRefKey, repo, rw);
     }
 
     Change change = rsrc.getChange();
     String str = input.base.trim();
     if (str.equals("")) {
-      // remove existing dependency to other patch set
-      return change.getDest().get();
+      // Remove existing dependency to other patch set.
+      Ref destRef = repo.exactRef(destRefKey.get());
+      if (destRef == null) {
+        throw new ResourceConflictException(
+            "can't rebase onto tip of branch " + destRefKey.get() + "; branch doesn't exist");
+      }
+      return destRef.getObjectId();
     }
 
     @SuppressWarnings("resource")
@@ -157,7 +167,7 @@
               + baseChange.getKey()
               + " is a descendant of the current change - recursion not allowed");
     }
-    return base.patchSet().getRevision().get();
+    return ObjectId.fromString(base.patchSet().getRevision().get());
   }
 
   private boolean isMergedInto(RevWalk rw, PatchSet base, PatchSet tip) throws IOException {
@@ -175,15 +185,12 @@
   @Override
   public UiAction.Description getDescription(RevisionResource resource) {
     PatchSet patchSet = resource.getPatchSet();
-    Branch.NameKey dest = resource.getChange().getDest();
-    boolean canRebase = false;
-    try {
-      canRebase = resource.getControl().canRebase(dbProvider.get());
-    } catch (OrmException e) {
-      log.error("Cannot check canRebase status. Assuming false.", e);
-    }
+    Change change = resource.getChange();
+    Branch.NameKey dest = change.getDest();
     boolean visible =
-        resource.getChange().getStatus().isOpen() && resource.isCurrent() && canRebase;
+        change.getStatus().isOpen()
+            && resource.isCurrent()
+            && resource.permissions().database(dbProvider).testOrFalse(ChangePermission.REBASE);
     boolean enabled = true;
 
     if (visible) {
@@ -196,13 +203,11 @@
         visible = false;
       }
     }
-    UiAction.Description descr =
-        new UiAction.Description()
-            .setLabel("Rebase")
-            .setTitle("Rebase onto tip of branch or parent change")
-            .setVisible(visible)
-            .setEnabled(enabled);
-    return descr;
+    return new UiAction.Description()
+        .setLabel("Rebase")
+        .setTitle("Rebase onto tip of branch or parent change")
+        .setVisible(visible)
+        .setEnabled(enabled);
   }
 
   public static class CurrentRevision implements RestModifyView<ChangeResource, RebaseInput> {
@@ -217,7 +222,8 @@
 
     @Override
     public ChangeInfo apply(ChangeResource rsrc, RebaseInput input)
-        throws EmailException, OrmException, UpdateException, RestApiException, IOException {
+        throws EmailException, OrmException, UpdateException, RestApiException, IOException,
+            PermissionBackendException {
       PatchSet ps = psUtil.current(rebase.dbProvider.get(), rsrc.getNotes());
       if (ps == null) {
         throw new ResourceConflictException("current revision is missing");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
index c03bb6f..16e3a08 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -26,7 +25,6 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.RebaseUtil.Base;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
@@ -36,8 +34,8 @@
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
@@ -48,8 +46,7 @@
 
 public class RebaseChangeOp implements BatchUpdateOp {
   public interface Factory {
-    RebaseChangeOp create(
-        ChangeControl ctl, PatchSet originalPatchSet, @Nullable String baseCommitish);
+    RebaseChangeOp create(ChangeControl ctl, PatchSet originalPatchSet, ObjectId baseCommitId);
   }
 
   private final PatchSetInserter.Factory patchSetInserterFactory;
@@ -60,10 +57,10 @@
   private final ChangeControl ctl;
   private final PatchSet originalPatchSet;
 
-  private String baseCommitish;
+  private ObjectId baseCommitId;
   private PersonIdent committerIdent;
   private boolean fireRevisionCreated = true;
-  private CommitValidators.Policy validate;
+  private boolean validate = true;
   private boolean checkAddPatchSetPermission = true;
   private boolean forceContentMerge;
   private boolean copyApprovals = true;
@@ -75,7 +72,7 @@
   private PatchSetInserter patchSetInserter;
   private PatchSet rebasedPatchSet;
 
-  @AssistedInject
+  @Inject
   RebaseChangeOp(
       PatchSetInserter.Factory patchSetInserterFactory,
       MergeUtil.Factory mergeUtilFactory,
@@ -83,14 +80,14 @@
       ChangeResource.Factory changeResourceFactory,
       @Assisted ChangeControl ctl,
       @Assisted PatchSet originalPatchSet,
-      @Assisted @Nullable String baseCommitish) {
+      @Assisted ObjectId baseCommitId) {
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.mergeUtilFactory = mergeUtilFactory;
     this.rebaseUtil = rebaseUtil;
     this.changeResourceFactory = changeResourceFactory;
     this.ctl = ctl;
     this.originalPatchSet = originalPatchSet;
-    this.baseCommitish = baseCommitish;
+    this.baseCommitId = baseCommitId;
   }
 
   public RebaseChangeOp setCommitterIdent(PersonIdent committerIdent) {
@@ -98,7 +95,7 @@
     return this;
   }
 
-  public RebaseChangeOp setValidatePolicy(CommitValidators.Policy validate) {
+  public RebaseChangeOp setValidate(boolean validate) {
     this.validate = validate;
     return this;
   }
@@ -144,19 +141,7 @@
     RevWalk rw = ctx.getRevWalk();
     RevCommit original = rw.parseCommit(ObjectId.fromString(oldRev.get()));
     rw.parseBody(original);
-
-    RevCommit baseCommit;
-    if (baseCommitish != null) {
-      baseCommit = rw.parseCommit(ctx.getRepository().resolve(baseCommitish));
-    } else {
-      baseCommit =
-          rw.parseCommit(
-              rebaseUtil.findBaseRevision(
-                  originalPatchSet,
-                  ctl.getChange().getDest(),
-                  ctx.getRepository(),
-                  ctx.getRevWalk()));
-    }
+    RevCommit baseCommit = rw.parseCommit(baseCommitId);
 
     String newCommitMessage;
     if (detailedCommitMessage) {
@@ -169,16 +154,15 @@
     }
 
     rebasedCommit = rebaseCommit(ctx, original, baseCommit, newCommitMessage);
-
-    RevId baseRevId =
-        new RevId((baseCommitish != null) ? baseCommitish : ObjectId.toString(baseCommit.getId()));
     Base base =
         rebaseUtil.parseBase(
             new RevisionResource(changeResourceFactory.create(ctl), originalPatchSet),
-            baseRevId.get());
+            baseCommitId.name());
 
     rebasedPatchSetId =
-        ChangeUtil.nextPatchSetId(ctx.getRepository(), ctl.getChange().currentPatchSetId());
+        ChangeUtil.nextPatchSetIdFromChangeRefsMap(
+            ctx.getRepoView().getRefs(originalPatchSet.getId().getParentKey().toRefPrefix()),
+            ctl.getChange().currentPatchSetId());
     patchSetInserter =
         patchSetInserterFactory
             .create(ctl, rebasedPatchSetId, rebasedCommit)
@@ -187,7 +171,8 @@
             .setNotify(NotifyHandling.NONE)
             .setFireRevisionCreated(fireRevisionCreated)
             .setCopyApprovals(copyApprovals)
-            .setCheckAddPatchSetPermission(checkAddPatchSetPermission);
+            .setCheckAddPatchSetPermission(checkAddPatchSetPermission)
+            .setValidate(validate);
     if (postMessage) {
       patchSetInserter.setMessage(
           "Patch Set "
@@ -200,9 +185,6 @@
     if (base != null) {
       patchSetInserter.setGroups(base.patchSet().getGroups());
     }
-    if (validate != null) {
-      patchSetInserter.setValidatePolicy(validate);
-    }
     patchSetInserter.updateRepo(ctx);
   }
 
@@ -261,7 +243,7 @@
     }
 
     ThreeWayMerger merger =
-        newMergeUtil().newThreeWayMerger(ctx.getRepository(), ctx.getInserter());
+        newMergeUtil().newThreeWayMerger(ctx.getInserter(), ctx.getRepoView().getConfig());
     merger.setBase(parentCommit);
     merger.merge(original, base);
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java
index b6c4d02..b362472 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.RestoreInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -29,11 +28,14 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.ChangeRestored;
 import com.google.gerrit.server.mail.send.ReplyToChangeSender;
 import com.google.gerrit.server.mail.send.RestoredSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -80,12 +82,10 @@
 
   @Override
   public ChangeInfo apply(ChangeResource req, RestoreInput input)
-      throws RestApiException, UpdateException, OrmException {
-    ChangeControl ctl = req.getControl();
-    if (!ctl.canRestore(dbProvider.get())) {
-      throw new AuthException("restore not permitted");
-    }
+      throws RestApiException, UpdateException, OrmException, PermissionBackendException {
+    req.permissions().database(dbProvider).check(ChangePermission.RESTORE);
 
+    ChangeControl ctl = req.getControl();
     Op op = new Op(input);
     try (BatchUpdate u =
         batchUpdateFactory.create(
@@ -110,7 +110,7 @@
     public boolean updateChange(ChangeContext ctx) throws OrmException, ResourceConflictException {
       change = ctx.getChange();
       if (change == null || change.getStatus() != Status.ABANDONED) {
-        throw new ResourceConflictException("change is " + status(change));
+        throw new ResourceConflictException("change is " + ChangeUtil.status(change));
       }
       PatchSet.Id psId = change.currentPatchSetId();
       ChangeUpdate update = ctx.getUpdate(psId);
@@ -150,20 +150,12 @@
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource resource) {
-    boolean canRestore = false;
-    try {
-      canRestore = resource.getControl().canRestore(dbProvider.get());
-    } catch (OrmException e) {
-      log.error("Cannot check canRestore status. Assuming false.", e);
-    }
+  public UiAction.Description getDescription(ChangeResource rsrc) {
     return new UiAction.Description()
         .setLabel("Restore")
         .setTitle("Restore the change")
-        .setVisible(resource.getChange().getStatus() == Status.ABANDONED && canRestore);
-  }
-
-  private static String status(Change change) {
-    return change != null ? change.getStatus().name().toLowerCase() : "deleted";
+        .setVisible(
+            rsrc.getChange().getStatus() == Status.ABANDONED
+                && rsrc.permissions().database(dbProvider).testOrFalse(ChangePermission.RESTORE));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java
index dc3c948..14e55c5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java
@@ -34,13 +34,13 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.extensions.events.ChangeReverted;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.mail.send.RevertedSender;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
@@ -64,6 +64,7 @@
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -133,7 +134,7 @@
     if (!refControl.canUpload()) {
       throw new AuthException("revert not permitted");
     } else if (change.getStatus() != Status.MERGED) {
-      throw new ResourceConflictException("change is " + status(change));
+      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     }
 
     Change.Id revertedChangeId = revert(req.getControl(), Strings.emptyToNull(input.message));
@@ -152,7 +153,9 @@
     Project.NameKey project = ctl.getProject().getNameKey();
     CurrentUser user = ctl.getUser();
     try (Repository git = repoManager.openRepository(project);
-        RevWalk revWalk = new RevWalk(git)) {
+        ObjectInserter oi = git.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk revWalk = new RevWalk(reader)) {
       RevCommit commitToRevert =
           revWalk.parseCommit(ObjectId.fromString(patch.getRevision().get()));
       if (commitToRevert.getParentCount() == 0) {
@@ -192,31 +195,28 @@
       revertCommitBuilder.setMessage(ChangeIdUtil.insertId(message, computedChangeId, true));
 
       Change.Id changeId = new Change.Id(seq.nextChangeId());
-      try (ObjectInserter oi = git.newObjectInserter()) {
-        ObjectId id = oi.insert(revertCommitBuilder);
-        oi.flush();
-        RevCommit revertCommit = revWalk.parseCommit(id);
+      ObjectId id = oi.insert(revertCommitBuilder);
+      oi.flush();
+      RevCommit revertCommit = revWalk.parseCommit(id);
 
-        ChangeInserter ins =
-            changeInserterFactory
-                .create(changeId, revertCommit, ctl.getChange().getDest().get())
-                .setValidatePolicy(CommitValidators.Policy.GERRIT)
-                .setTopic(changeToRevert.getTopic());
-        ins.setMessage("Uploaded patch set 1.");
+      ChangeInserter ins =
+          changeInserterFactory
+              .create(changeId, revertCommit, ctl.getChange().getDest().get())
+              .setTopic(changeToRevert.getTopic());
+      ins.setMessage("Uploaded patch set 1.");
 
-        Set<Account.Id> reviewers = new HashSet<>();
-        reviewers.add(changeToRevert.getOwner());
-        reviewers.addAll(approvalsUtil.getReviewers(db.get(), ctl.getNotes()).all());
-        reviewers.remove(user.getAccountId());
-        ins.setReviewers(reviewers);
+      Set<Account.Id> reviewers = new HashSet<>();
+      reviewers.add(changeToRevert.getOwner());
+      reviewers.addAll(approvalsUtil.getReviewers(db.get(), ctl.getNotes()).all());
+      reviewers.remove(user.getAccountId());
+      ins.setReviewers(reviewers);
 
-        try (BatchUpdate bu = updateFactory.create(db.get(), project, user, now)) {
-          bu.setRepository(git, revWalk, oi);
-          bu.insertChange(ins);
-          bu.addOp(changeId, new NotifyOp(ctl.getChange(), ins));
-          bu.addOp(changeToRevert.getId(), new PostRevertedMessageOp(computedChangeId));
-          bu.execute();
-        }
+      try (BatchUpdate bu = updateFactory.create(db.get(), project, user, now)) {
+        bu.setRepository(git, revWalk, oi);
+        bu.insertChange(ins);
+        bu.addOp(changeId, new NotifyOp(ctl.getChange(), ins));
+        bu.addOp(changeToRevert.getId(), new PostRevertedMessageOp(computedChangeId));
+        bu.execute();
       }
       return changeId;
     } catch (RepositoryNotFoundException e) {
@@ -234,10 +234,6 @@
                 && resource.getControl().getRefControl().canUpload());
   }
 
-  private static String status(Change change) {
-    return change != null ? change.getStatus().name().toLowerCase() : "deleted";
-  }
-
   private class NotifyOp implements BatchUpdateOp {
     private final Change change;
     private final ChangeInserter ins;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
index ac7f15e..33c773a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -20,7 +20,6 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
@@ -30,6 +29,9 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -44,6 +46,7 @@
 @Singleton
 public class ReviewerJson {
   private final Provider<ReviewDb> db;
+  private final PermissionBackend permissionBackend;
   private final ChangeData.Factory changeDataFactory;
   private final ApprovalsUtil approvalsUtil;
   private final AccountLoader.Factory accountLoaderFactory;
@@ -51,23 +54,31 @@
   @Inject
   ReviewerJson(
       Provider<ReviewDb> db,
+      PermissionBackend permissionBackend,
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
       AccountLoader.Factory accountLoaderFactory) {
     this.db = db;
+    this.permissionBackend = permissionBackend;
     this.changeDataFactory = changeDataFactory;
     this.approvalsUtil = approvalsUtil;
     this.accountLoaderFactory = accountLoaderFactory;
   }
 
-  public List<ReviewerInfo> format(Collection<ReviewerResource> rsrcs) throws OrmException {
+  public List<ReviewerInfo> format(Collection<ReviewerResource> rsrcs)
+      throws OrmException, PermissionBackendException {
     List<ReviewerInfo> infos = Lists.newArrayListWithCapacity(rsrcs.size());
     AccountLoader loader = accountLoaderFactory.create(true);
+    ChangeData cd = null;
     for (ReviewerResource rsrc : rsrcs) {
+      if (cd == null || !cd.getId().equals(rsrc.getChangeId())) {
+        cd = changeDataFactory.create(db.get(), rsrc.getControl().getNotes());
+      }
       ReviewerInfo info =
           format(
               new ReviewerInfo(rsrc.getReviewerUser().getAccountId().get()),
-              rsrc.getReviewerControl());
+              permissionBackend.user(rsrc.getReviewerUser()).database(db).change(cd),
+              cd);
       loader.put(info);
       infos.add(info);
     }
@@ -75,27 +86,34 @@
     return infos;
   }
 
-  public List<ReviewerInfo> format(ReviewerResource rsrc) throws OrmException {
+  public List<ReviewerInfo> format(ReviewerResource rsrc)
+      throws OrmException, PermissionBackendException {
     return format(ImmutableList.<ReviewerResource>of(rsrc));
   }
 
-  public ReviewerInfo format(ReviewerInfo out, ChangeControl ctl) throws OrmException {
-    PatchSet.Id psId = ctl.getChange().currentPatchSetId();
+  public ReviewerInfo format(ReviewerInfo out, PermissionBackend.ForChange perm, ChangeData cd)
+      throws OrmException, PermissionBackendException {
+    PatchSet.Id psId = cd.change().currentPatchSetId();
+    ChangeControl ctl = cd.changeControl().forUser(perm.user());
     return format(
         out,
-        ctl,
+        perm,
+        cd,
         approvalsUtil.byPatchSetUser(db.get(), ctl, psId, new Account.Id(out._accountId)));
   }
 
   public ReviewerInfo format(
-      ReviewerInfo out, ChangeControl ctl, Iterable<PatchSetApproval> approvals)
-      throws OrmException {
-    LabelTypes labelTypes = ctl.getLabelTypes();
+      ReviewerInfo out,
+      PermissionBackend.ForChange perm,
+      ChangeData cd,
+      Iterable<PatchSetApproval> approvals)
+      throws OrmException, PermissionBackendException {
+    LabelTypes labelTypes = cd.getLabelTypes();
 
     // Don't use Maps.newTreeMap(Comparator) due to OpenJDK bug 100167.
     out.approvals = new TreeMap<>(labelTypes.nameComparator());
     for (PatchSetApproval ca : approvals) {
-      for (PermissionRange pr : ctl.getLabelRanges()) {
+      for (PermissionRange pr : cd.changeControl().getLabelRanges()) {
         if (!pr.isEmpty()) {
           LabelType at = labelTypes.byLabel(ca.getLabelId());
           if (at != null) {
@@ -107,7 +125,6 @@
 
     // Add dummy approvals for all permitted labels for the user even if they
     // do not exist in the DB.
-    ChangeData cd = changeDataFactory.create(db.get(), ctl);
     PatchSet ps = cd.currentPatchSet();
     if (ps != null) {
       for (SubmitRecord rec :
@@ -117,8 +134,10 @@
         }
         for (SubmitRecord.Label label : rec.labels) {
           String name = label.label;
+          LabelType type = labelTypes.byLabel(name);
           if (!out.approvals.containsKey(name)
-              && !ctl.getRange(Permission.forLabel(name)).isEmpty()) {
+              && type != null
+              && perm.test(new LabelPermission(type))) {
             out.approvals.put(name, formatValue((short) 0));
           }
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
index 6ff4a50..f6f7919 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
@@ -14,11 +14,15 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.Assisted;
@@ -36,7 +40,8 @@
 
   private final ChangeResource change;
   private final RevisionResource revision;
-  private final IdentifiedUser user;
+  @Nullable private final IdentifiedUser user;
+  @Nullable private final Address address;
 
   @AssistedInject
   ReviewerResource(
@@ -44,8 +49,9 @@
       @Assisted ChangeResource change,
       @Assisted Account.Id id) {
     this.change = change;
-    this.revision = null;
     this.user = userFactory.create(id);
+    this.revision = null;
+    this.address = null;
   }
 
   @AssistedInject
@@ -56,6 +62,21 @@
     this.revision = revision;
     this.change = revision.getChangeResource();
     this.user = userFactory.create(id);
+    this.address = null;
+  }
+
+  ReviewerResource(ChangeResource change, Address address) {
+    this.change = change;
+    this.address = address;
+    this.revision = null;
+    this.user = null;
+  }
+
+  ReviewerResource(RevisionResource revision, Address address) {
+    this.revision = revision;
+    this.change = revision.getChangeResource();
+    this.address = address;
+    this.user = null;
   }
 
   public ChangeResource getChangeResource() {
@@ -75,10 +96,28 @@
   }
 
   public IdentifiedUser getReviewerUser() {
+    checkArgument(user != null, "no user provided");
     return user;
   }
 
+  public Address getReviewerByEmail() {
+    checkArgument(address != null, "no address provided");
+    return address;
+  }
+
   /**
+   * Check if this resource was constructed by email or by {@code Account.Id}.
+   *
+   * @return true if the resource was constructed by providing an {@code Address}; false if the
+   *     resource was constructed by providing an {@code Account.Id}.
+   */
+  public boolean isByEmail() {
+    return user == null;
+  }
+
+  /**
+   * Get the control for the caller's user.
+   *
    * @return the control for the caller's user (as opposed to the reviewer's user as returned by
    *     {@link #getReviewerControl()}).
    */
@@ -87,10 +126,13 @@
   }
 
   /**
+   * Get the control for the reviewer's user.
+   *
    * @return the control for the reviewer's user (as opposed to the caller's user as returned by
    *     {@link #getControl()}).
    */
   public ChangeControl getReviewerControl() {
+    checkArgument(user != null, "no user provided");
     return change.getControl().forUser(user);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java
index 14c74bc..0762f0e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountsCollection;
+import com.google.gerrit.server.mail.Address;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -69,12 +70,26 @@
   @Override
   public ReviewerResource parse(ChangeResource rsrc, IdString id)
       throws OrmException, ResourceNotFoundException, AuthException {
-    Account.Id accountId = accounts.parse(TopLevelResource.INSTANCE, id).getUser().getAccountId();
+    Address address = Address.tryParse(id.get());
 
+    Account.Id accountId = null;
+    try {
+      accountId = accounts.parse(TopLevelResource.INSTANCE, id).getUser().getAccountId();
+    } catch (ResourceNotFoundException e) {
+      if (address == null) {
+        throw e;
+      }
+    }
     // See if the id exists as a reviewer for this change
-    if (fetchAccountIds(rsrc).contains(accountId)) {
+    if (accountId != null && fetchAccountIds(rsrc).contains(accountId)) {
       return resourceFactory.create(rsrc, accountId);
     }
+
+    // See if the address exists as a reviewer on the change
+    if (address != null && rsrc.getNotes().getReviewersByEmail().all().contains(address)) {
+      return new ReviewerResource(rsrc, address);
+    }
+
     throw new ResourceNotFoundException(id);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java
index 4d35f9e..32132bc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.inject.TypeLiteral;
 import java.util.Optional;
@@ -51,6 +52,10 @@
     return cacheable;
   }
 
+  public PermissionBackend.ForChange permissions() {
+    return change.permissions();
+  }
+
   public ChangeResource getChangeResource() {
     return change;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionReviewers.java
index d3623cf..2dc7ad8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionReviewers.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountsCollection;
+import com.google.gerrit.server.mail.Address;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -73,14 +74,28 @@
     if (!rsrc.isCurrent()) {
       throw new MethodNotAllowedException("Cannot access on non-current patch set");
     }
+    Address address = Address.tryParse(id.get());
 
-    Account.Id accountId = accounts.parse(TopLevelResource.INSTANCE, id).getUser().getAccountId();
-
+    Account.Id accountId = null;
+    try {
+      accountId = accounts.parse(TopLevelResource.INSTANCE, id).getUser().getAccountId();
+    } catch (ResourceNotFoundException e) {
+      if (address == null) {
+        throw e;
+      }
+    }
     Collection<Account.Id> reviewers =
         approvalsUtil.getReviewers(dbProvider.get(), rsrc.getNotes()).all();
+    // See if the id exists as a reviewer for this change
     if (reviewers.contains(accountId)) {
       return resourceFactory.create(rsrc, accountId);
     }
+
+    // See if the address exists as a reviewer on the change
+    if (address != null && rsrc.getNotes().getReviewersByEmail().all().contains(address)) {
+      return new ReviewerResource(rsrc, address);
+    }
+
     throw new ResourceNotFoundException(id);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java
index a16f2f9..5ecb904 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java
@@ -37,6 +37,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 
 @Singleton
 public class Revisions implements ChildCollection<ChangeResource, RevisionResource> {
@@ -143,8 +144,9 @@
     Optional<ChangeEdit> edit = editUtil.byChange(change.getChange());
     if (edit.isPresent()) {
       PatchSet ps = new PatchSet(new PatchSet.Id(change.getId(), 0));
-      ps.setRevision(edit.get().getRevision());
-      if (revid == null || edit.get().getRevision().equals(revid)) {
+      RevId editRevId = new RevId(ObjectId.toString(edit.get().getEditCommit()));
+      ps.setRevision(editRevId);
+      if (revid == null || editRevId.equals(revid)) {
         return Collections.singletonList(new RevisionResource(change, ps, edit));
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetAssigneeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetAssigneeOp.java
index 409be9d..73a6c60 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetAssigneeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetAssigneeOp.java
@@ -17,16 +17,12 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountsCollection;
 import com.google.gerrit.server.extensions.events.AssigneeChanged;
 import com.google.gerrit.server.mail.send.SetAssigneeSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -36,9 +32,9 @@
 import com.google.gerrit.server.validators.AssigneeValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -46,93 +42,74 @@
   private static final Logger log = LoggerFactory.getLogger(SetAssigneeOp.class);
 
   public interface Factory {
-    SetAssigneeOp create(String assignee);
+    SetAssigneeOp create(IdentifiedUser assignee);
   }
 
-  private final AccountsCollection accounts;
   private final ChangeMessagesUtil cmUtil;
   private final DynamicSet<AssigneeValidationListener> validationListeners;
-  private final String assignee;
+  private final IdentifiedUser newAssignee;
   private final AssigneeChanged assigneeChanged;
   private final SetAssigneeSender.Factory setAssigneeSenderFactory;
   private final Provider<IdentifiedUser> user;
   private final IdentifiedUser.GenericFactory userFactory;
 
   private Change change;
-  private Account newAssignee;
-  private Account oldAssignee;
+  private IdentifiedUser oldAssignee;
 
-  @AssistedInject
+  @Inject
   SetAssigneeOp(
-      AccountsCollection accounts,
       ChangeMessagesUtil cmUtil,
       DynamicSet<AssigneeValidationListener> validationListeners,
       AssigneeChanged assigneeChanged,
       SetAssigneeSender.Factory setAssigneeSenderFactory,
       Provider<IdentifiedUser> user,
       IdentifiedUser.GenericFactory userFactory,
-      @Assisted String assignee) {
-    this.accounts = accounts;
+      @Assisted IdentifiedUser newAssignee) {
     this.cmUtil = cmUtil;
     this.validationListeners = validationListeners;
     this.assigneeChanged = assigneeChanged;
     this.setAssigneeSenderFactory = setAssigneeSenderFactory;
     this.user = user;
     this.userFactory = userFactory;
-    this.assignee = checkNotNull(assignee);
+    this.newAssignee = checkNotNull(newAssignee, "assignee");
   }
 
   @Override
   public boolean updateChange(ChangeContext ctx) throws OrmException, RestApiException {
     change = ctx.getChange();
-    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
-    IdentifiedUser newAssigneeUser = accounts.parse(assignee);
-    newAssignee = newAssigneeUser.getAccount();
-    IdentifiedUser oldAssigneeUser = null;
-    if (change.getAssignee() != null) {
-      oldAssigneeUser = userFactory.create(change.getAssignee());
-      oldAssignee = oldAssigneeUser.getAccount();
-      if (newAssignee.equals(oldAssignee)) {
-        return false;
-      }
-    }
-    if (!newAssignee.isActive()) {
-      throw new UnprocessableEntityException(
-          String.format("Account of %s is not active", assignee));
-    }
-    if (!ctx.getControl().forUser(newAssigneeUser).isRefVisible()) {
-      throw new AuthException(
-          String.format("Change %s is not visible to %s.", change.getChangeId(), assignee));
+    if (newAssignee.getAccountId().equals(change.getAssignee())) {
+      return false;
     }
     try {
       for (AssigneeValidationListener validator : validationListeners) {
-        validator.validateAssignee(change, newAssignee);
+        validator.validateAssignee(change, newAssignee.getAccount());
       }
     } catch (ValidationException e) {
       throw new ResourceConflictException(e.getMessage());
     }
+
+    if (change.getAssignee() != null) {
+      oldAssignee = userFactory.create(change.getAssignee());
+    }
+
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
     // notedb
-    update.setAssignee(newAssignee.getId());
+    update.setAssignee(newAssignee.getAccountId());
     // reviewdb
-    change.setAssignee(newAssignee.getId());
-    addMessage(ctx, update, oldAssigneeUser, newAssigneeUser);
+    change.setAssignee(newAssignee.getAccountId());
+    addMessage(ctx, update);
     return true;
   }
 
-  private void addMessage(
-      ChangeContext ctx,
-      ChangeUpdate update,
-      IdentifiedUser previousAssignee,
-      IdentifiedUser newAssignee)
-      throws OrmException {
+  private void addMessage(ChangeContext ctx, ChangeUpdate update) throws OrmException {
     StringBuilder msg = new StringBuilder();
     msg.append("Assignee ");
-    if (previousAssignee == null) {
+    if (oldAssignee == null) {
       msg.append("added: ");
       msg.append(newAssignee.getNameEmail());
     } else {
       msg.append("changed from: ");
-      msg.append(previousAssignee.getNameEmail());
+      msg.append(oldAssignee.getNameEmail());
       msg.append(" to: ");
       msg.append(newAssignee.getNameEmail());
     }
@@ -145,16 +122,17 @@
   public void postUpdate(Context ctx) throws OrmException {
     try {
       SetAssigneeSender cm =
-          setAssigneeSenderFactory.create(change.getProject(), change.getId(), newAssignee.getId());
+          setAssigneeSenderFactory.create(
+              change.getProject(), change.getId(), newAssignee.getAccountId());
       cm.setFrom(user.get().getAccountId());
       cm.send();
     } catch (Exception err) {
       log.error("Cannot send email to new assignee of change " + change.getId(), err);
     }
-    assigneeChanged.fire(change, ctx.getAccount(), oldAssignee, ctx.getWhen());
-  }
-
-  public Account.Id getNewAssignee() {
-    return newAssignee != null ? newAssignee.getId() : null;
+    assigneeChanged.fire(
+        change,
+        ctx.getAccount(),
+        oldAssignee != null ? oldAssignee.getAccount() : null,
+        ctx.getWhen());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java
index 0e78c18..724598a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java
@@ -39,8 +39,8 @@
 import com.google.gerrit.server.validators.HashtagValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.HashSet;
@@ -64,7 +64,7 @@
   private Set<String> toRemove;
   private ImmutableSortedSet<String> updatedHashtags;
 
-  @AssistedInject
+  @Inject
   SetHashtagsOp(
       NotesMigration notesMigration,
       ChangeMessagesUtil cmUtil,
@@ -94,9 +94,7 @@
       updatedHashtags = ImmutableSortedSet.of();
       return false;
     }
-    if (!ctx.getControl().canEditHashtags()) {
-      throw new AuthException("Editing hashtags not permitted");
-    }
+
     change = ctx.getChange();
     ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
     ChangeNotes notes = update.getNotes().load();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetPrivateOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetPrivateOp.java
new file mode 100644
index 0000000..1cebcc2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetPrivateOp.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2017 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.change;
+
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gwtorm.server.OrmException;
+
+class SetPrivateOp implements BatchUpdateOp {
+  private final ChangeMessagesUtil cmUtil;
+  private final boolean isPrivate;
+
+  SetPrivateOp(ChangeMessagesUtil cmUtil, boolean isPrivate) {
+    this.cmUtil = cmUtil;
+    this.isPrivate = isPrivate;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws ResourceConflictException, OrmException {
+    Change change = ctx.getChange();
+    if (change.getStatus() == Change.Status.MERGED) {
+      throw new ResourceConflictException("change is merged");
+    }
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+    change.setPrivate(isPrivate);
+    change.setLastUpdatedOn(ctx.getWhen());
+    update.setPrivate(isPrivate);
+    addMessage(ctx, update);
+    return true;
+  }
+
+  private void addMessage(ChangeContext ctx, ChangeUpdate update) throws OrmException {
+    Change c = ctx.getChange();
+    ChangeMessage cmsg =
+        ChangeMessagesUtil.newMessage(
+            ctx,
+            c.isPrivate() ? "Set private" : "Unset private",
+            c.isPrivate()
+                ? ChangeMessagesUtil.TAG_SET_PRIVATE
+                : ChangeMessagesUtil.TAG_UNSET_PRIVATE);
+    cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetReadyForReview.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetReadyForReview.java
new file mode 100644
index 0000000..dbc82b7
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetReadyForReview.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2017 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.change;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Status;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.change.WorkInProgressOp.Input;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class SetReadyForReview
+    implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final ChangeMessagesUtil cmUtil;
+  private final Provider<ReviewDb> db;
+
+  @Inject
+  SetReadyForReview(
+      BatchUpdate.Factory batchUpdateFactory, ChangeMessagesUtil cmUtil, Provider<ReviewDb> db) {
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.cmUtil = cmUtil;
+    this.db = db;
+  }
+
+  @Override
+  public Response<?> apply(ChangeResource rsrc, Input input)
+      throws RestApiException, UpdateException {
+    Change change = rsrc.getChange();
+    if (!rsrc.isUserOwner()) {
+      throw new AuthException("not allowed to set ready for review");
+    }
+
+    if (change.getStatus() != Status.NEW) {
+      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
+    }
+
+    if (!change.isWorkInProgress()) {
+      throw new ResourceConflictException("change is not work in progress");
+    }
+
+    try (BatchUpdate bu =
+        batchUpdateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      bu.addOp(rsrc.getChange().getId(), new WorkInProgressOp(cmUtil, false, input));
+      bu.execute();
+      return Response.ok("");
+    }
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new Description()
+        .setLabel("Ready")
+        .setTitle("Set Ready For Review")
+        .setVisible(
+            rsrc.isUserOwner()
+                && rsrc.getChange().getStatus() == Status.NEW
+                && rsrc.getChange().isWorkInProgress());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetWorkInProgress.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetWorkInProgress.java
new file mode 100644
index 0000000..aa93fc2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetWorkInProgress.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2017 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.change;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Status;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.change.WorkInProgressOp.Input;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class SetWorkInProgress
+    implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final ChangeMessagesUtil cmUtil;
+  private final Provider<ReviewDb> db;
+
+  @Inject
+  SetWorkInProgress(
+      BatchUpdate.Factory batchUpdateFactory, ChangeMessagesUtil cmUtil, Provider<ReviewDb> db) {
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.cmUtil = cmUtil;
+    this.db = db;
+  }
+
+  @Override
+  public Response<?> apply(ChangeResource rsrc, Input input)
+      throws RestApiException, UpdateException {
+    Change change = rsrc.getChange();
+    if (!rsrc.isUserOwner()) {
+      throw new AuthException("not allowed to set work in progress");
+    }
+
+    if (change.getStatus() != Status.NEW) {
+      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
+    }
+
+    if (change.isWorkInProgress()) {
+      throw new ResourceConflictException("change is already work in progress");
+    }
+
+    try (BatchUpdate bu =
+        batchUpdateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      bu.addOp(rsrc.getChange().getId(), new WorkInProgressOp(cmUtil, true, input));
+      bu.execute();
+      return Response.ok("");
+    }
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new Description()
+        .setLabel("WIP")
+        .setTitle("Set Work In Progress")
+        .setVisible(
+            rsrc.isUserOwner()
+                && rsrc.getChange().getStatus() == Status.NEW
+                && !rsrc.getChange().isWorkInProgress());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
index 01020fa..3e489da 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
@@ -51,7 +52,9 @@
 import com.google.gerrit.server.git.MergeOp;
 import com.google.gerrit.server.git.MergeSuperSet;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
@@ -62,6 +65,7 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Collection;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -93,6 +97,7 @@
       "This change depends on other changes which are not ready";
   private static final String BLOCKED_HIDDEN_SUBMIT_TOOLTIP =
       "This change depends on other hidden changes which are not ready";
+  private static final String BLOCKED_WORK_IN_PROGRESS = "This change is marked work in progress";
   private static final String CLICK_FAILURE_TOOLTIP = "Clicking the button would fail";
   private static final String CHANGE_UNMERGEABLE = "Problems with integrating this change";
   private static final String CHANGES_NOT_MERGEABLE = "Problems with change(s): ";
@@ -122,13 +127,13 @@
 
   private final Provider<ReviewDb> dbProvider;
   private final GitRepositoryManager repoManager;
+  private final PermissionBackend permissionBackend;
   private final ChangeData.Factory changeDataFactory;
   private final ChangeMessagesUtil cmUtil;
   private final ChangeNotes.Factory changeNotesFactory;
   private final Provider<MergeOp> mergeOpProvider;
   private final Provider<MergeSuperSet> mergeSuperSet;
   private final AccountsCollection accounts;
-  private final ChangesCollection changes;
   private final String label;
   private final String labelWithParents;
   private final ParameterizedString titlePattern;
@@ -143,25 +148,25 @@
   Submit(
       Provider<ReviewDb> dbProvider,
       GitRepositoryManager repoManager,
+      PermissionBackend permissionBackend,
       ChangeData.Factory changeDataFactory,
       ChangeMessagesUtil cmUtil,
       ChangeNotes.Factory changeNotesFactory,
       Provider<MergeOp> mergeOpProvider,
       Provider<MergeSuperSet> mergeSuperSet,
       AccountsCollection accounts,
-      ChangesCollection changes,
       @GerritServerConfig Config cfg,
       Provider<InternalChangeQuery> queryProvider,
       PatchSetUtil psUtil) {
     this.dbProvider = dbProvider;
     this.repoManager = repoManager;
+    this.permissionBackend = permissionBackend;
     this.changeDataFactory = changeDataFactory;
     this.cmUtil = cmUtil;
     this.changeNotesFactory = changeNotesFactory;
     this.mergeOpProvider = mergeOpProvider;
     this.mergeSuperSet = mergeSuperSet;
     this.accounts = accounts;
-    this.changes = changes;
     this.label =
         MoreObjects.firstNonNull(
             Strings.emptyToNull(cfg.getString("change", null, "submitLabel")), "Submit");
@@ -193,18 +198,20 @@
 
   @Override
   public Output apply(RevisionResource rsrc, SubmitInput input)
-      throws RestApiException, RepositoryNotFoundException, IOException, OrmException {
+      throws RestApiException, RepositoryNotFoundException, IOException, OrmException,
+          PermissionBackendException {
     input.onBehalfOf = Strings.emptyToNull(input.onBehalfOf);
+    IdentifiedUser submitter;
     if (input.onBehalfOf != null) {
-      rsrc = onBehalfOf(rsrc, input);
+      submitter = onBehalfOf(rsrc, input);
+    } else {
+      rsrc.permissions().check(ChangePermission.SUBMIT);
+      submitter = rsrc.getUser().asIdentifiedUser();
     }
-    ChangeControl control = rsrc.getControl();
-    IdentifiedUser caller = control.getUser().asIdentifiedUser();
+
     Change change = rsrc.getChange();
-    if (input.onBehalfOf == null && !control.canSubmit()) {
-      throw new AuthException("submit not permitted");
-    } else if (!change.getStatus().isOpen()) {
-      throw new ResourceConflictException("change is " + status(change));
+    if (!change.getStatus().isOpen()) {
+      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     } else if (!ProjectUtil.branchExists(repoManager, change.getDest())) {
       throw new ResourceConflictException(
           String.format("destination branch \"%s\" not found.", change.getDest().get()));
@@ -217,7 +224,7 @@
 
     try (MergeOp op = mergeOpProvider.get()) {
       ReviewDb db = dbProvider.get();
-      op.merge(db, change, caller, true, input, false);
+      op.merge(db, change, submitter, true, input, false);
       try {
         change =
             changeNotesFactory.createChecked(db, change.getProject(), change.getId()).getChange();
@@ -238,7 +245,7 @@
       case ABANDONED:
       case DRAFT:
       default:
-        throw new ResourceConflictException("change is " + status(change));
+        throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     }
   }
 
@@ -250,20 +257,25 @@
    */
   private String problemsForSubmittingChangeset(ChangeData cd, ChangeSet cs, CurrentUser user) {
     try {
-      @SuppressWarnings("resource")
-      ReviewDb db = dbProvider.get();
       if (cs.furtherHiddenChanges()) {
         return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
       }
       for (ChangeData c : cs.changes()) {
-        ChangeControl changeControl = c.changeControl(user);
-
-        if (!changeControl.isVisible(db)) {
+        Set<ChangePermission> can =
+            permissionBackend
+                .user(user)
+                .database(dbProvider)
+                .change(c)
+                .test(EnumSet.of(ChangePermission.READ, ChangePermission.SUBMIT));
+        if (!can.contains(ChangePermission.READ)) {
           return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
         }
-        if (!changeControl.canSubmit()) {
+        if (!can.contains(ChangePermission.SUBMIT)) {
           return BLOCKED_SUBMIT_TOOLTIP;
         }
+        if (c.change().isWorkInProgress()) {
+          return BLOCKED_WORK_IN_PROGRESS;
+        }
         MergeOp.checkSubmitRule(c);
       }
 
@@ -281,7 +293,7 @@
       }
     } catch (ResourceConflictException e) {
       return BLOCKED_SUBMIT_TOOLTIP;
-    } catch (OrmException | IOException e) {
+    } catch (PermissionBackendException | OrmException | IOException e) {
       log.error("Error checking if change is submittable", e);
       throw new OrmRuntimeException("Could not determine problems for the change", e);
     }
@@ -290,20 +302,23 @@
 
   @Override
   public UiAction.Description getDescription(RevisionResource resource) {
-    PatchSet.Id current = resource.getChange().currentPatchSetId();
-    String topic = resource.getChange().getTopic();
-    boolean visible =
-        !resource.getPatchSet().isDraft()
-            && resource.getChange().getStatus().isOpen()
-            && resource.getPatchSet().getId().equals(current)
-            && resource.getControl().canSubmit();
+    Change change = resource.getChange();
+    String topic = change.getTopic();
     ReviewDb db = dbProvider.get();
     ChangeData cd = changeDataFactory.create(db, resource.getControl());
-
+    boolean visible;
     try {
+      visible =
+          change.getStatus().isOpen()
+              && resource.isCurrent()
+              && !resource.getPatchSet().isDraft()
+              && resource.permissions().test(ChangePermission.SUBMIT);
       MergeOp.checkSubmitRule(cd);
     } catch (ResourceConflictException e) {
       visible = false;
+    } catch (PermissionBackendException e) {
+      log.error("Error checking if change is submittable", e);
+      throw new OrmRuntimeException("Could not check submit permission", e);
     } catch (OrmException e) {
       log.error("Error checking if change is submittable", e);
       throw new OrmRuntimeException("Could not determine problems for the change", e);
@@ -367,7 +382,7 @@
     Map<String, String> params =
         ImmutableMap.of(
             "patchSet", String.valueOf(resource.getPatchSet().getPatchSetId()),
-            "branch", resource.getChange().getDest().getShortName(),
+            "branch", change.getDest().getShortName(),
             "commit", ObjectId.fromString(revId.get()).abbreviate(7).name(),
             "submitSize", String.valueOf(cs.size()));
     ParameterizedString tp = cs.size() > 1 ? titlePatternWithAncestors : titlePattern;
@@ -390,10 +405,6 @@
         .orNull();
   }
 
-  static String status(Change change) {
-    return change != null ? change.getStatus().name().toLowerCase() : "deleted";
-  }
-
   public Collection<ChangeData> unmergeableChanges(ChangeSet cs) throws OrmException, IOException {
     Set<ChangeData> mergeabilityMap = new HashSet<>();
     for (ChangeData change : cs.changes()) {
@@ -458,24 +469,21 @@
     return commits;
   }
 
-  private RevisionResource onBehalfOf(RevisionResource rsrc, SubmitInput in)
-      throws AuthException, UnprocessableEntityException, OrmException {
-    ChangeControl caller = rsrc.getControl();
-    if (!caller.canSubmit()) {
-      throw new AuthException("submit not permitted");
-    }
-    if (!caller.canSubmitAs()) {
-      throw new AuthException("submit on behalf of not permitted");
-    }
-    ChangeControl target =
-        caller.forUser(accounts.parseOnBehalfOf(caller.getUser(), in.onBehalfOf));
-    if (!target.getRefControl().isVisible()) {
+  private IdentifiedUser onBehalfOf(RevisionResource rsrc, SubmitInput in)
+      throws AuthException, UnprocessableEntityException, OrmException, PermissionBackendException {
+    PermissionBackend.ForChange perm = rsrc.permissions().database(dbProvider);
+    perm.check(ChangePermission.SUBMIT);
+    perm.check(ChangePermission.SUBMIT_AS);
+
+    CurrentUser caller = rsrc.getUser();
+    IdentifiedUser submitter = accounts.parseOnBehalfOf(caller, in.onBehalfOf);
+    try {
+      perm.user(submitter).check(ChangePermission.READ);
+    } catch (AuthException e) {
       throw new UnprocessableEntityException(
-          String.format(
-              "on_behalf_of account %s cannot see destination ref",
-              target.getUser().getAccountId()));
+          String.format("on_behalf_of account %s cannot see change", submitter.getAccountId()));
     }
-    return new RevisionResource(changes.parse(target), rsrc.getPatchSet());
+    return submitter;
   }
 
   public static boolean wholeTopicEnabled(Config config) {
@@ -510,7 +518,8 @@
 
     @Override
     public ChangeInfo apply(ChangeResource rsrc, SubmitInput input)
-        throws RestApiException, RepositoryNotFoundException, IOException, OrmException {
+        throws RestApiException, RepositoryNotFoundException, IOException, OrmException,
+            PermissionBackendException {
       PatchSet ps = psUtil.current(dbProvider.get(), rsrc.getNotes());
       if (ps == null) {
         throw new ResourceConflictException("current revision is missing");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java
index 5260730..1daa7e3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java
@@ -27,6 +27,8 @@
 import com.google.gerrit.server.ReviewersUtil.VisibilityControl;
 import com.google.gerrit.server.account.AccountVisibility;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -45,6 +47,7 @@
   )
   boolean excludeGroups;
 
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> self;
 
   @Inject
@@ -52,10 +55,12 @@
       AccountVisibility av,
       GenericFactory identifiedUserFactory,
       Provider<ReviewDb> dbProvider,
+      PermissionBackend permissionBackend,
       Provider<CurrentUser> self,
       @GerritServerConfig Config cfg,
       ReviewersUtil reviewersUtil) {
     super(av, identifiedUserFactory, dbProvider, cfg, reviewersUtil);
+    this.permissionBackend = permissionBackend;
     this.self = self;
   }
 
@@ -73,7 +78,7 @@
         excludeGroups);
   }
 
-  private VisibilityControl getVisibility(final ChangeResource rsrc) {
+  private VisibilityControl getVisibility(ChangeResource rsrc) {
     if (rsrc.getControl().getRefControl().isVisibleByRegisteredUsers()) {
       return new VisibilityControl() {
         @Override
@@ -82,13 +87,15 @@
         }
       };
     }
+
+    // Use the destination reference, not the change, as drafts may deny
+    // anyone who is not already a reviewer.
+    PermissionBackend.ForRef perm = permissionBackend.user(self).ref(rsrc.getChange().getDest());
     return new VisibilityControl() {
       @Override
       public boolean isVisibleTo(Account.Id account) throws OrmException {
         IdentifiedUser who = identifiedUserFactory.create(account);
-        // we can't use changeControl directly as it won't suggest reviewers
-        // to drafts
-        return rsrc.getControl().forUser(who).isRefVisible();
+        return perm.user(who).testOrFalse(RefPermission.READ);
       }
     };
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Unignore.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Unignore.java
new file mode 100644
index 0000000..081fc22
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Unignore.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2017 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.change;
+
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class Unignore
+    implements RestModifyView<ChangeResource, Unignore.Input>, UiAction<ChangeResource> {
+  private static final Logger log = LoggerFactory.getLogger(Unignore.class);
+
+  public static class Input {}
+
+  private final Provider<IdentifiedUser> self;
+  private final StarredChangesUtil stars;
+
+  @Inject
+  Unignore(Provider<IdentifiedUser> self, StarredChangesUtil stars) {
+    this.self = self;
+    this.stars = stars;
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Unignore")
+        .setTitle("Unignore the change")
+        .setVisible(!rsrc.isUserOwner() && isIgnored(rsrc));
+  }
+
+  @Override
+  public Response<String> apply(ChangeResource rsrc, Input input) throws RestApiException {
+    try {
+      if (rsrc.isUserOwner() || !isIgnored(rsrc)) {
+        // early exit for own changes and not ignored changes
+        return Response.ok("");
+      }
+      stars.unignore(self.get().getAccountId(), rsrc.getProject(), rsrc.getChange().getId());
+    } catch (OrmException e) {
+      throw new RestApiException("failed to unignore change", e);
+    }
+    return Response.ok("");
+  }
+
+  private boolean isIgnored(ChangeResource rsrc) {
+    try {
+      return stars.isIgnoredBy(rsrc.getChange().getId(), self.get().getAccountId());
+    } catch (OrmException e) {
+      log.error("failed to check ignored star", e);
+    }
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Unmute.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Unmute.java
new file mode 100644
index 0000000..49b41cb
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Unmute.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2017 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.change;
+
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class Unmute
+    implements RestModifyView<ChangeResource, Unmute.Input>, UiAction<ChangeResource> {
+  private static final Logger log = LoggerFactory.getLogger(Unmute.class);
+
+  public static class Input {}
+
+  private final Provider<IdentifiedUser> self;
+  private final StarredChangesUtil stars;
+
+  @Inject
+  Unmute(Provider<IdentifiedUser> self, StarredChangesUtil stars) {
+    this.self = self;
+    this.stars = stars;
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Unmute")
+        .setTitle("Unmute the change")
+        .setVisible(!rsrc.isUserOwner() && isUnMuteable(rsrc.getChange()));
+  }
+
+  @Override
+  public Response<String> apply(ChangeResource rsrc, Input input) throws RestApiException {
+    try {
+      if (rsrc.isUserOwner() || !isMuted(rsrc.getChange())) {
+        // early exit for own changes and not muted changes
+        return Response.ok("");
+      }
+      stars.unmute(self.get().getAccountId(), rsrc.getProject(), rsrc.getChange());
+    } catch (OrmException e) {
+      throw new RestApiException("failed to unmute change", e);
+    }
+    return Response.ok("");
+  }
+
+  private boolean isMuted(Change change) {
+    try {
+      return stars.isMutedBy(change, self.get().getAccountId());
+    } catch (OrmException e) {
+      log.error("failed to check muted star", e);
+    }
+    return false;
+  }
+
+  private boolean isUnMuteable(Change change) {
+    try {
+      return isMuted(change) && !stars.isIgnoredBy(change.getId(), self.get().getAccountId());
+    } catch (OrmException e) {
+      log.error("failed to check ignored star", e);
+    }
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/WorkInProgressOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/WorkInProgressOp.java
new file mode 100644
index 0000000..7f6e543
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2017 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.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gwtorm.server.OrmException;
+
+/* Set work in progress or ready for review state on a change */
+public class WorkInProgressOp implements BatchUpdateOp {
+  public static class Input {
+    String message;
+
+    public Input() {}
+
+    public Input(String message) {
+      this.message = message;
+    }
+  }
+
+  private final ChangeMessagesUtil cmUtil;
+  private final boolean workInProgress;
+  private final Input in;
+
+  WorkInProgressOp(ChangeMessagesUtil cmUtil, boolean workInProgress, Input in) {
+    this.cmUtil = cmUtil;
+    this.workInProgress = workInProgress;
+    this.in = in;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws OrmException {
+    Change change = ctx.getChange();
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+    change.setWorkInProgress(workInProgress);
+    change.setLastUpdatedOn(ctx.getWhen());
+    update.setWorkInProgress(workInProgress);
+    addMessage(ctx, update);
+    return true;
+  }
+
+  private void addMessage(ChangeContext ctx, ChangeUpdate update) throws OrmException {
+    Change c = ctx.getChange();
+    StringBuilder buf =
+        new StringBuilder(c.isWorkInProgress() ? "Set Work In Progress" : "Set Ready For Review");
+
+    String m = Strings.nullToEmpty(in.message).trim();
+    if (!m.isEmpty()) {
+      buf.append("\n\n");
+      buf.append(m);
+    }
+
+    ChangeMessage cmsg =
+        ChangeMessagesUtil.newMessage(
+            ctx,
+            buf.toString(),
+            c.isWorkInProgress()
+                ? ChangeMessagesUtil.TAG_SET_WIP
+                : ChangeMessagesUtil.TAG_SET_READY);
+
+    cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
index 6cdb5e56..7b93277 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.config;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_MAILTO;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_UUID;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
 
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.auth.openid.OpenIdProviderPattern;
 import com.google.gwtjsonrpc.server.SignedToken;
 import com.google.gwtjsonrpc.server.XsrfException;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CachesCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/CachesCollection.java
index f002f8d..1e88842 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/CachesCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/CachesCollection.java
@@ -27,8 +27,10 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -40,6 +42,7 @@
 
   private final DynamicMap<RestView<CacheResource>> views;
   private final Provider<ListCaches> list;
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> self;
   private final DynamicMap<Cache<?, ?>> cacheMap;
   private final PostCaches postCaches;
@@ -48,11 +51,13 @@
   CachesCollection(
       DynamicMap<RestView<CacheResource>> views,
       Provider<ListCaches> list,
+      PermissionBackend permissionBackend,
       Provider<CurrentUser> self,
       DynamicMap<Cache<?, ?>> cacheMap,
       PostCaches postCaches) {
     this.views = views;
     this.list = list;
+    this.permissionBackend = permissionBackend;
     this.self = self;
     this.cacheMap = cacheMap;
     this.postCaches = postCaches;
@@ -65,15 +70,8 @@
 
   @Override
   public CacheResource parse(ConfigResource parent, IdString id)
-      throws AuthException, ResourceNotFoundException {
-    CurrentUser user = self.get();
-    if (user instanceof AnonymousUser) {
-      throw new AuthException("Authentication required");
-    } else if (!user.isIdentifiedUser()) {
-      throw new ResourceNotFoundException();
-    } else if (!user.getCapabilities().canViewCaches()) {
-      throw new AuthException("not allowed to view caches");
-    }
+      throws AuthException, ResourceNotFoundException, PermissionBackendException {
+    permissionBackend.user(self).check(GlobalPermission.VIEW_CACHES);
 
     String cacheName = id.get();
     String pluginName = "gerrit";
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CheckAccess.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/CheckAccess.java
new file mode 100644
index 0000000..84db266
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/CheckAccess.java
@@ -0,0 +1,128 @@
+// Copyright (C) 2017 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.config;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.api.config.AccessCheckInfo;
+import com.google.gerrit.extensions.api.config.AccessCheckInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import javax.servlet.http.HttpServletResponse;
+
+@Singleton
+public class CheckAccess implements RestModifyView<ConfigResource, AccessCheckInput> {
+  private final Provider<IdentifiedUser> currentUser;
+  private final AccountResolver accountResolver;
+  private final Provider<ReviewDb> db;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final ProjectCache projectCache;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  CheckAccess(
+      Provider<IdentifiedUser> currentUser,
+      AccountResolver resolver,
+      Provider<ReviewDb> db,
+      IdentifiedUser.GenericFactory userFactory,
+      ProjectCache projectCache,
+      PermissionBackend permissionBackend) {
+    this.currentUser = currentUser;
+    this.accountResolver = resolver;
+    this.db = db;
+    this.userFactory = userFactory;
+    this.projectCache = projectCache;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  public AccessCheckInfo apply(ConfigResource unused, AccessCheckInput input)
+      throws OrmException, PermissionBackendException, RestApiException, IOException {
+    permissionBackend.user(currentUser.get()).check(GlobalPermission.ADMINISTRATE_SERVER);
+
+    if (input == null) {
+      throw new BadRequestException("input is required");
+    }
+    if (Strings.isNullOrEmpty(input.account)) {
+      throw new BadRequestException("input requires 'account'");
+    }
+    if (Strings.isNullOrEmpty(input.project)) {
+      throw new BadRequestException("input requires 'project'");
+    }
+
+    Account match = accountResolver.find(db.get(), input.account);
+    if (match == null) {
+      throw new BadRequestException(String.format("cannot find account %s", input.account));
+    }
+
+    AccessCheckInfo info = new AccessCheckInfo();
+
+    Project.NameKey key = new Project.NameKey(input.project);
+    if (projectCache.get(key) == null) {
+      info.message = String.format("project %s does not exist", key);
+      info.status = HttpServletResponse.SC_NOT_FOUND;
+      return info;
+    }
+
+    IdentifiedUser user = userFactory.create(match.getId());
+    try {
+      permissionBackend.user(user).project(key).check(ProjectPermission.ACCESS);
+    } catch (AuthException | PermissionBackendException e) {
+      info.message =
+          String.format(
+              "user %s (%s) cannot see project %s",
+              user.getNameEmail(), user.getAccount().getId(), key);
+      info.status = HttpServletResponse.SC_FORBIDDEN;
+      return info;
+    }
+
+    if (!Strings.isNullOrEmpty(input.ref)) {
+      try {
+        permissionBackend
+            .user(user)
+            .ref(new Branch.NameKey(key, input.ref))
+            .check(RefPermission.READ);
+      } catch (AuthException | PermissionBackendException e) {
+        info.status = HttpServletResponse.SC_FORBIDDEN;
+        info.message =
+            String.format(
+                "user %s (%s) cannot see ref %s in project %s",
+                user.getNameEmail(), user.getAccount().getId(), input.ref, key);
+        return info;
+      }
+    }
+
+    info.status = HttpServletResponse.SC_OK;
+    return info;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CheckConsistency.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/CheckConsistency.java
new file mode 100644
index 0000000..f424995
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/CheckConsistency.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2017 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.config;
+
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.CheckAccountExternalIdsResultInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class CheckConsistency implements RestModifyView<ConfigResource, ConsistencyCheckInput> {
+  private final Provider<IdentifiedUser> userProvider;
+  private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker;
+
+  @Inject
+  CheckConsistency(
+      Provider<IdentifiedUser> currentUser,
+      ExternalIdsConsistencyChecker externalIdsConsistencyChecker) {
+    this.userProvider = currentUser;
+    this.externalIdsConsistencyChecker = externalIdsConsistencyChecker;
+  }
+
+  @Override
+  public ConsistencyCheckInfo apply(ConfigResource resource, ConsistencyCheckInput input)
+      throws RestApiException, IOException {
+    IdentifiedUser user = userProvider.get();
+    if (!user.isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+    if (!user.getCapabilities().canAccessDatabase()) {
+      throw new AuthException("not allowed to run consistency checks");
+    }
+
+    if (input == null || input.checkAccountExternalIds == null) {
+      throw new BadRequestException("input required");
+    }
+
+    ConsistencyCheckInfo consistencyCheckInfo = new ConsistencyCheckInfo();
+    if (input.checkAccountExternalIds != null) {
+      consistencyCheckInfo.checkAccountExternalIdsResult =
+          new CheckAccountExternalIdsResultInfo(externalIdsConsistencyChecker.check());
+    }
+
+    return consistencyCheckInfo;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/FlushCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/FlushCache.java
index 5e19091..366dae1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/FlushCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/FlushCache.java
@@ -23,6 +23,9 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.FlushCache.Input;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -34,17 +37,20 @@
 
   public static final String WEB_SESSIONS = "web_sessions";
 
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> self;
 
   @Inject
-  public FlushCache(Provider<CurrentUser> self) {
+  public FlushCache(PermissionBackend permissionBackend, Provider<CurrentUser> self) {
+    this.permissionBackend = permissionBackend;
     this.self = self;
   }
 
   @Override
-  public Response<String> apply(CacheResource rsrc, Input input) throws AuthException {
-    if (WEB_SESSIONS.equals(rsrc.getName()) && !self.get().getCapabilities().canMaintainServer()) {
-      throw new AuthException(String.format("only site maintainers can flush %s", WEB_SESSIONS));
+  public Response<String> apply(CacheResource rsrc, Input input)
+      throws AuthException, PermissionBackendException {
+    if (WEB_SESSIONS.equals(rsrc.getName())) {
+      permissionBackend.user(self).check(GlobalPermission.MAINTAIN_SERVER);
     }
 
     rsrc.getCache().invalidateAll();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
index b3e15b5..8eaa6ec 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -93,6 +93,7 @@
 import com.google.gerrit.server.account.GroupIncludeCacheImpl;
 import com.google.gerrit.server.account.GroupMembers;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.account.externalids.ExternalIdModule;
 import com.google.gerrit.server.api.accounts.AccountExternalIdCreator;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.UniversalAuthBackend;
@@ -107,6 +108,7 @@
 import com.google.gerrit.server.events.EventFactory;
 import com.google.gerrit.server.events.EventsMetrics;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.git.AbandonOp;
 import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.git.EmailMerge;
@@ -132,7 +134,7 @@
 import com.google.gerrit.server.git.validators.UploadValidationListener;
 import com.google.gerrit.server.git.validators.UploadValidators;
 import com.google.gerrit.server.group.GroupModule;
-import com.google.gerrit.server.index.change.ReindexAfterUpdate;
+import com.google.gerrit.server.index.change.ReindexAfterRefUpdate;
 import com.google.gerrit.server.mail.EmailModule;
 import com.google.gerrit.server.mail.ListMailFilter;
 import com.google.gerrit.server.mail.MailFilter;
@@ -157,16 +159,15 @@
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
 import com.google.gerrit.server.project.AccessControlModule;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.CommentLinkProvider;
 import com.google.gerrit.server.project.PermissionCollection;
 import com.google.gerrit.server.project.ProjectCacheImpl;
-import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.ProjectNode;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SectionSortCache;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ChangeQueryProcessor;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
 import com.google.gerrit.server.ssh.SshAddressesModule;
 import com.google.gerrit.server.tools.ToolsCatalog;
@@ -229,6 +230,7 @@
     install(new AccessControlModule());
     install(new CmdLineParserModule());
     install(new EmailModule());
+    install(new ExternalIdModule());
     install(new GitModule());
     install(new GroupModule());
     install(new NoteDbModule(cfg));
@@ -288,11 +290,10 @@
 
     bind(PatchSetInfoFactory.class);
     bind(IdentifiedUser.GenericFactory.class).in(SINGLETON);
-    bind(ChangeControl.GenericFactory.class);
-    bind(ProjectControl.GenericFactory.class);
     bind(AccountControl.Factory.class);
 
     install(new AuditModule());
+    bind(UiActions.class);
     install(new com.google.gerrit.server.access.Module());
     install(new com.google.gerrit.server.account.Module());
     install(new com.google.gerrit.server.api.Module());
@@ -332,7 +333,7 @@
     DynamicSet.setOf(binder(), GarbageCollectorListener.class);
     DynamicSet.setOf(binder(), HeadUpdatedListener.class);
     DynamicSet.setOf(binder(), UsageDataPublishedListener.class);
-    DynamicSet.bind(binder(), GitReferenceUpdatedListener.class).to(ReindexAfterUpdate.class);
+    DynamicSet.bind(binder(), GitReferenceUpdatedListener.class).to(ReindexAfterRefUpdate.class);
     DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
         .to(ProjectConfigEntry.UpdateChecker.class);
     DynamicSet.setOf(binder(), EventListener.class);
@@ -380,6 +381,8 @@
 
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class);
+    DynamicMap.mapOf(binder(), ChangeQueryProcessor.ChangeAttributeFactory.class);
+
     install(new GitwebConfig.LegacyModule(cfg));
 
     bind(AnonymousUser.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
index c0da3f3..7450b32 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
@@ -216,7 +216,7 @@
     info.replyLabel =
         Optional.ofNullable(cfg.getString("change", null, "replyLabel")).orElse("Reply") + "\u2026";
     info.updateDelay =
-        (int) ConfigUtil.getTimeUnit(cfg, "change", null, "updateDelay", 30, TimeUnit.SECONDS);
+        (int) ConfigUtil.getTimeUnit(cfg, "change", null, "updateDelay", 300, TimeUnit.SECONDS);
     info.submitWholeTopic = Submit.wholeTopicEnabled(cfg);
     return info;
   }
@@ -310,9 +310,15 @@
     PluginConfigInfo info = new PluginConfigInfo();
     info.hasAvatars = toBoolean(avatar.get() != null);
     info.jsResourcePaths = new ArrayList<>();
+    info.htmlResourcePaths = new ArrayList<>();
     for (WebUiPlugin u : plugins) {
-      info.jsResourcePaths.add(
-          String.format("plugins/%s/%s", u.getPluginName(), u.getJavaScriptResourcePath()));
+      String path =
+          String.format("plugins/%s/%s", u.getPluginName(), u.getJavaScriptResourcePath());
+      if (path.endsWith(".html")) {
+        info.htmlResourcePaths.add(path);
+      } else {
+        info.jsResourcePaths.add(path);
+      }
     }
     return info;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTasks.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTasks.java
index 7e9bd71..bbda9eb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTasks.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTasks.java
@@ -19,13 +19,14 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.TaskInfoFactory;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.ProjectTask;
 import com.google.gerrit.server.git.WorkQueue.Task;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.util.IdGenerator;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -41,37 +42,49 @@
 
 @Singleton
 public class ListTasks implements RestReadView<ConfigResource> {
+  private final PermissionBackend permissionBackend;
   private final WorkQueue workQueue;
-  private final ProjectCache projectCache;
-  private final Provider<IdentifiedUser> self;
+  private final Provider<CurrentUser> self;
 
   @Inject
-  public ListTasks(WorkQueue workQueue, ProjectCache projectCache, Provider<IdentifiedUser> self) {
+  public ListTasks(
+      PermissionBackend permissionBackend, WorkQueue workQueue, Provider<CurrentUser> self) {
+    this.permissionBackend = permissionBackend;
     this.workQueue = workQueue;
-    this.projectCache = projectCache;
     this.self = self;
   }
 
   @Override
-  public List<TaskInfo> apply(ConfigResource resource) throws AuthException {
+  public List<TaskInfo> apply(ConfigResource resource)
+      throws AuthException, PermissionBackendException {
     CurrentUser user = self.get();
     if (!user.isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
 
     List<TaskInfo> allTasks = getTasks();
-    if (user.getCapabilities().canViewQueue()) {
+    try {
+      permissionBackend.user(user).check(GlobalPermission.VIEW_QUEUE);
       return allTasks;
+    } catch (AuthException e) {
+      // Fall through to filter tasks.
     }
-    Map<String, Boolean> visibilityCache = new HashMap<>();
 
+    Map<String, Boolean> visibilityCache = new HashMap<>();
     List<TaskInfo> visibleTasks = new ArrayList<>();
     for (TaskInfo task : allTasks) {
       if (task.projectName != null) {
         Boolean visible = visibilityCache.get(task.projectName);
         if (visible == null) {
-          ProjectState e = projectCache.get(new Project.NameKey(task.projectName));
-          visible = e != null ? e.controlFor(user).isVisible() : false;
+          try {
+            permissionBackend
+                .user(user)
+                .project(new Project.NameKey(task.projectName))
+                .check(ProjectPermission.ACCESS);
+            visible = true;
+          } catch (AuthException e) {
+            visible = false;
+          }
           visibilityCache.put(task.projectName, visible);
         }
         if (visible) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java
index a05058e..4f93a1a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java
@@ -36,6 +36,8 @@
     child(CONFIG_KIND, "top-menus").to(TopMenuCollection.class);
     get(CONFIG_KIND, "version").to(GetVersion.class);
     get(CONFIG_KIND, "info").to(GetServerInfo.class);
+    post(CONFIG_KIND, "check.consistency").to(CheckConsistency.class);
+    post(CONFIG_KIND, "check.access").to(CheckAccess.class);
     get(CONFIG_KIND, "preferences").to(GetPreferences.class);
     put(CONFIG_KIND, "preferences").to(SetPreferences.class);
     get(CONFIG_KIND, "preferences.diff").to(GetDiffPreferences.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java
index 3cfa2b9..d08f0a9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.config.PostCaches.Input;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
@@ -66,7 +67,8 @@
 
   @Override
   public Response<String> apply(ConfigResource rsrc, Input input)
-      throws AuthException, BadRequestException, UnprocessableEntityException {
+      throws AuthException, BadRequestException, UnprocessableEntityException,
+          PermissionBackendException {
     if (input == null || input.operation == null) {
       throw new BadRequestException("operation must be specified");
     }
@@ -90,7 +92,7 @@
     }
   }
 
-  private void flushAll() throws AuthException {
+  private void flushAll() throws AuthException, PermissionBackendException {
     for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
       CacheResource cacheResource =
           new CacheResource(e.getPluginName(), e.getExportName(), e.getProvider());
@@ -101,7 +103,8 @@
     }
   }
 
-  private void flush(List<String> cacheNames) throws UnprocessableEntityException, AuthException {
+  private void flush(List<String> cacheNames)
+      throws UnprocessableEntityException, AuthException, PermissionBackendException {
     List<CacheResource> cacheResources = new ArrayList<>(cacheNames.size());
 
     for (String n : cacheNames) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
index 33e68d3..a2e0356 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
@@ -18,8 +18,8 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.util.ServerRequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 
 /**
  * Provider of the group(s) which should become owners of a newly created project. The only matching
@@ -40,7 +40,7 @@
     ProjectOwnerGroupsProvider create(Project.NameKey project);
   }
 
-  @AssistedInject
+  @Inject
   public ProjectOwnerGroupsProvider(
       GroupBackend gb,
       ThreadLocalRequestContext context,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java
index 3987aed..4358186 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java
@@ -41,7 +41,7 @@
   @Override
   public ReviewDb get() {
     if (db == null) {
-      final ReviewDb c;
+      ReviewDb c;
       try {
         c = schema.open();
       } catch (OrmException e) {
@@ -51,12 +51,9 @@
         cleanup
             .get()
             .add(
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    c.close();
-                    db = null;
-                  }
+                () -> {
+                  c.close();
+                  db = null;
                 });
       } catch (Throwable e) {
         c.close();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/TasksCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/TasksCollection.java
index b239856..fcaee8e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/TasksCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/TasksCollection.java
@@ -21,12 +21,13 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.ProjectTask;
 import com.google.gerrit.server.git.WorkQueue.Task;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -36,21 +37,21 @@
   private final DynamicMap<RestView<TaskResource>> views;
   private final ListTasks list;
   private final WorkQueue workQueue;
-  private final Provider<IdentifiedUser> self;
-  private final ProjectCache projectCache;
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
 
   @Inject
   TasksCollection(
       DynamicMap<RestView<TaskResource>> views,
       ListTasks list,
       WorkQueue workQueue,
-      Provider<IdentifiedUser> self,
-      ProjectCache projectCache) {
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend) {
     this.views = views;
     this.list = list;
     this.workQueue = workQueue;
     this.self = self;
-    this.projectCache = projectCache;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
@@ -60,30 +61,42 @@
 
   @Override
   public TaskResource parse(ConfigResource parent, IdString id)
-      throws ResourceNotFoundException, AuthException {
+      throws ResourceNotFoundException, AuthException, PermissionBackendException {
     CurrentUser user = self.get();
     if (!user.isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
 
+    int taskId;
     try {
-      int taskId = (int) Long.parseLong(id.get(), 16);
-      Task<?> task = workQueue.getTask(taskId);
-      if (task != null) {
-        if (self.get().getCapabilities().canViewQueue()) {
-          return new TaskResource(task);
-        } else if (task instanceof ProjectTask) {
-          ProjectTask<?> projectTask = ((ProjectTask<?>) task);
-          ProjectState e = projectCache.get(projectTask.getProjectNameKey());
-          if (e != null && e.controlFor(user).isVisible()) {
-            return new TaskResource(task);
-          }
-        }
-      }
-      throw new ResourceNotFoundException(id);
+      taskId = (int) Long.parseLong(id.get(), 16);
     } catch (NumberFormatException e) {
       throw new ResourceNotFoundException(id);
     }
+
+    Task<?> task = workQueue.getTask(taskId);
+    if (task instanceof ProjectTask) {
+      try {
+        permissionBackend
+            .user(user)
+            .project(((ProjectTask<?>) task).getProjectNameKey())
+            .check(ProjectPermission.ACCESS);
+        return new TaskResource(task);
+      } catch (AuthException e) {
+        // Fall through and try view queue permission.
+      }
+    }
+
+    if (task != null) {
+      try {
+        permissionBackend.user(user).check(GlobalPermission.VIEW_QUEUE);
+        return new TaskResource(task);
+      } catch (AuthException e) {
+        // Fall through and return not found.
+      }
+    }
+
+    throw new ResourceNotFoundException(id);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/ChangeAttribute.java b/gerrit-server/src/main/java/com/google/gerrit/server/data/ChangeAttribute.java
index 1a8a788..0467c92 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/data/ChangeAttribute.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/data/ChangeAttribute.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.data;
 
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.reviewdb.client.Change;
 import java.util.List;
 
@@ -43,4 +44,5 @@
   public List<DependencyAttribute> neededBy;
   public List<SubmitRecordAttribute> submitRecords;
   public List<AccountAttribute> allReviewers;
+  public List<PluginDefinedInfo> plugins;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEdit.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEdit.java
index a6464a7..e641abc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEdit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEdit.java
@@ -18,11 +18,6 @@
 
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.server.IdentifiedUser;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
@@ -33,44 +28,25 @@
  * change number and P is the patch set number it is based on.
  */
 public class ChangeEdit {
-  private final IdentifiedUser user;
   private final Change change;
-  private final Ref ref;
+  private final String editRefName;
   private final RevCommit editCommit;
   private final PatchSet basePatchSet;
 
   public ChangeEdit(
-      IdentifiedUser user, Change change, Ref ref, RevCommit editCommit, PatchSet basePatchSet) {
-    checkNotNull(user);
-    checkNotNull(change);
-    checkNotNull(ref);
-    checkNotNull(editCommit);
-    checkNotNull(basePatchSet);
-    this.user = user;
-    this.change = change;
-    this.ref = ref;
-    this.editCommit = editCommit;
-    this.basePatchSet = basePatchSet;
+      Change change, String editRefName, RevCommit editCommit, PatchSet basePatchSet) {
+    this.change = checkNotNull(change);
+    this.editRefName = checkNotNull(editRefName);
+    this.editCommit = checkNotNull(editCommit);
+    this.basePatchSet = checkNotNull(basePatchSet);
   }
 
   public Change getChange() {
     return change;
   }
 
-  public IdentifiedUser getUser() {
-    return user;
-  }
-
-  public Ref getRef() {
-    return ref;
-  }
-
-  public RevId getRevision() {
-    return new RevId(ObjectId.toString(ref.getObjectId()));
-  }
-
   public String getRefName() {
-    return RefNames.refsEdit(user.getAccountId(), change.getId(), basePatchSet.getId());
+    return editRefName;
   }
 
   public RevCommit getEditCommit() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditJson.java
index 41af77d..78baef7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditJson.java
@@ -51,6 +51,7 @@
     EditInfo out = new EditInfo();
     out.commit = fillCommit(edit.getEditCommit());
     out.baseRevision = edit.getBasePatchSet().getRevision().get();
+    out.basePatchSetNumber = edit.getBasePatchSet().getPatchSetId();
     if (downloadCommands) {
       out.fetch = fillFetchMap(edit);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index 3d1fb79..75da89d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
@@ -44,6 +45,7 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.sql.Timestamp;
+import java.util.List;
 import java.util.Optional;
 import java.util.TimeZone;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -116,8 +118,7 @@
 
     PatchSet currentPatchSet = lookupCurrentPatchSet(changeControl);
     ObjectId patchSetCommitId = getPatchSetCommitId(currentPatchSet);
-    createEditReference(
-        repository, changeControl, currentPatchSet, patchSetCommitId, TimeUtil.nowTs());
+    createEdit(repository, changeControl, currentPatchSet, patchSetCommitId, TimeUtil.nowTs());
   }
 
   /**
@@ -218,9 +219,9 @@
         createCommit(repository, basePatchSetCommit, baseTree, newCommitMessage, nowTimestamp);
 
     if (optionalChangeEdit.isPresent()) {
-      updateEditReference(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
+      updateEdit(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
     } else {
-      createEditReference(repository, changeControl, basePatchSet, newEditCommit, nowTimestamp);
+      createEdit(repository, changeControl, basePatchSet, newEditCommit, nowTimestamp);
     }
   }
 
@@ -308,7 +309,7 @@
     RevCommit baseCommit =
         optionalChangeEdit.map(ChangeEdit::getEditCommit).orElse(basePatchSetCommit);
 
-    ObjectId newTreeId = createNewTree(repository, baseCommit, treeModification);
+    ObjectId newTreeId = createNewTree(repository, baseCommit, ImmutableList.of(treeModification));
 
     String commitMessage = baseCommit.getFullMessage();
     Timestamp nowTimestamp = TimeUtil.nowTs();
@@ -316,12 +317,64 @@
         createCommit(repository, basePatchSetCommit, newTreeId, commitMessage, nowTimestamp);
 
     if (optionalChangeEdit.isPresent()) {
-      updateEditReference(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
+      updateEdit(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
     } else {
-      createEditReference(repository, changeControl, basePatchSet, newEditCommit, nowTimestamp);
+      createEdit(repository, changeControl, basePatchSet, newEditCommit, nowTimestamp);
     }
   }
 
+  /**
+   * Applies the indicated modifications to the specified patch set. If a change edit exists and is
+   * based on the same patch set, the modified patch set tree is merged with the change edit. If the
+   * change edit doesn't exist, a new one will be created.
+   *
+   * @param repository the affected Git repository
+   * @param changeControl the {@code ChangeControl} of the change to which the patch set belongs
+   * @param patchSet the {@code PatchSet} which should be modified
+   * @param treeModifications the modifications which should be applied
+   * @return the resulting {@code ChangeEdit}
+   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
+   * @throws InvalidChangeOperationException if the existing change edit is based on another patch
+   *     set or no change edit exists but the specified patch set isn't the current one
+   * @throws MergeConflictException if the modified patch set tree can't be merged with an existing
+   *     change edit
+   */
+  public ChangeEdit combineWithModifiedPatchSetTree(
+      Repository repository,
+      ChangeControl changeControl,
+      PatchSet patchSet,
+      List<TreeModification> treeModifications)
+      throws AuthException, IOException, InvalidChangeOperationException, MergeConflictException,
+          OrmException {
+    ensureAuthenticatedAndPermitted(changeControl);
+
+    Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(changeControl);
+    ensureAllowedPatchSet(changeControl, optionalChangeEdit, patchSet);
+
+    RevCommit patchSetCommit = lookupCommit(repository, patchSet);
+    ObjectId newTreeId = createNewTree(repository, patchSetCommit, treeModifications);
+
+    if (optionalChangeEdit.isPresent()) {
+      ChangeEdit changeEdit = optionalChangeEdit.get();
+      newTreeId = merge(repository, changeEdit, newTreeId);
+      if (ObjectId.equals(newTreeId, changeEdit.getEditCommit().getTree())) {
+        // Modifications are already contained in the change edit.
+        return changeEdit;
+      }
+    }
+
+    String commitMessage =
+        optionalChangeEdit.map(ChangeEdit::getEditCommit).orElse(patchSetCommit).getFullMessage();
+    Timestamp nowTimestamp = TimeUtil.nowTs();
+    ObjectId newEditCommit =
+        createCommit(repository, patchSetCommit, newTreeId, commitMessage, nowTimestamp);
+
+    if (optionalChangeEdit.isPresent()) {
+      return updateEdit(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
+    }
+    return createEdit(repository, changeControl, patchSet, newEditCommit, nowTimestamp);
+  }
+
   private void ensureAuthenticatedAndPermitted(ChangeControl changeControl)
       throws AuthException, OrmException {
     ensureAuthenticated();
@@ -340,7 +393,31 @@
     }
   }
 
-  private String getWellFormedCommitMessage(String commitMessage) {
+  private static void ensureAllowedPatchSet(
+      ChangeControl changeControl, Optional<ChangeEdit> optionalChangeEdit, PatchSet patchSet)
+      throws InvalidChangeOperationException {
+    if (optionalChangeEdit.isPresent()) {
+      ChangeEdit changeEdit = optionalChangeEdit.get();
+      if (!isBasedOn(changeEdit, patchSet)) {
+        throw new InvalidChangeOperationException(
+            String.format(
+                "Only the patch set %s on which the existing change edit is based may be modified "
+                    + "(specified patch set: %s)",
+                changeEdit.getBasePatchSet().getId(), patchSet.getId()));
+      }
+    } else {
+      PatchSet.Id patchSetId = patchSet.getId();
+      PatchSet.Id currentPatchSetId = changeControl.getChange().currentPatchSetId();
+      if (!patchSetId.equals(currentPatchSetId)) {
+        throw new InvalidChangeOperationException(
+            String.format(
+                "A change edit may only be created for the current patch set %s (and not for %s)",
+                currentPatchSetId, patchSetId));
+      }
+    }
+  }
+
+  private static String getWellFormedCommitMessage(String commitMessage) {
     String wellFormedMessage = Strings.nullToEmpty(commitMessage).trim();
     checkState(!wellFormedMessage.isEmpty(), "Commit message cannot be null or empty");
     wellFormedMessage = wellFormedMessage + "\n";
@@ -372,16 +449,21 @@
   private static RevCommit lookupCommit(Repository repository, PatchSet patchSet)
       throws IOException {
     ObjectId patchSetCommitId = getPatchSetCommitId(patchSet);
+    return lookupCommit(repository, patchSetCommitId);
+  }
+
+  private static RevCommit lookupCommit(Repository repository, ObjectId commitId)
+      throws IOException {
     try (RevWalk revWalk = new RevWalk(repository)) {
-      return revWalk.parseCommit(patchSetCommitId);
+      return revWalk.parseCommit(commitId);
     }
   }
 
   private static ObjectId createNewTree(
-      Repository repository, RevCommit baseCommit, TreeModification treeModification)
+      Repository repository, RevCommit baseCommit, List<TreeModification> treeModifications)
       throws IOException, InvalidChangeOperationException {
     TreeCreator treeCreator = new TreeCreator(baseCommit);
-    treeCreator.addTreeModification(treeModification);
+    treeCreator.addTreeModifications(treeModifications);
     ObjectId newTreeId = treeCreator.createNewTreeAndGetId(repository);
 
     if (ObjectId.equals(newTreeId, baseCommit.getTree())) {
@@ -390,7 +472,7 @@
     return newTreeId;
   }
 
-  private ObjectId merge(Repository repository, ChangeEdit changeEdit, ObjectId newTreeId)
+  private static ObjectId merge(Repository repository, ChangeEdit changeEdit, ObjectId newTreeId)
       throws IOException, MergeConflictException {
     PatchSet basePatchSet = changeEdit.getBasePatchSet();
     ObjectId basePatchSetCommitId = getPatchSetCommitId(basePatchSet);
@@ -436,17 +518,20 @@
     return ObjectId.fromString(patchSet.getRevision().get());
   }
 
-  private void createEditReference(
+  private ChangeEdit createEdit(
       Repository repository,
       ChangeControl changeControl,
       PatchSet basePatchSet,
-      ObjectId newEditCommit,
+      ObjectId newEditCommitId,
       Timestamp timestamp)
       throws IOException, OrmException {
     Change change = changeControl.getChange();
     String editRefName = getEditRefName(change, basePatchSet);
-    updateReference(repository, editRefName, ObjectId.zeroId(), newEditCommit, timestamp);
+    updateReference(repository, editRefName, ObjectId.zeroId(), newEditCommitId, timestamp);
     reindex(change);
+
+    RevCommit newEditCommit = lookupCommit(repository, newEditCommitId);
+    return new ChangeEdit(change, editRefName, newEditCommit, basePatchSet);
   }
 
   private String getEditRefName(Change change, PatchSet basePatchSet) {
@@ -454,13 +539,17 @@
     return RefNames.refsEdit(me.getAccountId(), change.getId(), basePatchSet.getId());
   }
 
-  private void updateEditReference(
-      Repository repository, ChangeEdit changeEdit, ObjectId newEditCommit, Timestamp timestamp)
+  private ChangeEdit updateEdit(
+      Repository repository, ChangeEdit changeEdit, ObjectId newEditCommitId, Timestamp timestamp)
       throws IOException, OrmException {
     String editRefName = changeEdit.getRefName();
     RevCommit currentEditCommit = changeEdit.getEditCommit();
-    updateReference(repository, editRefName, currentEditCommit, newEditCommit, timestamp);
+    updateReference(repository, editRefName, currentEditCommit, newEditCommitId, timestamp);
     reindex(changeEdit.getChange());
+
+    RevCommit newEditCommit = lookupCommit(repository, newEditCommitId);
+    return new ChangeEdit(
+        changeEdit.getChange(), editRefName, newEditCommit, changeEdit.getBasePatchSet());
   }
 
   private void updateReference(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
index 0cfdeed..1e11968 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -53,6 +53,7 @@
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
@@ -149,7 +150,7 @@
       try (RevWalk rw = new RevWalk(repo)) {
         RevCommit commit = rw.parseCommit(ref.getObjectId());
         PatchSet basePs = getBasePatchSet(ctl, ref);
-        return Optional.of(new ChangeEdit(u, change, ref, commit, basePs));
+        return Optional.of(new ChangeEdit(change, ref.getName(), commit, basePs));
       }
     }
   }
@@ -157,6 +158,7 @@
   /**
    * Promote change edit to patch set, by squashing the edit into its parent.
    *
+   * @param ctl the {@code ChangeControl} of the change to which the change edit belongs
    * @param edit change edit to publish
    * @param notify Notify handling that defines to whom email notifications should be sent after the
    *     change edit is published.
@@ -167,21 +169,22 @@
    * @throws RestApiException
    */
   public void publish(
+      ChangeControl ctl,
       final ChangeEdit edit,
       NotifyHandling notify,
       ListMultimap<RecipientType, Account.Id> accountsToNotify)
       throws IOException, OrmException, RestApiException, UpdateException {
     Change change = edit.getChange();
     try (Repository repo = gitManager.openRepository(change.getProject());
-        RevWalk rw = new RevWalk(repo);
-        ObjectInserter oi = repo.newObjectInserter()) {
+        ObjectInserter oi = repo.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk rw = new RevWalk(reader)) {
       PatchSet basePatchSet = edit.getBasePatchSet();
       if (!basePatchSet.getId().equals(change.currentPatchSetId())) {
         throw new ResourceConflictException("only edit for current patch set can be published");
       }
 
       RevCommit squashed = squashEdit(rw, oi, edit.getEditCommit(), basePatchSet);
-      ChangeControl ctl = changeControlFactory.controlFor(db.get(), change, edit.getUser());
       PatchSet.Id psId = ChangeUtil.nextPatchSetId(repo, change.currentPatchSetId());
       PatchSetInserter inserter =
           patchSetInserterFactory
@@ -194,7 +197,8 @@
 
       // Previously checked that the base patch set is the current patch set.
       ObjectId prior = ObjectId.fromString(basePatchSet.getRevision().get());
-      ChangeKind kind = changeKindCache.getChangeKind(change.getProject(), repo, prior, squashed);
+      ChangeKind kind =
+          changeKindCache.getChangeKind(change.getProject(), rw, repo.getConfig(), prior, squashed);
       if (kind == ChangeKind.NO_CODE_CHANGE) {
         message.append("Commit message was updated.");
         inserter.setDescription("Edit commit message");
@@ -218,7 +222,7 @@
             new BatchUpdateOp() {
               @Override
               public void updateRepo(RepoContext ctx) throws Exception {
-                deleteRef(ctx.getRepository(), edit);
+                ctx.addRefUpdate(edit.getEditCommit().copy(), ObjectId.zeroId(), edit.getRefName());
               }
             });
         bu.execute();
@@ -280,7 +284,7 @@
   private static void deleteRef(Repository repo, ChangeEdit edit) throws IOException {
     String refName = edit.getRefName();
     RefUpdate ru = repo.updateRef(refName, true);
-    ru.setExpectedOldObjectId(edit.getRef().getObjectId());
+    ru.setExpectedOldObjectId(edit.getEditCommit());
     ru.setForceUpdate(true);
     RefUpdate.Result result = ru.delete();
     switch (result) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
index dc35309..3d75e6a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.io.ByteStreams;
 import com.google.gerrit.extensions.restapi.RawInput;
 import java.io.IOException;
@@ -53,6 +54,16 @@
     return Collections.singletonList(changeContentEdit);
   }
 
+  @Override
+  public String getFilePath() {
+    return filePath;
+  }
+
+  @VisibleForTesting
+  RawInput getNewContent() {
+    return newContent;
+  }
+
   /** A {@code PathEdit} which changes the contents of a file. */
   private static class ChangeContent extends DirCacheEditor.PathEdit {
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
index 62da19a..feffb70 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
@@ -34,4 +34,9 @@
     DirCacheEditor.DeletePath deletePathEdit = new DirCacheEditor.DeletePath(filePath);
     return Collections.singletonList(deletePathEdit);
   }
+
+  @Override
+  public String getFilePath() {
+    return filePath;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RenameFileModification.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
index aeacd23..b847599 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
@@ -52,4 +52,9 @@
       }
     }
   }
+
+  @Override
+  public String getFilePath() {
+    return newFilePath;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
index 1bd55f6..393a866 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
@@ -58,4 +58,9 @@
       }
     }
   }
+
+  @Override
+  public String getFilePath() {
+    return filePath;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeCreator.java
index 7e9a96a..e867e76 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeCreator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeCreator.java
@@ -36,8 +36,6 @@
 public class TreeCreator {
 
   private final RevCommit baseCommit;
-  // At the moment, a list wouldn't be necessary as only one modification is
-  // applied per created tree. This is going to change in the near future.
   private final List<TreeModification> treeModifications = new ArrayList<>();
 
   public TreeCreator(RevCommit baseCommit) {
@@ -45,14 +43,14 @@
   }
 
   /**
-   * Apply a modification to the tree which is taken as a basis. If this method is called multiple
+   * Apply modifications to the tree which is taken as a basis. If this method is called multiple
    * times, the modifications are applied subsequently in exactly the order they were provided.
    *
-   * @param treeModification a modification which should be applied to the base tree
+   * @param treeModifications modifications which should be applied to the base tree
    */
-  public void addTreeModification(TreeModification treeModification) {
-    checkNotNull(treeModification, "treeModification must not be null");
-    treeModifications.add(treeModification);
+  public void addTreeModifications(List<TreeModification> treeModifications) {
+    checkNotNull(treeModifications, "treeModifications must not be null");
+    this.treeModifications.addAll(treeModifications);
   }
 
   /**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeModification.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeModification.java
index 217a309..2656707 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeModification.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeModification.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.edit.tree;
 
+import com.google.common.annotations.VisibleForTesting;
 import java.io.IOException;
 import java.util.List;
 import org.eclipse.jgit.dircache.DirCacheEditor;
@@ -35,4 +36,14 @@
    */
   List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit)
       throws IOException;
+
+  /**
+   * Indicates a file path which is affected by this {@code TreeModification}. If the modification
+   * refers to several file paths (e.g. renaming a file), returning either of them is appropriate as
+   * long as the returned value is deterministic.
+   *
+   * @return an affected file path
+   */
+  @VisibleForTesting
+  String getFilePath();
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/CommitReceivedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/CommitReceivedEvent.java
index 17fc52b..c0f9c29 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/CommitReceivedEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/CommitReceivedEvent.java
@@ -16,14 +16,19 @@
 
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
+import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
-public class CommitReceivedEvent extends RefEvent {
+public class CommitReceivedEvent extends RefEvent implements AutoCloseable {
   static final String TYPE = "commit-received";
   public ReceiveCommand command;
   public Project project;
   public String refName;
+  public RevWalk revWalk;
   public RevCommit commit;
   public IdentifiedUser user;
 
@@ -35,14 +40,18 @@
       ReceiveCommand command,
       Project project,
       String refName,
-      RevCommit commit,
-      IdentifiedUser user) {
+      ObjectReader reader,
+      ObjectId commitId,
+      IdentifiedUser user)
+      throws IOException {
     this();
     this.command = command;
     this.project = project;
     this.refName = refName;
-    this.commit = commit;
+    this.revWalk = new RevWalk(reader);
+    this.commit = revWalk.parseCommit(commitId);
     this.user = user;
+    revWalk.parseBody(commit);
   }
 
   @Override
@@ -54,4 +63,9 @@
   public String getRefName() {
     return refName;
   }
+
+  @Override
+  public void close() {
+    revWalk.close();
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
index 80dcb78..ce04f26 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
@@ -311,7 +311,7 @@
     // set whose parent matches this patch set's revision.
     for (ChangeData cd :
         queryProvider.get().byProjectGroups(change.getProject(), currentPs.getGroups())) {
-      patchSets:
+      PATCH_SETS:
       for (PatchSet ps : cd.patchSets()) {
         RevCommit commit = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
         for (RevCommit p : commit.getParents()) {
@@ -319,7 +319,7 @@
             continue;
           }
           ca.neededBy.add(newNeededBy(checkNotNull(cd.change()), ps));
-          continue patchSets;
+          continue PATCH_SETS;
         }
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java
index 85ee4f9..ae15cfd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java
@@ -16,20 +16,27 @@
 
 import com.google.common.base.Predicate;
 import com.google.common.collect.FluentIterable;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestCollection;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.webui.PrivateInternals_UiActionDescription;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.CapabilityUtils;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import java.util.Objects;
+import java.util.Set;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+@Singleton
 public class UiActions {
   private static final Logger log = LoggerFactory.getLogger(UiActions.class);
 
@@ -37,57 +44,70 @@
     return UiAction.Description::isEnabled;
   }
 
-  public static <R extends RestResource> FluentIterable<UiAction.Description> from(
-      RestCollection<?, R> collection, R resource, Provider<CurrentUser> userProvider) {
-    return from(collection.views(), resource, userProvider);
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> userProvider;
+
+  @Inject
+  UiActions(PermissionBackend permissionBackend, Provider<CurrentUser> userProvider) {
+    this.permissionBackend = permissionBackend;
+    this.userProvider = userProvider;
   }
 
-  public static <R extends RestResource> FluentIterable<UiAction.Description> from(
-      DynamicMap<RestView<R>> views, R resource, Provider<CurrentUser> userProvider) {
+  public <R extends RestResource> FluentIterable<UiAction.Description> from(
+      RestCollection<?, R> collection, R resource) {
+    return from(collection.views(), resource);
+  }
+
+  public <R extends RestResource> FluentIterable<UiAction.Description> from(
+      DynamicMap<RestView<R>> views, R resource) {
     return FluentIterable.from(views)
-        .transform(
-            (DynamicMap.Entry<RestView<R>> e) -> {
-              int d = e.getExportName().indexOf('.');
-              if (d < 0) {
-                return null;
-              }
-
-              RestView<R> view;
-              try {
-                view = e.getProvider().get();
-              } catch (RuntimeException err) {
-                log.error(
-                    String.format(
-                        "error creating view %s.%s", e.getPluginName(), e.getExportName()),
-                    err);
-                return null;
-              }
-
-              if (!(view instanceof UiAction)) {
-                return null;
-              }
-
-              try {
-                CapabilityUtils.checkRequiresCapability(
-                    userProvider, e.getPluginName(), view.getClass());
-              } catch (AuthException exc) {
-                return null;
-              }
-
-              UiAction.Description dsc = ((UiAction<R>) view).getDescription(resource);
-              if (dsc == null || !dsc.isVisible()) {
-                return null;
-              }
-
-              String name = e.getExportName().substring(d + 1);
-              PrivateInternals_UiActionDescription.setMethod(
-                  dsc, e.getExportName().substring(0, d));
-              PrivateInternals_UiActionDescription.setId(
-                  dsc, "gerrit".equals(e.getPluginName()) ? name : e.getPluginName() + '~' + name);
-              return dsc;
-            })
+        .transform((e) -> describe(e, resource))
         .filter(Objects::nonNull);
   }
 
-  private UiActions() {}
+  @Nullable
+  private <R extends RestResource> UiAction.Description describe(
+      DynamicMap.Entry<RestView<R>> e, R resource) {
+    int d = e.getExportName().indexOf('.');
+    if (d < 0) {
+      return null;
+    }
+
+    RestView<R> view;
+    try {
+      view = e.getProvider().get();
+    } catch (RuntimeException err) {
+      log.error(
+          String.format("error creating view %s.%s", e.getPluginName(), e.getExportName()), err);
+      return null;
+    }
+
+    if (!(view instanceof UiAction)) {
+      return null;
+    }
+
+    try {
+      Set<GlobalOrPluginPermission> need =
+          GlobalPermission.fromAnnotation(e.getPluginName(), view.getClass());
+      if (!need.isEmpty() && permissionBackend.user(userProvider).test(need).isEmpty()) {
+        // A permission is required, but test returned no candidates.
+        return null;
+      }
+    } catch (PermissionBackendException err) {
+      log.error(
+          String.format("exception testing view %s.%s", e.getPluginName(), e.getExportName()), err);
+      return null;
+    }
+
+    UiAction.Description dsc = ((UiAction<R>) view).getDescription(resource);
+    if (dsc == null || !dsc.isVisible()) {
+      return null;
+    }
+
+    String name = e.getExportName().substring(d + 1);
+    PrivateInternals_UiActionDescription.setMethod(dsc, e.getExportName().substring(0, d));
+    PrivateInternals_UiActionDescription.setId(
+        dsc, "gerrit".equals(e.getPluginName()) ? name : e.getPluginName() + '~' + name);
+    return dsc;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java b/gerrit-server/src/main/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
new file mode 100644
index 0000000..3fc786b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
@@ -0,0 +1,154 @@
+// Copyright (C) 2017 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.fixes;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.FixReplacement;
+import com.google.gerrit.server.change.FileContentUtil;
+import com.google.gerrit.server.edit.tree.ChangeFileContentModification;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+
+/** An interpreter for {@code FixReplacement}s. */
+@Singleton
+public class FixReplacementInterpreter {
+
+  private static final Comparator<FixReplacement> ASC_RANGE_FIX_REPLACEMENT_COMPARATOR =
+      Comparator.comparing(fixReplacement -> fixReplacement.range);
+
+  private final FileContentUtil fileContentUtil;
+
+  @Inject
+  public FixReplacementInterpreter(FileContentUtil fileContentUtil) {
+    this.fileContentUtil = fileContentUtil;
+  }
+
+  /**
+   * Transforms the given {@code FixReplacement}s into {@code TreeModification}s.
+   *
+   * @param repository the affected Git repository
+   * @param projectState the affected project
+   * @param patchSetCommitId the patch set which should be modified
+   * @param fixReplacements the replacements which should be applied
+   * @return a list of {@code TreeModification}s representing the given replacements
+   * @throws ResourceNotFoundException if a file to which one of the replacements refers doesn't
+   *     exist
+   * @throws ResourceConflictException if the replacements can't be transformed into {@code
+   *     TreeModification}s
+   */
+  public List<TreeModification> toTreeModifications(
+      Repository repository,
+      ProjectState projectState,
+      ObjectId patchSetCommitId,
+      List<FixReplacement> fixReplacements)
+      throws ResourceNotFoundException, IOException, ResourceConflictException {
+    checkNotNull(fixReplacements, "Fix replacements must not be null");
+
+    Map<String, List<FixReplacement>> fixReplacementsPerFilePath =
+        fixReplacements
+            .stream()
+            .collect(Collectors.groupingBy(fixReplacement -> fixReplacement.path));
+
+    List<TreeModification> treeModifications = new ArrayList<>();
+    for (Map.Entry<String, List<FixReplacement>> entry : fixReplacementsPerFilePath.entrySet()) {
+      TreeModification treeModification =
+          toTreeModification(
+              repository, projectState, patchSetCommitId, entry.getKey(), entry.getValue());
+      treeModifications.add(treeModification);
+    }
+    return treeModifications;
+  }
+
+  private TreeModification toTreeModification(
+      Repository repository,
+      ProjectState projectState,
+      ObjectId patchSetCommitId,
+      String filePath,
+      List<FixReplacement> fixReplacements)
+      throws ResourceNotFoundException, IOException, ResourceConflictException {
+    String fileContent = getFileContent(repository, projectState, patchSetCommitId, filePath);
+    String newFileContent = getNewFileContent(fileContent, fixReplacements);
+    return new ChangeFileContentModification(filePath, RawInputUtil.create(newFileContent));
+  }
+
+  private String getFileContent(
+      Repository repository, ProjectState projectState, ObjectId patchSetCommitId, String filePath)
+      throws ResourceNotFoundException, IOException {
+    try (BinaryResult fileContent =
+        fileContentUtil.getContent(repository, projectState, patchSetCommitId, filePath)) {
+      return fileContent.asString();
+    }
+  }
+
+  private static String getNewFileContent(String fileContent, List<FixReplacement> fixReplacements)
+      throws ResourceConflictException {
+    List<FixReplacement> sortedReplacements = new ArrayList<>(fixReplacements);
+    sortedReplacements.sort(ASC_RANGE_FIX_REPLACEMENT_COMPARATOR);
+
+    LineIdentifier lineIdentifier = new LineIdentifier(fileContent);
+    StringModifier fileContentModifier = new StringModifier(fileContent);
+    for (FixReplacement fixReplacement : sortedReplacements) {
+      Comment.Range range = fixReplacement.range;
+      try {
+        int startLineIndex = lineIdentifier.getStartIndexOfLine(range.startLine);
+        int startLineLength = lineIdentifier.getLengthOfLine(range.startLine);
+
+        int endLineIndex = lineIdentifier.getStartIndexOfLine(range.endLine);
+        int endLineLength = lineIdentifier.getLengthOfLine(range.endLine);
+
+        if (range.startChar > startLineLength || range.endChar > endLineLength) {
+          throw new ResourceConflictException(
+              String.format(
+                  "Range %s refers to a non-existent offset (start line length: %s,"
+                      + " end line length: %s)",
+                  toString(range), startLineLength, endLineLength));
+        }
+
+        int startIndex = startLineIndex + range.startChar;
+        int endIndex = endLineIndex + range.endChar;
+        fileContentModifier.replace(startIndex, endIndex, fixReplacement.replacement);
+      } catch (StringIndexOutOfBoundsException e) {
+        // Most of the StringIndexOutOfBoundsException should never occur because we reject fix
+        // replacements for invalid ranges. However, we can't cover all cases for efficiency
+        // reasons. For instance, we don't determine the number of lines in a file. That's why we
+        // need to map this exception and thus provide a meaningful error.
+        throw new ResourceConflictException(
+            String.format("Cannot apply fix replacement for range %s", toString(range)), e);
+      }
+    }
+    return fileContentModifier.getResult();
+  }
+
+  private static String toString(Comment.Range range) {
+    return String.format(
+        "(%s:%s - %s:%s)", range.startLine, range.startChar, range.endLine, range.endChar);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/fixes/LineIdentifier.java b/gerrit-server/src/main/java/com/google/gerrit/server/fixes/LineIdentifier.java
new file mode 100644
index 0000000..c32d822
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/fixes/LineIdentifier.java
@@ -0,0 +1,110 @@
+// Copyright (C) 2017 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.fixes;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * An identifier of lines in a string. Lines are sequences of characters which are separated by any
+ * Unicode linebreak sequence as defined by the regular expression {@code \R}. If data for several
+ * lines is requested, calls which are ordered according to ascending line numbers are the most
+ * efficient.
+ */
+class LineIdentifier {
+
+  private static final Pattern LINE_SEPARATOR_PATTERN = Pattern.compile("\\R");
+  private final Matcher lineSeparatorMatcher;
+
+  private int nextLineNumber;
+  private int nextLineStartIndex;
+  private int currentLineStartIndex;
+  private int currentLineEndIndex;
+
+  LineIdentifier(String string) {
+    checkNotNull(string);
+    lineSeparatorMatcher = LINE_SEPARATOR_PATTERN.matcher(string);
+    reset();
+  }
+
+  /**
+   * Returns the start index of the indicated line within the given string. Start indices are
+   * zero-based while line numbers are one-based.
+   *
+   * <p><b>Note:</b> Requesting data for several lines is more efficient if those calls occur with
+   * increasing line number.
+   *
+   * @param lineNumber the line whose start index should be determined
+   * @return the start index of the line
+   * @throws StringIndexOutOfBoundsException if the line number is negative, zero or greater than
+   *     the identified number of lines
+   */
+  public int getStartIndexOfLine(int lineNumber) {
+    findLine(lineNumber);
+    return currentLineStartIndex;
+  }
+
+  /**
+   * Returns the length of the indicated line in the given string. The character(s) used to separate
+   * lines aren't included in the count. Line numbers are one-based.
+   *
+   * <p><b>Note:</b> Requesting data for several lines is more efficient if those calls occur with
+   * increasing line number.
+   *
+   * @param lineNumber the line whose length should be determined
+   * @return the length of the line
+   * @throws StringIndexOutOfBoundsException if the line number is negative, zero or greater than
+   *     the identified number of lines
+   */
+  public int getLengthOfLine(int lineNumber) {
+    findLine(lineNumber);
+    return currentLineEndIndex - currentLineStartIndex;
+  }
+
+  private void findLine(int targetLineNumber) {
+    if (targetLineNumber <= 0) {
+      throw new StringIndexOutOfBoundsException("Line number must be positive");
+    }
+    if (targetLineNumber < nextLineNumber) {
+      reset();
+    }
+    while (nextLineNumber < targetLineNumber + 1 && lineSeparatorMatcher.find()) {
+      currentLineStartIndex = nextLineStartIndex;
+      currentLineEndIndex = lineSeparatorMatcher.start();
+      nextLineStartIndex = lineSeparatorMatcher.end();
+      nextLineNumber++;
+    }
+
+    // End of string
+    if (nextLineNumber == targetLineNumber) {
+      currentLineStartIndex = nextLineStartIndex;
+      currentLineEndIndex = lineSeparatorMatcher.regionEnd();
+    }
+    if (nextLineNumber < targetLineNumber) {
+      throw new StringIndexOutOfBoundsException(
+          String.format("Line %d isn't available", targetLineNumber));
+    }
+  }
+
+  private void reset() {
+    nextLineNumber = 1;
+    nextLineStartIndex = 0;
+    currentLineStartIndex = 0;
+    currentLineEndIndex = 0;
+    lineSeparatorMatcher.reset();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/fixes/StringModifier.java b/gerrit-server/src/main/java/com/google/gerrit/server/fixes/StringModifier.java
new file mode 100644
index 0000000..ccd40b3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/fixes/StringModifier.java
@@ -0,0 +1,77 @@
+// Copyright (C) 2017 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.fixes;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+/**
+ * A modifier of a string. It allows to replace multiple parts of a string by indicating those parts
+ * with indices based on the unmodified string. There is one limitation though: Replacements which
+ * affect lower indices of the string must be specified before replacements for higher indices.
+ */
+class StringModifier {
+
+  private final StringBuilder stringBuilder;
+
+  private int characterShift = 0;
+  private int previousEndOffset = Integer.MIN_VALUE;
+
+  StringModifier(String string) {
+    checkNotNull(string, "string must not be null");
+    stringBuilder = new StringBuilder(string);
+  }
+
+  /**
+   * Replaces part of the string with another content. When called multiple times, the calls must be
+   * ordered according to increasing start indices. Overlapping replacement regions aren't
+   * supported.
+   *
+   * @param startIndex the beginning index in the unmodified string (inclusive)
+   * @param endIndex the ending index in the unmodified string (exclusive)
+   * @param replacement the string which should be used instead of the original content
+   * @throws StringIndexOutOfBoundsException if the start index is smaller than the end index of a
+   *     previous call of this method
+   */
+  public void replace(int startIndex, int endIndex, String replacement) {
+    checkNotNull(replacement, "replacement string must not be null");
+    if (previousEndOffset > startIndex) {
+      throw new StringIndexOutOfBoundsException(
+          String.format(
+              "Not supported to replace the content starting at index %s after previous "
+                  + "replacement which ended at index %s",
+              startIndex, previousEndOffset));
+    }
+    int shiftedStartIndex = startIndex + characterShift;
+    int shiftedEndIndex = endIndex + characterShift;
+    if (shiftedEndIndex > stringBuilder.length()) {
+      throw new StringIndexOutOfBoundsException(
+          String.format("end %s > length %s", shiftedEndIndex, stringBuilder.length()));
+    }
+    stringBuilder.replace(shiftedStartIndex, shiftedEndIndex, replacement);
+
+    int replacedContentLength = endIndex - startIndex;
+    characterShift += replacement.length() - replacedContentLength;
+    previousEndOffset = endIndex;
+  }
+
+  /**
+   * Returns the modified string including all specified replacements.
+   *
+   * @return the modified string
+   */
+  public String getResult() {
+    return stringBuilder.toString();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java
index 99b647a..f4185c9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.ChangeAbandoned;
 import com.google.gerrit.server.mail.send.AbandonedSender;
@@ -34,8 +35,8 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -64,7 +65,7 @@
         @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify);
   }
 
-  @AssistedInject
+  @Inject
   AbandonOp(
       AbandonedSender.Factory abandonedSenderFactory,
       ChangeMessagesUtil cmUtil,
@@ -96,7 +97,7 @@
     PatchSet.Id psId = change.currentPatchSetId();
     ChangeUpdate update = ctx.getUpdate(psId);
     if (!change.getStatus().isOpen()) {
-      throw new ResourceConflictException("change is " + status(change));
+      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     } else if (change.getStatus() == Change.Status.DRAFT) {
       throw new ResourceConflictException("draft changes cannot be abandoned");
     }
@@ -137,8 +138,4 @@
     }
     changeAbandoned.fire(change, patchSet, account, msgTxt, ctx.getWhen(), notifyHandling);
   }
-
-  private static String status(Change change) {
-    return change != null ? change.getStatus().name().toLowerCase() : "deleted";
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java
index e680ea7..46916c8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java
@@ -23,8 +23,8 @@
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.util.RequestId;
 import com.google.gerrit.server.util.SubmoduleSectionParser;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -55,7 +55,7 @@
   private final RequestId submissionId;
   Set<SubmoduleSubscription> subscriptions;
 
-  @AssistedInject
+  @Inject
   GitModules(
       @CanonicalWebUrl @Nullable String canonicalWebUrl,
       @Assisted Branch.NameKey branch,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java
index 6a05d22..323f352 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java
@@ -142,16 +142,6 @@
     return Result.create(unchanged, updated, deleted);
   }
 
-  /**
-   * @param ctl change control (for any user).
-   * @param lt label type.
-   * @param id account ID.
-   * @return whether the given account ID has any permissions to vote on this label for this change.
-   */
-  public boolean canVote(ChangeControl ctl, LabelType lt, Account.Id id) {
-    return !getRange(ctl, lt, id).isEmpty();
-  }
-
   private PatchSetApproval copy(PatchSetApproval src) {
     return new PatchSetApproval(src.getPatchSetId(), src);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java
index 1511da0..cf95d18 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java
@@ -343,6 +343,8 @@
           commitStatus.problem(
               cd.getId(),
               "Change " + cd.getId() + " is " + cd.change().getStatus().toString().toLowerCase());
+        } else if (cd.change().isWorkInProgress()) {
+          commitStatus.problem(cd.getId(), "Change " + cd.getId() + " is work in progress");
         } else {
           checkSubmitRule(cd);
         }
@@ -519,9 +521,7 @@
             submitStrategyFactory.create(
                 submitting.submitType(),
                 db,
-                or.repo,
                 or.rw,
-                or.ins,
                 or.canMergeFlag,
                 getAlreadyAccepted(or, ob.oldTip),
                 allCommits,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
index 2526db194..6446fdd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
@@ -222,8 +222,8 @@
   }
 
   public CodeReviewCommit createCherryPickFromCommit(
-      Repository repo,
       ObjectInserter inserter,
+      Config repoConfig,
       RevCommit mergeTip,
       RevCommit originalCommit,
       PersonIdent cherryPickCommitterIdent,
@@ -234,7 +234,7 @@
       throws MissingObjectException, IncorrectObjectTypeException, IOException,
           MergeIdenticalTreeException, MergeConflictException {
 
-    final ThreeWayMerger m = newThreeWayMerger(repo, inserter);
+    final ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig);
 
     m.setBase(originalCommit.getParent(parentIndex));
     if (m.merge(mergeTip, originalCommit)) {
@@ -255,8 +255,8 @@
   }
 
   public static RevCommit createMergeCommit(
-      Repository repo,
       ObjectInserter inserter,
+      Config repoConfig,
       RevCommit mergeTip,
       RevCommit originalCommit,
       String mergeStrategy,
@@ -271,7 +271,7 @@
           "'" + originalCommit.getName() + "' has already been merged");
     }
 
-    Merger m = newMerger(repo, inserter, mergeStrategy);
+    Merger m = newMerger(inserter, repoConfig, mergeStrategy);
     if (m.merge(false, mergeTip, originalCommit)) {
       ObjectId tree = m.getResultTreeId();
 
@@ -486,7 +486,7 @@
     }
 
     try (ObjectInserter ins = new InMemoryInserter(repo)) {
-      return newThreeWayMerger(repo, ins).merge(new AnyObjectId[] {mergeTip, toMerge});
+      return newThreeWayMerger(ins, repo.getConfig()).merge(new AnyObjectId[] {mergeTip, toMerge});
     } catch (LargeObjectException e) {
       log.warn("Cannot merge due to LargeObjectException: " + toMerge.name());
       return false;
@@ -542,7 +542,7 @@
       // that on the current merge tip.
       //
       try (ObjectInserter ins = new InMemoryInserter(repo)) {
-        ThreeWayMerger m = newThreeWayMerger(repo, ins);
+        ThreeWayMerger m = newThreeWayMerger(ins, repo.getConfig());
         m.setBase(toMerge.getParent(0));
         return m.merge(mergeTip, toMerge);
       } catch (IOException e) {
@@ -575,14 +575,14 @@
   public CodeReviewCommit mergeOneCommit(
       PersonIdent author,
       PersonIdent committer,
-      Repository repo,
       CodeReviewRevWalk rw,
       ObjectInserter inserter,
+      Config repoConfig,
       Branch.NameKey destBranch,
       CodeReviewCommit mergeTip,
       CodeReviewCommit n)
       throws IntegrationException {
-    final ThreeWayMerger m = newThreeWayMerger(repo, inserter);
+    ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig);
     try {
       if (m.merge(new AnyObjectId[] {mergeTip, n})) {
         return writeMergeCommit(
@@ -706,8 +706,8 @@
     }
   }
 
-  public ThreeWayMerger newThreeWayMerger(final Repository repo, final ObjectInserter inserter) {
-    return newThreeWayMerger(repo, inserter, mergeStrategyName());
+  public ThreeWayMerger newThreeWayMerger(ObjectInserter inserter, Config repoConfig) {
+    return newThreeWayMerger(inserter, repoConfig, mergeStrategyName());
   }
 
   public String mergeStrategyName() {
@@ -730,8 +730,8 @@
   }
 
   public static ThreeWayMerger newThreeWayMerger(
-      Repository repo, final ObjectInserter inserter, String strategyName) {
-    Merger m = newMerger(repo, inserter, strategyName);
+      ObjectInserter inserter, Config repoConfig, String strategyName) {
+    Merger m = newMerger(inserter, repoConfig, strategyName);
     checkArgument(
         m instanceof ThreeWayMerger,
         "merge strategy %s does not support three-way merging",
@@ -739,12 +739,10 @@
     return (ThreeWayMerger) m;
   }
 
-  public static Merger newMerger(
-      Repository repo, final ObjectInserter inserter, String strategyName) {
+  public static Merger newMerger(ObjectInserter inserter, Config repoConfig, String strategyName) {
     MergeStrategy strategy = MergeStrategy.get(strategyName);
     checkArgument(strategy != null, "invalid merge strategy: %s", strategyName);
-    Merger m = strategy.newMerger(repo, true);
-    m.setObjectInserter(
+    return strategy.newMerger(
         new ObjectInserter.Filter() {
           @Override
           protected ObjectInserter delegate() {
@@ -756,8 +754,8 @@
 
           @Override
           public void close() {}
-        });
-    return m;
+        },
+        repoConfig);
   }
 
   public void markCleanMerges(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java
index a7c8b53..9439a8b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -34,9 +34,9 @@
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.Collections;
 import java.util.concurrent.ExecutorService;
@@ -74,7 +74,7 @@
   private PatchSet patchSet;
   private PatchSetInfo info;
 
-  @AssistedInject
+  @Inject
   MergedByPushOp(
       PatchSetInfoFactory patchSetInfoFactory,
       ChangeMessagesUtil cmUtil,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java
index e3b1ad6..b057c92 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java
@@ -22,7 +22,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -185,7 +184,7 @@
   private boolean closeRepository;
   private IdentifiedUser author;
 
-  @AssistedInject
+  @Inject
   public MetaDataUpdate(
       GitReferenceUpdated gitRefUpdated,
       @Assisted Project.NameKey projectName,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/PerThreadRequestScope.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/PerThreadRequestScope.java
index 20f053a..ac031eb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/PerThreadRequestScope.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/PerThreadRequestScope.java
@@ -64,18 +64,15 @@
     }
 
     public <T> Callable<T> scope(RequestContext requestContext, Callable<T> callable) {
-      final Context ctx = new Context();
-      final Callable<T> wrapped = context(requestContext, cleanup(callable));
-      return new Callable<T>() {
-        @Override
-        public T call() throws Exception {
-          Context old = current.get();
-          current.set(ctx);
-          try {
-            return wrapped.call();
-          } finally {
-            current.set(old);
-          }
+      Context ctx = new Context();
+      Callable<T> wrapped = context(requestContext, cleanup(callable));
+      return () -> {
+        Context old = current.get();
+        current.set(ctx);
+        try {
+          return wrapped.call();
+        } finally {
+          current.set(old);
         }
       };
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
index 61d8cfe..f1a35d7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
@@ -155,6 +155,9 @@
       ImmutableSet.of(
           "MaxWithBlock", "AnyWithBlock", "MaxNoBlock", "NoBlock", "NoOp", "PatchSetLock");
 
+  private static final String REVIEWER = "reviewer";
+  private static final String KEY_ENABLE_REVIEWER_BY_EMAIL = "enableByEmail";
+
   private static final String LEGACY_PERMISSION_PUSH_TAG = "pushTag";
   private static final String LEGACY_PERMISSION_PUSH_SIGNED_TAG = "pushSignedTag";
 
@@ -163,6 +166,9 @@
   private static final SubmitType DEFAULT_SUBMIT_ACTION = SubmitType.MERGE_IF_NECESSARY;
   private static final ProjectState DEFAULT_STATE_VALUE = ProjectState.ACTIVE;
 
+  private static final String EXTENSION_PANELS = "extension-panels";
+  private static final String KEY_PANEL = "panel";
+
   private Project.NameKey projectName;
   private Project project;
   private AccountsSection accountsSection;
@@ -182,6 +188,7 @@
   private boolean checkReceivedObjects;
   private Set<String> sectionsWithUnknownPermissions;
   private boolean hasLegacyPermissions;
+  private Map<String, List<String>> extensionPanelSections;
 
   public static ProjectConfig read(MetaDataUpdate update)
       throws IOException, ConfigInvalidException {
@@ -197,6 +204,10 @@
     return r;
   }
 
+  public Map<String, List<String>> getExtensionPanelSections() {
+    return extensionPanelSections;
+  }
+
   public static CommentLinkInfoImpl buildCommentLink(Config cfg, String name, boolean allowRaw)
       throws IllegalArgumentException {
     String match = cfg.getString(COMMENTLINK, name, KEY_MATCH);
@@ -507,6 +518,8 @@
     p.setMaxObjectSizeLimit(rc.getString(RECEIVE, null, KEY_MAX_OBJECT_SIZE_LIMIT));
     p.setRejectImplicitMerges(
         getEnum(rc, RECEIVE, null, KEY_REJECT_IMPLICIT_MERGES, InheritableBoolean.INHERIT));
+    p.setEnableReviewerByEmail(
+        getEnum(rc, REVIEWER, null, KEY_ENABLE_REVIEWER_BY_EMAIL, InheritableBoolean.INHERIT));
 
     p.setSubmitType(getEnum(rc, SUBMIT, null, KEY_ACTION, DEFAULT_SUBMIT_ACTION));
     p.setUseContentMerge(getEnum(rc, SUBMIT, null, KEY_MERGE_CONTENT, InheritableBoolean.INHERIT));
@@ -526,6 +539,7 @@
     mimeTypes = new ConfiguredMimeTypes(projectName.get(), rc);
     loadPluginSections(rc);
     loadReceiveSection(rc);
+    loadExtensionPanelSections(rc);
   }
 
   private void loadAccountsSection(Config rc, Map<String, GroupReference> groupsByName) {
@@ -534,6 +548,25 @@
         loadPermissionRules(rc, ACCOUNTS, null, KEY_SAME_GROUP_VISIBILITY, groupsByName, false));
   }
 
+  private void loadExtensionPanelSections(Config rc) {
+    Map<String, String> lowerNames = Maps.newHashMapWithExpectedSize(2);
+    extensionPanelSections = Maps.newLinkedHashMap();
+    for (String name : rc.getSubsections(EXTENSION_PANELS)) {
+      String lower = name.toLowerCase();
+      if (lowerNames.containsKey(lower)) {
+        error(
+            new ValidationError(
+                PROJECT_CONFIG,
+                String.format(
+                    "Extension Panels \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower))));
+      }
+      lowerNames.put(lower, name);
+      extensionPanelSections.put(
+          name,
+          new ArrayList<>(Arrays.asList(rc.getStringList(EXTENSION_PANELS, name, KEY_PANEL))));
+    }
+  }
+
   private void loadContributorAgreements(Config rc, Map<String, GroupReference> groupsByName) {
     contributorAgreements = new HashMap<>();
     for (String name : rc.getSubsections(CONTRIBUTOR_AGREEMENT)) {
@@ -1052,6 +1085,13 @@
         KEY_REJECT_IMPLICIT_MERGES,
         p.getRejectImplicitMerges(),
         InheritableBoolean.INHERIT);
+    set(
+        rc,
+        REVIEWER,
+        null,
+        KEY_ENABLE_REVIEWER_BY_EMAIL,
+        p.getEnableReviewerByEmail(),
+        InheritableBoolean.INHERIT);
 
     set(rc, SUBMIT, null, KEY_ACTION, p.getSubmitType(), DEFAULT_SUBMIT_ACTION);
     set(rc, SUBMIT, null, KEY_MERGE_CONTENT, p.getUseContentMerge(), InheritableBoolean.INHERIT);
@@ -1288,40 +1328,55 @@
 
       setBooleanConfigKey(
           rc,
+          LABEL,
           name,
           KEY_ALLOW_POST_SUBMIT,
           label.allowPostSubmit(),
           LabelType.DEF_ALLOW_POST_SUBMIT);
       setBooleanConfigKey(
-          rc, name, KEY_COPY_MIN_SCORE, label.isCopyMinScore(), LabelType.DEF_COPY_MIN_SCORE);
-      setBooleanConfigKey(
-          rc, name, KEY_COPY_MAX_SCORE, label.isCopyMaxScore(), LabelType.DEF_COPY_MAX_SCORE);
+          rc,
+          LABEL,
+          name,
+          KEY_COPY_MIN_SCORE,
+          label.isCopyMinScore(),
+          LabelType.DEF_COPY_MIN_SCORE);
       setBooleanConfigKey(
           rc,
+          LABEL,
+          name,
+          KEY_COPY_MAX_SCORE,
+          label.isCopyMaxScore(),
+          LabelType.DEF_COPY_MAX_SCORE);
+      setBooleanConfigKey(
+          rc,
+          LABEL,
           name,
           KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
           label.isCopyAllScoresOnTrivialRebase(),
           LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
       setBooleanConfigKey(
           rc,
+          LABEL,
           name,
           KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
           label.isCopyAllScoresIfNoCodeChange(),
           LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
       setBooleanConfigKey(
           rc,
+          LABEL,
           name,
           KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
           label.isCopyAllScoresIfNoChange(),
           LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
       setBooleanConfigKey(
           rc,
+          LABEL,
           name,
           KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
           label.isCopyAllScoresOnMergeFirstParentUpdate(),
           LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
       setBooleanConfigKey(
-          rc, name, KEY_CAN_OVERRIDE, label.canOverride(), LabelType.DEF_CAN_OVERRIDE);
+          rc, LABEL, name, KEY_CAN_OVERRIDE, label.canOverride(), LabelType.DEF_CAN_OVERRIDE);
       List<String> values = Lists.newArrayListWithCapacity(label.getValues().size());
       for (LabelValue value : label.getValues()) {
         values.add(value.format());
@@ -1335,11 +1390,11 @@
   }
 
   private static void setBooleanConfigKey(
-      Config rc, String name, String key, boolean value, boolean defaultValue) {
+      Config rc, String section, String name, String key, boolean value, boolean defaultValue) {
     if (value == defaultValue) {
-      rc.unset(LABEL, name, key);
+      rc.unset(section, name, key);
     } else {
-      rc.setBoolean(LABEL, name, key, value);
+      rc.setBoolean(section, name, key, value);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
index 399dfc7..11871e7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
@@ -105,6 +105,11 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -292,6 +297,8 @@
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeNotes.Factory notesFactory;
   private final AccountResolver accountResolver;
+  private final PermissionBackend permissionBackend;
+  private final PermissionBackend.ForProject permissions;
   private final CmdLineParser.Factory optionParserFactory;
   private final GitReferenceUpdated gitRefUpdated;
   private final PatchSetInfoFactory patchSetInfoFactory;
@@ -357,6 +364,7 @@
       Provider<InternalChangeQuery> queryProvider,
       ChangeNotes.Factory notesFactory,
       AccountResolver accountResolver,
+      PermissionBackend permissionBackend,
       CmdLineParser.Factory optionParserFactory,
       GitReferenceUpdated gitRefUpdated,
       PatchSetInfoFactory patchSetInfoFactory,
@@ -389,13 +397,14 @@
       SetHashtagsOp.Factory hashtagsFactory,
       ReplaceOp.Factory replaceOpFactory,
       MergedByPushOp.Factory mergedByPushOpFactory)
-      throws IOException {
+      throws IOException, PermissionBackendException {
     this.user = projectControl.getUser().asIdentifiedUser();
     this.db = db;
     this.seq = seq;
     this.queryProvider = queryProvider;
     this.notesFactory = notesFactory;
     this.accountResolver = accountResolver;
+    this.permissionBackend = permissionBackend;
     this.optionParserFactory = optionParserFactory;
     this.gitRefUpdated = gitRefUpdated;
     this.patchSetInfoFactory = patchSetInfoFactory;
@@ -463,9 +472,15 @@
           }
         });
 
-    if (!projectControl.allRefsAreVisible()) {
+    permissions = permissionBackend.user(user).project(project.getNameKey());
+    // If the user lacks READ permission, some references may be filtered and hidden from view.
+    // Check objects mentioned inside the incoming pack file are reachable from visible refs.
+    try {
+      permissionBackend.user(user).project(project.getNameKey()).check(ProjectPermission.READ);
+    } catch (AuthException e) {
       rp.setCheckReferencedObjectsAreReachable(receiveConfig.checkReferencedObjectsAreReachable);
     }
+
     rp.setAdvertiseRefsHook(
         new VisibleRefFilter(tagCache, notesFactory, changeCache, repo, projectControl, db, false));
     List<AdvertiseRefsHook> advHooks = new ArrayList<>(3);
@@ -579,7 +594,16 @@
     batch.setRefLogIdent(rp.getRefLogIdent());
     batch.setRefLogMessage("push", true);
 
-    parseCommands(commands);
+    try {
+      parseCommands(commands);
+    } catch (PermissionBackendException err) {
+      for (ReceiveCommand cmd : batch.getCommands()) {
+        if (cmd.getResult() == NOT_ATTEMPTED) {
+          cmd.setResult(REJECTED_OTHER_REASON, "internal server error");
+        }
+      }
+      logError(String.format("Failed to process refs in %s", project.getName()), err);
+    }
     if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
       selectNewAndReplacedChangesFromMagicBranch();
     }
@@ -844,8 +868,10 @@
     try (BatchUpdate bu =
             batchUpdateFactory.create(
                 db, magicBranch.dest.getParentKey(), user.materializedCopy(), TimeUtil.nowTs());
-        ObjectInserter ins = repo.newObjectInserter()) {
-      bu.setRepository(repo, rp.getRevWalk(), ins).updateChangesInParallel();
+        ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      bu.setRepository(repo, rw, ins).updateChangesInParallel();
       bu.setRequestId(receiveId);
       for (ReplaceRequest replace : replaceByChange.values()) {
         if (replace.inputCommand == magicBranch.cmd) {
@@ -921,7 +947,8 @@
     return displayName;
   }
 
-  private void parseCommands(Collection<ReceiveCommand> commands) {
+  private void parseCommands(Collection<ReceiveCommand> commands)
+      throws PermissionBackendException {
     List<String> optionList = rp.getPushOptions();
     if (optionList != null) {
       for (String option : optionList) {
@@ -1040,10 +1067,13 @@
                   continue;
                 }
               } else {
-                if (!oldParent.equals(newParent)
-                    && !user.getCapabilities().canAdministrateServer()) {
-                  reject(cmd, "invalid project configuration: only Gerrit admin can set parent");
-                  continue;
+                if (!oldParent.equals(newParent)) {
+                  try {
+                    permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+                  } catch (AuthException e) {
+                    reject(cmd, "invalid project configuration: only Gerrit admin can set parent");
+                    continue;
+                  }
                 }
 
                 if (projectCache.get(newParent) == null) {
@@ -1152,24 +1182,29 @@
     }
   }
 
-  private void parseUpdate(ReceiveCommand cmd) {
+  private void parseUpdate(ReceiveCommand cmd) throws PermissionBackendException {
     logDebug("Updating {}", cmd);
-    RefControl ctl = projectControl.controlForRef(cmd.getRefName());
-    if (ctl.canUpdate()) {
+    boolean ok;
+    try {
+      permissions.ref(cmd.getRefName()).check(RefPermission.UPDATE);
+      ok = true;
+    } catch (AuthException err) {
+      ok = false;
+    }
+    if (ok) {
       if (isHead(cmd) && !isCommit(cmd)) {
         return;
       }
-
       if (!validRefOperation(cmd)) {
         return;
       }
-      validateNewCommits(ctl, cmd);
+      validateNewCommits(projectControl.controlForRef(cmd.getRefName()), cmd);
       batch.addCommand(cmd);
     } else {
-      if (RefNames.REFS_CONFIG.equals(ctl.getRefName())) {
+      if (RefNames.REFS_CONFIG.equals(cmd.getRefName())) {
         errors.put(Error.CONFIG_UPDATE, RefNames.REFS_CONFIG);
       } else {
-        errors.put(Error.UPDATE, ctl.getRefName());
+        errors.put(Error.UPDATE, cmd.getRefName());
       }
       reject(cmd, "prohibited by Gerrit: ref update access denied");
     }
@@ -1213,7 +1248,7 @@
     }
   }
 
-  private void parseRewind(ReceiveCommand cmd) {
+  private void parseRewind(ReceiveCommand cmd) throws PermissionBackendException {
     RevCommit newObject;
     try {
       newObject = rp.getRevWalk().parseCommit(cmd.getNewId());
@@ -1236,7 +1271,14 @@
       }
     }
 
-    if (ctl.canForceUpdate()) {
+    boolean ok;
+    try {
+      permissions.ref(cmd.getRefName()).check(RefPermission.FORCE_UPDATE);
+      ok = true;
+    } catch (AuthException err) {
+      ok = false;
+    }
+    if (ok) {
       if (!validRefOperation(cmd)) {
         return;
       }
@@ -1251,6 +1293,8 @@
     private static final Splitter COMMAS = Splitter.on(',').omitEmptyStrings();
 
     final ReceiveCommand cmd;
+    final LabelTypes labelTypes;
+    final NotesMigration notesMigration;
     Branch.NameKey dest;
     RefControl ctl;
     Set<Account.Id> reviewer = Sets.newLinkedHashSet();
@@ -1258,10 +1302,8 @@
     Map<String, Short> labels = new HashMap<>();
     String message;
     List<RevCommit> baseCommit;
-    LabelTypes labelTypes;
     CmdLineParser clp;
     Set<String> hashtags = new HashSet<>();
-    NotesMigration notesMigration;
 
     @Option(name = "--base", metaVar = "BASE", usage = "merge base of changes")
     List<ObjectId> base;
@@ -1272,6 +1314,22 @@
     @Option(name = "--draft", usage = "mark new/updated changes as draft")
     boolean draft;
 
+    @Option(name = "--private", usage = "mark new/updated change as private")
+    boolean isPrivate;
+
+    @Option(name = "--remove-private", usage = "remove privacy flag from updated change")
+    boolean removePrivate;
+
+    @Option(
+      name = "--wip",
+      aliases = {"-work-in-progress"},
+      usage = "mark change as work in progress"
+    )
+    boolean workInProgress;
+
+    @Option(name = "--ready", usage = "mark change as ready")
+    boolean ready;
+
     @Option(
       name = "--edit",
       aliases = {"-e"},
@@ -1525,6 +1583,16 @@
       return;
     }
 
+    if (magicBranch.isPrivate && magicBranch.removePrivate) {
+      reject(cmd, "the options 'private' and 'remove-private' are mutually exclusive");
+      return;
+    }
+
+    if (magicBranch.workInProgress && magicBranch.ready) {
+      reject(cmd, "the options 'wip' and 'ready' are mutually exclusive");
+      return;
+    }
+
     if (magicBranch.draft && magicBranch.submit) {
       reject(cmd, "cannot submit draft");
       return;
@@ -2134,8 +2202,10 @@
           changeInserterFactory
               .create(changeId, commit, refName)
               .setTopic(magicBranch.topic)
+              .setPrivate(magicBranch.isPrivate)
+              .setWorkInProgress(magicBranch.workInProgress)
               // Changes already validated in validateNewCommits.
-              .setValidatePolicy(CommitValidators.Policy.NONE);
+              .setValidate(false);
 
       if (magicBranch.draft) {
         ins.setDraft(magicBranch.draft);
@@ -2185,6 +2255,7 @@
                 .setUpdateRef(false)
                 .setPatchSetDescription(magicBranch.message));
         if (!magicBranch.hashtags.isEmpty()) {
+          // Any change owner is allowed to add hashtags when creating a change.
           bu.addOp(
               changeId,
               hashtagsFactory.create(new HashtagsInput(magicBranch.hashtags)).setFireEvent(false));
@@ -2468,13 +2539,12 @@
         if (edit.get().getBasePatchSet().getId().equals(psId)) {
           // replace edit
           cmd =
-              new ReceiveCommand(
-                  edit.get().getRef().getObjectId(), newCommitId, edit.get().getRefName());
+              new ReceiveCommand(edit.get().getEditCommit(), newCommitId, edit.get().getRefName());
         } else {
           // delete old edit ref on rebase
           prev =
               new ReceiveCommand(
-                  edit.get().getRef().getObjectId(), ObjectId.zeroId(), edit.get().getRefName());
+                  edit.get().getEditCommit(), ObjectId.zeroId(), edit.get().getRefName());
           createEditCommand();
         }
       } else {
@@ -2495,7 +2565,8 @@
 
     private void newPatchSet() throws IOException {
       RevCommit newCommit = rp.getRevWalk().parseCommit(newCommitId);
-      psId = ChangeUtil.nextPatchSetId(allRefs, notes.getChange().currentPatchSetId());
+      psId =
+          ChangeUtil.nextPatchSetIdFromAllRefsMap(allRefs, notes.getChange().currentPatchSetId());
       info = patchSetInfoFactory.get(rp.getRevWalk(), newCommit, psId);
       cmd = new ReceiveCommand(ObjectId.zeroId(), newCommitId, psId.toRefName());
     }
@@ -2550,8 +2621,10 @@
       try (BatchUpdate bu =
               batchUpdateFactory.create(
                   db, projectControl.getProject().getNameKey(), user, TimeUtil.nowTs());
-          ObjectInserter ins = repo.newObjectInserter()) {
-        bu.setRepository(repo, rp.getRevWalk(), ins);
+          ObjectInserter ins = repo.newObjectInserter();
+          ObjectReader reader = ins.newReader();
+          RevWalk rw = new RevWalk(reader)) {
+        bu.setRepository(repo, rw, ins);
         bu.setRequestId(receiveId);
         addOps(bu, replaceProgress);
         bu.execute();
@@ -2751,21 +2824,18 @@
 
     RevCommit c = rw.parseCommit(id);
     rw.parseBody(c);
-    CommitReceivedEvent receiveEvent =
-        new CommitReceivedEvent(cmd, project, ctl.getRefName(), c, user);
 
-    CommitValidators.Policy policy;
-    if (magicBranch != null
-        && cmd.getRefName().equals(magicBranch.cmd.getRefName())
-        && magicBranch.merged) {
-      policy = CommitValidators.Policy.MERGED;
-    } else {
-      policy = CommitValidators.Policy.RECEIVE_COMMITS;
-    }
-
-    try {
-      messages.addAll(
-          commitValidatorsFactory.create(policy, ctl, sshInfo, repo).validate(receiveEvent));
+    try (CommitReceivedEvent receiveEvent =
+        new CommitReceivedEvent(cmd, project, ctl.getRefName(), rw.getObjectReader(), c, user)) {
+      boolean isMerged =
+          magicBranch != null
+              && cmd.getRefName().equals(magicBranch.cmd.getRefName())
+              && magicBranch.merged;
+      CommitValidators validators =
+          isMerged
+              ? commitValidatorsFactory.forMergedCommits(ctl)
+              : commitValidatorsFactory.forReceiveCommits(ctl, sshInfo, repo, rw);
+      messages.addAll(validators.validate(receiveEvent));
     } catch (CommitValidationException e) {
       logDebug("Commit validation failed on {}", c.name());
       messages.addAll(e.getMessages());
@@ -2783,14 +2853,15 @@
         !MagicBranch.isMagicBranch(refName),
         "shouldn't be auto-closing changes on magic branch %s",
         refName);
-    RevWalk rw = rp.getRevWalk();
     // TODO(dborowitz): Combine this BatchUpdate with the main one in
     // insertChangesAndPatchSets.
     try (BatchUpdate bu =
             batchUpdateFactory.create(
                 db, projectControl.getProject().getNameKey(), user, TimeUtil.nowTs());
-        ObjectInserter ins = repo.newObjectInserter()) {
-      bu.setRepository(repo, rp.getRevWalk(), ins).updateChangesInParallel();
+        ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      bu.setRepository(repo, rw, ins).updateChangesInParallel();
       bu.setRequestId(receiveId);
       // TODO(dborowitz): Teach BatchUpdate to ignore missing changes.
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveConfig.java
index 063f395..723fb6f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveConfig.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.common.data.GlobalCapability.BATCH_CHANGES_LIMIT;
 
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -39,8 +40,9 @@
   }
 
   public int getEffectiveMaxBatchChangesLimit(CurrentUser user) {
-    if (user.getCapabilities().canPerform(BATCH_CHANGES_LIMIT)) {
-      return user.getCapabilities().getRange(BATCH_CHANGES_LIMIT).getMax();
+    CapabilityControl cap = user.getCapabilities();
+    if (cap.hasExplicitRange(BATCH_CHANGES_LIMIT)) {
+      return cap.getRange(BATCH_CHANGES_LIMIT).getMax();
     }
     return systemMaxBatchChanges;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
index 6ac5da1..5322b2e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromReviewers;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
@@ -55,24 +56,21 @@
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import com.google.inject.util.Providers;
 import java.io.IOException;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushCertificate;
-import org.eclipse.jgit.transport.ReceiveCommand;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -83,9 +81,9 @@
         Branch.NameKey dest,
         boolean checkMergedInto,
         @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
-        @Assisted("priorCommit") RevCommit priorCommit,
+        @Assisted("priorCommitId") ObjectId priorCommit,
         @Assisted("patchSetId") PatchSet.Id patchSetId,
-        @Assisted("commit") RevCommit commit,
+        @Assisted("commitId") ObjectId commitId,
         PatchSetInfo info,
         List<String> groups,
         @Nullable MagicBranchInput magicBranch,
@@ -115,9 +113,9 @@
   private final Branch.NameKey dest;
   private final boolean checkMergedInto;
   private final PatchSet.Id priorPatchSetId;
-  private final RevCommit priorCommit;
+  private final ObjectId priorCommitId;
   private final PatchSet.Id patchSetId;
-  private final RevCommit commit;
+  private final ObjectId commitId;
   private final PatchSetInfo info;
   private final MagicBranchInput magicBranch;
   private final PushCertificate pushCertificate;
@@ -125,6 +123,7 @@
 
   private final Map<String, Short> approvals = new HashMap<>();
   private final MailRecipients recipients = new MailRecipients();
+  private RevCommit commit;
   private Change change;
   private PatchSet newPatchSet;
   private ChangeKind changeKind;
@@ -134,7 +133,7 @@
   private RequestScopePropagator requestScopePropagator;
   private boolean updateRef;
 
-  @AssistedInject
+  @Inject
   ReplaceOp(
       AccountResolver accountResolver,
       ApprovalCopier approvalCopier,
@@ -154,9 +153,9 @@
       @Assisted Branch.NameKey dest,
       @Assisted boolean checkMergedInto,
       @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
-      @Assisted("priorCommit") RevCommit priorCommit,
+      @Assisted("priorCommitId") ObjectId priorCommitId,
       @Assisted("patchSetId") PatchSet.Id patchSetId,
-      @Assisted("commit") RevCommit commit,
+      @Assisted("commitId") ObjectId commitId,
       @Assisted PatchSetInfo info,
       @Assisted List<String> groups,
       @Assisted @Nullable MagicBranchInput magicBranch,
@@ -180,9 +179,9 @@
     this.dest = dest;
     this.checkMergedInto = checkMergedInto;
     this.priorPatchSetId = priorPatchSetId;
-    this.priorCommit = priorCommit;
+    this.priorCommitId = priorCommitId.copy();
     this.patchSetId = patchSetId;
-    this.commit = commit;
+    this.commitId = commitId.copy();
     this.info = info;
     this.groups = groups;
     this.magicBranch = magicBranch;
@@ -192,20 +191,26 @@
 
   @Override
   public void updateRepo(RepoContext ctx) throws Exception {
+    commit = ctx.getRevWalk().parseCommit(commitId);
+    ctx.getRevWalk().parseBody(commit);
     changeKind =
         changeKindCache.getChangeKind(
-            projectControl.getProject().getNameKey(), ctx.getRepository(), priorCommit, commit);
+            projectControl.getProject().getNameKey(),
+            ctx.getRevWalk(),
+            ctx.getRepoView().getConfig(),
+            priorCommitId,
+            commitId);
 
     if (checkMergedInto) {
-      Ref mergedInto = findMergedInto(ctx, dest.get(), commit);
+      String mergedInto = findMergedInto(ctx, dest.get(), commit);
       if (mergedInto != null) {
         mergedByPushOp =
-            mergedByPushOpFactory.create(requestScopePropagator, patchSetId, mergedInto.getName());
+            mergedByPushOpFactory.create(requestScopePropagator, patchSetId, mergedInto);
       }
     }
 
     if (updateRef) {
-      ctx.addRefUpdate(new ReceiveCommand(ObjectId.zeroId(), commit, patchSetId.toRefName()));
+      ctx.addRefUpdate(ObjectId.zeroId(), commitId, patchSetId.toRefName());
     }
   }
 
@@ -240,6 +245,20 @@
       if (magicBranch.topic != null && !magicBranch.topic.equals(ctx.getChange().getTopic())) {
         update.setTopic(magicBranch.topic);
       }
+      if (magicBranch.removePrivate) {
+        change.setPrivate(false);
+        update.setPrivate(false);
+      } else if (magicBranch.isPrivate) {
+        change.setPrivate(true);
+        update.setPrivate(true);
+      }
+      if (magicBranch.ready) {
+        change.setWorkInProgress(false);
+        update.setWorkInProgress(false);
+      } else if (magicBranch.workInProgress) {
+        change.setWorkInProgress(true);
+        update.setWorkInProgress(true);
+      }
     }
 
     boolean draft = magicBranch != null && magicBranch.draft;
@@ -252,7 +271,7 @@
             ctx.getRevWalk(),
             update,
             patchSetId,
-            commit,
+            commitId,
             draft,
             groups,
             pushCertificate != null ? pushCertificate.toTextWithSignature() : null,
@@ -305,13 +324,14 @@
     if (!Strings.isNullOrEmpty(reviewMessage)) {
       message.append("\n").append(reviewMessage);
     }
+    boolean workInProgress = magicBranch != null && magicBranch.workInProgress;
     msg =
         ChangeMessagesUtil.newMessage(
             patchSetId,
             ctx.getUser(),
             ctx.getWhen(),
             message.toString(),
-            ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
+            ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
     cmUtil.addChangeMessage(ctx.getDb(), update, msg);
 
     if (mergedByPushOp == null) {
@@ -376,7 +396,7 @@
 
     List<String> idList = commit.getFooterLines(CHANGE_ID);
     if (idList.isEmpty()) {
-      change.setKey(new Change.Key("I" + commit.name()));
+      change.setKey(new Change.Key("I" + commitId.name()));
     } else {
       change.setKey(new Change.Key(idList.get(idList.size() - 1).trim()));
     }
@@ -391,7 +411,7 @@
     final Account account = ctx.getAccount();
     if (!updateRef) {
       gitRefUpdated.fire(
-          ctx.getProject(), newPatchSet.getRefName(), ObjectId.zeroId(), commit, account);
+          ctx.getProject(), newPatchSet.getRefName(), ObjectId.zeroId(), commitId, account);
     }
 
     if (changeKind != ChangeKind.TRIVIAL_REBASE) {
@@ -498,18 +518,17 @@
     return this;
   }
 
-  private Ref findMergedInto(Context ctx, String first, RevCommit commit) {
+  private static String findMergedInto(Context ctx, String first, RevCommit commit) {
     try {
-      RefDatabase refDatabase = ctx.getRepository().getRefDatabase();
-
-      Ref firstRef = refDatabase.exactRef(first);
-      if (firstRef != null && isMergedInto(ctx.getRevWalk(), commit, firstRef)) {
-        return firstRef;
+      RevWalk rw = ctx.getRevWalk();
+      Optional<ObjectId> firstId = ctx.getRepoView().getRef(first);
+      if (firstId.isPresent() && rw.isMergedInto(commit, rw.parseCommit(firstId.get()))) {
+        return first;
       }
 
-      for (Ref ref : refDatabase.getRefs(Constants.R_HEADS).values()) {
-        if (isMergedInto(ctx.getRevWalk(), commit, ref)) {
-          return ref;
+      for (Map.Entry<String, ObjectId> e : ctx.getRepoView().getRefs(R_HEADS).entrySet()) {
+        if (rw.isMergedInto(commit, rw.parseCommit(e.getValue()))) {
+          return R_HEADS + e.getKey();
         }
       }
       return null;
@@ -518,8 +537,4 @@
       return null;
     }
   }
-
-  private static boolean isMergedInto(RevWalk rw, RevCommit commit, Ref ref) throws IOException {
-    return rw.isMergedInto(commit, rw.parseCommit(ref.getObjectId()));
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java
index e7a86f1..6b2493a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git;
 
 import java.io.IOException;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
@@ -44,4 +45,9 @@
     ids.put(refName, id);
     return id;
   }
+
+  /** @return an unmodifiable view of the refs that have been cached by this instance. */
+  public Map<String, Optional<ObjectId>> getCachedRefs() {
+    return Collections.unmodifiableMap(ids);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
index 56c0c44..93aea89 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
@@ -35,8 +35,8 @@
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.update.RepoOnlyOp;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.ArrayDeque;
 import java.util.ArrayList;
@@ -63,7 +63,6 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
 import org.eclipse.jgit.transport.RefSpec;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -82,7 +81,7 @@
     public void updateRepo(RepoContext ctx) throws Exception {
       CodeReviewCommit c = composeGitlinksCommit(branch);
       if (c != null) {
-        ctx.addRefUpdate(new ReceiveCommand(c.getParent(0), c, branch.get()));
+        ctx.addRefUpdate(c.getParent(0), c, branch.get());
         addBranchTip(branch, c);
       }
     }
@@ -117,7 +116,7 @@
   // map of superproject and its branches which has submodule subscriptions
   private final SetMultimap<Project.NameKey, Branch.NameKey> branchesByProject;
 
-  @AssistedInject
+  @Inject
   public SubmoduleOp(
       GitModules.Factory gitmodulesFactory,
       @GerritPersonIdent PersonIdent myIdent,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
index 47d416c..b58fb55 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -35,10 +36,8 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefDatabase;
@@ -62,7 +61,7 @@
   private final ReviewDb reviewDb;
   private final boolean showMetadata;
   private String userEditPrefix;
-  private Set<Change.Id> visibleChanges;
+  private Map<Change.Id, Branch.NameKey> visibleChanges;
 
   public VisibleRefFilter(
       TagCache tagCache,
@@ -221,26 +220,29 @@
         visibleChanges = visibleChangesBySearch();
       }
     }
-    return visibleChanges.contains(changeId);
+    return visibleChanges.containsKey(changeId);
   }
 
   private boolean visibleEdit(String name) {
-    if (userEditPrefix != null && name.startsWith(userEditPrefix)) {
-      Change.Id id = Change.Id.fromEditRefPart(name);
-      if (id != null) {
-        return visible(id);
-      }
+    Change.Id id = Change.Id.fromEditRefPart(name);
+    // Initialize if it wasn't yet
+    if (visibleChanges == null) {
+      visible(id);
+    }
+    if (id != null) {
+      return (userEditPrefix != null && name.startsWith(userEditPrefix) && visible(id))
+          || projectCtl.controlForRef(visibleChanges.get(id)).isEditVisible();
     }
     return false;
   }
 
-  private Set<Change.Id> visibleChangesBySearch() {
+  private Map<Change.Id, Branch.NameKey> visibleChangesBySearch() {
     Project project = projectCtl.getProject();
     try {
-      Set<Change.Id> visibleChanges = new HashSet<>();
+      Map<Change.Id, Branch.NameKey> visibleChanges = new HashMap<>();
       for (ChangeData cd : changeCache.getChangeData(reviewDb, project.getNameKey())) {
         if (projectCtl.controlForIndexedChange(cd.change()).isVisible(reviewDb, cd)) {
-          visibleChanges.add(cd.getId());
+          visibleChanges.put(cd.getId(), cd.change().getDest());
         }
       }
       return visibleChanges;
@@ -250,24 +252,24 @@
               + project.getName()
               + ", assuming no changes are visible",
           e);
-      return Collections.emptySet();
+      return Collections.emptyMap();
     }
   }
 
-  private Set<Change.Id> visibleChangesByScan() {
+  private Map<Change.Id, Branch.NameKey> visibleChangesByScan() {
     Project.NameKey project = projectCtl.getProject().getNameKey();
     try {
-      Set<Change.Id> visibleChanges = new HashSet<>();
+      Map<Change.Id, Branch.NameKey> visibleChanges = new HashMap<>();
       for (ChangeNotes cn : changeNotesFactory.scan(db, reviewDb, project)) {
         if (projectCtl.controlFor(cn).isVisible(reviewDb)) {
-          visibleChanges.add(cn.getChangeId());
+          visibleChanges.put(cn.getChangeId(), cn.getChange().getDest());
         }
       }
       return visibleChanges;
     } catch (IOException | OrmException e) {
       log.error(
           "Cannot load changes for project " + project + ", assuming no changes are visible", e);
-      return Collections.emptySet();
+      return Collections.emptyMap();
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
index 96b5b55..49399ef 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
@@ -37,7 +37,6 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.ReceiveCommand;
 
 public class CherryPick extends SubmitStrategy {
 
@@ -95,7 +94,10 @@
       // delta relative to that one parent and redoing that on the current merge
       // tip.
       args.rw.parseBody(toMerge);
-      psId = ChangeUtil.nextPatchSetId(args.repo, toMerge.change().currentPatchSetId());
+      psId =
+          ChangeUtil.nextPatchSetIdFromChangeRefsMap(
+              ctx.getRepoView().getRefs(getId().toRefPrefix()),
+              toMerge.change().currentPatchSetId());
       RevCommit mergeTip = args.mergeTip.getCurrentTip();
       args.rw.parseBody(mergeTip);
       String cherryPickCmtMsg = args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
@@ -105,8 +107,8 @@
       try {
         newCommit =
             args.mergeUtil.createCherryPickFromCommit(
-                args.repo,
-                args.inserter,
+                ctx.getInserter(),
+                ctx.getRepoView().getConfig(),
                 args.mergeTip.getCurrentTip(),
                 toMerge,
                 committer,
@@ -132,7 +134,7 @@
       args.mergeTip.moveTipTo(newCommit, newCommit);
       args.commitStatus.put(newCommit);
 
-      ctx.addRefUpdate(new ReceiveCommand(ObjectId.zeroId(), newCommit, psId.toRefName()));
+      ctx.addRefUpdate(ObjectId.zeroId(), newCommit, psId.toRefName());
       patchSetInfo = args.patchSetInfoFactory.get(ctx.getRevWalk(), newCommit, psId);
     }
 
@@ -195,9 +197,9 @@
             args.mergeUtil.mergeOneCommit(
                 myIdent,
                 myIdent,
-                args.repo,
                 args.rw,
-                args.inserter,
+                ctx.getInserter(),
+                ctx.getRepoView().getConfig(),
                 args.destBranch,
                 mergeTip.getCurrentTip(),
                 toMerge);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java
index 2a6680c..9fb75a8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java
@@ -36,16 +36,13 @@
               + " onto a null tip; expected at least one fast-forward prior to"
               + " this operation");
     }
-    // TODO(dborowitz): args.rw is needed because it's a CodeReviewRevWalk.
-    // When hoisting BatchUpdate into MergeOp, we will need to teach
-    // BatchUpdate how to produce CodeReviewRevWalks.
     CodeReviewCommit merged =
         args.mergeUtil.mergeOneCommit(
             caller,
             args.serverIdent,
-            ctx.getRepository(),
             args.rw,
             ctx.getInserter(),
+            ctx.getRepoView().getConfig(),
             args.destBranch,
             args.mergeTip.getCurrentTip(),
             toMerge);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java
index 43ab01b..a156a90 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.server.git.MergeIdenticalTreeException;
 import com.google.gerrit.server.git.MergeTip;
 import com.google.gerrit.server.git.RebaseSorter;
-import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.update.ChangeContext;
@@ -42,8 +41,8 @@
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.ReceiveCommand;
 
 /** This strategy covers RebaseAlways and RebaseIfNecessary ones. */
 public class RebaseSubmitStrategy extends SubmitStrategy {
@@ -116,9 +115,6 @@
     public void updateRepoImpl(RepoContext ctx)
         throws IntegrationException, InvalidChangeOperationException, RestApiException, IOException,
             OrmException {
-      // TODO(dborowitz): args.rw is needed because it's a CodeReviewRevWalk.
-      // When hoisting BatchUpdate into MergeOp, we will need to teach
-      // BatchUpdate how to produce CodeReviewRevWalks.
       if (args.mergeUtil.canFastForward(
           args.mergeSorter, args.mergeTip.getCurrentTip(), args.rw, toMerge)) {
         if (!rebaseAlways) {
@@ -129,7 +125,10 @@
         }
         // RebaseAlways means we modify commit message.
         args.rw.parseBody(toMerge);
-        newPatchSetId = ChangeUtil.nextPatchSetId(args.repo, toMerge.change().currentPatchSetId());
+        newPatchSetId =
+            ChangeUtil.nextPatchSetIdFromChangeRefsMap(
+                ctx.getRepoView().getRefs(getId().toRefPrefix()),
+                toMerge.change().currentPatchSetId());
         RevCommit mergeTip = args.mergeTip.getCurrentTip();
         args.rw.parseBody(mergeTip);
         String cherryPickCmtMsg = args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
@@ -138,8 +137,8 @@
         try {
           newCommit =
               args.mergeUtil.createCherryPickFromCommit(
-                  args.repo,
-                  args.inserter,
+                  ctx.getInserter(),
+                  ctx.getRepoView().getConfig(),
                   args.mergeTip.getCurrentTip(),
                   toMerge,
                   committer,
@@ -156,20 +155,19 @@
           toMerge.setStatusCode(SKIPPED_IDENTICAL_TREE);
           return;
         }
-        ctx.addRefUpdate(
-            new ReceiveCommand(ObjectId.zeroId(), newCommit, newPatchSetId.toRefName()));
+        ctx.addRefUpdate(ObjectId.zeroId(), newCommit, newPatchSetId.toRefName());
       } else {
         // Stale read of patch set is ok; see comments in RebaseChangeOp.
         PatchSet origPs =
             args.psUtil.get(ctx.getDb(), toMerge.getControl().getNotes(), toMerge.getPatchsetId());
         rebaseOp =
             args.rebaseFactory
-                .create(toMerge.getControl(), origPs, args.mergeTip.getCurrentTip().name())
+                .create(toMerge.getControl(), origPs, args.mergeTip.getCurrentTip())
                 .setFireRevisionCreated(false)
                 // Bypass approval copier since SubmitStrategyOp copy all approvals
                 // later anyway.
                 .setCopyApprovals(false)
-                .setValidatePolicy(CommitValidators.Policy.NONE)
+                .setValidate(false)
                 .setCheckAddPatchSetPermission(false)
                 // RebaseAlways should set always modify commit message like
                 // Cherry-Pick strategy.
@@ -269,9 +267,9 @@
             args.mergeUtil.mergeOneCommit(
                 caller,
                 caller,
-                args.repo,
                 args.rw,
-                args.inserter,
+                ctx.getInserter(),
+                ctx.getRepoView().getConfig(),
                 args.destBranch,
                 mergeTip.getCurrentTip(),
                 toMerge);
@@ -303,11 +301,14 @@
   }
 
   static boolean dryRun(
-      SubmitDryRun.Arguments args, CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
+      SubmitDryRun.Arguments args,
+      Repository repo,
+      CodeReviewCommit mergeTip,
+      CodeReviewCommit toMerge)
       throws IntegrationException {
     // Test for merge instead of cherry pick to avoid false negatives
     // on commit chains.
     return !args.mergeUtil.hasMissingDependencies(args.mergeSorter, toMerge)
-        && args.mergeUtil.canMerge(args.mergeSorter, args.repo, mergeTip, toMerge);
+        && args.mergeUtil.canMerge(args.mergeSorter, repo, mergeTip, toMerge);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java
index d375b6e..da35f78 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java
@@ -120,9 +120,9 @@
       case MERGE_IF_NECESSARY:
         return MergeIfNecessary.dryRun(args, tipCommit, toMergeCommit);
       case REBASE_IF_NECESSARY:
-        return RebaseIfNecessary.dryRun(args, tipCommit, toMergeCommit);
+        return RebaseIfNecessary.dryRun(args, repo, tipCommit, toMergeCommit);
       case REBASE_ALWAYS:
-        return RebaseAlways.dryRun(args, tipCommit, toMergeCommit);
+        return RebaseAlways.dryRun(args, repo, tipCommit, toMergeCommit);
       default:
         String errorMsg = "No submit strategy for: " + submitType;
         log.error(errorMsg);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
index f721978..f9b9add 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
@@ -53,17 +53,15 @@
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.RequestId;
+import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Set;
-import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevFlag;
 
@@ -92,8 +90,6 @@
           CodeReviewRevWalk rw,
           IdentifiedUser caller,
           MergeTip mergeTip,
-          ObjectInserter inserter,
-          Repository repo,
           RevFlag canMergeFlag,
           ReviewDb db,
           Set<RevCommit> alreadyAccepted,
@@ -128,8 +124,6 @@
     final CommitStatus commitStatus;
     final IdentifiedUser caller;
     final MergeTip mergeTip;
-    final ObjectInserter inserter;
-    final Repository repo;
     final RevFlag canMergeFlag;
     final ReviewDb db;
     final Set<RevCommit> alreadyAccepted;
@@ -145,7 +139,7 @@
     final MergeUtil mergeUtil;
     final boolean dryrun;
 
-    @AssistedInject
+    @Inject
     Arguments(
         AccountCache accountCache,
         ApprovalsUtil approvalsUtil,
@@ -170,8 +164,6 @@
         @Assisted CodeReviewRevWalk rw,
         @Assisted IdentifiedUser caller,
         @Assisted MergeTip mergeTip,
-        @Assisted ObjectInserter inserter,
-        @Assisted Repository repo,
         @Assisted RevFlag canMergeFlag,
         @Assisted ReviewDb db,
         @Assisted Set<RevCommit> alreadyAccepted,
@@ -204,8 +196,6 @@
       this.rw = rw;
       this.caller = caller;
       this.mergeTip = mergeTip;
-      this.inserter = inserter;
-      this.repo = repo;
       this.canMergeFlag = canMergeFlag;
       this.db = db;
       this.alreadyAccepted = alreadyAccepted;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java
index fc4817d..8f43a49 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java
@@ -32,8 +32,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Set;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevFlag;
 import org.slf4j.Logger;
@@ -54,9 +52,7 @@
   public SubmitStrategy create(
       SubmitType submitType,
       ReviewDb db,
-      Repository repo,
       CodeReviewRevWalk rw,
-      ObjectInserter inserter,
       RevFlag canMergeFlag,
       Set<RevCommit> alreadyAccepted,
       Set<CodeReviewCommit> incoming,
@@ -78,8 +74,6 @@
             rw,
             caller,
             mergeTip,
-            inserter,
-            repo,
             canMergeFlag,
             db,
             alreadyAccepted,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
index 89bd560..6bf8b2e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
@@ -53,7 +53,6 @@
 import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
@@ -62,7 +61,6 @@
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.slf4j.Logger;
@@ -105,6 +103,12 @@
   @Override
   public final void updateRepo(RepoContext ctx) throws Exception {
     logDebug("{}#updateRepo for change {}", getClass().getSimpleName(), toMerge.change().getId());
+    checkState(
+        ctx.getRevWalk() == args.rw,
+        "SubmitStrategyOp requires callers to call BatchUpdate#setRepository with exactly the same"
+            + " CodeReviewRevWalk instance from the SubmitStrategy.Arguments: %s != %s",
+        ctx.getRevWalk(),
+        args.rw);
     // Run the submit strategy implementation and record the merge tip state so
     // we can create the ref update.
     CodeReviewCommit tipBefore = args.mergeTip.getCurrentTip();
@@ -162,19 +166,20 @@
     }
     CodeReviewRevWalk rw = (CodeReviewRevWalk) ctx.getRevWalk();
     Change.Id id = getId();
+    String refPrefix = id.toRefPrefix();
 
-    Collection<Ref> refs = ctx.getRepository().getRefDatabase().getRefs(id.toRefPrefix()).values();
+    Map<String, ObjectId> refs = ctx.getRepoView().getRefs(refPrefix);
     List<CodeReviewCommit> commits = new ArrayList<>(refs.size());
-    for (Ref ref : refs) {
-      PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+    for (Map.Entry<String, ObjectId> e : refs.entrySet()) {
+      PatchSet.Id psId = PatchSet.Id.fromRef(refPrefix + e.getKey());
       if (psId == null) {
         continue;
       }
       try {
-        CodeReviewCommit c = rw.parseCommit(ref.getObjectId());
+        CodeReviewCommit c = rw.parseCommit(e.getValue());
         c.setPatchsetId(psId);
         commits.add(c);
-      } catch (MissingObjectException | IncorrectObjectTypeException e) {
+      } catch (MissingObjectException | IncorrectObjectTypeException ex) {
         continue; // Bogus ref, can't be merged into tip so we don't care.
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java
index 24ff379..07f3b21 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java
@@ -14,30 +14,30 @@
 
 package com.google.gerrit.server.git.validators;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.validators.ValidationException;
-import java.util.Collections;
 import java.util.List;
 
 public class CommitValidationException extends ValidationException {
   private static final long serialVersionUID = 1L;
-  private final List<CommitValidationMessage> messages;
+  private final ImmutableList<CommitValidationMessage> messages;
 
   public CommitValidationException(String reason, List<CommitValidationMessage> messages) {
     super(reason);
-    this.messages = messages;
+    this.messages = ImmutableList.copyOf(messages);
   }
 
   public CommitValidationException(String reason) {
     super(reason);
-    this.messages = Collections.emptyList();
+    this.messages = ImmutableList.of();
   }
 
   public CommitValidationException(String reason, Throwable why) {
     super(reason, why);
-    this.messages = Collections.emptyList();
+    this.messages = ImmutableList.of();
   }
 
-  public List<CommitValidationMessage> getMessages() {
+  public ImmutableList<CommitValidationMessage> getMessages() {
     return messages;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 630dd32..d8a19bf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -66,20 +66,6 @@
 public class CommitValidators {
   private static final Logger log = LoggerFactory.getLogger(CommitValidators.class);
 
-  public enum Policy {
-    /** Use {@link Factory#forGerritCommits}. */
-    GERRIT,
-
-    /** Use {@link Factory#forReceiveCommits}. */
-    RECEIVE_COMMITS,
-
-    /** Use {@link Factory#forMergedCommits}. */
-    MERGED,
-
-    /** Do not validate commits. */
-    NONE
-  }
-
   @Singleton
   public static class Factory {
     private final PersonIdent gerritIdent;
@@ -103,44 +89,25 @@
           cfg != null ? cfg.getString("gerrit", null, "installCommitMsgHookCommand") : null;
     }
 
-    public CommitValidators create(
-        Policy policy, RefControl refControl, SshInfo sshInfo, Repository repo) throws IOException {
-      switch (policy) {
-        case RECEIVE_COMMITS:
-          return forReceiveCommits(refControl, sshInfo, repo);
-        case GERRIT:
-          return forGerritCommits(refControl, sshInfo, repo);
-        case MERGED:
-          return forMergedCommits(refControl);
-        case NONE:
-          return none();
-        default:
-          throw new IllegalArgumentException("unspported policy: " + policy);
-      }
+    public CommitValidators forReceiveCommits(
+        RefControl refControl, SshInfo sshInfo, Repository repo, RevWalk rw) throws IOException {
+      NoteMap rejectCommits = BanCommit.loadRejectCommitsMap(repo, rw);
+      return new CommitValidators(
+          ImmutableList.of(
+              new UploadMergesPermissionValidator(refControl),
+              new AmendedGerritMergeCommitValidationListener(refControl, gerritIdent),
+              new AuthorUploaderValidator(refControl, canonicalWebUrl),
+              new CommitterUploaderValidator(refControl, canonicalWebUrl),
+              new SignedOffByValidator(refControl),
+              new ChangeIdValidator(
+                  refControl, canonicalWebUrl, installCommitMsgHookCommand, sshInfo),
+              new ConfigValidator(refControl, rw, allUsers),
+              new BannedCommitsValidator(rejectCommits),
+              new PluginCommitValidationListener(pluginValidators),
+              new BlockExternalIdUpdateListener(allUsers)));
     }
 
-    private CommitValidators forReceiveCommits(
-        RefControl refControl, SshInfo sshInfo, Repository repo) throws IOException {
-      try (RevWalk rw = new RevWalk(repo)) {
-        NoteMap rejectCommits = BanCommit.loadRejectCommitsMap(repo, rw);
-        return new CommitValidators(
-            ImmutableList.of(
-                new UploadMergesPermissionValidator(refControl),
-                new AmendedGerritMergeCommitValidationListener(refControl, gerritIdent),
-                new AuthorUploaderValidator(refControl, canonicalWebUrl),
-                new CommitterUploaderValidator(refControl, canonicalWebUrl),
-                new SignedOffByValidator(refControl),
-                new ChangeIdValidator(
-                    refControl, canonicalWebUrl, installCommitMsgHookCommand, sshInfo),
-                new ConfigValidator(refControl, repo, allUsers),
-                new BannedCommitsValidator(rejectCommits),
-                new PluginCommitValidationListener(pluginValidators),
-                new BlockExternalIdUpdateListener(allUsers)));
-      }
-    }
-
-    private CommitValidators forGerritCommits(
-        RefControl refControl, SshInfo sshInfo, Repository repo) {
+    public CommitValidators forGerritCommits(RefControl refControl, SshInfo sshInfo, RevWalk rw) {
       return new CommitValidators(
           ImmutableList.of(
               new UploadMergesPermissionValidator(refControl),
@@ -149,12 +116,12 @@
               new SignedOffByValidator(refControl),
               new ChangeIdValidator(
                   refControl, canonicalWebUrl, installCommitMsgHookCommand, sshInfo),
-              new ConfigValidator(refControl, repo, allUsers),
+              new ConfigValidator(refControl, rw, allUsers),
               new PluginCommitValidationListener(pluginValidators),
               new BlockExternalIdUpdateListener(allUsers)));
     }
 
-    private CommitValidators forMergedCommits(RefControl refControl) {
+    public CommitValidators forMergedCommits(RefControl refControl) {
       // Generally only include validators that are based on permissions of the
       // user creating a change for a merged commit; generally exclude
       // validators that would require amending the change in order to correct.
@@ -174,10 +141,6 @@
               new AuthorUploaderValidator(refControl, canonicalWebUrl),
               new CommitterUploaderValidator(refControl, canonicalWebUrl)));
     }
-
-    private CommitValidators none() {
-      return new CommitValidators(ImmutableList.<CommitValidationListener>of());
-    }
   }
 
   private final List<CommitValidationListener> validators;
@@ -354,12 +317,12 @@
   /** If this is the special project configuration branch, validate the config. */
   public static class ConfigValidator implements CommitValidationListener {
     private final RefControl refControl;
-    private final Repository repo;
+    private final RevWalk rw;
     private final AllUsersName allUsers;
 
-    public ConfigValidator(RefControl refControl, Repository repo, AllUsersName allUsers) {
+    public ConfigValidator(RefControl refControl, RevWalk rw, AllUsersName allUsers) {
       this.refControl = refControl;
-      this.repo = repo;
+      this.rw = rw;
       this.allUsers = allUsers;
     }
 
@@ -373,7 +336,7 @@
 
         try {
           ProjectConfig cfg = new ProjectConfig(receiveEvent.project.getNameKey());
-          cfg.load(repo, receiveEvent.command.getNewId());
+          cfg.load(rw, receiveEvent.command.getNewId());
           if (!cfg.getValidationErrors().isEmpty()) {
             addError("Invalid project configuration:", messages);
             for (ValidationError err : cfg.getValidationErrors()) {
@@ -401,7 +364,7 @@
         if (accountId != null) {
           try {
             WatchConfig wc = new WatchConfig(accountId);
-            wc.load(repo, receiveEvent.command.getNewId());
+            wc.load(rw, receiveEvent.command.getNewId());
             if (!wc.getValidationErrors().isEmpty()) {
               addError("Invalid project configuration:", messages);
               for (ValidationError err : wc.getValidationErrors()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java
index 150965c..298c650 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicMap.Entry;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
@@ -29,6 +30,9 @@
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
@@ -36,8 +40,12 @@
 import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class MergeValidators {
+  private static final Logger log = LoggerFactory.getLogger(MergeValidators.class);
+
   private final DynamicSet<MergeValidationListener> mergeValidationListeners;
   private final ProjectConfigValidator.Factory projectConfigValidatorFactory;
 
@@ -93,6 +101,7 @@
 
     private final AllProjectsName allProjectsName;
     private final ProjectCache projectCache;
+    private final PermissionBackend permissionBackend;
     private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
 
     public interface Factory {
@@ -103,9 +112,11 @@
     public ProjectConfigValidator(
         AllProjectsName allProjectsName,
         ProjectCache projectCache,
+        PermissionBackend permissionBackend,
         DynamicMap<ProjectConfigEntry> pluginConfigEntries) {
       this.allProjectsName = allProjectsName;
       this.projectCache = projectCache;
+      this.permissionBackend = permissionBackend;
       this.pluginConfigEntries = pluginConfigEntries;
     }
 
@@ -132,8 +143,13 @@
             }
           } else {
             if (!oldParent.equals(newParent)) {
-              if (!caller.getCapabilities().canAdministrateServer()) {
+              try {
+                permissionBackend.user(caller).check(GlobalPermission.ADMINISTRATE_SERVER);
+              } catch (AuthException e) {
                 throw new MergeValidationException(SET_BY_ADMIN);
+              } catch (PermissionBackendException e) {
+                log.warn("Cannot check ADMINISTRATE_SERVER", e);
+                throw new MergeValidationException("validation unavailable");
               }
 
               if (projectCache.get(newParent) == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java
index da3c123..a626998 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java
@@ -11,15 +11,20 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
+
 package com.google.gerrit.server.git.validators;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.Project.NameKey;
+import com.google.gerrit.server.git.RefCache;
+import com.google.gerrit.server.update.ChainedReceiveCommands;
 import com.google.gerrit.server.validators.ValidationException;
-import java.util.Map;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Repository;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
@@ -37,41 +42,58 @@
 public interface OnSubmitValidationListener {
   class Arguments {
     private Project.NameKey project;
-    private Repository repository;
-    private ObjectReader objectReader;
-    private Map<String, ReceiveCommand> commands;
+    private RevWalk rw;
+    private ImmutableMap<String, ReceiveCommand> commands;
+    private RefCache refs;
 
-    public Arguments(
-        NameKey project,
-        Repository repository,
-        ObjectReader objectReader,
-        Map<String, ReceiveCommand> commands) {
-      this.project = project;
-      this.repository = repository;
-      this.objectReader = objectReader;
-      this.commands = commands;
+    /**
+     * @param project project.
+     * @param rw revwalk that can read unflushed objects from {@code refs}.
+     * @param commands commands to be executed.
+     */
+    Arguments(Project.NameKey project, RevWalk rw, ChainedReceiveCommands commands) {
+      this.project = checkNotNull(project);
+      this.rw = checkNotNull(rw);
+      this.refs = checkNotNull(commands);
+      this.commands = ImmutableMap.copyOf(commands.getCommands());
     }
 
+    /** Get the project name for this operation. */
     public Project.NameKey getProject() {
       return project;
     }
 
-    /** @return a read only repository */
-    public Repository getRepository() {
-      return repository;
-    }
-
-    public RevWalk newRevWalk() {
-      return new RevWalk(objectReader);
+    /**
+     * Get a revwalk for this operation.
+     *
+     * <p>This instance is able to read all objects mentioned in {@link #getCommands()} and {@link
+     * #getRef(String)}.
+     *
+     * @return open revwalk.
+     */
+    public RevWalk getRevWalk() {
+      return rw;
     }
 
     /**
-     * @return a map from ref to op on it covering all ref ops to be performed on this repository as
-     *     part of ongoing submit operation.
+     * @return a map from ref to commands covering all ref operations to be performed on this
+     *     repository as part of the ongoing submit operation.
      */
-    public Map<String, ReceiveCommand> getCommands() {
+    public ImmutableMap<String, ReceiveCommand> getCommands() {
       return commands;
     }
+
+    /**
+     * Get a ref from the repository.
+     *
+     * @param name ref name; can be any ref, not just the ones mentioned in {@link #getCommands()}.
+     * @return latest value of a ref in the repository, as if all commands from {@link
+     *     #getCommands()} had already been applied.
+     * @throws IOException if an error occurred reading the ref.
+     */
+    public Optional<ObjectId> getRef(String name) throws IOException {
+      return refs.get(name);
+    }
   }
 
   /**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java
index 55935d1..460889c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java
@@ -11,18 +11,18 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
+
 package com.google.gerrit.server.git.validators;
 
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.IntegrationException;
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener.Arguments;
+import com.google.gerrit.server.update.ChainedReceiveCommands;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
-import java.util.Map;
 import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 public class OnSubmitValidators {
   public interface Factory {
@@ -37,14 +37,12 @@
   }
 
   public void validate(
-      Project.NameKey project,
-      Repository repo,
-      ObjectReader objectReader,
-      Map<String, ReceiveCommand> commands)
+      Project.NameKey project, ObjectReader objectReader, ChainedReceiveCommands commands)
       throws IntegrationException {
-    try {
-      for (OnSubmitValidationListener listener : this.listeners) {
-        listener.preBranchUpdate(new Arguments(project, repo, objectReader, commands));
+    try (RevWalk rw = new RevWalk(objectReader)) {
+      Arguments args = new Arguments(project, rw, commands);
+      for (OnSubmitValidationListener listener : listeners) {
+        listener.preBranchUpdate(args);
       }
     } catch (ValidationException e) {
       throw new IntegrationException(e.getMessage());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
index 80792c3..0c4eb89 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
@@ -14,10 +14,13 @@
 package com.google.gerrit.server.git.validators;
 
 import com.google.common.base.Predicate;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.events.RefReceivedEvent;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
@@ -42,15 +45,18 @@
         update.getExpectedOldObjectId(), update.getNewObjectId(), update.getName(), type);
   }
 
-  private final RefReceivedEvent event;
+  private final AllUsersName allUsersName;
   private final DynamicSet<RefOperationValidationListener> refOperationValidationListeners;
+  private final RefReceivedEvent event;
 
   @Inject
   RefOperationValidators(
+      AllUsersName allUsersName,
       DynamicSet<RefOperationValidationListener> refOperationValidationListeners,
       @Assisted Project project,
       @Assisted IdentifiedUser user,
       @Assisted ReceiveCommand cmd) {
+    this.allUsersName = allUsersName;
     this.refOperationValidationListeners = refOperationValidationListeners;
     event = new RefReceivedEvent();
     event.command = cmd;
@@ -59,11 +65,13 @@
   }
 
   public List<ValidationMessage> validateForRefOperation() throws RefOperationValidationException {
-
     List<ValidationMessage> messages = new ArrayList<>();
     boolean withException = false;
+    List<RefOperationValidationListener> listeners = new ArrayList<>();
+    listeners.add(new DisallowDeletionOfUserBranches(allUsersName));
+    refOperationValidationListeners.forEach(l -> listeners.add(l));
     try {
-      for (RefOperationValidationListener listener : refOperationValidationListeners) {
+      for (RefOperationValidationListener listener : listeners) {
         messages.addAll(listener.onRefOperation(event));
       }
     } catch (ValidationException e) {
@@ -95,4 +103,26 @@
       return input.isError();
     }
   }
+
+  private static class DisallowDeletionOfUserBranches implements RefOperationValidationListener {
+    private final AllUsersName allUsersName;
+
+    DisallowDeletionOfUserBranches(AllUsersName allUsersName) {
+      this.allUsersName = allUsersName;
+    }
+
+    @Override
+    public List<ValidationMessage> onRefOperation(RefReceivedEvent refEvent)
+        throws ValidationException {
+      if (refEvent.project.getNameKey().equals(allUsersName)
+          && (refEvent.command.getRefName().startsWith(RefNames.REFS_USERS)
+              && !refEvent.command.getRefName().equals(RefNames.REFS_USERS_DEFAULT))
+          && refEvent.command.getType().equals(ReceiveCommand.Type.DELETE)) {
+        if (!refEvent.user.getCapabilities().canAccessDatabase()) {
+          throw new ValidationException("Not allowed to delete user branch.");
+        }
+      }
+      return ImmutableList.of();
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidationListener.java
index 971f455..82ed658 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidationListener.java
@@ -39,6 +39,7 @@
    * @param repository The repository
    * @param project The project
    * @param remoteHost Remote address/hostname of the user
+   * @param up the UploadPack instance being processed
    * @param wants The list of wanted objects. These may be RevObject or RevCommit if the processor
    *     parsed them. Implementors should not rely on the values being parsed.
    * @param haves The list of common objects. Empty on an initial clone request. These may be
@@ -47,12 +48,34 @@
    * @throws ValidationException to block the upload and send a message back to the end-user over
    *     the client's protocol connection.
    */
-  void onPreUpload(
+  default void onPreUpload(
       Repository repository,
       Project project,
       String remoteHost,
       UploadPack up,
       Collection<? extends ObjectId> wants,
       Collection<? extends ObjectId> haves)
-      throws ValidationException;
+      throws ValidationException {}
+
+  /**
+   * Invoked before negotiation round is started.
+   *
+   * @param repository The repository
+   * @param project The project
+   * @param remoteHost Remote address/hostname of the user
+   * @param up the UploadPack instance being processed
+   * @param wants The list of wanted objects. These may be RevObject or RevCommit if the processor
+   *     parsed them. Implementors should not rely on the values being parsed.
+   * @param cntOffered number of objects the client has offered.
+   * @throws ValidationException to block the upload and send a message back to the end-user over
+   *     the client's protocol connection.
+   */
+  default void onBeginNegotiate(
+      Repository repository,
+      Project project,
+      String remoteHost,
+      UploadPack up,
+      Collection<? extends ObjectId> wants,
+      int cntOffered)
+      throws ValidationException {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidators.java
index 52b76e8..84d4586 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidators.java
@@ -65,7 +65,15 @@
   @Override
   public void onBeginNegotiateRound(
       UploadPack up, Collection<? extends ObjectId> wants, int cntOffered)
-      throws ServiceMayNotContinueException {}
+      throws ServiceMayNotContinueException {
+    for (UploadValidationListener validator : uploadValidationListeners) {
+      try {
+        validator.onBeginNegotiate(repository, project, remoteHost, up, wants, cntOffered);
+      } catch (ValidationException e) {
+        throw new UploadValidationException(e.getMessage());
+      }
+    }
+  }
 
   @Override
   public void onEndNegotiateRound(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java
index 9bf14e7..5e3110c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java
@@ -356,7 +356,6 @@
 
   private List<AccountGroup> filterGroups(Collection<AccountGroup> groups) {
     List<AccountGroup> filteredGroups = new ArrayList<>(groups.size());
-    boolean isAdmin = identifiedUser.get().getCapabilities().canAdministrateServer();
     for (AccountGroup group : groups) {
       if (!Strings.isNullOrEmpty(matchSubstring)) {
         if (!group
@@ -372,13 +371,11 @@
       if (!groupsToInspect.isEmpty() && !groupsToInspect.contains(group.getGroupUUID())) {
         continue;
       }
-      if (!isAdmin) {
-        GroupControl c = groupControlFactory.controlFor(group);
-        if (!c.isVisible()) {
-          continue;
-        }
+
+      GroupControl c = groupControlFactory.controlFor(group);
+      if (c.isVisible()) {
+        filteredGroups.add(group);
       }
-      filteredGroups.add(group);
     }
     Collections.sort(filteredGroups, new GroupComparator());
     return filteredGroups;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexConfig.java
index a368190..5c3cdf2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexConfig.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.auto.value.AutoValue;
+import java.util.function.IntConsumer;
 import org.eclipse.jgit.lib.Config;
 
 /**
@@ -30,29 +31,61 @@
   private static final int DEFAULT_MAX_TERMS = 1024;
 
   public static IndexConfig createDefault() {
-    return create(0, 0, DEFAULT_MAX_TERMS);
+    return builder().build();
   }
 
-  public static IndexConfig fromConfig(Config cfg) {
-    return create(
-        cfg.getInt("index", null, "maxLimit", 0),
-        cfg.getInt("index", null, "maxPages", 0),
-        cfg.getInt("index", null, "maxTerms", 0));
+  public static Builder fromConfig(Config cfg) {
+    Builder b = builder();
+    setIfPresent(cfg, "maxLimit", b::maxLimit);
+    setIfPresent(cfg, "maxPages", b::maxPages);
+    setIfPresent(cfg, "maxTerms", b::maxTerms);
+    return b;
   }
 
-  public static IndexConfig create(int maxLimit, int maxPages, int maxTerms) {
-    return new AutoValue_IndexConfig(
-        checkLimit(maxLimit, "maxLimit", Integer.MAX_VALUE),
-        checkLimit(maxPages, "maxPages", Integer.MAX_VALUE),
-        checkLimit(maxTerms, "maxTerms", DEFAULT_MAX_TERMS));
-  }
-
-  private static int checkLimit(int limit, String name, int defaultValue) {
-    if (limit == 0) {
-      return defaultValue;
+  private static void setIfPresent(Config cfg, String name, IntConsumer setter) {
+    int n = cfg.getInt("index", null, name, 0);
+    if (n != 0) {
+      setter.accept(n);
     }
+  }
+
+  public static Builder builder() {
+    return new AutoValue_IndexConfig.Builder()
+        .maxLimit(Integer.MAX_VALUE)
+        .maxPages(Integer.MAX_VALUE)
+        .maxTerms(DEFAULT_MAX_TERMS)
+        .separateChangeSubIndexes(false);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder maxLimit(int maxLimit);
+
+    abstract int maxLimit();
+
+    public abstract Builder maxPages(int maxPages);
+
+    abstract int maxPages();
+
+    public abstract Builder maxTerms(int maxTerms);
+
+    abstract int maxTerms();
+
+    public abstract Builder separateChangeSubIndexes(boolean separate);
+
+    abstract IndexConfig autoBuild();
+
+    public IndexConfig build() {
+      IndexConfig cfg = autoBuild();
+      checkLimit(cfg.maxLimit(), "maxLimit");
+      checkLimit(cfg.maxPages(), "maxPages");
+      checkLimit(cfg.maxTerms(), "maxTerms");
+      return cfg;
+    }
+  }
+
+  private static void checkLimit(int limit, String name) {
     checkArgument(limit > 0, "%s must be positive: %s", name, limit);
-    return limit;
   }
 
   /**
@@ -71,4 +104,9 @@
    *     for performance reasons.
    */
   public abstract int maxTerms();
+
+  /**
+   * @return whether different subsets of changes may be stored in different physical sub-indexes.
+   */
+  public abstract boolean separateChangeSubIndexes();
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
index 96aec3f..9258913 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
@@ -24,7 +24,7 @@
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.index.FieldDef;
 import com.google.gerrit.server.index.SchemaUtil;
 import java.sql.Timestamp;
@@ -76,6 +76,14 @@
                       .transform(String::toLowerCase)
                       .toSet());
 
+  public static final FieldDef<AccountState, String> PREFERRED_EMAIL =
+      prefix("preferredemail")
+          .build(
+              a -> {
+                String preferredEmail = a.getAccount().getPreferredEmail();
+                return preferredEmail != null ? preferredEmail.toLowerCase() : null;
+              });
+
   public static final FieldDef<AccountState, Timestamp> REGISTERED =
       timestamp("registered").build(a -> a.getAccount().getRegisteredOn());
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
index 011f1d1..0bd3d2d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
@@ -36,7 +36,9 @@
 
   @Deprecated static final Schema<AccountState> V3 = schema(V2, AccountField.FULL_NAME);
 
-  static final Schema<AccountState> V4 = schema(V3);
+  @Deprecated static final Schema<AccountState> V4 = schema(V3);
+
+  static final Schema<AccountState> V5 = schema(V4, AccountField.PREFERRED_EMAIL);
 
   public static final AccountSchemaDefinitions INSTANCE = new AccountSchemaDefinitions();
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
index 1b84e8e..36a409a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
@@ -33,7 +33,6 @@
 import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -76,31 +75,28 @@
   }
 
   private SiteIndexer.Result reindexAccounts(
-      final AccountIndex index, List<Account.Id> ids, ProgressMonitor progress) {
+      AccountIndex index, List<Account.Id> ids, ProgressMonitor progress) {
     progress.beginTask("Reindexing accounts", ids.size());
     List<ListenableFuture<?>> futures = new ArrayList<>(ids.size());
     AtomicBoolean ok = new AtomicBoolean(true);
-    final AtomicInteger done = new AtomicInteger();
-    final AtomicInteger failed = new AtomicInteger();
+    AtomicInteger done = new AtomicInteger();
+    AtomicInteger failed = new AtomicInteger();
     Stopwatch sw = Stopwatch.createStarted();
-    for (final Account.Id id : ids) {
-      final String desc = "account " + id;
+    for (Account.Id id : ids) {
+      String desc = "account " + id;
       ListenableFuture<?> future =
           executor.submit(
-              new Callable<Void>() {
-                @Override
-                public Void call() throws Exception {
-                  try {
-                    accountCache.evict(id);
-                    index.replace(accountCache.get(id));
-                    verboseWriter.println("Reindexed " + desc);
-                    done.incrementAndGet();
-                  } catch (Exception e) {
-                    failed.incrementAndGet();
-                    throw e;
-                  }
-                  return null;
+              () -> {
+                try {
+                  accountCache.evict(id);
+                  index.replace(accountCache.get(id));
+                  verboseWriter.println("Reindexed " + desc);
+                  done.incrementAndGet();
+                } catch (Exception e) {
+                  failed.incrementAndGet();
+                  throw e;
                 }
+                return null;
               });
       addErrorListener(future, desc, progress, ok);
       futures.add(future);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
index b8acadc..8765607 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -40,12 +40,12 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.index.FieldDef;
@@ -53,6 +53,7 @@
 import com.google.gerrit.server.index.SchemaUtil;
 import com.google.gerrit.server.index.change.StalenessChecker.RefState;
 import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -73,8 +74,10 @@
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Objects;
 import java.util.Set;
 import java.util.function.Function;
+import java.util.stream.Stream;
 import org.eclipse.jgit.revwalk.FooterLine;
 
 /**
@@ -184,6 +187,12 @@
   public static final FieldDef<ChangeData, Iterable<String>> REVIEWER =
       exact("reviewer2").stored().buildRepeatable(cd -> getReviewerFieldValues(cd.reviewers()));
 
+  /** Reviewer(s) associated with the change that do not have a gerrit account. */
+  public static final FieldDef<ChangeData, Iterable<String>> REVIEWER_BY_EMAIL =
+      exact("reviewer_by_email")
+          .stored()
+          .buildRepeatable(cd -> getReviewerByEmailFieldValues(cd.reviewersByEmail()));
+
   @VisibleForTesting
   static List<String> getReviewerFieldValues(ReviewerSet reviewers) {
     List<String> r = new ArrayList<>(reviewers.asTable().size() * 2);
@@ -200,6 +209,27 @@
     return state.toString() + ',' + id;
   }
 
+  @VisibleForTesting
+  static List<String> getReviewerByEmailFieldValues(ReviewerByEmailSet reviewersByEmail) {
+    List<String> r = new ArrayList<>(reviewersByEmail.asTable().size() * 2);
+    for (Table.Cell<ReviewerStateInternal, Address, Timestamp> c :
+        reviewersByEmail.asTable().cellSet()) {
+      String v = getReviewerByEmailFieldValue(c.getRowKey(), c.getColumnKey());
+      r.add(v);
+      if (c.getColumnKey().getName() != null) {
+        // Add another entry without the name to provide search functionality on the email
+        Address emailOnly = new Address(c.getColumnKey().getEmail());
+        r.add(getReviewerByEmailFieldValue(c.getRowKey(), emailOnly));
+      }
+      r.add(v + ',' + c.getValue().getTime());
+    }
+    return r;
+  }
+
+  public static String getReviewerByEmailFieldValue(ReviewerStateInternal state, Address adr) {
+    return state.toString() + ',' + adr;
+  }
+
   public static ReviewerSet parseReviewerFieldValues(Iterable<String> values) {
     ImmutableTable.Builder<ReviewerStateInternal, Account.Id, Timestamp> b =
         ImmutableTable.builder();
@@ -220,6 +250,25 @@
     return ReviewerSet.fromTable(b.build());
   }
 
+  public static ReviewerByEmailSet parseReviewerByEmailFieldValues(Iterable<String> values) {
+    ImmutableTable.Builder<ReviewerStateInternal, Address, Timestamp> b = ImmutableTable.builder();
+    for (String v : values) {
+      int f = v.indexOf(',');
+      if (f < 0) {
+        continue;
+      }
+      int l = v.lastIndexOf(',');
+      if (l == f) {
+        continue;
+      }
+      b.put(
+          ReviewerStateInternal.valueOf(v.substring(0, f)),
+          Address.parse(v.substring(f + 1, l)),
+          new Timestamp(Long.valueOf(v.substring(l + 1, v.length()))));
+    }
+    return ReviewerByEmailSet.fromTable(b.build());
+  }
+
   /** Commit ID of any patch set on the change, using prefix match. */
   public static final FieldDef<ChangeData, Iterable<String>> COMMIT =
       prefix(ChangeQueryBuilder.FIELD_COMMIT).buildRepeatable(ChangeField::getRevisions);
@@ -250,13 +299,8 @@
                 return Sets.newHashSet(a.trackingFooters.extract(footers).values());
               });
 
-  /** List of labels on the current patch set. */
-  @Deprecated
-  public static final FieldDef<ChangeData, Iterable<String>> LABEL =
-      exact(ChangeQueryBuilder.FIELD_LABEL).buildRepeatable(cd -> getLabels(cd, false));
-
   /** List of labels on the current patch set including change owner votes. */
-  public static final FieldDef<ChangeData, Iterable<String>> LABEL2 =
+  public static final FieldDef<ChangeData, Iterable<String>> LABEL =
       exact("label2").buildRepeatable(cd -> getLabels(cd, true));
 
   private static Iterable<String> getLabels(ChangeData cd, boolean owners) throws OrmException {
@@ -338,16 +382,11 @@
   public static final FieldDef<ChangeData, Iterable<String>> COMMENT =
       fullText(ChangeQueryBuilder.FIELD_COMMENT)
           .buildRepeatable(
-              cd -> {
-                Set<String> r = new HashSet<>();
-                for (Comment c : cd.publishedComments()) {
-                  r.add(c.message);
-                }
-                for (ChangeMessage m : cd.messages()) {
-                  r.add(m.getMessage());
-                }
-                return r;
-              });
+              cd ->
+                  Stream.concat(
+                          cd.publishedComments().stream().map(c -> c.message),
+                          cd.messages().stream().map(ChangeMessage::getMessage))
+                      .collect(toSet()));
 
   /** Number of unresolved comments of the change. */
   public static final FieldDef<ChangeData, Integer> UNRESOLVED_COMMENT_COUNT =
@@ -385,22 +424,25 @@
       intRange(ChangeQueryBuilder.FIELD_DELTA)
           .build(cd -> cd.changedLines().map(c -> c.insertions + c.deletions).orElse(null));
 
+  /** Determines if this change is private. */
+  public static final FieldDef<ChangeData, String> PRIVATE =
+      exact(ChangeQueryBuilder.FIELD_PRIVATE).build(cd -> cd.change().isPrivate() ? "1" : "0");
+
+  /** Determines if this change is work in progress. */
+  public static final FieldDef<ChangeData, String> WIP =
+      exact(ChangeQueryBuilder.FIELD_WIP).build(cd -> cd.change().isWorkInProgress() ? "1" : "0");
+
   /** Users who have commented on this change. */
   public static final FieldDef<ChangeData, Iterable<Integer>> COMMENTBY =
       integer(ChangeQueryBuilder.FIELD_COMMENTBY)
           .buildRepeatable(
-              cd -> {
-                Set<Integer> r = new HashSet<>();
-                for (ChangeMessage m : cd.messages()) {
-                  if (m.getAuthor() != null) {
-                    r.add(m.getAuthor().get());
-                  }
-                }
-                for (Comment c : cd.publishedComments()) {
-                  r.add(c.author.getId().get());
-                }
-                return r;
-              });
+              cd ->
+                  Stream.concat(
+                          cd.messages().stream().map(ChangeMessage::getAuthor),
+                          cd.publishedComments().stream().map(c -> c.author.getId()))
+                      .filter(Objects::nonNull)
+                      .map(Account.Id::get)
+                      .collect(toSet()));
 
   /** Star labels on this change in the format: &lt;account-id&gt;:&lt;label&gt; */
   public static final FieldDef<ChangeData, Iterable<String>> STAR =
@@ -423,13 +465,8 @@
   public static final FieldDef<ChangeData, Iterable<String>> GROUP =
       exact(ChangeQueryBuilder.FIELD_GROUP)
           .buildRepeatable(
-              cd -> {
-                Set<String> r = Sets.newHashSetWithExpectedSize(1);
-                for (PatchSet ps : cd.patchSets()) {
-                  r.addAll(ps.getGroups());
-                }
-                return r;
-              });
+              cd ->
+                  cd.patchSets().stream().flatMap(ps -> ps.getGroups().stream()).collect(toSet()));
 
   public static final ProtobufCodec<PatchSet> PATCH_SET_CODEC =
       CodecFactory.encoder(PatchSet.class);
@@ -469,11 +506,7 @@
                 if (reviewedBy.isEmpty()) {
                   return ImmutableSet.of(NOT_REVIEWED);
                 }
-                List<Integer> result = new ArrayList<>(reviewedBy.size());
-                for (Account.Id id : reviewedBy) {
-                  result.add(id.get());
-                }
-                return result;
+                return reviewedBy.stream().map(Account.Id::get).collect(toList());
               });
 
   // Submit rule options in this class should never use fastEvalLabels. This
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index a788f8c..4edfab2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -107,7 +107,7 @@
   private final ListeningExecutorService executor;
   private final DynamicSet<ChangeIndexedListener> indexedListeners;
   private final StalenessChecker stalenessChecker;
-  private final boolean reindexAfterIndexUpdate;
+  private final boolean autoReindexIfStale;
 
   @AssistedInject
   ChangeIndexer(
@@ -131,7 +131,7 @@
     this.indexedListeners = indexedListeners;
     this.stalenessChecker = stalenessChecker;
     this.batchExecutor = batchExecutor;
-    this.reindexAfterIndexUpdate = reindexAfterIndexUpdate(cfg);
+    this.autoReindexIfStale = autoReindexIfStale(cfg);
     this.index = index;
     this.indexes = null;
   }
@@ -158,13 +158,13 @@
     this.indexedListeners = indexedListeners;
     this.stalenessChecker = stalenessChecker;
     this.batchExecutor = batchExecutor;
-    this.reindexAfterIndexUpdate = reindexAfterIndexUpdate(cfg);
+    this.autoReindexIfStale = autoReindexIfStale(cfg);
     this.index = null;
     this.indexes = indexes;
   }
 
-  private static boolean reindexAfterIndexUpdate(Config cfg) {
-    return cfg.getBoolean("index", null, "testReindexAfterUpdate", true);
+  private static boolean autoReindexIfStale(Config cfg) {
+    return cfg.getBoolean("index", null, "testAutoReindexIfStale", true);
   }
 
   /**
@@ -221,7 +221,7 @@
     // and fix the staleness. It doesn't matter which order the two
     // reindexIfStale calls actually execute in; we are guaranteed that at least
     // one of them will execute after the second index write, (4).
-    reindexAfterIndexUpdate(cd);
+    autoReindexIfStale(cd);
   }
 
   private void fireChangeIndexedEvent(int id) {
@@ -253,7 +253,7 @@
   public void index(ReviewDb db, Change change) throws IOException, OrmException {
     index(newChangeData(db, change));
     // See comment in #index(ChangeData).
-    reindexAfterIndexUpdate(change.getProject(), change.getId());
+    autoReindexIfStale(change.getProject(), change.getId());
   }
 
   /**
@@ -268,7 +268,7 @@
     ChangeData cd = newChangeData(db, project, changeId);
     index(cd);
     // See comment in #index(ChangeData).
-    reindexAfterIndexUpdate(cd);
+    autoReindexIfStale(cd);
   }
 
   /**
@@ -304,16 +304,16 @@
     return submit(new ReindexIfStaleTask(project, id), batchExecutor);
   }
 
-  private void reindexAfterIndexUpdate(ChangeData cd) throws IOException {
+  private void autoReindexIfStale(ChangeData cd) throws IOException {
     try {
-      reindexAfterIndexUpdate(cd.project(), cd.getId());
+      autoReindexIfStale(cd.project(), cd.getId());
     } catch (OrmException e) {
       throw new IOException(e);
     }
   }
 
-  private void reindexAfterIndexUpdate(Project.NameKey project, Change.Id id) {
-    if (reindexAfterIndexUpdate) {
+  private void autoReindexIfStale(Project.NameKey project, Change.Id id) {
+    if (autoReindexIfStale) {
       // Don't retry indefinitely; if this fails the change will be stale.
       @SuppressWarnings("unused")
       Future<?> possiblyIgnoredError = reindexIfStale(project, id);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index d988612..ec507f4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -22,75 +22,58 @@
 
 public class ChangeSchemaDefinitions extends SchemaDefinitions<ChangeData> {
   @Deprecated
-  static final Schema<ChangeData> V32 =
+  static final Schema<ChangeData> V39 =
       schema(
-          ChangeField.LEGACY_ID,
+          ChangeField.ADDED,
+          ChangeField.APPROVAL,
+          ChangeField.ASSIGNEE,
+          ChangeField.AUTHOR,
+          ChangeField.CHANGE,
+          ChangeField.COMMENT,
+          ChangeField.COMMENTBY,
+          ChangeField.COMMIT,
+          ChangeField.COMMITTER,
+          ChangeField.COMMIT_MESSAGE,
+          ChangeField.DELETED,
+          ChangeField.DELTA,
+          ChangeField.DRAFTBY,
+          ChangeField.EDITBY,
+          ChangeField.EXACT_COMMIT,
+          ChangeField.EXACT_TOPIC,
+          ChangeField.FILE_PART,
+          ChangeField.FUZZY_TOPIC,
+          ChangeField.GROUP,
+          ChangeField.HASHTAG,
+          ChangeField.HASHTAG_CASE_AWARE,
           ChangeField.ID,
-          ChangeField.STATUS,
+          ChangeField.LABEL,
+          ChangeField.LEGACY_ID,
+          ChangeField.MERGEABLE,
+          ChangeField.OWNER,
+          ChangeField.PATCH_SET,
+          ChangeField.PATH,
           ChangeField.PROJECT,
           ChangeField.PROJECTS,
           ChangeField.REF,
-          ChangeField.EXACT_TOPIC,
-          ChangeField.FUZZY_TOPIC,
-          ChangeField.UPDATED,
-          ChangeField.FILE_PART,
-          ChangeField.PATH,
-          ChangeField.OWNER,
-          ChangeField.COMMIT,
-          ChangeField.TR,
-          ChangeField.LABEL,
-          ChangeField.COMMIT_MESSAGE,
-          ChangeField.COMMENT,
-          ChangeField.CHANGE,
-          ChangeField.APPROVAL,
-          ChangeField.MERGEABLE,
-          ChangeField.ADDED,
-          ChangeField.DELETED,
-          ChangeField.DELTA,
-          ChangeField.HASHTAG,
-          ChangeField.COMMENTBY,
-          ChangeField.PATCH_SET,
-          ChangeField.GROUP,
-          ChangeField.SUBMISSIONID,
-          ChangeField.EDITBY,
+          ChangeField.REF_STATE,
+          ChangeField.REF_STATE_PATTERN,
           ChangeField.REVIEWEDBY,
-          ChangeField.EXACT_COMMIT,
-          ChangeField.AUTHOR,
-          ChangeField.COMMITTER,
-          ChangeField.DRAFTBY,
-          ChangeField.HASHTAG_CASE_AWARE,
+          ChangeField.REVIEWER,
           ChangeField.STAR,
           ChangeField.STARBY,
-          ChangeField.REVIEWER);
-
-  @Deprecated static final Schema<ChangeData> V33 = schema(V32, ChangeField.ASSIGNEE);
-
-  @Deprecated
-  static final Schema<ChangeData> V34 =
-      new Schema.Builder<ChangeData>()
-          .add(V33)
-          .remove(ChangeField.LABEL)
-          .add(ChangeField.LABEL2)
-          .build();
-
-  @Deprecated
-  static final Schema<ChangeData> V35 =
-      schema(
-          V34,
-          ChangeField.SUBMIT_RECORD,
+          ChangeField.STATUS,
           ChangeField.STORED_SUBMIT_RECORD_LENIENT,
-          ChangeField.STORED_SUBMIT_RECORD_STRICT);
+          ChangeField.STORED_SUBMIT_RECORD_STRICT,
+          ChangeField.SUBMISSIONID,
+          ChangeField.SUBMIT_RECORD,
+          ChangeField.TR,
+          ChangeField.UNRESOLVED_COMMENT_COUNT,
+          ChangeField.UPDATED);
 
-  @Deprecated
-  static final Schema<ChangeData> V36 =
-      schema(V35, ChangeField.REF_STATE, ChangeField.REF_STATE_PATTERN);
+  @Deprecated static final Schema<ChangeData> V40 = schema(V39, ChangeField.PRIVATE);
+  @Deprecated static final Schema<ChangeData> V41 = schema(V40, ChangeField.REVIEWER_BY_EMAIL);
 
-  @Deprecated static final Schema<ChangeData> V37 = schema(V36);
-
-  @Deprecated
-  static final Schema<ChangeData> V38 = schema(V37, ChangeField.UNRESOLVED_COMMENT_COUNT);
-
-  static final Schema<ChangeData> V39 = schema(V38);
+  static final Schema<ChangeData> V42 = schema(V41, ChangeField.WIP);
 
   public static final String NAME = "changes";
   public static final ChangeSchemaDefinitions INSTANCE = new ChangeSchemaDefinitions();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
similarity index 81%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterUpdate.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
index 2f6f898..a9f5306 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
@@ -21,11 +21,15 @@
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.QueueProvider.QueueType;
 import com.google.gerrit.server.index.IndexExecutor;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -41,38 +45,64 @@
 import java.util.List;
 import java.util.concurrent.Callable;
 import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-public class ReindexAfterUpdate implements GitReferenceUpdatedListener {
-  private static final Logger log = LoggerFactory.getLogger(ReindexAfterUpdate.class);
+public class ReindexAfterRefUpdate implements GitReferenceUpdatedListener {
+  private static final Logger log = LoggerFactory.getLogger(ReindexAfterRefUpdate.class);
 
   private final OneOffRequestContext requestContext;
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeIndexer.Factory indexerFactory;
   private final ChangeIndexCollection indexes;
   private final ChangeNotes.Factory notesFactory;
+  private final AllUsersName allUsersName;
+  private final AccountCache accountCache;
   private final ListeningExecutorService executor;
+  private final boolean enabled;
 
   @Inject
-  ReindexAfterUpdate(
+  ReindexAfterRefUpdate(
+      @GerritServerConfig Config cfg,
       OneOffRequestContext requestContext,
       Provider<InternalChangeQuery> queryProvider,
       ChangeIndexer.Factory indexerFactory,
       ChangeIndexCollection indexes,
       ChangeNotes.Factory notesFactory,
+      AllUsersName allUsersName,
+      AccountCache accountCache,
       @IndexExecutor(QueueType.BATCH) ListeningExecutorService executor) {
     this.requestContext = requestContext;
     this.queryProvider = queryProvider;
     this.indexerFactory = indexerFactory;
     this.indexes = indexes;
     this.notesFactory = notesFactory;
+    this.allUsersName = allUsersName;
+    this.accountCache = accountCache;
     this.executor = executor;
+    this.enabled = cfg.getBoolean("index", null, "reindexAfterRefUpdate", true);
   }
 
   @Override
-  public void onGitReferenceUpdated(final Event event) {
-    if (event.getRefName().startsWith(RefNames.REFS_CHANGES)
+  public void onGitReferenceUpdated(Event event) {
+    if (allUsersName.get().equals(event.getProjectName())) {
+      Account.Id accountId = Account.Id.fromRef(event.getRefName());
+      if (accountId != null) {
+        try {
+          if (event.isDelete()) {
+            // TODO(ekempin): Delete account from cache and index.
+          } else {
+            accountCache.evict(accountId);
+          }
+        } catch (IOException e) {
+          log.error(String.format("Reindex account %s failed.", accountId), e);
+        }
+      }
+    }
+
+    if (!enabled
+        || event.getRefName().startsWith(RefNames.REFS_CHANGES)
         || event.getRefName().startsWith(RefNames.REFS_DRAFT_COMMENTS)
         || event.getRefName().startsWith(RefNames.REFS_USERS)) {
       return;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java
index 872dfaf..07e0203 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.joining;
 
@@ -135,15 +136,24 @@
 
   @VisibleForTesting
   static boolean reviewDbChangeIsStale(Change indexChange, @Nullable Change reviewDbChange) {
+    checkNotNull(indexChange);
+    PrimaryStorage storageFromIndex = PrimaryStorage.of(indexChange);
+    PrimaryStorage storageFromReviewDb = PrimaryStorage.of(reviewDbChange);
     if (reviewDbChange == null) {
-      return false; // Nothing the caller can do.
+      if (storageFromIndex == PrimaryStorage.REVIEW_DB) {
+        return true; // Index says it should have been in ReviewDb, but it wasn't.
+      }
+      return false; // Not in ReviewDb, but that's ok.
     }
     checkArgument(
         indexChange.getId().equals(reviewDbChange.getId()),
         "mismatched change ID: %s != %s",
         indexChange.getId(),
         reviewDbChange.getId());
-    if (PrimaryStorage.of(reviewDbChange) != PrimaryStorage.REVIEW_DB) {
+    if (storageFromIndex != storageFromReviewDb) {
+      return true; // Primary storage differs, definitely stale.
+    }
+    if (storageFromReviewDb != PrimaryStorage.REVIEW_DB) {
       return false; // Not a ReviewDb change, don't check rowVersion.
     }
     return reviewDbChange.getRowVersion() != indexChange.getRowVersion();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
index ec486b5..4014102 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
@@ -32,7 +32,6 @@
 import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -79,30 +78,27 @@
     progress.beginTask("Reindexing groups", uuids.size());
     List<ListenableFuture<?>> futures = new ArrayList<>(uuids.size());
     AtomicBoolean ok = new AtomicBoolean(true);
-    final AtomicInteger done = new AtomicInteger();
-    final AtomicInteger failed = new AtomicInteger();
+    AtomicInteger done = new AtomicInteger();
+    AtomicInteger failed = new AtomicInteger();
     Stopwatch sw = Stopwatch.createStarted();
-    for (final AccountGroup.UUID uuid : uuids) {
-      final String desc = "group " + uuid;
+    for (AccountGroup.UUID uuid : uuids) {
+      String desc = "group " + uuid;
       ListenableFuture<?> future =
           executor.submit(
-              new Callable<Void>() {
-                @Override
-                public Void call() throws Exception {
-                  try {
-                    AccountGroup oldGroup = groupCache.get(uuid);
-                    if (oldGroup != null) {
-                      groupCache.evict(oldGroup);
-                    }
-                    index.replace(groupCache.get(uuid));
-                    verboseWriter.println("Reindexed " + desc);
-                    done.incrementAndGet();
-                  } catch (Exception e) {
-                    failed.incrementAndGet();
-                    throw e;
+              () -> {
+                try {
+                  AccountGroup oldGroup = groupCache.get(uuid);
+                  if (oldGroup != null) {
+                    groupCache.evict(oldGroup);
                   }
-                  return null;
+                  index.replace(groupCache.get(uuid));
+                  verboseWriter.println("Reindexed " + desc);
+                  done.incrementAndGet();
+                } catch (Exception e) {
+                  failed.incrementAndGet();
+                  throw e;
                 }
+                return null;
               });
       addErrorListener(future, desc, progress, ok);
       futures.add(future);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java
index f3b08fb..7f3ac01 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java
@@ -42,6 +42,14 @@
     throw new IllegalArgumentException("Invalid email address: " + in);
   }
 
+  public static Address tryParse(String in) {
+    try {
+      return parse(in);
+    } catch (IllegalArgumentException e) {
+      return null;
+    }
+  }
+
   final String name;
   final String email;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddKeySender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddKeySender.java
index 0c09639..30938f1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddKeySender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddKeySender.java
@@ -17,9 +17,13 @@
 import com.google.common.base.Joiner;
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.util.List;
@@ -31,6 +35,7 @@
     AddKeySender create(IdentifiedUser user, List<String> gpgKey);
   }
 
+  private final PermissionBackend permissionBackend;
   private final IdentifiedUser callingUser;
   private final IdentifiedUser user;
   private final AccountSshKey sshKey;
@@ -39,10 +44,12 @@
   @AssistedInject
   public AddKeySender(
       EmailArguments ea,
+      PermissionBackend permissionBackend,
       IdentifiedUser callingUser,
       @Assisted IdentifiedUser user,
       @Assisted AccountSshKey sshKey) {
     super(ea, "addkey");
+    this.permissionBackend = permissionBackend;
     this.callingUser = callingUser;
     this.user = user;
     this.sshKey = sshKey;
@@ -52,10 +59,12 @@
   @AssistedInject
   public AddKeySender(
       EmailArguments ea,
+      PermissionBackend permissionBackend,
       IdentifiedUser callingUser,
       @Assisted IdentifiedUser user,
       @Assisted List<String> gpgKeys) {
     super(ea, "addkey");
+    this.permissionBackend = permissionBackend;
     this.callingUser = callingUser;
     this.user = user;
     this.sshKey = null;
@@ -71,12 +80,25 @@
 
   @Override
   protected boolean shouldSendMessage() {
-    /*
-     * Don't send an email if no keys are added, or an admin is adding a key to
-     * a user.
-     */
-    return (sshKey != null || gpgKeys.size() > 0)
-        && (user.equals(callingUser) || !callingUser.getCapabilities().canAdministrateServer());
+    if (sshKey == null && (gpgKeys == null || gpgKeys.isEmpty())) {
+      // Don't email if no keys were added.
+      return false;
+    }
+
+    if (user.equals(callingUser)) {
+      // Send email if the user self-added a key; this notification is necessary to alert
+      // the user if their account was compromised and a key was unexpectedly added.
+      return true;
+    }
+
+    try {
+      // Don't email if an administrator added a key on behalf of the user.
+      permissionBackend.user(callingUser).check(GlobalPermission.ADMINISTRATE_SERVER);
+      return false;
+    } catch (AuthException | PermissionBackendException e) {
+      // Send email if a non-administrator modified the keys, e.g. by MODIFY_ACCOUNT.
+      return true;
+    }
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index bc09488..b9ade64 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.common.base.Splitter;
 import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -39,6 +40,8 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
+import com.google.template.soy.data.SoyListData;
+import com.google.template.soy.data.SoyMapData;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.text.MessageFormat;
@@ -180,6 +183,18 @@
     setHeader("X-Gerrit-Change-Number", "" + change.getChangeId());
     setChangeUrlHeader();
     setCommitIdHeader();
+
+    if (notify.ordinal() >= NotifyHandling.OWNER_REVIEWERS.ordinal()) {
+      try {
+        addByEmail(
+            RecipientType.CC, changeData.reviewersByEmail().byState(ReviewerStateInternal.CC));
+        addByEmail(
+            RecipientType.TO,
+            changeData.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER));
+      } catch (OrmException e) {
+        throw new EmailException("Failed to add unregistered CCs " + change.getChangeId(), e);
+      }
+    }
   }
 
   private void setChangeUrlHeader() {
@@ -440,6 +455,7 @@
     soyContext.put("coverLetter", getCoverLetter());
     soyContext.put("fromName", getNameFor(fromId));
     soyContext.put("fromEmail", getNameEmailFor(fromId));
+    soyContext.put("diffLines", getDiffTemplateData());
 
     soyContextEmailData.put("unifiedDiff", getUnifiedDiff());
     soyContextEmailData.put("changeDetail", getChangeDetail());
@@ -539,4 +555,37 @@
       }
     }
   }
+
+  /**
+   * Generate a Soy list of maps representing each line of the unified diff. The line maps will have
+   * a 'type' key which maps to one of 'common', 'add' or 'remove' and a 'text' key which maps to
+   * the line's content.
+   */
+  private SoyListData getDiffTemplateData() {
+    SoyListData result = new SoyListData();
+    Splitter lineSplitter = Splitter.on(System.getProperty("line.separator"));
+    for (String diffLine : lineSplitter.split(getUnifiedDiff())) {
+      SoyMapData lineData = new SoyMapData();
+      lineData.put("text", diffLine);
+
+      // Skip empty lines and lines that look like diff headers.
+      if (diffLine.isEmpty() || diffLine.startsWith("---") || diffLine.startsWith("+++")) {
+        lineData.put("type", "common");
+      } else {
+        switch (diffLine.charAt(0)) {
+          case '+':
+            lineData.put("type", "add");
+            break;
+          case '-':
+            lineData.put("type", "remove");
+            break;
+          default:
+            lineData.put("type", "common");
+            break;
+        }
+      }
+      result.add(lineData);
+    }
+    return result;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java
index b572e8d..21e9ad5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -155,7 +155,9 @@
     }
     if (notify.compareTo(NotifyHandling.ALL) >= 0) {
       bccStarredBy();
-      includeWatchers(NotifyType.ALL_COMMENTS, !patchSet.isDraft());
+      includeWatchers(
+          NotifyType.ALL_COMMENTS,
+          !patchSet.isDraft() && !change.isWorkInProgress() && !change.isPrivate());
     }
     removeUsersThatIgnoredTheChange();
 
@@ -588,6 +590,7 @@
 
     footers.add("Gerrit-Comment-Date: " + getCommentTimestamp());
     footers.add("Gerrit-HasComments: " + (hasComments ? "Yes" : "No"));
+    footers.add("Gerrit-HasLabels: " + (labels.isEmpty() ? "No" : "Yes"));
   }
 
   private String getLine(PatchFile fileInfo, short side, int lineNbr) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CreateChangeSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
index 3e9e62c..8757a28 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
@@ -48,9 +48,13 @@
     super.init();
 
     boolean isDraft = change.getStatus() == Change.Status.DRAFT;
+
     try {
       // Try to mark interested owners with TO and CC or BCC line.
-      Watchers matching = getWatchers(NotifyType.NEW_CHANGES, !isDraft);
+      Watchers matching =
+          getWatchers(
+              NotifyType.NEW_CHANGES,
+              !isDraft && !change.isWorkInProgress() && !change.isPrivate());
       for (Account.Id user :
           Iterables.concat(matching.to.accounts, matching.cc.accounts, matching.bcc.accounts)) {
         if (isOwnerOfProjectOrBranch(user)) {
@@ -69,7 +73,8 @@
       log.warn("Cannot notify watchers for new change", err);
     }
 
-    includeWatchers(NotifyType.NEW_PATCHSETS, !isDraft);
+    includeWatchers(
+        NotifyType.NEW_PATCHSETS, !isDraft && !change.isWorkInProgress() && !change.isPrivate());
   }
 
   private boolean isOwnerOfProjectOrBranch(Account.Id user) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
index a563846..0fea7ce 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.mail.Address;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -32,6 +33,7 @@
 /** Let users know that a reviewer and possibly her review have been removed. */
 public class DeleteReviewerSender extends ReplyToChangeSender {
   private final Set<Account.Id> reviewers = new HashSet<>();
+  private final Set<Address> reviewersByEmail = new HashSet<>();
 
   public interface Factory extends ReplyToChangeSender.Factory<DeleteReviewerSender> {
     @Override
@@ -49,6 +51,10 @@
     reviewers.addAll(cc);
   }
 
+  public void addReviewersByEmail(Collection<Address> cc) {
+    reviewersByEmail.addAll(cc);
+  }
+
   @Override
   protected void init() throws EmailException {
     super.init();
@@ -58,6 +64,7 @@
     ccExistingReviewers();
     includeWatchers(NotifyType.ALL_COMMENTS);
     add(RecipientType.TO, reviewers);
+    addByEmail(RecipientType.TO, reviewersByEmail);
     removeUsersThatIgnoredTheChange();
   }
 
@@ -70,13 +77,16 @@
   }
 
   public List<String> getReviewerNames() {
-    if (reviewers.isEmpty()) {
+    if (reviewers.isEmpty() && reviewersByEmail.isEmpty()) {
       return null;
     }
     List<String> names = new ArrayList<>();
     for (Account.Id id : reviewers) {
       names.add(getNameFor(id));
     }
+    for (Address a : reviewersByEmail) {
+      names.add(a.toString());
+    }
     return names;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailArguments.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailArguments.java
index 9306c7a..683416f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailArguments.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailArguments.java
@@ -80,6 +80,7 @@
   final DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners;
   final StarredChangesUtil starredChangesUtil;
   final Provider<InternalAccountQuery> accountQueryProvider;
+  final OutgoingEmailValidator validator;
 
   @Inject
   EmailArguments(
@@ -111,7 +112,8 @@
       SitePaths site,
       DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners,
       StarredChangesUtil starredChangesUtil,
-      Provider<InternalAccountQuery> accountQueryProvider) {
+      Provider<InternalAccountQuery> accountQueryProvider,
+      OutgoingEmailValidator validator) {
     this.server = server;
     this.projectCache = projectCache;
     this.groupBackend = groupBackend;
@@ -141,5 +143,6 @@
     this.outgoingEmailValidationListeners = outgoingEmailValidationListeners;
     this.starredChangesUtil = starredChangesUtil;
     this.accountQueryProvider = accountQueryProvider;
+    this.validator = validator;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MergedSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MergedSender.java
index 47115af..4d48990 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -41,7 +41,7 @@
   public MergedSender(EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id)
       throws OrmException {
     super(ea, "merged", newChangeData(ea, project, id));
-    labelTypes = changeData.changeControl().getLabelTypes();
+    labelTypes = changeData.getLabelTypes();
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index f1a9ad8..3f6d991 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import java.util.ArrayList;
@@ -28,7 +29,9 @@
 /** Sends an email alerting a user to a new change for them to review. */
 public abstract class NewChangeSender extends ChangeEmail {
   private final Set<Account.Id> reviewers = new HashSet<>();
+  private final Set<Address> reviewersByEmail = new HashSet<>();
   private final Set<Account.Id> extraCC = new HashSet<>();
+  private final Set<Address> extraCCByEmail = new HashSet<>();
 
   protected NewChangeSender(EmailArguments ea, ChangeData cd) throws OrmException {
     super(ea, "newchange", cd);
@@ -38,10 +41,18 @@
     reviewers.addAll(cc);
   }
 
+  public void addReviewersByEmail(final Collection<Address> cc) {
+    reviewersByEmail.addAll(cc);
+  }
+
   public void addExtraCC(final Collection<Account.Id> cc) {
     extraCC.addAll(cc);
   }
 
+  public void addExtraCCByEmail(final Collection<Address> cc) {
+    extraCCByEmail.addAll(cc);
+  }
+
   @Override
   protected void init() throws EmailException {
     super.init();
@@ -55,9 +66,11 @@
       case ALL:
       default:
         add(RecipientType.CC, extraCC);
+        extraCCByEmail.stream().forEach(cc -> add(RecipientType.CC, cc));
         //$FALL-THROUGH$
       case OWNER_REVIEWERS:
         add(RecipientType.TO, reviewers);
+        addByEmail(RecipientType.TO, reviewersByEmail);
         break;
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index 730b710..c2ae0bc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -441,6 +441,13 @@
     }
   }
 
+  /** Schedule this message for delivery to the listed address. */
+  protected void addByEmail(final RecipientType rt, final Collection<Address> list) {
+    for (final Address id : list) {
+      add(rt, id);
+    }
+  }
+
   protected void add(final RecipientType rt, final UserIdentity who) {
     if (who != null && who.getAccount() != null) {
       add(rt, who.getAccount());
@@ -471,7 +478,7 @@
   /** Schedule delivery of this message to the given account. */
   protected void add(final RecipientType rt, final Address addr) {
     if (addr != null && addr.getEmail() != null && addr.getEmail().length() > 0) {
-      if (!OutgoingEmailValidator.isValid(addr.getEmail())) {
+      if (!args.validator.isValid(addr.getEmail())) {
         log.warn("Not emailing " + addr.getEmail() + " (invalid email address)");
       } else if (!args.emailSender.canEmail(addr.getEmail())) {
         log.warn("Not emailing " + addr.getEmail() + " (prohibited by allowrcpt)");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java
index 2d9db1d..1a4d39b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java
@@ -16,15 +16,34 @@
 
 import static org.apache.commons.validator.routines.DomainValidator.ArrayType.GENERIC_PLUS;
 
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import org.apache.commons.validator.routines.DomainValidator;
 import org.apache.commons.validator.routines.EmailValidator;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
+@Singleton
 public class OutgoingEmailValidator {
-  static {
-    DomainValidator.updateTLDOverride(GENERIC_PLUS, new String[] {"local"});
+  private static final Logger log = LoggerFactory.getLogger(OutgoingEmailValidator.class);
+
+  @Inject
+  OutgoingEmailValidator(@GerritServerConfig Config config) {
+    String[] allowTLD = config.getStringList("sendemail", null, "allowTLD");
+    if (allowTLD.length != 0) {
+      try {
+        DomainValidator.updateTLDOverride(GENERIC_PLUS, allowTLD);
+      } catch (IllegalStateException e) {
+        // Should only happen in tests, where the OutgoingEmailValidator
+        // is instantiated repeatedly.
+        log.error("Failed to update TLD override: " + e.getMessage());
+      }
+    }
   }
 
-  public static boolean isValid(String addr) {
+  public boolean isValid(String addr) {
     return EmailValidator.getInstance(true, true).isValid(addr);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
index c90000f..1483e21 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
@@ -66,7 +66,9 @@
     add(RecipientType.CC, extraCC);
     rcptToAuthors(RecipientType.CC);
     bccStarredBy();
-    includeWatchers(NotifyType.NEW_PATCHSETS, !patchSet.isDraft());
+    includeWatchers(
+        NotifyType.NEW_PATCHSETS,
+        !patchSet.isDraft() && !change.isWorkInProgress() && !change.isPrivate());
     removeUsersThatIgnoredTheChange();
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java b/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java
index e9e3c71..15ee1bd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java
@@ -16,6 +16,7 @@
 
 import eu.medsea.mimeutil.MimeType;
 import eu.medsea.mimeutil.MimeUtil2;
+import java.io.InputStream;
 
 public interface FileTypeRegistry {
   /**
@@ -33,6 +34,20 @@
   MimeType getMimeType(String path, byte[] content);
 
   /**
+   * Get the most specific MIME type available for a file.
+   *
+   * @param path name of the file. The base name (component after the last '/') may be used to help
+   *     determine the MIME type, such as by examining the extension (portion after the last '.' if
+   *     present).
+   * @param is InputStream corresponding to the complete file content. The content may be used to
+   *     guess the MIME type by examining the beginning for common file headers.
+   * @return the MIME type for this content. If the MIME type is not recognized or cannot be
+   *     determined, {@link MimeUtil2#UNKNOWN_MIME_TYPE} which is an alias for {@code
+   *     application/octet-stream}.
+   */
+  MimeType getMimeType(String path, InputStream is);
+
+  /**
    * Is this content type safe to transmit to a browser directly?
    *
    * @param type the MIME type of the file content.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java b/gerrit-server/src/main/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java
index 859363c..77ba79d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java
@@ -20,6 +20,7 @@
 import eu.medsea.mimeutil.MimeException;
 import eu.medsea.mimeutil.MimeType;
 import eu.medsea.mimeutil.MimeUtil2;
+import java.io.InputStream;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -87,6 +88,23 @@
         log.warn("Unable to determine MIME type from content", e);
       }
     }
+    return getMimeType(mimeTypes, path);
+  }
+
+  @Override
+  @SuppressWarnings("unchecked")
+  public MimeType getMimeType(final String path, final InputStream is) {
+    Set<MimeType> mimeTypes = new HashSet<>();
+    try {
+      mimeTypes.addAll(mimeUtil.getMimeTypes(is));
+    } catch (MimeException e) {
+      log.warn("Unable to determine MIME type from content", e);
+    }
+    return getMimeType(mimeTypes, path);
+  }
+
+  @SuppressWarnings("unchecked")
+  private MimeType getMimeType(Set<MimeType> mimeTypes, final String path) {
     try {
       mimeTypes.addAll(mimeUtil.getMimeTypes(path));
     } catch (MimeException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index 4cb570a..91a93b0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -123,7 +123,10 @@
     this.args = checkNotNull(args);
     this.changeId = checkNotNull(changeId);
     this.primaryStorage = primaryStorage;
-    this.autoRebuild = primaryStorage == PrimaryStorage.REVIEW_DB && autoRebuild;
+    this.autoRebuild =
+        primaryStorage == PrimaryStorage.REVIEW_DB
+            && !args.migration.disableChangeReviewDb()
+            && autoRebuild;
   }
 
   public Change.Id getChangeId() {
@@ -143,7 +146,7 @@
     if (!read && primaryStorage == PrimaryStorage.NOTE_DB) {
       throw new OrmException("NoteDb is required to read change " + changeId);
     }
-    boolean readOrWrite = read || args.migration.writeChanges();
+    boolean readOrWrite = read || args.migration.rawWriteChangesSetting();
     if (!readOrWrite && !autoRebuild) {
       loadDefaults();
       return self();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java
index d5b1b3d..7a25163a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java
@@ -233,7 +233,7 @@
     // last time this file was updated.
     checkColumns(Change.Id.class, 1);
 
-    checkColumns(Change.class, 1, 2, 3, 4, 5, 7, 8, 10, 12, 13, 14, 17, 18, 19, 101);
+    checkColumns(Change.class, 1, 2, 3, 4, 5, 7, 8, 10, 12, 13, 14, 17, 18, 19, 20, 21, 101);
     checkColumns(ChangeMessage.Key.class, 1, 2);
     checkColumns(ChangeMessage.class, 1, 2, 3, 4, 5, 6, 7);
     checkColumns(PatchSet.Id.class, 1, 2);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index c848987..ce3b664 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -73,6 +73,7 @@
   public static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
   public static final FooterKey FOOTER_PATCH_SET_DESCRIPTION =
       new FooterKey("Patch-set-description");
+  public static final FooterKey FOOTER_PRIVATE = new FooterKey("Private");
   public static final FooterKey FOOTER_READ_ONLY_UNTIL = new FooterKey("Read-only-until");
   public static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user");
   public static final FooterKey FOOTER_STATUS = new FooterKey("Status");
@@ -81,6 +82,7 @@
   public static final FooterKey FOOTER_SUBMITTED_WITH = new FooterKey("Submitted-with");
   public static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
   public static final FooterKey FOOTER_TAG = new FooterKey("Tag");
+  public static final FooterKey FOOTER_WORK_IN_PROGRESS = new FooterKey("Work-in-progress");
 
   private static final String AUTHOR = "Author";
   private static final String BASE_PATCH_SET = "Base-for-patch-set";
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 4993a5d..967bb69 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -48,6 +48,7 @@
 import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.git.RefCache;
@@ -94,6 +95,7 @@
     return new ConfigInvalidException("Change " + changeId + ": " + String.format(fmt, args));
   }
 
+  @Nullable
   public static Change readOneReviewDbChange(ReviewDb db, Change.Id id) throws OrmException {
     return ReviewDbUtil.unwrapDb(db).changes().get(id);
   }
@@ -250,9 +252,15 @@
       List<ChangeNotes> notes = new ArrayList<>();
       if (args.migration.enabled()) {
         for (Change.Id cid : changeIds) {
-          ChangeNotes cn = create(db, project, cid);
-          if (cn.getChange() != null && predicate.test(cn)) {
-            notes.add(cn);
+          try {
+            ChangeNotes cn = create(db, project, cid);
+            if (cn.getChange() != null && predicate.test(cn)) {
+              notes.add(cn);
+            }
+          } catch (NoSuchChangeException e) {
+            // Match ReviewDb behavior, returning not found; maybe the caller learned about it from
+            // a dangling patch set ref or something.
+            continue;
           }
         }
         return notes;
@@ -428,6 +436,11 @@
     return state.reviewers();
   }
 
+  /** @return reviewers that do not currently have a Gerrit account and were added by email. */
+  public ReviewerByEmailSet getReviewersByEmail() {
+    return state.reviewersByEmail();
+  }
+
   public ImmutableList<ReviewerStatusUpdate> getReviewerUpdates() {
     return state.reviewerUpdates();
   }
@@ -563,6 +576,20 @@
     return state.readOnlyUntil();
   }
 
+  public boolean isPrivate() {
+    if (state.isPrivate() == null) {
+      return false;
+    }
+    return state.isPrivate();
+  }
+
+  public boolean isWorkInProgress() {
+    if (state.isWorkInProgress() == null) {
+      return false;
+    }
+    return state.isWorkInProgress();
+  }
+
   @Override
   protected void onLoad(LoadHandle handle)
       throws NoSuchChangeException, IOException, ConfigInvalidException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index dac999c..a8ed423 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -24,6 +24,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET_DESCRIPTION;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PRIVATE;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_READ_ONLY_UNTIL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
@@ -32,6 +33,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_WORK_IN_PROGRESS;
 import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
 import static java.util.stream.Collectors.joining;
 
@@ -62,8 +64,10 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.LabelVote;
 import java.io.IOException;
@@ -127,6 +131,7 @@
   // Private final but mutable members initialized in the constructor and filled
   // in during the parsing process.
   private final Table<Account.Id, ReviewerStateInternal, Timestamp> reviewers;
+  private final Table<Address, ReviewerStateInternal, Timestamp> reviewersByEmail;
   private final List<Account.Id> allPastReviewers;
   private final List<ReviewerStatusUpdate> reviewerUpdates;
   private final List<SubmitRecord> submitRecords;
@@ -157,6 +162,8 @@
   private String tag;
   private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
   private Timestamp readOnlyUntil;
+  private Boolean isPrivate;
+  private Boolean workInProgress;
 
   ChangeNotesParser(
       Change.Id changeId,
@@ -172,6 +179,7 @@
     approvals = new LinkedHashMap<>();
     bufferedApprovals = new ArrayList<>();
     reviewers = HashBasedTable.create();
+    reviewersByEmail = HashBasedTable.create();
     allPastReviewers = new ArrayList<>();
     reviewerUpdates = new ArrayList<>();
     submitRecords = Lists.newArrayListWithExpectedSize(1);
@@ -199,6 +207,7 @@
       parseNotes();
       allPastReviewers.addAll(reviewers.rowKeySet());
       pruneReviewers();
+      pruneReviewersByEmail();
 
       updatePatchSetStates();
       checkMandatoryFooters();
@@ -232,13 +241,16 @@
         patchSets,
         buildApprovals(),
         ReviewerSet.fromTable(Tables.transpose(reviewers)),
+        ReviewerByEmailSet.fromTable(Tables.transpose(reviewersByEmail)),
         allPastReviewers,
         buildReviewerUpdates(),
         submitRecords,
         buildAllMessages(),
         buildMessagesByPatchSet(),
         comments,
-        readOnlyUntil);
+        readOnlyUntil,
+        isPrivate,
+        workInProgress);
   }
 
   private PatchSet.Id buildCurrentPatchSetId() {
@@ -371,6 +383,9 @@
       for (String line : commit.getFooterLineValues(state.getFooterKey())) {
         parseReviewer(ts, state, line);
       }
+      for (String line : commit.getFooterLineValues(state.getByEmailFooterKey())) {
+        parseReviewerByEmail(ts, state, line);
+      }
       // Don't update timestamp when a reviewer was added, matching RevewDb
       // behavior.
     }
@@ -379,6 +394,14 @@
       parseReadOnlyUntil(commit);
     }
 
+    if (isPrivate == null) {
+      parseIsPrivate(commit);
+    }
+
+    if (workInProgress == null) {
+      parseWorkInProgress(commit);
+    }
+
     if (lastUpdatedOn == null || ts.after(lastUpdatedOn)) {
       lastUpdatedOn = ts;
     }
@@ -910,6 +933,19 @@
     }
   }
 
+  private void parseReviewerByEmail(Timestamp ts, ReviewerStateInternal state, String line)
+      throws ConfigInvalidException {
+    Address adr;
+    try {
+      adr = Address.parse(line);
+    } catch (IllegalArgumentException e) {
+      throw invalidFooter(state.getByEmailFooterKey(), line);
+    }
+    if (!reviewersByEmail.containsRow(adr)) {
+      reviewersByEmail.put(adr, state, ts);
+    }
+  }
+
   private void parseReadOnlyUntil(ChangeNotesCommit commit) throws ConfigInvalidException {
     String raw = parseOneFooter(commit, FOOTER_READ_ONLY_UNTIL);
     if (raw == null) {
@@ -924,6 +960,34 @@
     }
   }
 
+  private void parseIsPrivate(ChangeNotesCommit commit) throws ConfigInvalidException {
+    String raw = parseOneFooter(commit, FOOTER_PRIVATE);
+    if (raw == null) {
+      return;
+    } else if (Boolean.TRUE.toString().equalsIgnoreCase(raw)) {
+      isPrivate = true;
+      return;
+    } else if (Boolean.FALSE.toString().equalsIgnoreCase(raw)) {
+      isPrivate = false;
+      return;
+    }
+    throw invalidFooter(FOOTER_PRIVATE, raw);
+  }
+
+  private void parseWorkInProgress(ChangeNotesCommit commit) throws ConfigInvalidException {
+    String raw = parseOneFooter(commit, FOOTER_WORK_IN_PROGRESS);
+    if (raw == null) {
+      return;
+    } else if (Boolean.TRUE.toString().equalsIgnoreCase(raw)) {
+      workInProgress = true;
+      return;
+    } else if (Boolean.FALSE.toString().equalsIgnoreCase(raw)) {
+      workInProgress = false;
+      return;
+    }
+    throw invalidFooter(FOOTER_WORK_IN_PROGRESS, raw);
+  }
+
   private void pruneReviewers() {
     Iterator<Table.Cell<Account.Id, ReviewerStateInternal, Timestamp>> rit =
         reviewers.cellSet().iterator();
@@ -935,6 +999,17 @@
     }
   }
 
+  private void pruneReviewersByEmail() {
+    Iterator<Table.Cell<Address, ReviewerStateInternal, Timestamp>> rit =
+        reviewersByEmail.cellSet().iterator();
+    while (rit.hasNext()) {
+      Table.Cell<Address, ReviewerStateInternal, Timestamp> e = rit.next();
+      if (e.getColumnKey() == ReviewerStateInternal.REMOVED) {
+        rit.remove();
+      }
+    }
+  }
+
   private void updatePatchSetStates() {
     Set<PatchSet.Id> missing = new TreeSet<>(ReviewDbUtil.intKeyOrdering());
     for (Iterator<PatchSet> it = patchSets.values().iterator(); it.hasNext(); ) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 7b25bbd..1087884 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
@@ -65,12 +66,15 @@
         ImmutableList.of(),
         ImmutableList.of(),
         ReviewerSet.empty(),
+        ReviewerByEmailSet.empty(),
         ImmutableList.of(),
         ImmutableList.of(),
         ImmutableList.of(),
         ImmutableList.of(),
         ImmutableListMultimap.of(),
         ImmutableListMultimap.of(),
+        null,
+        null,
         null);
   }
 
@@ -94,13 +98,16 @@
       Map<PatchSet.Id, PatchSet> patchSets,
       ListMultimap<PatchSet.Id, PatchSetApproval> approvals,
       ReviewerSet reviewers,
+      ReviewerByEmailSet reviewersByEmail,
       List<Account.Id> allPastReviewers,
       List<ReviewerStatusUpdate> reviewerUpdates,
       List<SubmitRecord> submitRecords,
       List<ChangeMessage> allChangeMessages,
       ListMultimap<PatchSet.Id, ChangeMessage> changeMessagesByPatchSet,
       ListMultimap<RevId, Comment> publishedComments,
-      @Nullable Timestamp readOnlyUntil) {
+      @Nullable Timestamp readOnlyUntil,
+      @Nullable Boolean isPrivate,
+      @Nullable Boolean workInProgress) {
     if (hashtags == null) {
       hashtags = ImmutableSet.of();
     }
@@ -119,19 +126,24 @@
             originalSubject,
             submissionId,
             assignee,
-            status),
+            status,
+            isPrivate,
+            workInProgress),
         ImmutableSet.copyOf(pastAssignees),
         ImmutableSet.copyOf(hashtags),
         ImmutableList.copyOf(patchSets.entrySet()),
         ImmutableList.copyOf(approvals.entries()),
         reviewers,
+        reviewersByEmail,
         ImmutableList.copyOf(allPastReviewers),
         ImmutableList.copyOf(reviewerUpdates),
         ImmutableList.copyOf(submitRecords),
         ImmutableList.copyOf(allChangeMessages),
         ImmutableListMultimap.copyOf(changeMessagesByPatchSet),
         ImmutableListMultimap.copyOf(publishedComments),
-        readOnlyUntil);
+        readOnlyUntil,
+        isPrivate,
+        workInProgress);
   }
 
   /**
@@ -174,6 +186,12 @@
     // TODO(dborowitz): Use a sensible default other than null
     @Nullable
     abstract Change.Status status();
+
+    @Nullable
+    abstract Boolean isPrivate();
+
+    @Nullable
+    abstract Boolean isWorkInProgress();
   }
 
   // Only null if NoteDb is disabled.
@@ -197,6 +215,8 @@
 
   abstract ReviewerSet reviewers();
 
+  abstract ReviewerByEmailSet reviewersByEmail();
+
   abstract ImmutableList<Account.Id> allPastReviewers();
 
   abstract ImmutableList<ReviewerStatusUpdate> reviewerUpdates();
@@ -212,6 +232,12 @@
   @Nullable
   abstract Timestamp readOnlyUntil();
 
+  @Nullable
+  abstract Boolean isPrivate();
+
+  @Nullable
+  abstract Boolean isWorkInProgress();
+
   Change newChange(Project.NameKey project) {
     ChangeColumns c = checkNotNull(columns(), "columns are required");
     Change change =
@@ -269,6 +295,8 @@
     change.setLastUpdatedOn(c.lastUpdatedOn());
     change.setSubmissionId(c.submissionId());
     change.setAssignee(c.assignee());
+    change.setPrivate(c.isPrivate() == null ? false : c.isPrivate());
+    change.setWorkInProgress(c.isWorkInProgress() == null ? false : c.isWorkInProgress());
 
     if (!patchSets().isEmpty()) {
       change.setCurrentPatchSet(c.currentPatchSetId(), c.subject(), c.originalSubject());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 7af0cb4..384daa8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -29,6 +29,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET_DESCRIPTION;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PRIVATE;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_READ_ONLY_UNTIL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
@@ -37,6 +38,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_WORK_IN_PROGRESS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.sanitizeFooter;
 import static java.util.Comparator.comparing;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
@@ -60,6 +62,7 @@
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.LabelVote;
@@ -127,6 +130,7 @@
 
   private final Table<String, Account.Id, Optional<Short>> approvals;
   private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>();
+  private final Map<Address, ReviewerStateInternal> reviewersByEmail = new LinkedHashMap<>();
   private final List<Comment> comments = new ArrayList<>();
 
   private String commitSubject;
@@ -149,6 +153,8 @@
   private String psDescription;
   private boolean currentPatchSet;
   private Timestamp readOnlyUntil;
+  private Boolean isPrivate;
+  private Boolean workInProgress;
 
   private ChangeDraftUpdate draftUpdate;
   private RobotCommentUpdate robotCommentUpdate;
@@ -469,6 +475,15 @@
     reviewers.put(reviewer, ReviewerStateInternal.REMOVED);
   }
 
+  public void putReviewerByEmail(Address reviewer, ReviewerStateInternal type) {
+    checkArgument(type != ReviewerStateInternal.REMOVED, "invalid ReviewerType");
+    reviewersByEmail.put(reviewer, type);
+  }
+
+  public void removeReviewerByEmail(Address reviewer) {
+    reviewersByEmail.put(reviewer, ReviewerStateInternal.REMOVED);
+  }
+
   public void setPatchSetState(PatchSetState psState) {
     this.psState = psState;
   }
@@ -658,6 +673,10 @@
       addIdent(msg, e.getKey()).append('\n');
     }
 
+    for (Map.Entry<Address, ReviewerStateInternal> e : reviewersByEmail.entrySet()) {
+      addFooter(msg, e.getValue().getByEmailFooterKey(), e.getKey().toString());
+    }
+
     for (Table.Cell<String, Account.Id, Optional<Short>> c : approvals.cellSet()) {
       addFooter(msg, FOOTER_LABEL);
       // Label names/values are safe to append without sanitizing.
@@ -711,6 +730,14 @@
       addFooter(msg, FOOTER_READ_ONLY_UNTIL, ChangeNoteUtil.formatTime(serverIdent, readOnlyUntil));
     }
 
+    if (isPrivate != null) {
+      addFooter(msg, FOOTER_PRIVATE, isPrivate);
+    }
+
+    if (workInProgress != null) {
+      addFooter(msg, FOOTER_WORK_IN_PROGRESS, workInProgress);
+    }
+
     cb.setMessage(msg.toString());
     try {
       ObjectId treeId = storeRevisionNotes(rw, ins, curr);
@@ -743,6 +770,7 @@
         && changeMessage == null
         && comments.isEmpty()
         && reviewers.isEmpty()
+        && reviewersByEmail.isEmpty()
         && changeId == null
         && branch == null
         && status == null
@@ -757,7 +785,9 @@
         && tag == null
         && psDescription == null
         && !currentPatchSet
-        && readOnlyUntil == null;
+        && readOnlyUntil == null
+        && isPrivate == null
+        && workInProgress == null;
   }
 
   ChangeDraftUpdate getDraftUpdate() {
@@ -777,6 +807,14 @@
     return isAllowWriteToNewtRef;
   }
 
+  public void setPrivate(boolean isPrivate) {
+    this.isPrivate = isPrivate;
+  }
+
+  public void setWorkInProgress(boolean workInProgress) {
+    this.workInProgress = workInProgress;
+  }
+
   void setReadOnlyUntil(Timestamp readOnlyUntil) {
     this.readOnlyUntil = readOnlyUntil;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ConfigNotesMigration.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ConfigNotesMigration.java
index c0b0525..cd00149 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ConfigNotesMigration.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ConfigNotesMigration.java
@@ -17,13 +17,11 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
 
-import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.util.Set;
 import org.eclipse.jgit.lib.Config;
 
 /**
@@ -44,36 +42,24 @@
     }
   }
 
-  private static final String NOTE_DB = "noteDb";
+  public static final String SECTION_NOTE_DB = "noteDb";
 
-  // All of these names must be reflected in the allowed set in checkConfig.
   private static final String DISABLE_REVIEW_DB = "disableReviewDb";
+  private static final String FUSE_UPDATES = "fuseUpdates";
   private static final String PRIMARY_STORAGE = "primaryStorage";
   private static final String READ = "read";
   private static final String SEQUENCE = "sequence";
   private static final String WRITE = "write";
 
-  private static void checkConfig(Config cfg) {
-    Set<String> keys = ImmutableSet.of(CHANGES.key());
-    Set<String> allowed =
-        ImmutableSet.of(
-            DISABLE_REVIEW_DB.toLowerCase(),
-            PRIMARY_STORAGE.toLowerCase(),
-            READ.toLowerCase(),
-            WRITE.toLowerCase(),
-            SEQUENCE.toLowerCase());
-    for (String t : cfg.getSubsections(NOTE_DB)) {
-      checkArgument(keys.contains(t.toLowerCase()), "invalid NoteDb table: %s", t);
-      for (String key : cfg.getNames(NOTE_DB, t)) {
-        checkArgument(allowed.contains(key.toLowerCase()), "invalid NoteDb key: %s.%s", t, key);
-      }
-    }
-  }
-
   public static Config allEnabledConfig() {
     Config cfg = new Config();
-    cfg.setBoolean(NOTE_DB, CHANGES.key(), WRITE, true);
-    cfg.setBoolean(NOTE_DB, CHANGES.key(), READ, true);
+    cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), WRITE, true);
+    cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), READ, true);
+    cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), SEQUENCE, true);
+    cfg.setString(SECTION_NOTE_DB, CHANGES.key(), PRIMARY_STORAGE, PrimaryStorage.NOTE_DB.name());
+    cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), DISABLE_REVIEW_DB, true);
+    // TODO(dborowitz): Set to true when FileRepository supports it.
+    cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), FUSE_UPDATES, false);
     return cfg;
   }
 
@@ -82,23 +68,24 @@
   private final boolean readChangeSequence;
   private final PrimaryStorage changePrimaryStorage;
   private final boolean disableChangeReviewDb;
+  private final boolean fuseUpdates;
 
   @Inject
-  ConfigNotesMigration(@GerritServerConfig Config cfg) {
-    checkConfig(cfg);
-
-    writeChanges = cfg.getBoolean(NOTE_DB, CHANGES.key(), WRITE, false);
-    readChanges = cfg.getBoolean(NOTE_DB, CHANGES.key(), READ, false);
+  public ConfigNotesMigration(@GerritServerConfig Config cfg) {
+    writeChanges = cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), WRITE, false);
+    readChanges = cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), READ, false);
 
     // Reading change sequence numbers from NoteDb is not the default even if
     // reading changes themselves is. Once this is enabled, it's not easy to
     // undo: ReviewDb might hand out numbers that have already been assigned by
     // NoteDb. This decision for the default may be reevaluated later.
-    readChangeSequence = cfg.getBoolean(NOTE_DB, CHANGES.key(), SEQUENCE, false);
+    readChangeSequence = cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), SEQUENCE, false);
 
     changePrimaryStorage =
-        cfg.getEnum(NOTE_DB, CHANGES.key(), PRIMARY_STORAGE, PrimaryStorage.REVIEW_DB);
-    disableChangeReviewDb = cfg.getBoolean(NOTE_DB, CHANGES.key(), DISABLE_REVIEW_DB, false);
+        cfg.getEnum(SECTION_NOTE_DB, CHANGES.key(), PRIMARY_STORAGE, PrimaryStorage.REVIEW_DB);
+    disableChangeReviewDb =
+        cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), DISABLE_REVIEW_DB, false);
+    fuseUpdates = cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), FUSE_UPDATES, false);
 
     checkArgument(
         !(disableChangeReviewDb && changePrimaryStorage != PrimaryStorage.NOTE_DB),
@@ -106,7 +93,7 @@
   }
 
   @Override
-  protected boolean writeChanges() {
+  public boolean rawWriteChangesSetting() {
     return writeChanges;
   }
 
@@ -129,4 +116,9 @@
   public boolean disableChangeReviewDb() {
     return disableChangeReviewDb;
   }
+
+  @Override
+  public boolean fuseUpdates() {
+    return fuseUpdates;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
index fef7fdf..12967b8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
@@ -78,11 +78,11 @@
       this.code = code;
     }
 
-    public static PrimaryStorage of(Change c) {
+    public static PrimaryStorage of(@Nullable Change c) {
       return of(NoteDbChangeState.parse(c));
     }
 
-    public static PrimaryStorage of(NoteDbChangeState s) {
+    public static PrimaryStorage of(@Nullable NoteDbChangeState s) {
       return s != null ? s.getPrimaryStorage() : REVIEW_DB;
     }
   }
@@ -150,12 +150,12 @@
     }
   }
 
-  public static NoteDbChangeState parse(Change c) {
+  public static NoteDbChangeState parse(@Nullable Change c) {
     return c != null ? parse(c.getId(), c.getNoteDbState()) : null;
   }
 
   @VisibleForTesting
-  public static NoteDbChangeState parse(Change.Id id, String str) {
+  public static NoteDbChangeState parse(Change.Id id, @Nullable String str) {
     if (Strings.isNullOrEmpty(str)) {
       // Return null rather than Optional as this is what goes in the field in
       // ReviewDb.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbTable.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbTable.java
index 255998c..be24e28 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbTable.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbTable.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.notedb;
 
-enum NoteDbTable {
+public enum NoteDbTable {
   ACCOUNTS,
   CHANGES;
 
-  String key() {
+  public String key() {
     return name().toLowerCase();
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index 59d7cbb..83193d5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -22,6 +22,7 @@
 import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ListMultimap;
@@ -43,9 +44,9 @@
 import com.google.gerrit.server.update.ChainedReceiveCommands;
 import com.google.gwtorm.server.OrmConcurrencyException;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.HashMap;
@@ -53,6 +54,7 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import org.eclipse.jgit.internal.JGitText;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectId;
@@ -168,11 +170,15 @@
     }
 
     void flush() throws IOException {
+      flushToFinalInserter();
+      finalIns.flush();
+    }
+
+    void flushToFinalInserter() throws IOException {
       checkState(finalIns != null);
       for (InsertedObject obj : tempIns.getInsertedObjects()) {
         finalIns.insert(obj.type(), obj.data().toByteArray());
       }
-      finalIns.flush();
       tempIns.clear();
     }
 
@@ -207,7 +213,7 @@
   private String refLogMessage;
   private PersonIdent refLogIdent;
 
-  @AssistedInject
+  @Inject
   NoteDbUpdateManager(
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
       GitRepositoryManager repoManager,
@@ -317,7 +323,13 @@
     return changeUpdates.isEmpty()
         && draftUpdates.isEmpty()
         && robotCommentUpdates.isEmpty()
-        && toDelete.isEmpty();
+        && toDelete.isEmpty()
+        && !hasCommands(changeRepo)
+        && !hasCommands(allUsersRepo);
+  }
+
+  private static boolean hasCommands(@Nullable OpenRepo or) {
+    return or != null && !or.cmds.isEmpty();
   }
 
   /**
@@ -385,6 +397,10 @@
       Set<Change.Id> changeIds = new HashSet<>();
       for (ReceiveCommand cmd : changeRepo.getCommandsSnapshot()) {
         Change.Id changeId = Change.Id.fromRef(cmd.getRefName());
+        if (changeId == null || !cmd.getRefName().equals(RefNames.changeMetaRef(changeId))) {
+          // Not a meta ref update, likely due to a repo update along with the change meta update.
+          continue;
+        }
         changeIds.add(changeId);
         Optional<ObjectId> metaId = Optional.of(cmd.getNewId());
         staged.put(
@@ -450,13 +466,19 @@
     }
   }
 
-  public void execute() throws OrmException, IOException {
+  @Nullable
+  public BatchRefUpdate execute() throws OrmException, IOException {
+    return execute(false);
+  }
+
+  @Nullable
+  public BatchRefUpdate execute(boolean dryrun) throws OrmException, IOException {
     // Check before even inspecting the list, as this is a programmer error.
     if (migration.failChangeWrites()) {
       throw new OrmException(CHANGES_READ_ONLY);
     }
     if (isEmpty()) {
-      return;
+      return null;
     }
     try (Timer1.Context timer = metrics.updateLatency.start(CHANGES)) {
       stage();
@@ -468,35 +490,78 @@
       // we may have stale draft comments. Doing it in this order allows stale
       // comments to be filtered out by ChangeNotes, reflecting the fact that
       // comments can only go from DRAFT to PUBLISHED, not vice versa.
-      execute(changeRepo);
-      execute(allUsersRepo);
+      BatchRefUpdate result = execute(changeRepo, dryrun);
+      execute(allUsersRepo, dryrun);
+      return result;
     } finally {
       close();
     }
   }
 
-  private void execute(OpenRepo or) throws IOException {
+  private BatchRefUpdate execute(OpenRepo or, boolean dryrun) throws IOException {
     if (or == null || or.cmds.isEmpty()) {
-      return;
+      return null;
     }
-    or.flush();
+    if (!dryrun) {
+      or.flush();
+    } else {
+      // OpenRepo buffers objects separately; caller may assume that objects are available in the
+      // inserter it previously passed via setChangeRepo.
+      or.flushToFinalInserter();
+    }
+
     BatchRefUpdate bru = or.repo.getRefDatabase().newBatchUpdate();
     bru.setRefLogMessage(firstNonNull(refLogMessage, "Update NoteDb refs"), false);
     bru.setRefLogIdent(refLogIdent != null ? refLogIdent : serverIdent.get());
     or.cmds.addTo(bru);
     bru.setAllowNonFastForwards(true);
-    bru.execute(or.rw, NullProgressMonitor.INSTANCE);
 
-    boolean lockFailure = false;
+    if (!dryrun) {
+      bru.execute(or.rw, NullProgressMonitor.INSTANCE);
+      checkResults(bru);
+    }
+    return bru;
+  }
+
+  /**
+   * Check results of all commands in the update batch, reducing to a single exception if there was
+   * a failure.
+   *
+   * <p>Throws {@link LockFailureException} if at least one command failed with {@code
+   * LOCK_FAILURE}, and the entire transaction was aborted, i.e. any non-{@code LOCK_FAILURE}
+   * results, if there were any, failed with "transaction aborted".
+   *
+   * <p>In particular, if the underlying ref database does not {@link
+   * org.eclipse.jgit.lib.RefDatabase#performsAtomicTransactions() perform atomic transactions},
+   * then a combination of {@code LOCK_FAILURE} on one ref and {@code OK} or another result on other
+   * refs will <em>not</em> throw {@code LockFailureException}.
+   *
+   * @param bru batch update; should already have been executed.
+   * @throws LockFailureException if the transaction was aborted due to lock failure.
+   * @throws IOException if any result was not {@code OK}.
+   */
+  @VisibleForTesting
+  static void checkResults(BatchRefUpdate bru) throws LockFailureException, IOException {
+    int lockFailure = 0;
+    int aborted = 0;
+    int failure = 0;
+
     for (ReceiveCommand cmd : bru.getCommands()) {
+      if (cmd.getResult() != ReceiveCommand.Result.OK) {
+        failure++;
+      }
       if (cmd.getResult() == ReceiveCommand.Result.LOCK_FAILURE) {
-        lockFailure = true;
-      } else if (cmd.getResult() != ReceiveCommand.Result.OK) {
-        throw new IOException("Update failed: " + bru);
+        lockFailure++;
+      } else if (cmd.getResult() == ReceiveCommand.Result.REJECTED_OTHER_REASON
+          && JGitText.get().transactionAborted.equals(cmd.getMessage())) {
+        aborted++;
       }
     }
-    if (lockFailure) {
-      throw new LockFailureException("Update failed with one or more lock failures: " + bru);
+
+    if (lockFailure + aborted == bru.getCommands().size()) {
+      throw new LockFailureException("Update aborted with one or more lock failures: " + bru);
+    } else if (failure > 0) {
+      throw new IOException("Update failed: " + bru);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java
index c708bfe..7b0d76f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java
@@ -49,6 +49,9 @@
   /**
    * Write changes to NoteDb.
    *
+   * <p>This method is awkwardly named because you should be using either {@link
+   * #commitChangeWrites()} or {@link #failChangeWrites()} instead.
+   *
    * <p>Updates to change data are written to NoteDb refs, but ReviewDb is still the source of
    * truth. Change data will not be written unless the NoteDb refs are already up to date, and the
    * write path will attempt to rebuild the change if not.
@@ -57,7 +60,7 @@
    * readChanges() = false}, writes to NoteDb are simply ignored; if {@code true}, any attempts to
    * write will generate an error.
    */
-  protected abstract boolean writeChanges();
+  public abstract boolean rawWriteChangesSetting();
 
   /**
    * Read sequential change ID numbers from NoteDb.
@@ -80,6 +83,19 @@
   public abstract boolean disableChangeReviewDb();
 
   /**
+   * Fuse meta ref updates in the same batch as code updates.
+   *
+   * <p>When set, each {@link com.google.gerrit.server.update.BatchUpdate} results in a single
+   * {@link org.eclipse.jgit.lib.BatchRefUpdate} to update both code and meta refs atomically.
+   * Setting this option with a repository backend that does not support atomic multi-ref
+   * transactions ({@link org.eclipse.jgit.lib.RefDatabase#performsAtomicTransactions()}) is a
+   * configuration error, and all updates will fail at runtime.
+   *
+   * <p>Has no effect if {@link #disableChangeReviewDb()} is false.
+   */
+  public abstract boolean fuseUpdates();
+
+  /**
    * Whether to fail when reading any data from NoteDb.
    *
    * <p>Used in conjunction with {@link #readChanges()} for tests.
@@ -99,14 +115,14 @@
     // same codepath. This specific condition is used by the auto-rebuilding
     // path to rebuild a change and stage the results, but not commit them due
     // to failChangeWrites().
-    return writeChanges() || readChanges();
+    return rawWriteChangesSetting() || readChanges();
   }
 
   public boolean failChangeWrites() {
-    return !writeChanges() && readChanges();
+    return !rawWriteChangesSetting() && readChanges();
   }
 
   public boolean enabled() {
-    return writeChanges() || readChanges();
+    return rawWriteChangesSetting() || readChanges();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
index f250646..fad9832 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
@@ -29,13 +29,17 @@
   /** The user was previously a reviewer on the change, but was removed. */
   REMOVED(new FooterKey("Removed"), ReviewerState.REMOVED);
 
+  public static ReviewerStateInternal fromReviewerState(ReviewerState state) {
+    return ReviewerStateInternal.values()[state.ordinal()];
+  }
+
   static {
     boolean ok = true;
     if (ReviewerStateInternal.values().length != ReviewerState.values().length) {
       ok = false;
     }
-    for (ReviewerStateInternal s : ReviewerStateInternal.values()) {
-      ok &= s.name().equals(s.state.name());
+    for (int i = 0; i < ReviewerStateInternal.values().length; i++) {
+      ok &= ReviewerState.values()[i].equals(ReviewerStateInternal.values()[i].state);
     }
     if (!ok) {
       throw new IllegalStateException(
@@ -58,6 +62,10 @@
     return footerKey;
   }
 
+  FooterKey getByEmailFooterKey() {
+    return new FooterKey(footerKey.getName() + "-email");
+  }
+
   public ReviewerState asReviewerState() {
     return state;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
index e6549f0..99d9615 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
@@ -24,8 +24,8 @@
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -44,7 +44,7 @@
   private RevisionNoteMap<RobotCommentsRevisionNote> revisionNoteMap;
   private ObjectId metaId;
 
-  @AssistedInject
+  @Inject
   RobotCommentNotes(Args args, @Assisted Change change) {
     super(args, change.getId(), PrimaryStorage.of(change), false);
     this.change = change;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java
index ad22330..29528f9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java
@@ -35,11 +35,17 @@
                   "^Change has been successfully (merged|cherry-picked|rebased|pushed).*$"),
           Change.Status.NEW, Pattern.compile("^Restored(\n.*)*$"));
 
+  private static final Pattern PRIVATE_SET_REGEXP = Pattern.compile("^Set private$");
+  private static final Pattern PRIVATE_UNSET_REGEXP = Pattern.compile("^Unset private$");
+
   private static final Pattern TOPIC_SET_REGEXP = Pattern.compile("^Topic set to (.+)$");
   private static final Pattern TOPIC_CHANGED_REGEXP =
       Pattern.compile("^Topic changed from (.+) to (.+)$");
   private static final Pattern TOPIC_REMOVED_REGEXP = Pattern.compile("^Topic (.+) removed$");
 
+  private static final Pattern WIP_SET_REGEXP = Pattern.compile("^Set Work In Progress$");
+  private static final Pattern WIP_UNSET_REGEXP = Pattern.compile("^Set Ready For Review$");
+
   private final Change change;
   private final Change noteDbChange;
   private final Optional<Change.Status> status;
@@ -80,7 +86,9 @@
   void apply(ChangeUpdate update) throws OrmException {
     checkUpdate(update);
     update.setChangeMessage(message.getMessage());
+    setPrivate(update);
     setTopic(update);
+    setWorkInProgress(update);
 
     if (status.isPresent()) {
       Change.Status s = status.get();
@@ -106,6 +114,25 @@
     return Optional.empty();
   }
 
+  private void setPrivate(ChangeUpdate update) {
+    String msg = message.getMessage();
+    if (msg == null) {
+      return;
+    }
+    Matcher m = PRIVATE_SET_REGEXP.matcher(msg);
+    if (m.matches()) {
+      update.setPrivate(true);
+      noteDbChange.setPrivate(true);
+      return;
+    }
+
+    m = PRIVATE_UNSET_REGEXP.matcher(msg);
+    if (m.matches()) {
+      update.setPrivate(false);
+      noteDbChange.setPrivate(false);
+    }
+  }
+
   private void setTopic(ChangeUpdate update) {
     String msg = message.getMessage();
     if (msg == null) {
@@ -133,6 +160,25 @@
     }
   }
 
+  private void setWorkInProgress(ChangeUpdate update) {
+    String msg = message.getMessage();
+    if (msg == null) {
+      return;
+    }
+    Matcher m = WIP_SET_REGEXP.matcher(msg);
+    if (m.matches()) {
+      update.setWorkInProgress(true);
+      noteDbChange.setWorkInProgress(true);
+      return;
+    }
+
+    m = WIP_UNSET_REGEXP.matcher(msg);
+    if (m.matches()) {
+      update.setWorkInProgress(false);
+      noteDbChange.setWorkInProgress(false);
+    }
+  }
+
   @Override
   protected void addToString(ToStringHelper helper) {
     helper.add("message", message);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java
index 6f9090f..8ce9987 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java
@@ -25,7 +25,6 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import java.io.IOException;
-import java.util.concurrent.Callable;
 
 public abstract class ChangeRebuilder {
   public static class NoPatchSetsException extends OrmException {
@@ -43,14 +42,11 @@
   }
 
   public final ListenableFuture<Result> rebuildAsync(
-      final Change.Id id, ListeningExecutorService executor) {
+      Change.Id id, ListeningExecutorService executor) {
     return executor.submit(
-        new Callable<Result>() {
-          @Override
-          public Result call() throws Exception {
-            try (ReviewDb db = schemaFactory.open()) {
-              return rebuild(db, id);
-            }
+        () -> {
+          try (ReviewDb db = schemaFactory.open()) {
+            return rebuild(db, id);
           }
         });
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java
index b1bd6ec..55d5a31 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java
@@ -57,6 +57,12 @@
       // TODO(dborowitz): Stamp approximate approvals at this time.
       update.fixStatus(change.getStatus());
     }
+    if (change.isPrivate() != noteDbChange.isPrivate()) {
+      update.setPrivate(change.isPrivate());
+    }
+    if (change.isWorkInProgress() != noteDbChange.isWorkInProgress()) {
+      update.setWorkInProgress(change.isWorkInProgress());
+    }
     if (change.getSubmissionId() != null && noteDbChange.getSubmissionId() == null) {
       update.setSubmissionId(change.getSubmissionId());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
index fa02691..188513f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
@@ -16,8 +16,8 @@
 
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -36,7 +36,7 @@
   private final DiffSummaryKey key;
   private final Project.NameKey project;
 
-  @AssistedInject
+  @Inject
   DiffSummaryLoader(PatchListCache plc, @Assisted DiffSummaryKey k, @Assisted Project.NameKey p) {
     patchListCache = plc;
     key = k;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java
index a571c46..54d9540 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java
@@ -18,8 +18,8 @@
 import com.google.common.base.Throwables;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.util.List;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
@@ -52,7 +52,7 @@
   private final IntraLineDiffKey key;
   private final IntraLineDiffArgs args;
 
-  @AssistedInject
+  @Inject
   IntraLineLoader(
       @DiffExecutor ExecutorService diffExecutor,
       @GerritServerConfig Config cfg,
@@ -75,12 +75,7 @@
   public IntraLineDiff call() throws Exception {
     Future<IntraLineDiff> result =
         diffExecutor.submit(
-            new Callable<IntraLineDiff>() {
-              @Override
-              public IntraLineDiff call() throws Exception {
-                return IntraLineLoader.compute(args.aText(), args.bText(), args.edits());
-              }
-            });
+            () -> IntraLineLoader.compute(args.aText(), args.bText(), args.edits()));
     try {
       return result.get(timeoutMillis, TimeUnit.MILLISECONDS);
     } catch (InterruptedException | TimeoutException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
index 124fe8e..b766a02 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
@@ -29,8 +29,8 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -86,7 +86,7 @@
   private final long timeoutMillis;
   private final boolean save;
 
-  @AssistedInject
+  @Inject
   PatchListLoader(
       GitRepositoryManager mgr,
       PatchListCache plc,
@@ -250,17 +250,13 @@
   }
 
   private FileHeader toFileHeader(
-      PatchListKey key, final DiffFormatter diffFormatter, final DiffEntry diffEntry)
-      throws IOException {
+      PatchListKey key, DiffFormatter diffFormatter, DiffEntry diffEntry) throws IOException {
 
     Future<FileHeader> result =
         diffExecutor.submit(
-            new Callable<FileHeader>() {
-              @Override
-              public FileHeader call() throws IOException {
-                synchronized (diffEntry) {
-                  return diffFormatter.toFileHeader(diffEntry);
-                }
+            () -> {
+              synchronized (diffEntry) {
+                return diffFormatter.toFileHeader(diffEntry);
               }
             });
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 82c6150..9e80f38 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -253,9 +253,7 @@
     return b;
   }
 
-  private ObjectId toObjectId(PatchSet ps)
-      throws NoSuchChangeException, AuthException, NoSuchChangeException, IOException,
-          OrmException {
+  private ObjectId toObjectId(PatchSet ps) throws AuthException, IOException, OrmException {
     if (ps.getId().get() == 0) {
       return getEditRev();
     }
@@ -271,11 +269,10 @@
     }
   }
 
-  private ObjectId getEditRev()
-      throws AuthException, NoSuchChangeException, IOException, OrmException {
+  private ObjectId getEditRev() throws AuthException, IOException, OrmException {
     edit = editReader.byChange(change);
     if (edit.isPresent()) {
-      return edit.get().getRef().getObjectId();
+      return edit.get().getEditCommit();
     }
     throw new NoSuchChangeException(change.getId());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ChangePermission.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ChangePermission.java
new file mode 100644
index 0000000..4b06861
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ChangePermission.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import com.google.gerrit.common.data.Permission;
+import java.util.Locale;
+import java.util.Optional;
+
+public enum ChangePermission implements ChangePermissionOrLabel {
+  READ(Permission.READ),
+  RESTORE,
+  DELETE,
+  ABANDON(Permission.ABANDON),
+  EDIT_ASSIGNEE(Permission.EDIT_ASSIGNEE),
+  EDIT_DESCRIPTION,
+  EDIT_HASHTAGS(Permission.EDIT_HASHTAGS),
+  EDIT_TOPIC_NAME(Permission.EDIT_TOPIC_NAME),
+  REMOVE_REVIEWER(Permission.REMOVE_REVIEWER),
+  ADD_PATCH_SET(Permission.ADD_PATCH_SET),
+  REBASE(Permission.REBASE),
+  SUBMIT(Permission.SUBMIT),
+  SUBMIT_AS(Permission.SUBMIT_AS);
+
+  private final String name;
+
+  ChangePermission() {
+    name = null;
+  }
+
+  ChangePermission(String name) {
+    this.name = name;
+  }
+
+  /** @return name used in {@code project.config} permissions. */
+  @Override
+  public Optional<String> permissionName() {
+    return Optional.ofNullable(name);
+  }
+
+  @Override
+  public String describeForException() {
+    return toString().toLowerCase(Locale.US).replace('_', ' ');
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
new file mode 100644
index 0000000..06c0d73
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import java.util.Optional;
+
+/** A {@link ChangePermission} or a {@link LabelPermission}. */
+public interface ChangePermissionOrLabel {
+  /** @return name used in {@code project.config} permissions. */
+  public Optional<String> permissionName();
+
+  /** @return readable identifier of this permission for exception message. */
+  public String describeForException();
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
new file mode 100644
index 0000000..24f5164
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
@@ -0,0 +1,173 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
+import com.google.gerrit.server.permissions.PermissionBackend.ForProject;
+import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Provider;
+import java.util.Collection;
+import java.util.Set;
+
+/**
+ * Helpers for {@link PermissionBackend} that must fail.
+ *
+ * <p>These helpers are useful to curry failure state identified inside a non-throwing factory
+ * method to the throwing {@code check} or {@code test} methods.
+ */
+public class FailedPermissionBackend {
+  public static ForProject project(String message) {
+    return project(message, null);
+  }
+
+  public static ForProject project(String message, Throwable cause) {
+    return new FailedProject(message, cause);
+  }
+
+  public static ForRef ref(String message) {
+    return ref(message, null);
+  }
+
+  public static ForRef ref(String message, Throwable cause) {
+    return new FailedRef(message, cause);
+  }
+
+  public static ForChange change(String message) {
+    return change(message, null);
+  }
+
+  public static ForChange change(String message, Throwable cause) {
+    return new FailedChange(message, cause);
+  }
+
+  private FailedPermissionBackend() {}
+
+  private static class FailedProject extends ForProject {
+    private final String message;
+    private final Throwable cause;
+
+    FailedProject(String message, Throwable cause) {
+      this.message = message;
+      this.cause = cause;
+    }
+
+    @Override
+    public ForProject database(Provider<ReviewDb> db) {
+      return this;
+    }
+
+    @Override
+    public ForProject user(CurrentUser user) {
+      return this;
+    }
+
+    @Override
+    public ForRef ref(String ref) {
+      return new FailedRef(message, cause);
+    }
+
+    @Override
+    public void check(ProjectPermission perm) throws PermissionBackendException {
+      throw new PermissionBackendException(message, cause);
+    }
+
+    @Override
+    public Set<ProjectPermission> test(Collection<ProjectPermission> permSet)
+        throws PermissionBackendException {
+      throw new PermissionBackendException(message, cause);
+    }
+  }
+
+  private static class FailedRef extends ForRef {
+    private final String message;
+    private final Throwable cause;
+
+    FailedRef(String message, Throwable cause) {
+      this.message = message;
+      this.cause = cause;
+    }
+
+    @Override
+    public ForRef database(Provider<ReviewDb> db) {
+      return this;
+    }
+
+    @Override
+    public ForRef user(CurrentUser user) {
+      return this;
+    }
+
+    @Override
+    public ForChange change(ChangeData cd) {
+      return new FailedChange(message, cause);
+    }
+
+    @Override
+    public ForChange change(ChangeNotes cd) {
+      return new FailedChange(message, cause);
+    }
+
+    @Override
+    public void check(RefPermission perm) throws PermissionBackendException {
+      throw new PermissionBackendException(message, cause);
+    }
+
+    @Override
+    public Set<RefPermission> test(Collection<RefPermission> permSet)
+        throws PermissionBackendException {
+      throw new PermissionBackendException(message, cause);
+    }
+  }
+
+  private static class FailedChange extends ForChange {
+    private final String message;
+    private final Throwable cause;
+
+    FailedChange(String message, Throwable cause) {
+      this.message = message;
+      this.cause = cause;
+    }
+
+    @Override
+    public ForChange database(Provider<ReviewDb> db) {
+      return this;
+    }
+
+    @Override
+    public ForChange user(CurrentUser user) {
+      return this;
+    }
+
+    @Override
+    public void check(ChangePermissionOrLabel perm) throws PermissionBackendException {
+      throw new PermissionBackendException(message, cause);
+    }
+
+    @Override
+    public <T extends ChangePermissionOrLabel> Set<T> test(Collection<T> permSet)
+        throws PermissionBackendException {
+      throw new PermissionBackendException(message, cause);
+    }
+
+    @Override
+    public CurrentUser user() {
+      throw new UnsupportedOperationException("FailedPermissionBackend is not scoped to user");
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/GlobalPermission.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/GlobalPermission.java
new file mode 100644
index 0000000..926057b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/GlobalPermission.java
@@ -0,0 +1,168 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.CapabilityScope;
+import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.api.access.PluginPermission;
+import java.lang.annotation.Annotation;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Locale;
+import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Global server permissions built into Gerrit. */
+public enum GlobalPermission implements GlobalOrPluginPermission {
+  ACCESS_DATABASE(GlobalCapability.ACCESS_DATABASE),
+  ADMINISTRATE_SERVER(GlobalCapability.ADMINISTRATE_SERVER),
+  CREATE_ACCOUNT(GlobalCapability.CREATE_ACCOUNT),
+  CREATE_GROUP(GlobalCapability.CREATE_GROUP),
+  CREATE_PROJECT(GlobalCapability.CREATE_PROJECT),
+  EMAIL_REVIEWERS(GlobalCapability.EMAIL_REVIEWERS),
+  FLUSH_CACHES(GlobalCapability.FLUSH_CACHES),
+  KILL_TASK(GlobalCapability.KILL_TASK),
+  MAINTAIN_SERVER(GlobalCapability.MAINTAIN_SERVER),
+  MODIFY_ACCOUNT(GlobalCapability.MODIFY_ACCOUNT),
+  RUN_AS(GlobalCapability.RUN_AS),
+  RUN_GC(GlobalCapability.RUN_GC),
+  STREAM_EVENTS(GlobalCapability.STREAM_EVENTS),
+  VIEW_ALL_ACCOUNTS(GlobalCapability.VIEW_ALL_ACCOUNTS),
+  VIEW_CACHES(GlobalCapability.VIEW_CACHES),
+  VIEW_CONNECTIONS(GlobalCapability.VIEW_CONNECTIONS),
+  VIEW_PLUGINS(GlobalCapability.VIEW_PLUGINS),
+  VIEW_QUEUE(GlobalCapability.VIEW_QUEUE);
+
+  private static final Logger log = LoggerFactory.getLogger(GlobalPermission.class);
+  private static final ImmutableMap<String, GlobalPermission> BY_NAME;
+
+  static {
+    ImmutableMap.Builder<String, GlobalPermission> m = ImmutableMap.builder();
+    for (GlobalPermission p : values()) {
+      m.put(p.permissionName(), p);
+    }
+    BY_NAME = m.build();
+  }
+
+  @Nullable
+  public static GlobalPermission byName(String name) {
+    return BY_NAME.get(name);
+  }
+
+  /**
+   * Extracts the {@code @RequiresCapability} or {@code @RequiresAnyCapability} annotation.
+   *
+   * @param pluginName name of the declaring plugin. May be {@code null} or {@code "gerrit"} for
+   *     classes originating from the core server.
+   * @param clazz target class to extract annotation from.
+   * @return empty set if no annotations were found, or a collection of permissions, any of which
+   *     are suitable to enable access.
+   * @throws PermissionBackendException the annotation could not be parsed.
+   */
+  public static Set<GlobalOrPluginPermission> fromAnnotation(
+      @Nullable String pluginName, Class<?> clazz) throws PermissionBackendException {
+    RequiresCapability rc = findAnnotation(clazz, RequiresCapability.class);
+    RequiresAnyCapability rac = findAnnotation(clazz, RequiresAnyCapability.class);
+    if (rc != null && rac != null) {
+      log.error(
+          String.format(
+              "Class %s uses both @%s and @%s",
+              clazz.getName(),
+              RequiresCapability.class.getSimpleName(),
+              RequiresAnyCapability.class.getSimpleName()));
+      throw new PermissionBackendException("cannot extract permission");
+    } else if (rc != null) {
+      return Collections.singleton(
+          resolve(pluginName, rc.value(), rc.scope(), clazz, RequiresCapability.class));
+    } else if (rac != null) {
+      Set<GlobalOrPluginPermission> r = new LinkedHashSet<>();
+      for (String capability : rac.value()) {
+        r.add(resolve(pluginName, capability, rac.scope(), clazz, RequiresAnyCapability.class));
+      }
+      return Collections.unmodifiableSet(r);
+    } else {
+      return Collections.emptySet();
+    }
+  }
+
+  public static Set<GlobalOrPluginPermission> fromAnnotation(Class<?> clazz)
+      throws PermissionBackendException {
+    return fromAnnotation(null, clazz);
+  }
+
+  private final String name;
+
+  GlobalPermission(String name) {
+    this.name = name;
+  }
+
+  /** @return name used in {@code project.config} permissions. */
+  @Override
+  public String permissionName() {
+    return name;
+  }
+
+  @Override
+  public String describeForException() {
+    return toString().toLowerCase(Locale.US).replace('_', ' ');
+  }
+
+  private static GlobalOrPluginPermission resolve(
+      @Nullable String pluginName,
+      String capability,
+      CapabilityScope scope,
+      Class<?> clazz,
+      Class<?> annotationClass)
+      throws PermissionBackendException {
+    if (pluginName != null
+        && !"gerrit".equals(pluginName)
+        && (scope == CapabilityScope.PLUGIN || scope == CapabilityScope.CONTEXT)) {
+      return new PluginPermission(pluginName, capability);
+    }
+
+    if (scope == CapabilityScope.PLUGIN) {
+      log.error(
+          String.format(
+              "Class %s uses @%s(scope=%s), but is not within a plugin",
+              clazz.getName(), annotationClass.getSimpleName(), scope.name()));
+      throw new PermissionBackendException("cannot extract permission");
+    }
+
+    GlobalPermission perm = byName(capability);
+    if (perm == null) {
+      log.error(
+          String.format("Class %s requires unknown capability %s", clazz.getName(), capability));
+      throw new PermissionBackendException("cannot extract permission");
+    }
+    return perm;
+  }
+
+  @Nullable
+  private static <T extends Annotation> T findAnnotation(Class<?> clazz, Class<T> annotation) {
+    for (; clazz != null; clazz = clazz.getSuperclass()) {
+      T t = clazz.getAnnotation(annotation);
+      if (t != null) {
+        return t;
+      }
+    }
+    return null;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/LabelPermission.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/LabelPermission.java
new file mode 100644
index 0000000..747c997
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/LabelPermission.java
@@ -0,0 +1,273 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
+import static com.google.gerrit.server.permissions.LabelPermission.ForUser.SELF;
+
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.server.util.LabelVote;
+import java.util.Optional;
+
+/** Permission representing a label. */
+public class LabelPermission implements ChangePermissionOrLabel {
+  public enum ForUser {
+    SELF,
+    ON_BEHALF_OF;
+  }
+
+  private final ForUser forUser;
+  private final String name;
+
+  /**
+   * Construct a reference to a label permission.
+   *
+   * @param type type description of the label.
+   */
+  public LabelPermission(LabelType type) {
+    this(SELF, type);
+  }
+
+  /**
+   * Construct a reference to a label permission.
+   *
+   * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+   * @param type type description of the label.
+   */
+  public LabelPermission(ForUser forUser, LabelType type) {
+    this(forUser, type.getName());
+  }
+
+  /**
+   * Construct a reference to a label permission.
+   *
+   * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+   */
+  public LabelPermission(String name) {
+    this(SELF, name);
+  }
+
+  /**
+   * Construct a reference to a label permission.
+   *
+   * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+   * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+   */
+  public LabelPermission(ForUser forUser, String name) {
+    this.forUser = checkNotNull(forUser, "ForUser");
+    this.name = LabelType.checkName(name);
+  }
+
+  /** @return {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
+  public ForUser forUser() {
+    return forUser;
+  }
+
+  /** @return name of the label, e.g. {@code "Code-Review"}. */
+  public String label() {
+    return name;
+  }
+
+  /** @return name used in {@code project.config} permissions. */
+  @Override
+  public Optional<String> permissionName() {
+    switch (forUser) {
+      case SELF:
+        return Optional.of(Permission.forLabel(name));
+      case ON_BEHALF_OF:
+        return Optional.of(Permission.forLabelAs(name));
+    }
+    return Optional.empty();
+  }
+
+  @Override
+  public String describeForException() {
+    if (forUser == ON_BEHALF_OF) {
+      return "labelAs " + name;
+    }
+    return "label " + name;
+  }
+
+  @Override
+  public int hashCode() {
+    return name.hashCode();
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (other instanceof LabelPermission) {
+      LabelPermission b = (LabelPermission) other;
+      return forUser == b.forUser && name.equals(b.name);
+    }
+    return false;
+  }
+
+  @Override
+  public String toString() {
+    if (forUser == ON_BEHALF_OF) {
+      return "LabelAs[" + name + ']';
+    }
+    return "Label[" + name + ']';
+  }
+
+  /** A {@link LabelPermission} at a specific value. */
+  public static class WithValue implements ChangePermissionOrLabel {
+    private final ForUser forUser;
+    private final LabelVote label;
+
+    /**
+     * Construct a reference to a label at a specific value.
+     *
+     * @param type description of the label.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(LabelType type, LabelValue value) {
+      this(SELF, type, value);
+    }
+
+    /**
+     * Construct a reference to a label at a specific value.
+     *
+     * @param type description of the label.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(LabelType type, short value) {
+      this(SELF, type.getName(), value);
+    }
+
+    /**
+     * Construct a reference to a label at a specific value.
+     *
+     * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+     * @param type description of the label.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(ForUser forUser, LabelType type, LabelValue value) {
+      this(forUser, type.getName(), value.getValue());
+    }
+
+    /**
+     * Construct a reference to a label at a specific value.
+     *
+     * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+     * @param type description of the label.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(ForUser forUser, LabelType type, short value) {
+      this(forUser, type.getName(), value);
+    }
+
+    /**
+     * Construct a reference to a label at a specific value.
+     *
+     * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(String name, short value) {
+      this(SELF, name, value);
+    }
+
+    /**
+     * Construct a reference to a label at a specific value.
+     *
+     * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+     * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(ForUser forUser, String name, short value) {
+      this(forUser, LabelVote.create(name, value));
+    }
+
+    /**
+     * Construct a reference to a label at a specific value.
+     *
+     * @param label label name and vote.
+     */
+    public WithValue(LabelVote label) {
+      this(SELF, label);
+    }
+
+    /**
+     * Construct a reference to a label at a specific value.
+     *
+     * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+     * @param label label name and vote.
+     */
+    public WithValue(ForUser forUser, LabelVote label) {
+      this.forUser = checkNotNull(forUser, "ForUser");
+      this.label = checkNotNull(label, "LabelVote");
+    }
+
+    /** @return {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
+    public ForUser forUser() {
+      return forUser;
+    }
+
+    /** @return name of the label, e.g. {@code "Code-Review"}. */
+    public String label() {
+      return label.label();
+    }
+
+    /** @return specific value of the label, e.g. 1 or 2. */
+    public short value() {
+      return label.value();
+    }
+
+    /** @return name used in {@code project.config} permissions. */
+    @Override
+    public Optional<String> permissionName() {
+      switch (forUser) {
+        case SELF:
+          return Optional.of(Permission.forLabel(label()));
+        case ON_BEHALF_OF:
+          return Optional.of(Permission.forLabelAs(label()));
+      }
+      return Optional.empty();
+    }
+
+    @Override
+    public String describeForException() {
+      if (forUser == ON_BEHALF_OF) {
+        return "labelAs " + label.formatWithEquals();
+      }
+      return "label " + label.formatWithEquals();
+    }
+
+    @Override
+    public int hashCode() {
+      return label.hashCode();
+    }
+
+    @Override
+    public boolean equals(Object other) {
+      if (other instanceof WithValue) {
+        WithValue b = (WithValue) other;
+        return forUser == b.forUser && label.equals(b.label);
+      }
+      return false;
+    }
+
+    @Override
+    public String toString() {
+      if (forUser == ON_BEHALF_OF) {
+        return "LabelAs[" + label.format() + ']';
+      }
+      return "Label[" + label.format() + ']';
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackend.java
new file mode 100644
index 0000000..156f106
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -0,0 +1,417 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Provider;
+import com.google.inject.util.Providers;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.Iterator;
+import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Checks authorization to perform an action on a project, reference, or change.
+ *
+ * <p>{@code check} methods should be used during action handlers to verify the user is allowed to
+ * exercise the specified permission. For convenience in implementation {@code check} methods throw
+ * {@link AuthException} if the permission is denied.
+ *
+ * <p>{@code test} methods should be used when constructing replies to the client and the result
+ * object needs to include a true/false hint indicating the user's ability to exercise the
+ * permission. This is suitable for configuring UI button state, but should not be relied upon to
+ * guard handlers before making state changes.
+ *
+ * <p>{@code PermissionBackend} is a singleton for the server, acting as a factory for lightweight
+ * request instances. Implementation classes may cache supporting data inside of {@link WithUser},
+ * {@link ForProject}, {@link ForRef}, and {@link ForChange} instances, in addition to storing
+ * within {@link CurrentUser} using a {@link com.google.gerrit.server.CurrentUser.PropertyKey}.
+ * {@link GlobalPermission} caching for {@link WithUser} may best cached inside {@link CurrentUser}
+ * as {@link WithUser} instances are frequently created.
+ *
+ * <p>Example use:
+ *
+ * <pre>
+ *   private final PermissionBackend permissions;
+ *   private final Provider<CurrentUser> user;
+ *
+ *   @Inject
+ *   Foo(PermissionBackend permissions, Provider<CurrentUser> user) {
+ *     this.permissions = permissions;
+ *     this.user = user;
+ *   }
+ *
+ *   public void apply(...) {
+ *     permissions.user(user).change(cd).check(ChangePermission.SUBMIT);
+ *   }
+ *
+ *   public UiAction.Description getDescription(ChangeResource rsrc) {
+ *     return new UiAction.Description()
+ *       .setLabel("Submit")
+ *       .setVisible(rsrc.permissions().testOrFalse(ChangePermission.SUBMIT));
+ * }
+ * </pre>
+ */
+public abstract class PermissionBackend {
+  private static final Logger logger = LoggerFactory.getLogger(PermissionBackend.class);
+
+  /** @return lightweight factory scoped to answer for the specified user. */
+  public abstract WithUser user(CurrentUser user);
+
+  /** @return lightweight factory scoped to answer for the specified user. */
+  public <U extends CurrentUser> WithUser user(Provider<U> user) {
+    return user(checkNotNull(user, "Provider<CurrentUser>").get());
+  }
+
+  /** PermissionBackend with an optional per-request ReviewDb handle. */
+  public abstract static class AcceptsReviewDb<T> {
+    protected Provider<ReviewDb> db;
+
+    public T database(Provider<ReviewDb> db) {
+      if (db != null) {
+        this.db = db;
+      }
+      return self();
+    }
+
+    public T database(ReviewDb db) {
+      return database(Providers.of(checkNotNull(db, "ReviewDb")));
+    }
+
+    @SuppressWarnings("unchecked")
+    private T self() {
+      return (T) this;
+    }
+  }
+
+  /** PermissionBackend scoped to a specific user. */
+  public abstract static class WithUser extends AcceptsReviewDb<WithUser> {
+    /** @return instance scoped for the specified project. */
+    public abstract ForProject project(Project.NameKey project);
+
+    /** @return instance scoped for the {@code ref}, and its parent project. */
+    public ForRef ref(Branch.NameKey ref) {
+      return project(ref.getParentKey()).ref(ref.get()).database(db);
+    }
+
+    /** @return instance scoped for the change, and its destination ref and project. */
+    public ForChange change(ChangeData cd) {
+      try {
+        return ref(cd.change().getDest()).change(cd);
+      } catch (OrmException e) {
+        return FailedPermissionBackend.change("unavailable", e);
+      }
+    }
+
+    /** @return instance scoped for the change, and its destination ref and project. */
+    public ForChange change(ChangeNotes notes) {
+      return ref(notes.getChange().getDest()).change(notes);
+    }
+
+    /** Verify scoped user can {@code perm}, throwing if denied. */
+    public abstract void check(GlobalOrPluginPermission perm)
+        throws AuthException, PermissionBackendException;
+
+    /**
+     * Verify scoped user can perform at least one listed permission.
+     *
+     * <p>If {@code any} is empty, the method completes normally and allows the caller to continue.
+     * Since no permissions were supplied to check, its assumed no permissions are necessary to
+     * continue with the caller's operation.
+     *
+     * <p>If the user has at least one of the permissions in {@code any}, the method completes
+     * normally, possibly without checking all listed permissions.
+     *
+     * <p>If {@code any} is non-empty and the user has none, {@link AuthException} is thrown for one
+     * of the failed permissions.
+     *
+     * @param any set of permissions to check.
+     */
+    public void checkAny(Set<GlobalOrPluginPermission> any)
+        throws PermissionBackendException, AuthException {
+      for (Iterator<GlobalOrPluginPermission> itr = any.iterator(); itr.hasNext(); ) {
+        try {
+          check(itr.next());
+          return;
+        } catch (AuthException err) {
+          if (!itr.hasNext()) {
+            throw err;
+          }
+        }
+      }
+    }
+
+    /** Filter {@code permSet} to permissions scoped user might be able to perform. */
+    public abstract <T extends GlobalOrPluginPermission> Set<T> test(Collection<T> permSet)
+        throws PermissionBackendException;
+
+    public boolean test(GlobalOrPluginPermission perm) throws PermissionBackendException {
+      return test(Collections.singleton(perm)).contains(perm);
+    }
+
+    public boolean testOrFalse(GlobalOrPluginPermission perm) {
+      try {
+        return test(perm);
+      } catch (PermissionBackendException e) {
+        logger.warn("Cannot test " + perm + "; assuming false", e);
+        return false;
+      }
+    }
+
+    /**
+     * Filter a set of projects using {@code check(perm)}.
+     *
+     * @param perm required permission in a project to be included in result.
+     * @param projects candidate set of projects; may be empty.
+     * @return filtered set of {@code projects} where {@code check(perm)} was successful.
+     * @throws PermissionBackendException backend cannot access its internal state.
+     */
+    public Set<Project.NameKey> filter(ProjectPermission perm, Collection<Project.NameKey> projects)
+        throws PermissionBackendException {
+      checkNotNull(perm, "ProjectPermission");
+      checkNotNull(projects, "projects");
+      Set<Project.NameKey> allowed = Sets.newHashSetWithExpectedSize(projects.size());
+      for (Project.NameKey project : projects) {
+        try {
+          project(project).check(perm);
+          allowed.add(project);
+        } catch (AuthException e) {
+          // Do not include this project in allowed.
+        }
+      }
+      return allowed;
+    }
+  }
+
+  /** PermissionBackend scoped to a user and project. */
+  public abstract static class ForProject extends AcceptsReviewDb<ForProject> {
+    /** @return new instance rescoped to same project, but different {@code user}. */
+    public abstract ForProject user(CurrentUser user);
+
+    /** @return instance scoped for {@code ref} in this project. */
+    public abstract ForRef ref(String ref);
+
+    /** Verify scoped user can {@code perm}, throwing if denied. */
+    public abstract void check(ProjectPermission perm)
+        throws AuthException, PermissionBackendException;
+
+    /** Filter {@code permSet} to permissions scoped user might be able to perform. */
+    public abstract Set<ProjectPermission> test(Collection<ProjectPermission> permSet)
+        throws PermissionBackendException;
+
+    public boolean test(ProjectPermission perm) throws PermissionBackendException {
+      return test(EnumSet.of(perm)).contains(perm);
+    }
+
+    public boolean testOrFalse(ProjectPermission perm) {
+      try {
+        return test(perm);
+      } catch (PermissionBackendException e) {
+        logger.warn("Cannot test " + perm + "; assuming false", e);
+        return false;
+      }
+    }
+  }
+
+  /** PermissionBackend scoped to a user, project and reference. */
+  public abstract static class ForRef extends AcceptsReviewDb<ForRef> {
+    /** @return new instance rescoped to same reference, but different {@code user}. */
+    public abstract ForRef user(CurrentUser user);
+
+    /** @return instance scoped to change. */
+    public abstract ForChange change(ChangeData cd);
+
+    /** @return instance scoped to change. */
+    public abstract ForChange change(ChangeNotes notes);
+
+    /** Verify scoped user can {@code perm}, throwing if denied. */
+    public abstract void check(RefPermission perm) throws AuthException, PermissionBackendException;
+
+    /** Filter {@code permSet} to permissions scoped user might be able to perform. */
+    public abstract Set<RefPermission> test(Collection<RefPermission> permSet)
+        throws PermissionBackendException;
+
+    public boolean test(RefPermission perm) throws PermissionBackendException {
+      return test(EnumSet.of(perm)).contains(perm);
+    }
+
+    /**
+     * Test if user may be able to perform the permission.
+     *
+     * <p>Similar to {@link #test(RefPermission)} except this method returns {@code false} instead
+     * of throwing an exception.
+     *
+     * @param perm the permission to test.
+     * @return true if the user might be able to perform the permission; false if the user may be
+     *     missing the necessary grants or state, or if the backend threw an exception.
+     */
+    public boolean testOrFalse(RefPermission perm) {
+      try {
+        return test(perm);
+      } catch (PermissionBackendException e) {
+        logger.warn("Cannot test " + perm + "; assuming false", e);
+        return false;
+      }
+    }
+  }
+
+  /** PermissionBackend scoped to a user, project, reference and change. */
+  public abstract static class ForChange extends AcceptsReviewDb<ForChange> {
+    /** @return user this instance is scoped to. */
+    public abstract CurrentUser user();
+
+    /** @return new instance rescoped to same change, but different {@code user}. */
+    public abstract ForChange user(CurrentUser user);
+
+    /** Verify scoped user can {@code perm}, throwing if denied. */
+    public abstract void check(ChangePermissionOrLabel perm)
+        throws AuthException, PermissionBackendException;
+
+    /** Filter {@code permSet} to permissions scoped user might be able to perform. */
+    public abstract <T extends ChangePermissionOrLabel> Set<T> test(Collection<T> permSet)
+        throws PermissionBackendException;
+
+    public boolean test(ChangePermissionOrLabel perm) throws PermissionBackendException {
+      return test(Collections.singleton(perm)).contains(perm);
+    }
+
+    /**
+     * Test if user may be able to perform the permission.
+     *
+     * <p>Similar to {@link #test(ChangePermissionOrLabel)} except this method returns {@code false}
+     * instead of throwing an exception.
+     *
+     * @param perm the permission to test.
+     * @return true if the user might be able to perform the permission; false if the user may be
+     *     missing the necessary grants or state, or if the backend threw an exception.
+     */
+    public boolean testOrFalse(ChangePermissionOrLabel perm) {
+      try {
+        return test(perm);
+      } catch (PermissionBackendException e) {
+        logger.warn("Cannot test " + perm + "; assuming false", e);
+        return false;
+      }
+    }
+
+    /**
+     * Test which values of a label the user may be able to set.
+     *
+     * @param label definition of the label to test values of.
+     * @return set containing values the user may be able to use; may be empty if none.
+     * @throws PermissionBackendException if failure consulting backend configuration.
+     */
+    public Set<LabelPermission.WithValue> test(LabelType label) throws PermissionBackendException {
+      return test(valuesOf(checkNotNull(label, "LabelType")));
+    }
+
+    /**
+     * Test which values of a group of labels the user may be able to set.
+     *
+     * @param types definition of the labels to test values of.
+     * @return set containing values the user may be able to use; may be empty if none.
+     * @throws PermissionBackendException if failure consulting backend configuration.
+     */
+    public Set<LabelPermission.WithValue> testLabels(Collection<LabelType> types)
+        throws PermissionBackendException {
+      checkNotNull(types, "LabelType");
+      return test(types.stream().flatMap((t) -> valuesOf(t).stream()).collect(toSet()));
+    }
+
+    private static Set<LabelPermission.WithValue> valuesOf(LabelType label) {
+      return label
+          .getValues()
+          .stream()
+          .map((v) -> new LabelPermission.WithValue(label, v))
+          .collect(toSet());
+    }
+
+    /**
+     * Squash a label value to the nearest allowed value.
+     *
+     * <p>For multi-valued labels like Code-Review with values -2..+2 a user may try to use +2, but
+     * only have permission for the -1..+1 range. The caller should have already tried:
+     *
+     * <pre>
+     * check(new LabelPermission.WithValue("Code-Review", 2));
+     * </pre>
+     *
+     * and caught {@link AuthException}. {@code squashThenCheck} will use {@link #test(LabelType)}
+     * to determine potential values of Code-Review the user can use, and select the nearest value
+     * along the same sign, e.g. -1 for -2 and +1 for +2.
+     *
+     * @param label definition of the label to test values of.
+     * @param val previously denied value the user attempted.
+     * @return nearest allowed value, or {@code 0} if no value was allowed.
+     * @throws PermissionBackendException backend cannot run test or check.
+     */
+    public short squashThenCheck(LabelType label, short val) throws PermissionBackendException {
+      short s = squashByTest(label, val);
+      if (s == 0 || s == val) {
+        return 0;
+      }
+      try {
+        check(new LabelPermission.WithValue(label, s));
+        return s;
+      } catch (AuthException e) {
+        return 0;
+      }
+    }
+
+    /**
+     * Squash a label value to the nearest allowed value using only test methods.
+     *
+     * <p>Tests all possible values and selects the closet available to {@code val} while matching
+     * the sign of {@code val}. Unlike {@code #squashThenCheck(LabelType, short)} this method only
+     * uses {@code test} methods and should not be used in contexts like a review handler without
+     * checking the resulting score.
+     *
+     * @param label definition of the label to test values of.
+     * @param val previously denied value the user attempted.
+     * @return nearest likely allowed value, or {@code 0} if no value was identified.
+     * @throws PermissionBackendException backend cannot run test.
+     */
+    public short squashByTest(LabelType label, short val) throws PermissionBackendException {
+      return nearest(test(label), val);
+    }
+
+    private static short nearest(Iterable<LabelPermission.WithValue> possible, short wanted) {
+      short s = 0;
+      for (LabelPermission.WithValue v : possible) {
+        if ((wanted < 0 && v.value() < 0 && wanted <= v.value() && v.value() < s)
+            || (wanted > 0 && v.value() > 0 && wanted >= v.value() && v.value() > s)) {
+          s = v.value();
+        }
+      }
+      return s;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackendException.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackendException.java
new file mode 100644
index 0000000..be02a6f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackendException.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.account.GroupBackend;
+
+/**
+ * Thrown when {@link PermissionBackend} cannot compute the result.
+ *
+ * <p>This is typically a transient failure, such as a required {@link GroupBackend} not responding
+ * to membership requests.
+ */
+public class PermissionBackendException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  public PermissionBackendException(String message) {
+    super(message);
+  }
+
+  public PermissionBackendException(@Nullable Throwable cause) {
+    super(cause);
+  }
+
+  public PermissionBackendException(String message, @Nullable Throwable cause) {
+    super(message, cause);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ProjectPermission.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ProjectPermission.java
new file mode 100644
index 0000000..85b66c4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ProjectPermission.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import com.google.gerrit.common.data.Permission;
+import java.util.Locale;
+import java.util.Optional;
+
+public enum ProjectPermission {
+  /**
+   * Can access at least one reference or change within the repository.
+   *
+   * <p>Checking this permission instead of {@link #READ} may require filtering to hide specific
+   * references or changes, which can be expensive.
+   */
+  ACCESS,
+
+  /**
+   * Can read all references in the repository.
+   *
+   * <p>This is a stronger form of {@link #ACCESS} where no filtering is required.
+   */
+  READ(Permission.READ);
+
+  private final String name;
+
+  ProjectPermission() {
+    name = null;
+  }
+
+  ProjectPermission(String name) {
+    this.name = name;
+  }
+
+  /** @return name used in {@code project.config} permissions. */
+  public Optional<String> permissionName() {
+    return Optional.ofNullable(name);
+  }
+
+  public String describeForException() {
+    return toString().toLowerCase(Locale.US).replace('_', ' ');
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/RefPermission.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/RefPermission.java
new file mode 100644
index 0000000..37744b0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/RefPermission.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import com.google.gerrit.common.data.Permission;
+import java.util.Locale;
+import java.util.Optional;
+
+public enum RefPermission {
+  READ(Permission.READ),
+  CREATE(Permission.CREATE),
+  DELETE(Permission.DELETE),
+  UPDATE(Permission.PUSH),
+  FORCE_UPDATE,
+
+  FORGE_AUTHOR(Permission.FORGE_AUTHOR),
+  FORGE_COMMITTER(Permission.FORGE_COMMITTER),
+  FORGE_SERVER(Permission.FORGE_SERVER),
+
+  CREATE_CHANGE;
+
+  private final String name;
+
+  RefPermission() {
+    name = null;
+  }
+
+  RefPermission(String name) {
+    this.name = name;
+  }
+
+  /** @return name used in {@code project.config} permissions. */
+  public Optional<String> permissionName() {
+    return Optional.ofNullable(name);
+  }
+
+  public String describeForException() {
+    return toString().toLowerCase(Locale.US).replace('_', ' ');
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DelegatingClassLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DelegatingClassLoader.java
new file mode 100644
index 0000000..daee9c7
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DelegatingClassLoader.java
@@ -0,0 +1,71 @@
+// 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.plugins;
+
+import com.google.common.io.ByteStreams;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.Enumeration;
+
+public class DelegatingClassLoader extends ClassLoader {
+  private final ClassLoader target;
+
+  public DelegatingClassLoader(ClassLoader parent, ClassLoader target) {
+    super(parent);
+    this.target = target;
+  }
+
+  @Override
+  public Class<?> findClass(String name) throws ClassNotFoundException {
+    String path = name.replace('.', '/') + ".class";
+    InputStream resource = target.getResourceAsStream(path);
+    if (resource != null) {
+      try {
+        byte[] bytes = ByteStreams.toByteArray(resource);
+        return defineClass(name, bytes, 0, bytes.length);
+      } catch (IOException e) {
+      }
+    }
+    throw new ClassNotFoundException(name);
+  }
+
+  @Override
+  public URL getResource(String name) {
+    URL rtn = getParent().getResource(name);
+    if (rtn == null) {
+      rtn = target.getResource(name);
+    }
+    return rtn;
+  }
+
+  @Override
+  public Enumeration<URL> getResources(String name) throws IOException {
+    Enumeration<URL> rtn = getParent().getResources(name);
+    if (rtn == null) {
+      rtn = target.getResources(name);
+    }
+    return rtn;
+  }
+
+  @Override
+  public InputStream getResourceAsStream(String name) {
+    InputStream rtn = getParent().getResourceAsStream(name);
+    if (rtn == null) {
+      rtn = target.getResourceAsStream(name);
+    }
+    return rtn;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
index ac57bc9..e0afc08 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -432,7 +432,7 @@
       String name = entry.getKey();
       Path path = entry.getValue();
       String fileName = path.getFileName().toString();
-      if (!isJsPlugin(fileName) && !serverPluginFactory.handles(path)) {
+      if (!isUiPlugin(fileName) && !serverPluginFactory.handles(path)) {
         log.warn("No Plugin provider was found that handles this file format: {}", fileName);
         continue;
       }
@@ -614,7 +614,7 @@
   private Plugin loadPlugin(String name, Path srcPlugin, FileSnapshot snapshot)
       throws InvalidPluginException {
     String pluginName = srcPlugin.getFileName().toString();
-    if (isJsPlugin(pluginName)) {
+    if (isUiPlugin(pluginName)) {
       return loadJsPlugin(name, srcPlugin, snapshot);
     } else if (serverPluginFactory.handles(srcPlugin)) {
       return loadServerPlugin(srcPlugin, snapshot);
@@ -746,8 +746,8 @@
 
   public String getGerritPluginName(Path srcPath) {
     String fileName = srcPath.getFileName().toString();
-    if (isJsPlugin(fileName)) {
-      return fileName.substring(0, fileName.length() - 3);
+    if (isUiPlugin(fileName)) {
+      return fileName.substring(0, fileName.lastIndexOf('.'));
     }
     if (serverPluginFactory.handles(srcPath)) {
       return serverPluginFactory.getPluginName(srcPath);
@@ -763,8 +763,8 @@
     return map;
   }
 
-  private static boolean isJsPlugin(String name) {
-    return isPlugin(name, "js");
+  private static boolean isUiPlugin(String name) {
+    return isPlugin(name, "js") || isPlugin(name, "html");
   }
 
   private static boolean isPlugin(String fileName, String ext) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/AccessControlModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/AccessControlModule.java
index 5c0d8d7..6d77267 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/AccessControlModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/AccessControlModule.java
@@ -46,8 +46,5 @@
         .annotatedWith(GitReceivePackGroups.class)
         .toProvider(GitReceivePackGroupsProvider.class)
         .in(SINGLETON);
-
-    bind(ChangeControl.Factory.class);
-    factory(ProjectControl.AssistedFactory.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
index ec114d8..8fabe44 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
@@ -15,13 +15,18 @@
 package com.google.gerrit.server.project;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 
 import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.RefConfigSection;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -32,13 +37,22 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.ChangePermissionOrLabel;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 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.EnumSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 
 /** Access control management for a user accessing a single change. */
 public class ChangeControl {
@@ -200,14 +214,12 @@
 
   /** Can this user see this change? */
   public boolean isVisible(ReviewDb db, @Nullable ChangeData cd) throws OrmException {
+    if (getChange().isPrivate() && !isPrivateVisible(db, cd)) {
+      return false;
+    }
     if (getChange().getStatus() == Change.Status.DRAFT && !isDraftVisible(db, cd)) {
       return false;
     }
-    return isRefVisible();
-  }
-
-  /** Can the user see this change? Does not account for draft status */
-  public boolean isRefVisible() {
     return getRefControl().isVisible();
   }
 
@@ -230,11 +242,11 @@
   }
 
   /** Can this user abandon this change? */
-  public boolean canAbandon(ReviewDb db) throws OrmException {
+  private boolean canAbandon(ReviewDb db) throws OrmException {
     return (isOwner() // owner (aka creator) of the change can abandon
             || getRefControl().isOwner() // branch owner can abandon
             || getProjectControl().isOwner() // project owner can abandon
-            || getUser().getCapabilities().canAdministrateServer() // site administers are god
+            || getUser().getCapabilities().isAdmin_DoNotUse() // site administers are god
             || getRefControl().canAbandon() // user can abandon a specific ref
         )
         && !isPatchSetLocked(db);
@@ -258,10 +270,13 @@
 
     switch (status) {
       case DRAFT:
-        return (isOwner() || getRefControl().canDeleteDrafts());
+        return isOwner()
+            || getRefControl().canDeleteDrafts()
+            || getUser().getCapabilities().isAdmin_DoNotUse();
       case NEW:
       case ABANDONED:
-        return (isAdmin() || (isOwner() && getRefControl().canDeleteOwnChanges()));
+        return (isOwner() && getRefControl().canDeleteOwnChanges())
+            || getUser().getCapabilities().isAdmin_DoNotUse();
       case MERGED:
       default:
         return false;
@@ -269,13 +284,13 @@
   }
 
   /** Can this user rebase this change? */
-  public boolean canRebase(ReviewDb db) throws OrmException {
+  private boolean canRebase(ReviewDb db) throws OrmException {
     return (isOwner() || getRefControl().canSubmit(isOwner()) || getRefControl().canRebase())
         && !isPatchSetLocked(db);
   }
 
   /** Can this user restore this change? */
-  public boolean canRestore(ReviewDb db) throws OrmException {
+  private boolean canRestore(ReviewDb db) throws OrmException {
     return canAbandon(db) // Anyone who can abandon the change can restore it back
         && getRefControl().canUpload(); // as long as you can upload too
   }
@@ -345,7 +360,7 @@
   }
 
   /** Is this user the owner of the change? */
-  public boolean isOwner() {
+  private boolean isOwner() {
     if (getUser().isIdentifiedUser()) {
       Account.Id id = getUser().asIdentifiedUser().getAccountId();
       return id.equals(getChange().getOwner());
@@ -354,7 +369,7 @@
   }
 
   /** Is this user assigned to this change? */
-  public boolean isAssignee() {
+  private boolean isAssignee() {
     Account.Id currentAssignee = notes.getChange().getAssignee();
     if (currentAssignee != null && getUser().isIdentifiedUser()) {
       Account.Id id = getUser().getAccountId();
@@ -364,12 +379,7 @@
   }
 
   /** Is this user a reviewer for the change? */
-  public boolean isReviewer(ReviewDb db) throws OrmException {
-    return isReviewer(db, null);
-  }
-
-  /** Is this user a reviewer for the change? */
-  public boolean isReviewer(ReviewDb db, @Nullable ChangeData cd) throws OrmException {
+  private boolean isReviewer(ReviewDb db, @Nullable ChangeData cd) throws OrmException {
     if (getUser().isIdentifiedUser()) {
       Collection<Account.Id> results = changeData(db, cd).reviewers().all();
       return results.contains(getUser().getAccountId());
@@ -377,10 +387,6 @@
     return false;
   }
 
-  public boolean isAdmin() {
-    return getUser().getCapabilities().canAdministrateServer();
-  }
-
   /** @return true if the user is allowed to remove this reviewer. */
   public boolean canRemoveReviewer(PatchSetApproval approval) {
     return canRemoveReviewer(approval.getAccountId(), approval.getValue());
@@ -407,7 +413,7 @@
       if (getRefControl().canRemoveReviewer() // has removal permissions
           || getRefControl().isOwner() // branch owner
           || getProjectControl().isOwner() // project owner
-          || getUser().getCapabilities().canAdministrateServer()) {
+          || getUser().getCapabilities().isAdmin_DoNotUse()) {
         return true;
       }
     }
@@ -416,12 +422,12 @@
   }
 
   /** Can this user edit the topic name? */
-  public boolean canEditTopicName() {
+  private boolean canEditTopicName() {
     if (getChange().getStatus().isOpen()) {
       return isOwner() // owner (aka creator) of the change can edit topic
           || getRefControl().isOwner() // branch owner can edit topic
           || getProjectControl().isOwner() // project owner can edit topic
-          || getUser().getCapabilities().canAdministrateServer() // site administers are god
+          || getUser().getCapabilities().isAdmin_DoNotUse() // site administers are god
           || getRefControl().canEditTopicName() // user can edit topic on a specific ref
       ;
     }
@@ -429,18 +435,18 @@
   }
 
   /** Can this user edit the description? */
-  public boolean canEditDescription() {
+  private boolean canEditDescription() {
     if (getChange().getStatus().isOpen()) {
       return isOwner() // owner (aka creator) of the change can edit desc
           || getRefControl().isOwner() // branch owner can edit desc
           || getProjectControl().isOwner() // project owner can edit desc
-          || getUser().getCapabilities().canAdministrateServer() // site administers are god
+          || getUser().getCapabilities().isAdmin_DoNotUse() // site administers are god
       ;
     }
     return false;
   }
 
-  public boolean canEditAssignee() {
+  private boolean canEditAssignee() {
     return isOwner()
         || getProjectControl().isOwner()
         || getRefControl().canEditAssignee()
@@ -448,22 +454,14 @@
   }
 
   /** Can this user edit the hashtag name? */
-  public boolean canEditHashtags() {
+  private boolean canEditHashtags() {
     return isOwner() // owner (aka creator) of the change can edit hashtags
         || getRefControl().isOwner() // branch owner can edit hashtags
         || getProjectControl().isOwner() // project owner can edit hashtags
-        || getUser().getCapabilities().canAdministrateServer() // site administers are god
+        || getUser().getCapabilities().isAdmin_DoNotUse() // site administers are god
         || getRefControl().canEditHashtags(); // user can edit hashtag on a specific ref
   }
 
-  public boolean canSubmit() {
-    return getRefControl().canSubmit(isOwner());
-  }
-
-  public boolean canSubmitAs() {
-    return getRefControl().canSubmitAs();
-  }
-
   private boolean match(String destBranch, String refPattern) {
     return RefPatternMatcher.getMatcher(refPattern).match(destBranch, getUser());
   }
@@ -478,4 +476,155 @@
         || getRefControl().canViewDrafts()
         || getUser().isInternalUser();
   }
+
+  private boolean isPrivateVisible(ReviewDb db, ChangeData cd) throws OrmException {
+    return isOwner()
+        || isReviewer(db, cd)
+        || getRefControl().canViewPrivateChanges()
+        || getUser().isInternalUser();
+  }
+
+  ForChange asForChange(@Nullable ChangeData cd, @Nullable Provider<ReviewDb> db) {
+    return new ForChangeImpl(cd, db);
+  }
+
+  private class ForChangeImpl extends ForChange {
+    private ChangeData cd;
+    private Map<String, PermissionRange> labels;
+
+    ForChangeImpl(@Nullable ChangeData cd, @Nullable Provider<ReviewDb> db) {
+      this.cd = cd;
+      this.db = db;
+    }
+
+    private ReviewDb db() {
+      if (db != null) {
+        return db.get();
+      } else if (cd != null) {
+        return cd.db();
+      } else {
+        return null;
+      }
+    }
+
+    private ChangeData changeData() {
+      if (cd == null) {
+        ReviewDb reviewDb = db();
+        checkState(reviewDb != null, "need ReviewDb");
+        cd = changeDataFactory.create(reviewDb, ChangeControl.this);
+      }
+      return cd;
+    }
+
+    @Override
+    public CurrentUser user() {
+      return getUser();
+    }
+
+    @Override
+    public ForChange user(CurrentUser user) {
+      return user().equals(user) ? this : forUser(user).asForChange(cd, db);
+    }
+
+    @Override
+    public void check(ChangePermissionOrLabel perm)
+        throws AuthException, PermissionBackendException {
+      if (!can(perm)) {
+        throw new AuthException(perm.describeForException() + " not permitted");
+      }
+    }
+
+    @Override
+    public <T extends ChangePermissionOrLabel> Set<T> test(Collection<T> permSet)
+        throws PermissionBackendException {
+      Set<T> ok = newSet(permSet);
+      for (T perm : permSet) {
+        if (can(perm)) {
+          ok.add(perm);
+        }
+      }
+      return ok;
+    }
+
+    private boolean can(ChangePermissionOrLabel perm) throws PermissionBackendException {
+      if (perm instanceof ChangePermission) {
+        return can((ChangePermission) perm);
+      } else if (perm instanceof LabelPermission) {
+        return can((LabelPermission) perm);
+      } else if (perm instanceof LabelPermission.WithValue) {
+        return can((LabelPermission.WithValue) perm);
+      }
+      throw new PermissionBackendException(perm + " unsupported");
+    }
+
+    private boolean can(ChangePermission perm) throws PermissionBackendException {
+      try {
+        switch (perm) {
+          case READ:
+            return isVisible(db(), changeData());
+          case ABANDON:
+            return canAbandon(db());
+          case DELETE:
+            return canDelete(db(), getChange().getStatus());
+          case ADD_PATCH_SET:
+            return canAddPatchSet(db());
+          case EDIT_ASSIGNEE:
+            return canEditAssignee();
+          case EDIT_DESCRIPTION:
+            return canEditDescription();
+          case EDIT_HASHTAGS:
+            return canEditHashtags();
+          case EDIT_TOPIC_NAME:
+            return canEditTopicName();
+          case REBASE:
+            return canRebase(db());
+          case RESTORE:
+            return canRestore(db());
+          case SUBMIT:
+            return getRefControl().canSubmit(isOwner());
+
+          case REMOVE_REVIEWER: // TODO Honor specific removal filters?
+          case SUBMIT_AS:
+            return getRefControl().canPerform(perm.permissionName().get());
+        }
+      } catch (OrmException e) {
+        throw new PermissionBackendException("unavailable", e);
+      }
+      throw new PermissionBackendException(perm + " unsupported");
+    }
+
+    private boolean can(LabelPermission perm) {
+      return !label(perm.permissionName().get()).isEmpty();
+    }
+
+    private boolean can(LabelPermission.WithValue perm) {
+      PermissionRange r = label(perm.permissionName().get());
+      if (perm.forUser() == ON_BEHALF_OF && r.isEmpty()) {
+        return false;
+      }
+      return r.contains(perm.value());
+    }
+
+    private PermissionRange label(String permission) {
+      if (labels == null) {
+        labels = Maps.newHashMapWithExpectedSize(4);
+      }
+      PermissionRange r = labels.get(permission);
+      if (r == null) {
+        r = getRange(permission);
+        labels.put(permission, r);
+      }
+      return r;
+    }
+  }
+
+  static <T extends ChangePermissionOrLabel> Set<T> newSet(Collection<T> permSet) {
+    if (permSet instanceof EnumSet) {
+      @SuppressWarnings({"unchecked", "rawtypes"})
+      Set<T> s = ((EnumSet) permSet).clone();
+      s.clear();
+      return s;
+    }
+    return Sets.newHashSetWithExpectedSize(permSet.size());
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckMergeability.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckMergeability.java
index f824f81..1a81726 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckMergeability.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckMergeability.java
@@ -92,7 +92,7 @@
     try (Repository git = gitManager.openRepository(resource.getNameKey());
         RevWalk rw = new RevWalk(git);
         ObjectInserter inserter = new InMemoryInserter(git)) {
-      Merger m = MergeUtil.newMerger(git, inserter, strategy);
+      Merger m = MergeUtil.newMerger(inserter, git.getConfig(), strategy);
 
       Ref destRef = git.getRefDatabase().exactRef(resource.getRef());
       if (destRef == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectsCollection.java
index 7aa5f68..2110034 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectsCollection.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -50,7 +51,7 @@
 
   @Override
   public ChildProjectResource parse(ProjectResource parent, IdString id)
-      throws ResourceNotFoundException, IOException {
+      throws ResourceNotFoundException, IOException, PermissionBackendException {
     ProjectResource p = projectsCollection.parse(TopLevelResource.INSTANCE, id);
     for (ProjectState pp : p.getControl().getProjectState().parents()) {
       if (parent.getNameKey().equals(pp.getProject().getNameKey())) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
index 2f02728..4f83d5f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.git.TransferConfig;
-import com.google.inject.util.Providers;
 import java.util.Arrays;
 import java.util.LinkedHashMap;
 import java.util.Map;
@@ -45,6 +44,7 @@
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
+      UiActions uiActions,
       DynamicMap<RestView<ProjectResource>> views) {
     ProjectState projectState = control.getProjectState();
     Project p = control.getProject();
@@ -58,6 +58,7 @@
     InheritedBooleanInfo enableSignedPush = new InheritedBooleanInfo();
     InheritedBooleanInfo requireSignedPush = new InheritedBooleanInfo();
     InheritedBooleanInfo rejectImplicitMerges = new InheritedBooleanInfo();
+    InheritedBooleanInfo enableReviewerByEmail = new InheritedBooleanInfo();
 
     useContributorAgreements.value = projectState.isUseContributorAgreements();
     useSignedOffBy.value = projectState.isUseSignedOffBy();
@@ -73,6 +74,7 @@
     enableSignedPush.configuredValue = p.getEnableSignedPush();
     requireSignedPush.configuredValue = p.getRequireSignedPush();
     rejectImplicitMerges.configuredValue = p.getRejectImplicitMerges();
+    enableReviewerByEmail.configuredValue = p.getEnableReviewerByEmail();
 
     ProjectState parentState = Iterables.getFirst(projectState.parents(), null);
     if (parentState != null) {
@@ -85,6 +87,7 @@
       enableSignedPush.inheritedValue = projectState.isEnableSignedPush();
       requireSignedPush.inheritedValue = projectState.isRequireSignedPush();
       rejectImplicitMerges.inheritedValue = projectState.isRejectImplicitMerges();
+      enableReviewerByEmail.inheritedValue = projectState.isEnableReviewerByEmail();
     }
 
     this.useContributorAgreements = useContributorAgreements;
@@ -93,6 +96,7 @@
     this.requireChangeId = requireChangeId;
     this.rejectImplicitMerges = rejectImplicitMerges;
     this.createNewChangeForAllNotInTarget = createNewChangeForAllNotInTarget;
+    this.enableReviewerByEmail = enableReviewerByEmail;
     if (serverEnableSignedPush) {
       this.enableSignedPush = enableSignedPush;
       this.requireSignedPush = requireSignedPush;
@@ -122,11 +126,12 @@
         getPluginConfig(control.getProjectState(), pluginConfigEntries, cfgFactory, allProjects);
 
     actions = new TreeMap<>();
-    for (UiAction.Description d :
-        UiActions.from(views, new ProjectResource(control), Providers.of(control.getUser()))) {
+    for (UiAction.Description d : uiActions.from(views, new ProjectResource(control))) {
       actions.put(d.getId(), new ActionInfo(d));
     }
     this.theme = projectState.getTheme();
+
+    this.extensionPanelNames = projectState.getConfig().getExtensionPanelSections();
   }
 
   private Map<String, Map<String, ConfigParameterInfo>> getPluginConfig(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java
index ff7e31e..199f5c0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java
@@ -55,6 +55,7 @@
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.git.RepositoryCaseMismatchException;
 import com.google.gerrit.server.group.GroupsCollection;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
@@ -148,7 +149,8 @@
   @Override
   public Response<ProjectInfo> apply(TopLevelResource resource, ProjectInput input)
       throws BadRequestException, UnprocessableEntityException, ResourceConflictException,
-          ResourceNotFoundException, IOException, ConfigInvalidException {
+          ResourceNotFoundException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     if (input == null) {
       input = new ProjectInput();
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultPermissionBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultPermissionBackend.java
new file mode 100644
index 0000000..07be8fe
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultPermissionBackend.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2017 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.project;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.FailedPermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.Set;
+
+@Singleton
+public class DefaultPermissionBackend extends PermissionBackend {
+  private final ProjectCache projectCache;
+
+  @Inject
+  DefaultPermissionBackend(ProjectCache projectCache) {
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public WithUser user(CurrentUser user) {
+    return new WithUserImpl(checkNotNull(user, "user"));
+  }
+
+  class WithUserImpl extends WithUser {
+    private final CurrentUser user;
+
+    WithUserImpl(CurrentUser user) {
+      this.user = checkNotNull(user, "user");
+    }
+
+    @Override
+    public ForProject project(Project.NameKey project) {
+      try {
+        ProjectState state = projectCache.checkedGet(project);
+        if (state != null) {
+          return state.controlFor(user).asForProject().database(db);
+        }
+        return FailedPermissionBackend.project("not found");
+      } catch (IOException e) {
+        return FailedPermissionBackend.project("unavailable", e);
+      }
+    }
+
+    @Override
+    public void check(GlobalOrPluginPermission perm)
+        throws AuthException, PermissionBackendException {
+      if (!can(perm)) {
+        throw new AuthException(perm.describeForException() + " not permitted");
+      }
+    }
+
+    @Override
+    public <T extends GlobalOrPluginPermission> Set<T> test(Collection<T> permSet)
+        throws PermissionBackendException {
+      Set<T> ok = newSet(permSet);
+      for (T perm : permSet) {
+        if (can(perm)) {
+          ok.add(perm);
+        }
+      }
+      return ok;
+    }
+
+    private boolean can(GlobalOrPluginPermission perm) throws PermissionBackendException {
+      return user.getCapabilities().doCanForDefaultPermissionBackend(perm);
+    }
+  }
+
+  private static <T extends GlobalOrPluginPermission> Set<T> newSet(Collection<T> permSet) {
+    if (permSet instanceof EnumSet) {
+      @SuppressWarnings({"unchecked", "rawtypes"})
+      Set<T> s = ((EnumSet) permSet).clone();
+      s.clear();
+      return s;
+    }
+    return Sets.newHashSetWithExpectedSize(permSet.size());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultPermissionBackendModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultPermissionBackendModule.java
new file mode 100644
index 0000000..4916353
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultPermissionBackendModule.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2017 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.project;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.inject.AbstractModule;
+import com.google.inject.Scopes;
+
+/** Binds the default {@link PermissionBackend}. */
+public class DefaultPermissionBackendModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    bind(PermissionBackend.class).to(DefaultPermissionBackend.class).in(Scopes.SINGLETON);
+    install(new LegacyControlsModule());
+  }
+
+  /** Binds legacy ProjectControl, RefControl, ChangeControl. */
+  public static class LegacyControlsModule extends FactoryModule {
+    @Override
+    protected void configure() {
+      // TODO(sop) Hide ProjectControl, RefControl, ChangeControl related bindings.
+      bind(ProjectControl.GenericFactory.class);
+      factory(ProjectControl.AssistedFactory.class);
+      bind(ChangeControl.GenericFactory.class);
+      bind(ChangeControl.Factory.class);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java
index 1fadef6..78a78c2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java
@@ -26,9 +26,9 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
@@ -64,7 +64,7 @@
     DeleteRef create(ProjectResource r);
   }
 
-  @AssistedInject
+  @Inject
   DeleteRef(
       Provider<IdentifiedUser> identifiedUser,
       GitRepositoryManager repoManager,
@@ -170,6 +170,7 @@
   private void deleteMultipleRefs(Repository r)
       throws OrmException, IOException, ResourceConflictException {
     BatchRefUpdate batchUpdate = r.getRefDatabase().newBatchUpdate();
+    batchUpdate.setAtomic(false);
     List<String> refs =
         prefix == null
             ? refsToDelete
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetAccess.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetAccess.java
index b464f68..997239d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetAccess.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetAccess.java
@@ -95,7 +95,7 @@
   public ProjectAccessInfo apply(Project.NameKey nameKey)
       throws ResourceNotFoundException, ResourceConflictException, IOException {
     try {
-      return this.apply(new ProjectResource(projectControlFactory.controlFor(nameKey, self.get())));
+      return apply(new ProjectResource(projectControlFactory.controlFor(nameKey, self.get())));
     } catch (NoSuchProjectException e) {
       throw new ResourceNotFoundException(nameKey.get());
     }
@@ -111,7 +111,7 @@
     Project.NameKey projectName = rsrc.getNameKey();
     ProjectAccessInfo info = new ProjectAccessInfo();
     ProjectConfig config;
-    ProjectControl pc = open(projectName);
+    ProjectControl pc = createProjectControl(projectName);
     RefControl metaConfigControl = pc.controlForRef(RefNames.REFS_CONFIG);
     try (MetaDataUpdate md = metaDataUpdateFactory.create(projectName)) {
       config = ProjectConfig.read(md);
@@ -120,11 +120,11 @@
         md.setMessage("Update group names\n");
         config.commit(md);
         projectCache.evict(config.getProject());
-        pc = open(projectName);
+        pc = createProjectControl(projectName);
       } else if (config.getRevision() != null
           && !config.getRevision().equals(pc.getProjectState().getConfig().getRevision())) {
         projectCache.evict(config.getProject());
-        pc = open(projectName);
+        pc = createProjectControl(projectName);
       }
     } catch (ConfigInvalidException e) {
       throw new ResourceConflictException(e.getMessage());
@@ -252,11 +252,10 @@
     return accessSectionInfo;
   }
 
-  private ProjectControl open(Project.NameKey projectName)
-      throws ResourceNotFoundException, IOException {
+  private ProjectControl createProjectControl(Project.NameKey projectName)
+      throws IOException, ResourceNotFoundException {
     try {
-      return projectControlFactory.validateFor(
-          projectName, ProjectControl.OWNER | ProjectControl.VISIBLE, self.get());
+      return projectControlFactory.controlFor(projectName, self.get());
     } catch (NoSuchProjectException e) {
       throw new ResourceNotFoundException(projectName.get());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java
index 8192e29..b1ba281 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
+import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.git.TransferConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -33,6 +34,7 @@
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final PluginConfigFactory cfgFactory;
   private final AllProjectsName allProjects;
+  private final UiActions uiActions;
   private final DynamicMap<RestView<ProjectResource>> views;
 
   @Inject
@@ -42,12 +44,14 @@
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
+      UiActions uiActions,
       DynamicMap<RestView<ProjectResource>> views) {
     this.serverEnableSignedPush = serverEnableSignedPush;
     this.config = config;
     this.pluginConfigEntries = pluginConfigEntries;
     this.allProjects = allProjects;
     this.cfgFactory = cfgFactory;
+    this.uiActions = uiActions;
     this.views = views;
   }
 
@@ -60,6 +64,7 @@
         pluginConfigEntries,
         cfgFactory,
         allProjects,
+        uiActions,
         views);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java
index a5b6458..09a6b86 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java
@@ -30,7 +30,6 @@
 import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
-import com.google.inject.util.Providers;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -48,6 +47,7 @@
 public class ListBranches implements RestReadView<ProjectResource> {
   private final GitRepositoryManager repoManager;
   private final DynamicMap<RestView<BranchResource>> branchViews;
+  private final UiActions uiActions;
   private final WebLinks webLinks;
 
   @Option(
@@ -99,9 +99,11 @@
   public ListBranches(
       GitRepositoryManager repoManager,
       DynamicMap<RestView<BranchResource>> branchViews,
+      UiActions uiActions,
       WebLinks webLinks) {
     this.repoManager = repoManager;
     this.branchViews = branchViews;
+    this.uiActions = uiActions;
     this.webLinks = webLinks;
   }
 
@@ -197,16 +199,15 @@
     info.ref = ref.getName();
     info.revision = ref.getObjectId() != null ? ref.getObjectId().name() : null;
     info.canDelete = !targets.contains(ref.getName()) && refControl.canDelete() ? true : null;
-    for (UiAction.Description d :
-        UiActions.from(
-            branchViews,
-            new BranchResource(refControl.getProjectControl(), info),
-            Providers.of(refControl.getUser()))) {
+
+    BranchResource rsrc = new BranchResource(refControl.getProjectControl(), info);
+    for (UiAction.Description d : uiActions.from(branchViews, rsrc)) {
       if (info.actions == null) {
         info.actions = new TreeMap<>();
       }
       info.actions.put(d.getId(), new ActionInfo(d));
     }
+
     List<WebLinkInfo> links =
         webLinks.getBranchLinks(
             refControl.getProjectControl().getProject().getName(), ref.getName());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java
index c14ade6..23a4417 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java
@@ -14,14 +14,21 @@
 
 package com.google.gerrit.server.project;
 
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.Multimap;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -33,20 +40,23 @@
   private boolean recursive;
 
   private final ProjectCache projectCache;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
   private final AllProjectsName allProjects;
   private final ProjectJson json;
-  private final ProjectNode.Factory projectNodeFactory;
 
   @Inject
   ListChildProjects(
       ProjectCache projectCache,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
       AllProjectsName allProjectsName,
-      ProjectJson json,
-      ProjectNode.Factory projectNodeFactory) {
+      ProjectJson json) {
     this.projectCache = projectCache;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
     this.allProjects = allProjectsName;
     this.json = json;
-    this.projectNodeFactory = projectNodeFactory;
   }
 
   public void setRecursive(boolean recursive) {
@@ -54,60 +64,82 @@
   }
 
   @Override
-  public List<ProjectInfo> apply(ProjectResource rsrc) {
+  public List<ProjectInfo> apply(ProjectResource rsrc) throws PermissionBackendException {
     if (recursive) {
-      return getChildProjectsRecursively(rsrc.getNameKey(), rsrc.getControl().getUser());
+      return recursiveChildProjects(rsrc.getNameKey());
     }
-    return getDirectChildProjects(rsrc.getNameKey());
+    return directChildProjects(rsrc.getNameKey());
   }
 
-  private List<ProjectInfo> getDirectChildProjects(Project.NameKey parent) {
-    List<ProjectInfo> childProjects = new ArrayList<>();
-    for (Project.NameKey projectName : projectCache.all()) {
-      ProjectState e = projectCache.get(projectName);
-      if (e == null) {
-        // If we can't get it from the cache, pretend it's not present.
-        continue;
-      }
-      if (parent.equals(e.getProject().getParent(allProjects))) {
-        childProjects.add(json.format(e.getProject()));
-      }
-    }
-    return childProjects;
-  }
-
-  private List<ProjectInfo> getChildProjectsRecursively(Project.NameKey parent, CurrentUser user) {
-    Map<Project.NameKey, ProjectNode> projects = new HashMap<>();
+  private List<ProjectInfo> directChildProjects(Project.NameKey parent)
+      throws PermissionBackendException {
+    Map<Project.NameKey, Project> children = new HashMap<>();
     for (Project.NameKey name : projectCache.all()) {
-      ProjectState p = projectCache.get(name);
-      if (p == null) {
-        // If we can't get it from the cache, pretend it's not present.
-        continue;
-      }
-      projects.put(name, projectNodeFactory.create(p.getProject(), p.controlFor(user).isVisible()));
-    }
-    for (ProjectNode key : projects.values()) {
-      ProjectNode node = projects.get(key.getParentName());
-      if (node != null) {
-        node.addChild(key);
+      ProjectState c = projectCache.get(name);
+      if (c != null && parent.equals(c.getProject().getParent(allProjects))) {
+        children.put(c.getProject().getNameKey(), c.getProject());
       }
     }
-
-    ProjectNode n = projects.get(parent);
-    if (n != null) {
-      return getChildProjectsRecursively(n);
-    }
-    return Collections.emptyList();
+    return permissionBackend
+        .user(user)
+        .filter(ProjectPermission.ACCESS, children.keySet())
+        .stream()
+        .sorted()
+        .map((p) -> json.format(children.get(p)))
+        .collect(toList());
   }
 
-  private List<ProjectInfo> getChildProjectsRecursively(ProjectNode p) {
-    List<ProjectInfo> allChildren = new ArrayList<>();
-    for (ProjectNode c : p.getChildren()) {
-      if (c.isVisible()) {
-        allChildren.add(json.format(c.getProject()));
-        allChildren.addAll(getChildProjectsRecursively(c));
+  private List<ProjectInfo> recursiveChildProjects(Project.NameKey parent)
+      throws PermissionBackendException {
+    Map<Project.NameKey, Project> projects = readAllProjects();
+    Multimap<Project.NameKey, Project.NameKey> children = parentToChildren(projects);
+    PermissionBackend.WithUser perm = permissionBackend.user(user);
+
+    List<ProjectInfo> results = new ArrayList<>();
+    depthFirstFormat(results, perm, projects, children, parent);
+    return results;
+  }
+
+  private Map<Project.NameKey, Project> readAllProjects() {
+    Map<Project.NameKey, Project> projects = new HashMap<>();
+    for (Project.NameKey name : projectCache.all()) {
+      ProjectState c = projectCache.get(name);
+      if (c != null) {
+        projects.put(c.getProject().getNameKey(), c.getProject());
       }
     }
-    return allChildren;
+    return projects;
+  }
+
+  /** Map of parent project to direct child. */
+  private Multimap<Project.NameKey, Project.NameKey> parentToChildren(
+      Map<Project.NameKey, Project> projects) {
+    Multimap<Project.NameKey, Project.NameKey> m = ArrayListMultimap.create();
+    for (Map.Entry<Project.NameKey, Project> e : projects.entrySet()) {
+      if (!allProjects.equals(e.getKey())) {
+        m.put(e.getValue().getParent(allProjects), e.getKey());
+      }
+    }
+    return m;
+  }
+
+  private void depthFirstFormat(
+      List<ProjectInfo> results,
+      PermissionBackend.WithUser perm,
+      Map<Project.NameKey, Project> projects,
+      Multimap<Project.NameKey, Project.NameKey> children,
+      Project.NameKey parent)
+      throws PermissionBackendException {
+    List<Project.NameKey> canSee =
+        perm.filter(ProjectPermission.ACCESS, children.get(parent))
+            .stream()
+            .sorted()
+            .collect(toList());
+    children.removeAll(parent); // removing all entries prevents cycles.
+
+    for (Project.NameKey c : canSee) {
+      results.add(json.format(projects.get(c)));
+      depthFirstFormat(results, perm, projects, children, c);
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java
index 4ea5a8f..d60a4a7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java
@@ -19,12 +19,20 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.DashboardsCollection.DashboardInfo;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.BlobBasedConfig;
@@ -41,42 +49,56 @@
   private static final Logger log = LoggerFactory.getLogger(ListDashboards.class);
 
   private final GitRepositoryManager gitManager;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
 
   @Option(name = "--inherited", usage = "include inherited dashboards")
   private boolean inherited;
 
   @Inject
-  ListDashboards(GitRepositoryManager gitManager) {
+  ListDashboards(
+      GitRepositoryManager gitManager,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user) {
     this.gitManager = gitManager;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
   }
 
   @Override
-  public List<?> apply(ProjectResource resource) throws ResourceNotFoundException, IOException {
-    ProjectControl ctl = resource.getControl();
-    String project = ctl.getProject().getName();
+  public List<?> apply(ProjectResource rsrc)
+      throws ResourceNotFoundException, IOException, PermissionBackendException {
+    String project = rsrc.getName();
     if (!inherited) {
-      return scan(resource.getControl(), project, true);
+      return scan(rsrc.getControl(), project, true);
     }
 
     List<List<DashboardInfo>> all = new ArrayList<>();
     boolean setDefault = true;
-    for (ProjectState ps : ctl.getProjectState().tree()) {
-      ctl = ps.controlFor(ctl.getUser());
-      if (ctl.isVisible()) {
-        List<DashboardInfo> list = scan(ctl, project, setDefault);
-        for (DashboardInfo d : list) {
-          if (d.isDefault != null && Boolean.TRUE.equals(d.isDefault)) {
-            setDefault = false;
-          }
+    for (ProjectState ps : tree(rsrc)) {
+      List<DashboardInfo> list = scan(ps.controlFor(user.get()), project, setDefault);
+      for (DashboardInfo d : list) {
+        if (d.isDefault != null && Boolean.TRUE.equals(d.isDefault)) {
+          setDefault = false;
         }
-        if (!list.isEmpty()) {
-          all.add(list);
-        }
+      }
+      if (!list.isEmpty()) {
+        all.add(list);
       }
     }
     return all;
   }
 
+  private Collection<ProjectState> tree(ProjectResource rsrc) throws PermissionBackendException {
+    Map<Project.NameKey, ProjectState> tree = new LinkedHashMap<>();
+    for (ProjectState ps : rsrc.getProjectState().tree()) {
+      tree.put(ps.getProject().getNameKey(), ps);
+    }
+    tree.keySet()
+        .retainAll(permissionBackend.user(user).filter(ProjectPermission.ACCESS, tree.keySet()));
+    return tree.values();
+  }
+
   private List<DashboardInfo> scan(ProjectControl ctl, String project, boolean setDefault)
       throws ResourceNotFoundException, IOException {
     Project.NameKey projectName = ctl.getProject().getNameKey();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
index a84fefd..b4e7380 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
@@ -14,15 +14,20 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.gerrit.extensions.client.ProjectState.HIDDEN;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -38,6 +43,9 @@
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.GroupsCollection;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.util.RegexListSearcher;
 import com.google.gerrit.server.util.TreeFormatter;
 import com.google.gson.reflect.TypeToken;
@@ -50,6 +58,8 @@
 import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
@@ -79,12 +89,22 @@
       boolean matches(Repository git) throws IOException {
         return !PERMISSIONS.matches(git);
       }
+
+      @Override
+      boolean useMatch() {
+        return true;
+      }
     },
     PARENT_CANDIDATES {
       @Override
       boolean matches(Repository git) {
         return true;
       }
+
+      @Override
+      boolean useMatch() {
+        return false;
+      }
     },
     PERMISSIONS {
       @Override
@@ -94,15 +114,27 @@
             && head.isSymbolic()
             && RefNames.REFS_CONFIG.equals(head.getLeaf().getName());
       }
+
+      @Override
+      boolean useMatch() {
+        return true;
+      }
     },
     ALL {
       @Override
       boolean matches(Repository git) {
         return true;
       }
+
+      @Override
+      boolean useMatch() {
+        return false;
+      }
     };
 
     abstract boolean matches(Repository git) throws IOException;
+
+    abstract boolean useMatch();
   }
 
   private final CurrentUser currentUser;
@@ -110,6 +142,7 @@
   private final GroupsCollection groupsCollection;
   private final GroupControl.Factory groupControlFactory;
   private final GitRepositoryManager repoManager;
+  private final PermissionBackend permissionBackend;
   private final ProjectNode.Factory projectNodeFactory;
   private final WebLinks webLinks;
 
@@ -229,6 +262,7 @@
       GroupsCollection groupsCollection,
       GroupControl.Factory groupControlFactory,
       GitRepositoryManager repoManager,
+      PermissionBackend permissionBackend,
       ProjectNode.Factory projectNodeFactory,
       WebLinks webLinks) {
     this.currentUser = currentUser;
@@ -236,6 +270,7 @@
     this.groupsCollection = groupsCollection;
     this.groupControlFactory = groupControlFactory;
     this.repoManager = repoManager;
+    this.permissionBackend = permissionBackend;
     this.projectNodeFactory = projectNodeFactory;
     this.webLinks = webLinks;
   }
@@ -262,7 +297,8 @@
   }
 
   @Override
-  public Object apply(TopLevelResource resource) throws BadRequestException {
+  public Object apply(TopLevelResource resource)
+      throws BadRequestException, PermissionBackendException {
     if (format == OutputFormat.TEXT) {
       ByteArrayOutputStream buf = new ByteArrayOutputStream();
       display(buf);
@@ -273,141 +309,123 @@
     return apply();
   }
 
-  public SortedMap<String, ProjectInfo> apply() throws BadRequestException {
+  public SortedMap<String, ProjectInfo> apply()
+      throws BadRequestException, PermissionBackendException {
     format = OutputFormat.JSON;
     return display(null);
   }
 
-  public SortedMap<String, ProjectInfo> display(OutputStream displayOutputStream)
-      throws BadRequestException {
+  public SortedMap<String, ProjectInfo> display(@Nullable OutputStream displayOutputStream)
+      throws BadRequestException, PermissionBackendException {
+    if (groupUuid != null) {
+      try {
+        if (!groupControlFactory.controlFor(groupUuid).isVisible()) {
+          return Collections.emptySortedMap();
+        }
+      } catch (NoSuchGroupException ex) {
+        return Collections.emptySortedMap();
+      }
+    }
+
     PrintWriter stdout = null;
     if (displayOutputStream != null) {
       stdout =
           new PrintWriter(new BufferedWriter(new OutputStreamWriter(displayOutputStream, UTF_8)));
     }
 
+    if (type == FilterType.PARENT_CANDIDATES) {
+      // Historically, PARENT_CANDIDATES implied showDescription.
+      showDescription = true;
+    }
+
     int foundIndex = 0;
     int found = 0;
     TreeMap<String, ProjectInfo> output = new TreeMap<>();
     Map<String, String> hiddenNames = new HashMap<>();
-    Set<String> rejected = new HashSet<>();
-
+    Map<Project.NameKey, Boolean> accessibleParents = new HashMap<>();
+    PermissionBackend.WithUser perm = permissionBackend.user(currentUser);
     final TreeMap<Project.NameKey, ProjectNode> treeMap = new TreeMap<>();
     try {
-      for (final Project.NameKey projectName : scan()) {
+      for (final Project.NameKey projectName : filter(perm)) {
         final ProjectState e = projectCache.get(projectName);
-        if (e == null) {
+        if (e == null || (!all && e.getProject().getState() == HIDDEN)) {
           // If we can't get it from the cache, pretend its not present.
-          //
+          // If all wasn't selected, and its HIDDEN, pretend its not present.
           continue;
         }
 
         final ProjectControl pctl = e.controlFor(currentUser);
-        if (groupUuid != null) {
-          try {
-            if (!groupControlFactory.controlFor(groupUuid).isVisible()) {
-              break;
-            }
-          } catch (NoSuchGroupException ex) {
-            break;
-          }
-          if (!pctl.getLocalGroups()
-              .contains(GroupReference.forGroup(groupsCollection.parseId(groupUuid.get())))) {
-            continue;
-          }
+        if (groupUuid != null
+            && !pctl.getLocalGroups()
+                .contains(GroupReference.forGroup(groupsCollection.parseId(groupUuid.get())))) {
+          continue;
         }
 
         ProjectInfo info = new ProjectInfo();
-        if (type == FilterType.PARENT_CANDIDATES) {
-          ProjectState parentState = Iterables.getFirst(e.parents(), null);
-          if (parentState != null
-              && !output.keySet().contains(parentState.getProject().getName())
-              && !rejected.contains(parentState.getProject().getName())) {
-            ProjectControl parentCtrl = parentState.controlFor(currentUser);
-            if (parentCtrl.isVisible() || parentCtrl.isOwner()) {
-              info.name = parentState.getProject().getName();
-              info.description = Strings.emptyToNull(parentState.getProject().getDescription());
-              info.state = parentState.getProject().getState();
+        if (showTree && !format.isJson()) {
+          treeMap.put(projectName, projectNodeFactory.create(pctl.getProject(), true));
+          continue;
+        }
+
+        info.name = projectName.get();
+        if (showTree && format.isJson()) {
+          ProjectState parent = Iterables.getFirst(e.parents(), null);
+          if (parent != null) {
+            if (isParentAccessible(accessibleParents, perm, parent)) {
+              info.parent = parent.getProject().getName();
             } else {
-              rejected.add(parentState.getProject().getName());
-              continue;
-            }
-          } else {
-            continue;
-          }
-
-        } else {
-          final boolean isVisible = pctl.isVisible() || (all && pctl.isOwner());
-          if (showTree && !format.isJson()) {
-            treeMap.put(projectName, projectNodeFactory.create(pctl.getProject(), isVisible));
-            continue;
-          }
-
-          if (!isVisible && !(showTree && pctl.isOwner())) {
-            // Require the project itself to be visible to the user.
-            //
-            continue;
-          }
-
-          info.name = projectName.get();
-          if (showTree && format.isJson()) {
-            ProjectState parent = Iterables.getFirst(e.parents(), null);
-            if (parent != null) {
-              ProjectControl parentCtrl = parent.controlFor(currentUser);
-              if (parentCtrl.isVisible() || parentCtrl.isOwner()) {
-                info.parent = parent.getProject().getName();
-              } else {
-                info.parent = hiddenNames.get(parent.getProject().getName());
-                if (info.parent == null) {
-                  info.parent = "?-" + (hiddenNames.size() + 1);
-                  hiddenNames.put(parent.getProject().getName(), info.parent);
-                }
+              info.parent = hiddenNames.get(parent.getProject().getName());
+              if (info.parent == null) {
+                info.parent = "?-" + (hiddenNames.size() + 1);
+                hiddenNames.put(parent.getProject().getName(), info.parent);
               }
             }
           }
-          if (showDescription) {
-            info.description = Strings.emptyToNull(e.getProject().getDescription());
-          }
+        }
 
-          info.state = e.getProject().getState();
+        if (showDescription) {
+          info.description = Strings.emptyToNull(e.getProject().getDescription());
+        }
+        info.state = e.getProject().getState();
 
-          try {
-            if (!showBranch.isEmpty()) {
-              try (Repository git = repoManager.openRepository(projectName)) {
-                if (!type.matches(git)) {
-                  continue;
-                }
+        try {
+          if (!showBranch.isEmpty()) {
+            try (Repository git = repoManager.openRepository(projectName)) {
+              if (!type.matches(git)) {
+                continue;
+              }
 
-                List<Ref> refs = getBranchRefs(projectName, pctl);
-                if (!hasValidRef(refs)) {
-                  continue;
-                }
+              List<Ref> refs = getBranchRefs(projectName, pctl);
+              if (!hasValidRef(refs)) {
+                continue;
+              }
 
-                for (int i = 0; i < showBranch.size(); i++) {
-                  Ref ref = refs.get(i);
-                  if (ref != null && ref.getObjectId() != null) {
-                    if (info.branches == null) {
-                      info.branches = new LinkedHashMap<>();
-                    }
-                    info.branches.put(showBranch.get(i), ref.getObjectId().name());
+              for (int i = 0; i < showBranch.size(); i++) {
+                Ref ref = refs.get(i);
+                if (ref != null && ref.getObjectId() != null) {
+                  if (info.branches == null) {
+                    info.branches = new LinkedHashMap<>();
                   }
-                }
-              }
-            } else if (!showTree && type != FilterType.ALL) {
-              try (Repository git = repoManager.openRepository(projectName)) {
-                if (!type.matches(git)) {
-                  continue;
+                  info.branches.put(showBranch.get(i), ref.getObjectId().name());
                 }
               }
             }
-
-          } catch (RepositoryNotFoundException err) {
-            // If the Git repository is gone, the project doesn't actually exist anymore.
-            continue;
-          } catch (IOException err) {
-            log.warn("Unexpected error reading " + projectName, err);
-            continue;
+          } else if (!showTree && type.useMatch()) {
+            try (Repository git = repoManager.openRepository(projectName)) {
+              if (!type.matches(git)) {
+                continue;
+              }
+            }
           }
+        } catch (RepositoryNotFoundException err) {
+          // If the Git repository is gone, the project doesn't actually exist anymore.
+          continue;
+        } catch (IOException err) {
+          log.warn("Unexpected error reading " + projectName, err);
+          continue;
+        }
+
+        if (type != FilterType.PARENT_CANDIDATES) {
           List<WebLinkInfo> links = webLinks.getProjectLinks(projectName.get());
           info.webLinks = links.isEmpty() ? null : links;
         }
@@ -415,7 +433,6 @@
         if (foundIndex++ < start) {
           continue;
         }
-
         if (limit > 0 && ++found > limit) {
           break;
         }
@@ -467,6 +484,46 @@
     }
   }
 
+  private Collection<Project.NameKey> filter(PermissionBackend.WithUser perm)
+      throws BadRequestException, PermissionBackendException {
+    Collection<Project.NameKey> matches = Lists.newArrayList(scan());
+    if (type == FilterType.PARENT_CANDIDATES) {
+      matches = parentsOf(matches);
+    }
+    return perm.filter(ProjectPermission.ACCESS, matches).stream().sorted().collect(toList());
+  }
+
+  private Collection<Project.NameKey> parentsOf(Collection<Project.NameKey> matches) {
+    Set<Project.NameKey> parents = new HashSet<>();
+    for (Project.NameKey p : matches) {
+      ProjectState ps = projectCache.get(p);
+      if (ps != null) {
+        Project.NameKey parent = ps.getProject().getParent();
+        if (parent != null) {
+          parents.add(parent);
+        }
+      }
+    }
+    return parents;
+  }
+
+  private boolean isParentAccessible(
+      Map<Project.NameKey, Boolean> checked, PermissionBackend.WithUser perm, ProjectState p)
+      throws PermissionBackendException {
+    Project.NameKey name = p.getProject().getNameKey();
+    Boolean b = checked.get(name);
+    if (b == null) {
+      try {
+        perm.project(name).check(ProjectPermission.ACCESS);
+        b = true;
+      } catch (AuthException denied) {
+        b = false;
+      }
+      checked.put(name, b);
+    }
+    return b;
+  }
+
   private Iterable<Project.NameKey> scan() throws BadRequestException {
     if (matchPrefix != null) {
       checkMatchOptions(matchSubstring == null && matchRegex == null);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java
index d7af195..11f3805 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java
@@ -24,6 +24,7 @@
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.server.change.CherryPickCommit;
 
 public class Module extends RestApiModule {
   @Override
@@ -96,6 +97,8 @@
     get(PROJECT_KIND, "config").to(GetConfig.class);
     put(PROJECT_KIND, "config").to(PutConfig.class);
 
+    post(COMMIT_KIND, "cherrypick").to(CherryPickCommit.class);
+
     factory(DeleteRef.Factory.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PermissionCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PermissionCollection.java
index 4bf3e47..9febb3f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PermissionCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PermissionCollection.java
@@ -98,7 +98,6 @@
           perUser = true;
           if (sm.match(ref, user)) {
             sectionToProject.put(sm.section, sm.project);
-            break;
           }
         } else if (sm.match(ref, null)) {
           sectionToProject.put(sm.section, sm.project);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheWarmer.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
index 5e0ba28..16a3b6b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
@@ -46,7 +46,7 @@
   public void start() {
     int cpus = Runtime.getRuntime().availableProcessors();
     if (config.getBoolean("cache", "projects", "loadOnStartup", false)) {
-      final ThreadPoolExecutor pool =
+      ThreadPoolExecutor pool =
           new ScheduledThreadPoolExecutor(
               config.getInt("cache", "projects", "loadThreads", cpus),
               new ThreadFactoryBuilder().setNameFormat("ProjectCacheLoader-%d").build());
@@ -54,25 +54,19 @@
 
       log.info("Loading project cache");
       scheduler.execute(
-          new Runnable() {
-            @Override
-            public void run() {
-              for (final Project.NameKey name : cache.all()) {
-                pool.execute(
-                    new Runnable() {
-                      @Override
-                      public void run() {
-                        cache.get(name);
-                      }
-                    });
-              }
-              pool.shutdown();
-              try {
-                pool.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);
-                log.info("Finished loading project cache");
-              } catch (InterruptedException e) {
-                log.warn("Interrupted while waiting for project cache to load");
-              }
+          () -> {
+            for (final Project.NameKey name : cache.all()) {
+              pool.execute(
+                  () -> {
+                    cache.get(name);
+                  });
+            }
+            pool.shutdown();
+            try {
+              pool.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);
+              log.info("Finished loading project cache");
+            } catch (InterruptedException e) {
+              log.warn("Interrupted while waiting for project cache to load");
             }
           });
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
index e9976c5..6291fb0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.MetricMaker;
@@ -45,6 +46,10 @@
 import com.google.gerrit.server.git.VisibleRefFilter;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackend.ForProject;
+import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
@@ -56,6 +61,7 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -70,9 +76,6 @@
 
 /** Access control management for a user accessing a project's data. */
 public class ProjectControl {
-  public static final int VISIBLE = 1 << 0;
-  public static final int OWNER = 1 << 1;
-
   private static final Logger log = LoggerFactory.getLogger(ProjectControl.class);
 
   public static class GenericFactory {
@@ -91,18 +94,6 @@
       }
       return p.controlFor(user);
     }
-
-    public ProjectControl validateFor(Project.NameKey nameKey, int need, CurrentUser user)
-        throws NoSuchProjectException, IOException {
-      final ProjectControl c = controlFor(nameKey, user);
-      if ((need & VISIBLE) == VISIBLE && c.isVisible()) {
-        return c;
-      }
-      if ((need & OWNER) == OWNER && c.isOwner()) {
-        return c;
-      }
-      throw new NoSuchProjectException(nameKey);
-    }
   }
 
   public static class Factory {
@@ -116,26 +107,6 @@
     public ProjectControl controlFor(final Project.NameKey nameKey) throws NoSuchProjectException {
       return userCache.get().get(nameKey);
     }
-
-    public ProjectControl validateFor(final Project.NameKey nameKey) throws NoSuchProjectException {
-      return validateFor(nameKey, VISIBLE);
-    }
-
-    public ProjectControl ownerFor(final Project.NameKey nameKey) throws NoSuchProjectException {
-      return validateFor(nameKey, OWNER);
-    }
-
-    public ProjectControl validateFor(final Project.NameKey nameKey, final int need)
-        throws NoSuchProjectException {
-      final ProjectControl c = controlFor(nameKey);
-      if ((need & VISIBLE) == VISIBLE && c.isVisible()) {
-        return c;
-      }
-      if ((need & OWNER) == OWNER && c.isOwner()) {
-        return c;
-      }
-      throw new NoSuchProjectException(nameKey);
-    }
   }
 
   public interface AssistedFactory {
@@ -274,21 +245,6 @@
     return getProject().getState().equals(com.google.gerrit.extensions.client.ProjectState.HIDDEN);
   }
 
-  /**
-   * Returns whether the project is readable to the current user. Note that the project could still
-   * be hidden.
-   */
-  public boolean isReadable() {
-    return (user.isInternalUser() || canPerformOnAnyRef(Permission.READ));
-  }
-
-  /**
-   * Returns whether the project is accessible to the current user, i.e. readable and not hidden.
-   */
-  public boolean isVisible() {
-    return isReadable() && !isHidden();
-  }
-
   public boolean canAddRefs() {
     return (canPerformOnAnyRef(Permission.CREATE) || isOwnerAnyRef());
   }
@@ -306,19 +262,14 @@
     return false;
   }
 
-  /** Can this user see all the refs in this projects? */
-  public boolean allRefsAreVisible() {
-    return allRefsAreVisible(Collections.<String>emptySet());
-  }
-
   public boolean allRefsAreVisible(Set<String> ignore) {
     return user.isInternalUser() || canPerformOnAllRefs(Permission.READ, ignore);
   }
 
-  /** Is this user a project owner? Ownership does not imply {@link #isVisible()} */
+  /** Is this user a project owner? */
   public boolean isOwner() {
     return (isDeclaredOwner() && !controlForRef("refs/*").isBlocked(Permission.OWNER))
-        || user.getCapabilities().canAdministrateServer();
+        || user.getCapabilities().isAdmin_DoNotUse();
   }
 
   private boolean isDeclaredOwner() {
@@ -331,7 +282,7 @@
 
   /** Does this user have ownership on at least one reference name? */
   public boolean isOwnerAnyRef() {
-    return canPerformOnAnyRef(Permission.OWNER) || user.getCapabilities().canAdministrateServer();
+    return canPerformOnAnyRef(Permission.OWNER) || user.getCapabilities().isAdmin_DoNotUse();
   }
 
   /** @return true if the user can upload to at least one reference */
@@ -565,4 +516,51 @@
     Map<String, Ref> refs = filter.filter(m, true);
     return !refs.isEmpty() && IncludedInResolver.includedInOne(repo, rw, commit, refs.values());
   }
+
+  ForProject asForProject() {
+    return new ForProjectImpl();
+  }
+
+  private class ForProjectImpl extends ForProject {
+    @Override
+    public ForProject user(CurrentUser user) {
+      return forUser(user).asForProject().database(db);
+    }
+
+    @Override
+    public ForRef ref(String ref) {
+      return controlForRef(ref).asForRef().database(db);
+    }
+
+    @Override
+    public void check(ProjectPermission perm) throws AuthException, PermissionBackendException {
+      if (!can(perm)) {
+        throw new AuthException(perm.describeForException() + " not permitted");
+      }
+    }
+
+    @Override
+    public Set<ProjectPermission> test(Collection<ProjectPermission> permSet)
+        throws PermissionBackendException {
+      EnumSet<ProjectPermission> ok = EnumSet.noneOf(ProjectPermission.class);
+      for (ProjectPermission perm : permSet) {
+        if (can(perm)) {
+          ok.add(perm);
+        }
+      }
+      return ok;
+    }
+
+    private boolean can(ProjectPermission perm) throws PermissionBackendException {
+      switch (perm) {
+        case ACCESS:
+          return (!isHidden() && (user.isInternalUser() || canPerformOnAnyRef(Permission.READ)))
+              || isOwner();
+
+        case READ:
+          return !isHidden() && allRefsAreVisible(Collections.emptySet());
+      }
+      throw new PermissionBackendException(perm + " unsupported");
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectResource.java
index b8830a0..2601a4a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectResource.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Project;
@@ -42,8 +41,8 @@
     return control.getProject().getNameKey();
   }
 
-  public ProjectState getState() {
-    return control.getProject().getState();
+  public ProjectState getProjectState() {
+    return control.getProjectState();
   }
 
   public ProjectControl getControl() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
index 8b8745e..32dc41f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
@@ -394,6 +394,10 @@
     return getInheritableBoolean(Project::getRejectImplicitMerges);
   }
 
+  public boolean isEnableReviewerByEmail() {
+    return getInheritableBoolean(Project::getEnableReviewerByEmail);
+  }
+
   public LabelTypes getLabelTypes() {
     Map<String, LabelType> types = new LinkedHashMap<>();
     for (ProjectState s : treeInOrder()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectsCollection.java
index dcb3404..d461a7d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectsCollection.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsCreate;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestCollection;
@@ -25,6 +27,9 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -37,6 +42,7 @@
   private final DynamicMap<RestView<ProjectResource>> views;
   private final Provider<ListProjects> list;
   private final ProjectControl.GenericFactory controlFactory;
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
   private final CreateProject.Factory createProjectFactory;
 
@@ -45,11 +51,13 @@
       DynamicMap<RestView<ProjectResource>> views,
       Provider<ListProjects> list,
       ProjectControl.GenericFactory controlFactory,
+      PermissionBackend permissionBackend,
       CreateProject.Factory factory,
       Provider<CurrentUser> user) {
     this.views = views;
     this.list = list;
     this.controlFactory = controlFactory;
+    this.permissionBackend = permissionBackend;
     this.user = user;
     this.createProjectFactory = factory;
   }
@@ -61,7 +69,7 @@
 
   @Override
   public ProjectResource parse(TopLevelResource parent, IdString id)
-      throws ResourceNotFoundException, IOException {
+      throws ResourceNotFoundException, IOException, PermissionBackendException {
     ProjectResource rsrc = _parse(id.get(), true);
     if (rsrc == null) {
       throw new ResourceNotFoundException(id);
@@ -77,8 +85,10 @@
    * @throws UnprocessableEntityException thrown if the project ID cannot be resolved or if the
    *     project is not visible to the calling user
    * @throws IOException thrown when there is an error.
+   * @throws PermissionBackendException
    */
-  public ProjectResource parse(String id) throws UnprocessableEntityException, IOException {
+  public ProjectResource parse(String id)
+      throws UnprocessableEntityException, IOException, PermissionBackendException {
     return parse(id, true);
   }
 
@@ -86,33 +96,43 @@
    * Parses a project ID from a request body and returns the project.
    *
    * @param id ID of the project, can be a project name
-   * @param checkVisibility Whether to check or not that project is visible to the calling user
+   * @param checkAccess if true, check the project is accessible by the current user
    * @return the project
    * @throws UnprocessableEntityException thrown if the project ID cannot be resolved or if the
    *     project is not visible to the calling user and checkVisibility is true.
    * @throws IOException thrown when there is an error.
+   * @throws PermissionBackendException
    */
-  public ProjectResource parse(String id, boolean checkVisibility)
-      throws UnprocessableEntityException, IOException {
-    ProjectResource rsrc = _parse(id, checkVisibility);
+  public ProjectResource parse(String id, boolean checkAccess)
+      throws UnprocessableEntityException, IOException, PermissionBackendException {
+    ProjectResource rsrc = _parse(id, checkAccess);
     if (rsrc == null) {
       throw new UnprocessableEntityException(String.format("Project Not Found: %s", id));
     }
     return rsrc;
   }
 
-  private ProjectResource _parse(String id, boolean checkVisibility) throws IOException {
+  @Nullable
+  private ProjectResource _parse(String id, boolean checkAccess)
+      throws IOException, PermissionBackendException {
     if (id.endsWith(Constants.DOT_GIT_EXT)) {
       id = id.substring(0, id.length() - Constants.DOT_GIT_EXT.length());
     }
+
+    Project.NameKey nameKey = new Project.NameKey(id);
     ProjectControl ctl;
     try {
-      ctl = controlFactory.controlFor(new Project.NameKey(id), user.get());
+      ctl = controlFactory.controlFor(nameKey, user.get());
     } catch (NoSuchProjectException e) {
       return null;
     }
-    if (checkVisibility && !ctl.isVisible() && !ctl.isOwner()) {
-      return null;
+
+    if (checkAccess) {
+      try {
+        permissionBackend.user(user).project(nameKey).check(ProjectPermission.ACCESS);
+      } catch (AuthException e) {
+        return null; // Pretend like not found on access denied.
+      }
     }
     return new ProjectResource(ctl);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
index c5ded54..806c01a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
@@ -22,9 +22,11 @@
 import com.google.gerrit.extensions.api.projects.ConfigValue;
 import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Project;
@@ -34,6 +36,7 @@
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
+import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.git.TransferConfig;
@@ -62,6 +65,7 @@
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final PluginConfigFactory cfgFactory;
   private final AllProjectsName allProjects;
+  private final UiActions uiActions;
   private final DynamicMap<RestView<ProjectResource>> views;
   private final Provider<CurrentUser> user;
 
@@ -75,6 +79,7 @@
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
+      UiActions uiActions,
       DynamicMap<RestView<ProjectResource>> views,
       Provider<CurrentUser> user) {
     this.serverEnableSignedPush = serverEnableSignedPush;
@@ -85,15 +90,15 @@
     this.pluginConfigEntries = pluginConfigEntries;
     this.cfgFactory = cfgFactory;
     this.allProjects = allProjects;
+    this.uiActions = uiActions;
     this.views = views;
     this.user = user;
   }
 
   @Override
-  public ConfigInfo apply(ProjectResource rsrc, ConfigInput input)
-      throws ResourceNotFoundException, BadRequestException, ResourceConflictException {
+  public ConfigInfo apply(ProjectResource rsrc, ConfigInput input) throws RestApiException {
     if (!rsrc.getControl().isOwner()) {
-      throw new ResourceNotFoundException(rsrc.getName());
+      throw new AuthException("restricted to project owner");
     }
     return apply(rsrc.getControl(), input);
   }
@@ -154,6 +159,10 @@
         p.setState(input.state);
       }
 
+      if (input.enableReviewerByEmail != null) {
+        p.setEnableReviewerByEmail(input.enableReviewerByEmail);
+      }
+
       if (input.pluginConfigValues != null) {
         setPluginConfigValues(ctrl.getProjectState(), projectConfig, input.pluginConfigValues);
       }
@@ -180,6 +189,7 @@
           pluginConfigEntries,
           cfgFactory,
           allProjects,
+          uiActions,
           views);
     } catch (RepositoryNotFoundException notFound) {
       throw new ResourceNotFoundException(projectName.get());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
index 8413b5a9..812872c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
@@ -14,17 +14,31 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.common.base.Preconditions.checkArgument;
+
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.FailedPermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
+import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -107,6 +121,11 @@
     return isVisible;
   }
 
+  /** Can this user see other users change edits? */
+  public boolean isEditVisible() {
+    return canViewPrivateChanges();
+  }
+
   /** True if this reference is visible by all REGISTERED_USERS */
   public boolean isVisibleByRegisteredUsers() {
     List<PermissionRule> access = relevant.getPermission(Permission.READ);
@@ -140,7 +159,7 @@
   }
 
   /** @return true if this user can add a new patch set to this ref */
-  public boolean canAddPatchSet() {
+  boolean canAddPatchSet() {
     return projectControl
             .controlForRef("refs/for/" + getRefName())
             .canPerform(Permission.ADD_PATCH_SET)
@@ -156,7 +175,7 @@
   }
 
   /** @return true if this user can rebase changes on this ref */
-  public boolean canRebase() {
+  boolean canRebase() {
     return canPerform(Permission.REBASE) && canWrite();
   }
 
@@ -173,13 +192,8 @@
     return canPerform(Permission.SUBMIT, isChangeOwner) && canWrite();
   }
 
-  /** @return true if this user was granted submitAs to this ref */
-  public boolean canSubmitAs() {
-    return canPerform(Permission.SUBMIT_AS);
-  }
-
   /** @return true if the user can update the reference as a fast-forward. */
-  public boolean canUpdate() {
+  private boolean canUpdate() {
     if (RefNames.REFS_CONFIG.equals(refName) && !projectControl.isOwner()) {
       // Pushing requires being at least project owner, in addition to push.
       // Pushing configuration changes modifies the access control
@@ -191,7 +205,7 @@
       // this why for the AllProjects project we allow administrators to push
       // configuration changes if they have push without being project owner.
       if (!(projectControl.getProjectState().isAllProjects()
-          && getUser().getCapabilities().canAdministrateServer())) {
+          && getUser().getCapabilities().isAdmin_DoNotUse())) {
         return false;
       }
     }
@@ -199,7 +213,7 @@
   }
 
   /** @return true if the user can rewind (force push) the reference. */
-  public boolean canForceUpdate() {
+  private boolean canForceUpdate() {
     if (!canWrite()) {
       return false;
     }
@@ -218,7 +232,7 @@
       case UNKNOWN:
       case WEB_BROWSER:
       default:
-        return getUser().getCapabilities().canAdministrateServer()
+        return getUser().getCapabilities().isAdmin_DoNotUse()
             || (isOwner() && !isForceBlocked(Permission.PUSH));
     }
   }
@@ -364,7 +378,7 @@
       case UNKNOWN:
       case WEB_BROWSER:
       default:
-        return getUser().getCapabilities().canAdministrateServer()
+        return getUser().getCapabilities().isAdmin_DoNotUse()
             || (isOwner() && !isForceBlocked(Permission.PUSH))
             || canPushWithForce()
             || canPerform(Permission.DELETE);
@@ -393,7 +407,7 @@
   }
 
   /** @return true if this user can abandon a change for this ref */
-  public boolean canAbandon() {
+  boolean canAbandon() {
     return canPerform(Permission.ABANDON);
   }
 
@@ -403,46 +417,51 @@
   }
 
   /** @return true if this user can view draft changes. */
-  public boolean canViewDrafts() {
+  boolean canViewDrafts() {
     return canPerform(Permission.VIEW_DRAFTS);
   }
 
+  /** @return true if this user can view private changes. */
+  boolean canViewPrivateChanges() {
+    return canPerform(Permission.VIEW_PRIVATE_CHANGES);
+  }
+
   /** @return true if this user can publish draft changes. */
-  public boolean canPublishDrafts() {
+  boolean canPublishDrafts() {
     return canPerform(Permission.PUBLISH_DRAFTS);
   }
 
   /** @return true if this user can delete draft changes. */
-  public boolean canDeleteDrafts() {
+  boolean canDeleteDrafts() {
     return canPerform(Permission.DELETE_DRAFTS);
   }
 
   /** @return true if this user can delete their own changes. */
-  public boolean canDeleteOwnChanges() {
+  boolean canDeleteOwnChanges() {
     return canPerform(Permission.DELETE_OWN_CHANGES);
   }
 
   /** @return true if this user can edit topic names. */
-  public boolean canEditTopicName() {
+  boolean canEditTopicName() {
     return canPerform(Permission.EDIT_TOPIC_NAME);
   }
 
   /** @return true if this user can edit hashtag names. */
-  public boolean canEditHashtags() {
+  boolean canEditHashtags() {
     return canPerform(Permission.EDIT_HASHTAGS);
   }
 
-  public boolean canEditAssignee() {
+  boolean canEditAssignee() {
     return canPerform(Permission.EDIT_ASSIGNEE);
   }
 
   /** @return true if this user can force edit topic names. */
-  public boolean canForceEditTopicName() {
+  boolean canForceEditTopicName() {
     return canForcePerform(Permission.EDIT_TOPIC_NAME);
   }
 
   /** All value ranges of any allowed label permission. */
-  public List<PermissionRange> getLabelRanges(boolean isChangeOwner) {
+  List<PermissionRange> getLabelRanges(boolean isChangeOwner) {
     List<PermissionRange> r = new ArrayList<>();
     for (Map.Entry<String, List<PermissionRule>> e : relevant.getDeclaredPermissions()) {
       if (Permission.isLabel(e.getKey())) {
@@ -463,12 +482,12 @@
   }
 
   /** The range of permitted values associated with a label permission. */
-  public PermissionRange getRange(String permission) {
+  PermissionRange getRange(String permission) {
     return getRange(permission, false);
   }
 
   /** The range of permitted values associated with a label permission. */
-  public PermissionRange getRange(String permission, boolean isChangeOwner) {
+  PermissionRange getRange(String permission, boolean isChangeOwner) {
     if (Permission.hasRange(permission)) {
       return toRange(permission, access(permission, isChangeOwner));
     }
@@ -642,4 +661,77 @@
     effective.put(permissionName, mine);
     return mine;
   }
+
+  ForRef asForRef() {
+    return new ForRefImpl();
+  }
+
+  private class ForRefImpl extends ForRef {
+    @Override
+    public ForRef user(CurrentUser user) {
+      return forUser(user).asForRef().database(db);
+    }
+
+    @Override
+    public ForChange change(ChangeData cd) {
+      try {
+        return cd.changeControl().forUser(getUser()).asForChange(cd, db);
+      } catch (OrmException e) {
+        return FailedPermissionBackend.change("unavailable", e);
+      }
+    }
+
+    @Override
+    public ForChange change(ChangeNotes notes) {
+      Change change = notes.getChange();
+      checkArgument(
+          getProjectControl().getProject().getNameKey().equals(change.getProject()),
+          "mismatched project");
+      return getProjectControl().controlFor(notes).asForChange(null, db);
+    }
+
+    @Override
+    public void check(RefPermission perm) throws AuthException, PermissionBackendException {
+      if (!can(perm)) {
+        throw new AuthException(perm.describeForException() + " not permitted");
+      }
+    }
+
+    @Override
+    public Set<RefPermission> test(Collection<RefPermission> permSet)
+        throws PermissionBackendException {
+      EnumSet<RefPermission> ok = EnumSet.noneOf(RefPermission.class);
+      for (RefPermission perm : permSet) {
+        if (can(perm)) {
+          ok.add(perm);
+        }
+      }
+      return ok;
+    }
+
+    private boolean can(RefPermission perm) throws PermissionBackendException {
+      switch (perm) {
+        case READ:
+          return isVisible();
+        case CREATE:
+          // TODO This isn't an accurate test.
+          return canPerform(perm.permissionName().get());
+        case DELETE:
+          return canDelete();
+        case UPDATE:
+          return canUpdate();
+        case FORCE_UPDATE:
+          return canForceUpdate();
+        case FORGE_AUTHOR:
+          return canForgeAuthor();
+        case FORGE_COMMITTER:
+          return canForgeCommitter();
+        case FORGE_SERVER:
+          return canForgeGerritServerIdentity();
+        case CREATE_CHANGE:
+          return canUpload();
+      }
+      throw new PermissionBackendException(perm + " unsupported");
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
index c74efc6..6c55c37 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
@@ -41,6 +41,9 @@
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.group.GroupsCollection;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -54,6 +57,7 @@
 @Singleton
 public class SetAccess implements RestModifyView<ProjectResource, ProjectAccessInput> {
   protected final GroupBackend groupBackend;
+  private final PermissionBackend permissionBackend;
   private final GroupsCollection groupsCollection;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final AllProjectsName allProjects;
@@ -65,6 +69,7 @@
   @Inject
   private SetAccess(
       GroupBackend groupBackend,
+      PermissionBackend permissionBackend,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       AllProjectsName allProjects,
       Provider<SetParent> setParent,
@@ -73,6 +78,7 @@
       GetAccess getAccess,
       Provider<IdentifiedUser> identifiedUser) {
     this.groupBackend = groupBackend;
+    this.permissionBackend = permissionBackend;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.allProjects = allProjects;
     this.setParent = setParent;
@@ -85,7 +91,7 @@
   @Override
   public ProjectAccessInfo apply(ProjectResource rsrc, ProjectAccessInput input)
       throws ResourceNotFoundException, ResourceConflictException, IOException, AuthException,
-          BadRequestException, UnprocessableEntityException {
+          BadRequestException, UnprocessableEntityException, PermissionBackendException {
     List<AccessSection> removals = getAccessSections(input.remove);
     List<AccessSection> additions = getAccessSections(input.add);
     MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
@@ -269,16 +275,11 @@
   }
 
   private void checkGlobalCapabilityPermissions(Project.NameKey projectName)
-      throws BadRequestException, AuthException {
-
+      throws BadRequestException, AuthException, PermissionBackendException {
     if (!allProjects.equals(projectName)) {
       throw new BadRequestException(
           "Cannot edit global capabilities for projects other than " + allProjects.get());
     }
-
-    if (!identifiedUser.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException(
-          "Editing global capabilities requires " + GlobalCapability.ADMINISTRATE_SERVER);
-    }
+    permissionBackend.user(identifiedUser).check(GlobalPermission.ADMINISTRATE_SERVER);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java
index f8d649b..7ec8706 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java
@@ -30,6 +30,9 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.SetParent.Input;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -45,12 +48,18 @@
   }
 
   private final ProjectCache cache;
+  private final PermissionBackend permissionBackend;
   private final MetaDataUpdate.Server updateFactory;
   private final AllProjectsName allProjects;
 
   @Inject
-  SetParent(ProjectCache cache, MetaDataUpdate.Server updateFactory, AllProjectsName allProjects) {
+  SetParent(
+      ProjectCache cache,
+      PermissionBackend permissionBackend,
+      MetaDataUpdate.Server updateFactory,
+      AllProjectsName allProjects) {
     this.cache = cache;
+    this.permissionBackend = permissionBackend;
     this.updateFactory = updateFactory;
     this.allProjects = allProjects;
   }
@@ -58,13 +67,13 @@
   @Override
   public String apply(ProjectResource rsrc, Input input)
       throws AuthException, ResourceConflictException, ResourceNotFoundException,
-          UnprocessableEntityException, IOException {
+          UnprocessableEntityException, IOException, PermissionBackendException {
     return apply(rsrc, input, true);
   }
 
   public String apply(ProjectResource rsrc, Input input, boolean checkIfAdmin)
       throws AuthException, ResourceConflictException, ResourceNotFoundException,
-          UnprocessableEntityException, IOException {
+          UnprocessableEntityException, IOException, PermissionBackendException {
     ProjectControl ctl = rsrc.getControl();
     String parentName =
         MoreObjects.firstNonNull(Strings.emptyToNull(input.parent), allProjects.get());
@@ -97,10 +106,11 @@
   }
 
   public void validateParentUpdate(final ProjectControl ctl, String newParent, boolean checkIfAdmin)
-      throws AuthException, ResourceConflictException, UnprocessableEntityException {
+      throws AuthException, ResourceConflictException, UnprocessableEntityException,
+          PermissionBackendException {
     IdentifiedUser user = ctl.getUser().asIdentifiedUser();
-    if (checkIfAdmin && !user.getCapabilities().canAdministrateServer()) {
-      throw new AuthException("not administrator");
+    if (checkIfAdmin) {
+      permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
     if (ctl.getProject().getNameKey().equals(allProjects)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SuggestParentCandidates.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SuggestParentCandidates.java
index 9d3005c..7c7f8a5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SuggestParentCandidates.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SuggestParentCandidates.java
@@ -14,65 +14,61 @@
 
 package com.google.gerrit.server.project;
 
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Comparator;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
-import java.util.TreeSet;
 
 @Singleton
 public class SuggestParentCandidates {
-  private final ProjectControl.Factory projectControlFactory;
   private final ProjectCache projectCache;
-  private final AllProjectsName allProject;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final AllProjectsName allProjects;
 
   @Inject
   SuggestParentCandidates(
-      final ProjectControl.Factory projectControlFactory,
-      final ProjectCache projectCache,
-      final AllProjectsName allProject) {
-    this.projectControlFactory = projectControlFactory;
+      ProjectCache projectCache,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      AllProjectsName allProjects) {
     this.projectCache = projectCache;
-    this.allProject = allProject;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
+    this.allProjects = allProjects;
   }
 
-  public List<Project.NameKey> getNameKeys() throws NoSuchProjectException {
-    List<Project> pList = getProjects();
-    final List<Project.NameKey> nameKeys = new ArrayList<>(pList.size());
-    for (Project p : pList) {
-      nameKeys.add(p.getNameKey());
-    }
-    return nameKeys;
+  public List<Project.NameKey> getNameKeys() throws PermissionBackendException {
+    return permissionBackend
+        .user(user)
+        .filter(ProjectPermission.ACCESS, parents())
+        .stream()
+        .sorted()
+        .collect(toList());
   }
 
-  public List<Project> getProjects() throws NoSuchProjectException {
-    Set<Project> projects =
-        new TreeSet<>(
-            new Comparator<Project>() {
-              @Override
-              public int compare(Project o1, Project o2) {
-                return o1.getName().compareTo(o2.getName());
-              }
-            });
+  private Set<Project.NameKey> parents() {
+    Set<Project.NameKey> parents = new HashSet<>();
     for (Project.NameKey p : projectCache.all()) {
-      try {
-        final ProjectControl control = projectControlFactory.controlFor(p);
-        final Project.NameKey parentK = control.getProject().getParent();
-        if (parentK != null) {
-          ProjectControl pControl = projectControlFactory.controlFor(parentK);
-          if (pControl.isVisible() || pControl.isOwner()) {
-            projects.add(pControl.getProject());
-          }
+      ProjectState ps = projectCache.get(p);
+      if (ps != null) {
+        Project.NameKey parent = ps.getProject().getParent();
+        if (parent != null) {
+          parents.add(parent);
         }
-      } catch (NoSuchProjectException e) {
-        continue;
       }
     }
-    projects.add(projectControlFactory.controlFor(allProject).getProject());
-    return new ArrayList<>(projects);
+    parents.add(allProjects);
+    return parents;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/IntPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/IntPredicate.java
index 6627687..2abcd58 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/IntPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/IntPredicate.java
@@ -16,25 +16,25 @@
 
 /** Predicate to filter a field by matching integer value. */
 public abstract class IntPredicate<T> extends OperatorPredicate<T> {
-  private final int value;
+  private final int intValue;
 
   public IntPredicate(final String name, final String value) {
     super(name, value);
-    this.value = Integer.parseInt(value);
+    this.intValue = Integer.parseInt(value);
   }
 
-  public IntPredicate(final String name, final int value) {
-    super(name, String.valueOf(value));
-    this.value = value;
+  public IntPredicate(final String name, final int intValue) {
+    super(name, String.valueOf(intValue));
+    this.intValue = intValue;
   }
 
   public int intValue() {
-    return value;
+    return intValue;
   }
 
   @Override
   public int hashCode() {
-    return getOperator().hashCode() * 31 + value;
+    return getOperator().hashCode() * 31 + intValue;
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/OperatorPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/OperatorPredicate.java
index 96a30ee..9413c5d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/OperatorPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/OperatorPredicate.java
@@ -18,10 +18,10 @@
 
 /** Predicate to filter a field by matching value. */
 public abstract class OperatorPredicate<T> extends Predicate<T> {
-  private final String name;
-  private final String value;
+  protected final String name;
+  protected final String value;
 
-  protected OperatorPredicate(final String name, final String value) {
+  public OperatorPredicate(final String name, final String value) {
     this.name = name;
     this.value = value;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
index 0a74647..e5ed44d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
@@ -20,9 +20,9 @@
 import com.google.gwtorm.server.OrmException;
 
 public class AccountIsVisibleToPredicate extends IsVisibleToPredicate<AccountState> {
-  private final AccountControl accountControl;
+  protected final AccountControl accountControl;
 
-  AccountIsVisibleToPredicate(AccountControl accountControl) {
+  public AccountIsVisibleToPredicate(AccountControl accountControl) {
     super(AccountQueryBuilder.FIELD_VISIBLETO, describe(accountControl.getUser()));
     this.accountControl = accountControl;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java
index 796539b..9bb5515 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -55,6 +55,13 @@
         AccountField.EMAIL, AccountQueryBuilder.FIELD_EMAIL, email.toLowerCase());
   }
 
+  static Predicate<AccountState> preferredEmail(String email) {
+    return new AccountPredicate(
+        AccountField.PREFERRED_EMAIL,
+        AccountQueryBuilder.FIELD_PREFERRED_EMAIL,
+        email.toLowerCase());
+  }
+
   static Predicate<AccountState> equalsName(String name) {
     return new AccountPredicate(
         AccountField.NAME_PART, AccountQueryBuilder.FIELD_NAME, name.toLowerCase());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
index 5ae6a67..c8857eb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
@@ -36,6 +36,7 @@
   public static final String FIELD_EMAIL = "email";
   public static final String FIELD_LIMIT = "limit";
   public static final String FIELD_NAME = "name";
+  public static final String FIELD_PREFERRED_EMAIL = "preferredemail";
   public static final String FIELD_USERNAME = "username";
   public static final String FIELD_VISIBLETO = "visibleto";
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
index c2b92aa..70d8484 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.index.IndexConfig;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.query.InternalQuery;
@@ -68,10 +68,6 @@
     return query(AccountPredicates.defaultPredicate(query));
   }
 
-  public List<AccountState> byEmailPrefix(String emailPrefix) throws OrmException {
-    return query(AccountPredicates.email(emailPrefix));
-  }
-
   public List<AccountState> byExternalId(String scheme, String id) throws OrmException {
     return byExternalId(ExternalId.Key.create(scheme, id));
   }
@@ -106,6 +102,10 @@
     return query(AccountPredicates.fullName(fullName));
   }
 
+  public List<AccountState> byPreferredEmail(String email) throws OrmException {
+    return query(AccountPredicates.preferredEmail(email));
+  }
+
   public List<AccountState> byWatchedProject(Project.NameKey project) throws OrmException {
     return query(AccountPredicates.watchedProject(project));
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AddedPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AddedPredicate.java
index b3cdd6a..05bf24bd2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AddedPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AddedPredicate.java
@@ -19,7 +19,7 @@
 import com.google.gwtorm.server.OrmException;
 
 public class AddedPredicate extends IntegerRangeChangePredicate {
-  AddedPredicate(String value) throws QueryParseException {
+  public AddedPredicate(String value) throws QueryParseException {
     super(ChangeField.ADDED, value);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java
index 7d51217..b9c4694 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java
@@ -20,9 +20,9 @@
 import java.util.Date;
 
 public class AfterPredicate extends TimestampRangeChangePredicate {
-  private final Date cut;
+  protected final Date cut;
 
-  AfterPredicate(String value) throws QueryParseException {
+  public AfterPredicate(String value) throws QueryParseException {
     super(ChangeField.UPDATED, ChangeQueryBuilder.FIELD_BEFORE, value);
     cut = parse(value);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java
index 0cd76bb..a5f4965 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java
@@ -25,9 +25,9 @@
 import java.sql.Timestamp;
 
 public class AgePredicate extends TimestampRangeChangePredicate {
-  private final long cut;
+  protected final long cut;
 
-  AgePredicate(String value) {
+  public AgePredicate(String value) {
     super(ChangeField.UPDATED, ChangeQueryBuilder.FIELD_AGE, value);
 
     long s = ConfigUtil.getTimeUnit(getValue(), 0, SECONDS);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AssigneePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AssigneePredicate.java
index 38622ed..848fd09 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AssigneePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AssigneePredicate.java
@@ -18,10 +18,10 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class AssigneePredicate extends ChangeIndexPredicate {
-  private final Account.Id id;
+public class AssigneePredicate extends ChangeIndexPredicate {
+  protected final Account.Id id;
 
-  AssigneePredicate(Account.Id id) {
+  public AssigneePredicate(Account.Id id) {
     super(ChangeField.ASSIGNEE, id.toString());
     this.id = id;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java
index dccd17e..3ee3352 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java
@@ -22,7 +22,7 @@
 import java.io.IOException;
 
 public class AuthorPredicate extends ChangeIndexPredicate {
-  AuthorPredicate(String value) {
+  public AuthorPredicate(String value) {
     super(AUTHOR, FIELD_AUTHOR, value.toLowerCase());
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java
index 9e443c9..bc57f15 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java
@@ -20,9 +20,9 @@
 import java.util.Date;
 
 public class BeforePredicate extends TimestampRangeChangePredicate {
-  private final Date cut;
+  protected final Date cut;
 
-  BeforePredicate(String value) throws QueryParseException {
+  public BeforePredicate(String value) throws QueryParseException {
     super(ChangeField.UPDATED, ChangeQueryBuilder.FIELD_BEFORE, value);
     cut = parse(value);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergeablePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BooleanPredicate.java
similarity index 80%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergeablePredicate.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/query/change/BooleanPredicate.java
index d998fa3..31e3ee1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergeablePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BooleanPredicate.java
@@ -14,15 +14,15 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.server.index.FieldDef;
 import com.google.gerrit.server.index.FieldDef.FillArgs;
-import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class IsMergeablePredicate extends ChangeIndexPredicate {
-  private final FillArgs args;
+public class BooleanPredicate extends ChangeIndexPredicate {
+  protected final FillArgs args;
 
-  IsMergeablePredicate(FillArgs args) {
-    super(ChangeField.MERGEABLE, "1");
+  public BooleanPredicate(FieldDef<ChangeData, String> field, FillArgs args) {
+    super(field, "1");
     this.args = args;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
index b62e3eb..677999f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -33,6 +33,7 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.reviewdb.client.Account;
@@ -52,6 +53,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.StarredChangesUtil;
@@ -352,6 +354,7 @@
   private StarsOf starsOf;
   private ImmutableMap<Account.Id, StarRef> starRefs;
   private ReviewerSet reviewers;
+  private ReviewerByEmailSet reviewersByEmail;
   private List<ReviewerStatusUpdate> reviewerUpdates;
   private PersonIdent author;
   private PersonIdent committer;
@@ -755,6 +758,10 @@
     return change;
   }
 
+  public LabelTypes getLabelTypes() throws OrmException {
+    return changeControl().getLabelTypes();
+  }
+
   public ChangeNotes notes() throws OrmException {
     if (notes == null) {
       if (!lazyLoad) {
@@ -954,6 +961,24 @@
     return reviewers;
   }
 
+  public ReviewerByEmailSet reviewersByEmail() throws OrmException {
+    if (reviewersByEmail == null) {
+      if (!lazyLoad) {
+        return ReviewerByEmailSet.empty();
+      }
+      reviewersByEmail = notes().getReviewersByEmail();
+    }
+    return reviewersByEmail;
+  }
+
+  public void setReviewersByEmail(ReviewerByEmailSet reviewersByEmail) {
+    this.reviewersByEmail = reviewersByEmail;
+  }
+
+  public ReviewerByEmailSet getReviewersByEmail() {
+    return reviewersByEmail;
+  }
+
   public List<ReviewerStatusUpdate> reviewerUpdates() throws OrmException {
     if (reviewerUpdates == null) {
       if (!lazyLoad) {
@@ -1065,6 +1090,8 @@
         mergeable = true;
       } else if (c.getStatus() == Change.Status.ABANDONED) {
         return null;
+      } else if (c.isWorkInProgress()) {
+        return null;
       } else {
         if (!lazyLoad) {
           return null;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
index 85d433a..d541d18 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
@@ -19,8 +19,8 @@
 import com.google.gwtorm.server.OrmException;
 
 /** Predicate over Change-Id strings (aka Change.Key). */
-class ChangeIdPredicate extends ChangeIndexPredicate {
-  ChangeIdPredicate(String id) {
+public class ChangeIdPredicate extends ChangeIndexPredicate {
+  public ChangeIdPredicate(String id) {
     super(ChangeField.ID, ChangeQueryBuilder.FIELD_CHANGE, id);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
index 0604f8b..0362c85 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.FieldDef;
 import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.query.Matchable;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
 
 public abstract class ChangeIndexPredicate extends IndexPredicate<ChangeData>
     implements Matchable<ChangeData> {
@@ -27,4 +30,11 @@
   protected ChangeIndexPredicate(FieldDef<ChangeData, ?> def, String name, String value) {
     super(def, name, value);
   }
+
+  protected static Predicate<ChangeData> create(Arguments args, Predicate<ChangeData> p) {
+    if (!args.allowsDrafts) {
+      return Predicate.and(p, Predicate.not(new ChangeStatusPredicate(Change.Status.DRAFT)));
+    }
+    return p;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index 8db62a7..632ec04 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -24,13 +24,13 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
 
-class ChangeIsVisibleToPredicate extends IsVisibleToPredicate<ChangeData> {
-  private final Provider<ReviewDb> db;
-  private final ChangeNotes.Factory notesFactory;
-  private final ChangeControl.GenericFactory changeControl;
-  private final CurrentUser user;
+public class ChangeIsVisibleToPredicate extends IsVisibleToPredicate<ChangeData> {
+  protected final Provider<ReviewDb> db;
+  protected final ChangeNotes.Factory notesFactory;
+  protected final ChangeControl.GenericFactory changeControl;
+  protected final CurrentUser user;
 
-  ChangeIsVisibleToPredicate(
+  public ChangeIsVisibleToPredicate(
       Provider<ReviewDb> db,
       ChangeNotes.Factory notesFactory,
       ChangeControl.GenericFactory changeControlFactory,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index af2cb60..22e8d58 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -61,9 +61,12 @@
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ListChildProjects;
 import com.google.gerrit.server.project.ProjectCache;
@@ -149,6 +152,7 @@
   public static final String FIELD_OWNERIN = "ownerin";
   public static final String FIELD_PARENTPROJECT = "parentproject";
   public static final String FIELD_PATH = "path";
+  public static final String FIELD_PRIVATE = "private";
   public static final String FIELD_PROJECT = "project";
   public static final String FIELD_PROJECTS = "projects";
   public static final String FIELD_REF = "ref";
@@ -164,6 +168,7 @@
   public static final String FIELD_UNRESOLVED_COMMENT_COUNT = "unresolved";
   public static final String FIELD_VISIBLETO = "visibleto";
   public static final String FIELD_WATCHEDBY = "watchedby";
+  public static final String FIELD_WIP = "wip";
 
   public static final String ARG_ID_USER = "user";
   public static final String ARG_ID_GROUP = "group";
@@ -179,6 +184,7 @@
     final AccountResolver accountResolver;
     final AllProjectsName allProjectsName;
     final AllUsersName allUsersName;
+    final PermissionBackend permissionBackend;
     final CapabilityControl.Factory capabilityControlFactory;
     final ChangeControl.GenericFactory changeControlGenericFactory;
     final ChangeData.Factory changeDataFactory;
@@ -218,6 +224,7 @@
         DynamicMap<ChangeHasOperandFactory> hasOperands,
         IdentifiedUser.GenericFactory userFactory,
         Provider<CurrentUser> self,
+        PermissionBackend permissionBackend,
         CapabilityControl.Factory capabilityControlFactory,
         ChangeControl.GenericFactory changeControlGenericFactory,
         ChangeNotes.Factory notesFactory,
@@ -250,6 +257,7 @@
           hasOperands,
           userFactory,
           self,
+          permissionBackend,
           capabilityControlFactory,
           changeControlGenericFactory,
           notesFactory,
@@ -284,6 +292,7 @@
         DynamicMap<ChangeHasOperandFactory> hasOperands,
         IdentifiedUser.GenericFactory userFactory,
         Provider<CurrentUser> self,
+        PermissionBackend permissionBackend,
         CapabilityControl.Factory capabilityControlFactory,
         ChangeControl.GenericFactory changeControlGenericFactory,
         ChangeNotes.Factory notesFactory,
@@ -314,6 +323,7 @@
       this.opFactories = opFactories;
       this.userFactory = userFactory;
       this.self = self;
+      this.permissionBackend = permissionBackend;
       this.capabilityControlFactory = capabilityControlFactory;
       this.notesFactory = notesFactory;
       this.changeControlGenericFactory = changeControlGenericFactory;
@@ -350,6 +360,7 @@
           hasOperands,
           userFactory,
           Providers.of(otherUser),
+          permissionBackend,
           capabilityControlFactory,
           changeControlGenericFactory,
           notesFactory,
@@ -561,7 +572,9 @@
     }
 
     if ("reviewer".equalsIgnoreCase(value)) {
-      return ReviewerPredicate.reviewer(args, self());
+      return Predicate.and(
+          Predicate.not(new BooleanPredicate(ChangeField.WIP, args.fillArgs)),
+          ReviewerPredicate.reviewer(args, self()));
     }
 
     if ("cc".equalsIgnoreCase(value)) {
@@ -569,7 +582,11 @@
     }
 
     if ("mergeable".equalsIgnoreCase(value)) {
-      return new IsMergeablePredicate(args.fillArgs);
+      return new BooleanPredicate(ChangeField.MERGEABLE, args.fillArgs);
+    }
+
+    if ("private".equalsIgnoreCase(value)) {
+      return new BooleanPredicate(ChangeField.PRIVATE, args.fillArgs);
     }
 
     if ("assigned".equalsIgnoreCase(value)) {
@@ -584,6 +601,14 @@
       return new SubmittablePredicate(SubmitRecord.Status.OK);
     }
 
+    if ("ignored".equalsIgnoreCase(value)) {
+      return star("ignore");
+    }
+
+    if ("wip".equalsIgnoreCase(value)) {
+      return new BooleanPredicate(ChangeField.WIP, args.fillArgs);
+    }
+
     try {
       return status(value);
     } catch (IllegalArgumentException e) {
@@ -937,17 +962,14 @@
 
   @Operator
   public Predicate<ChangeData> reviewer(String who) throws QueryParseException, OrmException {
-    return Predicate.or(
-        parseAccount(who)
-            .stream()
-            .map(id -> ReviewerPredicate.reviewer(args, id))
-            .collect(toList()));
+    return Predicate.and(
+        Predicate.not(new BooleanPredicate(ChangeField.WIP, args.fillArgs)),
+        reviewerByState(who, ReviewerStateInternal.REVIEWER));
   }
 
   @Operator
   public Predicate<ChangeData> cc(String who) throws QueryParseException, OrmException {
-    return Predicate.or(
-        parseAccount(who).stream().map(id -> ReviewerPredicate.cc(args, id)).collect(toList()));
+    return reviewerByState(who, ReviewerStateInternal.CC);
   }
 
   @Operator
@@ -1176,4 +1198,37 @@
   private Account.Id self() throws QueryParseException {
     return args.getIdentifiedUser().getAccountId();
   }
+
+  public Predicate<ChangeData> reviewerByState(String who, ReviewerStateInternal state)
+      throws QueryParseException, OrmException {
+    Predicate<ChangeData> reviewerByEmailPredicate = null;
+    if (args.index.getSchema().hasField(ChangeField.REVIEWER_BY_EMAIL)) {
+      Address address = Address.tryParse(who);
+      if (address != null) {
+        reviewerByEmailPredicate = ReviewerByEmailPredicate.forState(args, address, state);
+      }
+    }
+
+    Predicate<ChangeData> reviewerPredicate = null;
+    try {
+      reviewerPredicate =
+          Predicate.or(
+              parseAccount(who)
+                  .stream()
+                  .map(id -> ReviewerPredicate.forState(args, id, state))
+                  .collect(toList()));
+    } catch (QueryParseException e) {
+      // Propagate this exception only if we can't use 'who' to query by email
+      if (reviewerByEmailPredicate == null) {
+        throw e;
+      }
+    }
+
+    if (reviewerPredicate != null && reviewerByEmailPredicate != null) {
+      return Predicate.or(reviewerPredicate, reviewerByEmailPredicate);
+    } else if (reviewerPredicate != null) {
+      return reviewerPredicate;
+    }
+    return reviewerByEmailPredicate;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
index 91a37d5..efe44fa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -17,6 +17,8 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_LIMIT;
 
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.index.IndexConfig;
@@ -32,12 +34,26 @@
 import com.google.gerrit.server.query.QueryProcessor;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Set;
 
-public class ChangeQueryProcessor extends QueryProcessor<ChangeData> {
+public class ChangeQueryProcessor extends QueryProcessor<ChangeData>
+    implements PluginDefinedAttributesFactory {
+  /**
+   * Register a ChangeAttributeFactory in a config Module like this:
+   *
+   * <p>bind(ChangeAttributeFactory.class) .annotatedWith(Exports.named("export-name"))
+   * .to(YourClass.class);
+   */
+  public interface ChangeAttributeFactory {
+    PluginDefinedInfo create(ChangeData a, ChangeQueryProcessor qp, String plugin);
+  }
+
   private final Provider<ReviewDb> db;
   private final ChangeControl.GenericFactory changeControlFactory;
   private final ChangeNotes.Factory notesFactory;
+  private final DynamicMap<ChangeAttributeFactory> attributeFactories;
 
   static {
     // It is assumed that basic rewrites do not touch visibleto predicates.
@@ -55,7 +71,8 @@
       ChangeIndexRewriter rewriter,
       Provider<ReviewDb> db,
       ChangeControl.GenericFactory changeControlFactory,
-      ChangeNotes.Factory notesFactory) {
+      ChangeNotes.Factory notesFactory,
+      DynamicMap<ChangeAttributeFactory> attributeFactories) {
     super(
         userProvider,
         metrics,
@@ -67,6 +84,7 @@
     this.db = db;
     this.changeControlFactory = changeControlFactory;
     this.notesFactory = notesFactory;
+    this.attributeFactories = attributeFactories;
   }
 
   @Override
@@ -82,6 +100,30 @@
   }
 
   @Override
+  public List<PluginDefinedInfo> create(ChangeData cd) {
+    List<PluginDefinedInfo> plugins = new ArrayList<>(attributeFactories.plugins().size());
+    for (String plugin : attributeFactories.plugins()) {
+      for (Provider<ChangeAttributeFactory> provider :
+          attributeFactories.byPlugin(plugin).values()) {
+        PluginDefinedInfo pda = null;
+        try {
+          pda = provider.get().create(cd, this, plugin);
+        } catch (RuntimeException e) {
+          /* Eat runtime exceptions so that queries don't fail. */
+        }
+        if (pda != null) {
+          pda.name = plugin;
+          plugins.add(pda);
+        }
+      }
+    }
+    if (plugins.isEmpty()) {
+      plugins = null;
+    }
+    return plugins;
+  }
+
+  @Override
   protected Predicate<ChangeData> enforceVisibility(Predicate<ChangeData> pred) {
     return new AndChangeSource(
         pred,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
index 9c16777..562608e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
@@ -36,9 +36,9 @@
  * <p>Status names are looked up by prefix case-insensitively.
  */
 public final class ChangeStatusPredicate extends ChangeIndexPredicate {
-  private static final TreeMap<String, Predicate<ChangeData>> PREDICATES;
-  private static final Predicate<ChangeData> CLOSED;
-  private static final Predicate<ChangeData> OPEN;
+  protected static final TreeMap<String, Predicate<ChangeData>> PREDICATES;
+  protected static final Predicate<ChangeData> CLOSED;
+  protected static final Predicate<ChangeData> OPEN;
 
   static {
     PREDICATES = new TreeMap<>();
@@ -84,9 +84,9 @@
     return CLOSED;
   }
 
-  private final Change.Status status;
+  protected final Change.Status status;
 
-  ChangeStatusPredicate(Change.Status status) {
+  public ChangeStatusPredicate(Change.Status status) {
     super(ChangeField.STATUS, canonicalize(status));
     this.status = status;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java
index 668c6f2..7ad7afe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java
@@ -21,10 +21,10 @@
 import com.google.gwtorm.server.OrmException;
 import java.util.Objects;
 
-class CommentByPredicate extends ChangeIndexPredicate {
-  private final Account.Id id;
+public class CommentByPredicate extends ChangeIndexPredicate {
+  protected final Account.Id id;
 
-  CommentByPredicate(Account.Id id) {
+  public CommentByPredicate(Account.Id id) {
     super(ChangeField.COMMENTBY, id.toString());
     this.id = id;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java
index 4779a16..85efe90 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java
@@ -21,10 +21,10 @@
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
-class CommentPredicate extends ChangeIndexPredicate {
-  private final ChangeIndex index;
+public class CommentPredicate extends ChangeIndexPredicate {
+  protected final ChangeIndex index;
 
-  CommentPredicate(ChangeIndex index, String value) {
+  public CommentPredicate(ChangeIndex index, String value) {
     super(ChangeField.COMMENT, value);
     this.index = index;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java
index 1188d5d..3fac217 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.server.index.FieldDef;
 import com.google.gwtorm.server.OrmException;
 
-class CommitPredicate extends ChangeIndexPredicate {
+public class CommitPredicate extends ChangeIndexPredicate {
   static FieldDef<ChangeData, ?> commitField(String id) {
     if (id.length() == OBJECT_ID_STRING_LENGTH) {
       return EXACT_COMMIT;
@@ -30,7 +30,7 @@
     return COMMIT;
   }
 
-  CommitPredicate(String id) {
+  public CommitPredicate(String id) {
     super(commitField(id), id);
   }
 
@@ -45,7 +45,7 @@
     return false;
   }
 
-  private boolean equals(PatchSet p, String id) {
+  protected boolean equals(PatchSet p, String id) {
     boolean exact = getField() == EXACT_COMMIT;
     String rev = p.getRevision() != null ? p.getRevision().get() : null;
     return (exact && id.equals(rev)) || (!exact && rev != null && rev.startsWith(id));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java
index cd1f3b2..797cb9d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java
@@ -22,7 +22,7 @@
 import java.io.IOException;
 
 public class CommitterPredicate extends ChangeIndexPredicate {
-  CommitterPredicate(String value) {
+  public CommitterPredicate(String value) {
     super(COMMITTER, FIELD_COMMITTER, value.toLowerCase());
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index 9b45890..4d8c6a1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -45,19 +45,19 @@
 import org.eclipse.jgit.treewalk.TreeWalk;
 import org.eclipse.jgit.treewalk.filter.TreeFilter;
 
-class ConflictsPredicate extends OrPredicate<ChangeData> {
+public class ConflictsPredicate extends OrPredicate<ChangeData> {
   // UI code may depend on this string, so use caution when changing.
-  private static final String TOO_MANY_FILES = "too many files to find conflicts";
+  protected static final String TOO_MANY_FILES = "too many files to find conflicts";
 
-  private final String value;
+  protected final String value;
 
-  ConflictsPredicate(Arguments args, String value, List<Change> changes)
+  public ConflictsPredicate(Arguments args, String value, List<Change> changes)
       throws QueryParseException, OrmException {
     super(predicates(args, value, changes));
     this.value = value;
   }
 
-  private static List<Predicate<ChangeData>> predicates(
+  public static List<Predicate<ChangeData>> predicates(
       final Arguments args, String value, List<Change> changes)
       throws QueryParseException, OrmException {
     int indexTerms = 0;
@@ -160,7 +160,7 @@
     return changePredicates;
   }
 
-  private static List<String> listFiles(Change c, Arguments args, ChangeDataCache changeDataCache)
+  public static List<String> listFiles(Change c, Arguments args, ChangeDataCache changeDataCache)
       throws OrmException {
     try (Repository repo = args.repoManager.openRepository(c.getProject());
         RevWalk rw = new RevWalk(repo)) {
@@ -200,17 +200,17 @@
     return ChangeQueryBuilder.FIELD_CONFLICTS + ":" + value;
   }
 
-  private static class ChangeDataCache {
-    private final Change change;
-    private final Provider<ReviewDb> db;
-    private final ChangeData.Factory changeDataFactory;
-    private final ProjectCache projectCache;
+  public static class ChangeDataCache {
+    protected final Change change;
+    protected final Provider<ReviewDb> db;
+    protected final ChangeData.Factory changeDataFactory;
+    protected final ProjectCache projectCache;
 
-    private ObjectId testAgainst;
-    private ProjectState projectState;
-    private Iterable<ObjectId> alreadyAccepted;
+    protected ObjectId testAgainst;
+    protected ProjectState projectState;
+    protected Iterable<ObjectId> alreadyAccepted;
 
-    ChangeDataCache(
+    public ChangeDataCache(
         Change change,
         Provider<ReviewDb> db,
         ChangeData.Factory changeDataFactory,
@@ -221,7 +221,7 @@
       this.projectCache = projectCache;
     }
 
-    ObjectId getTestAgainst() throws OrmException {
+    protected ObjectId getTestAgainst() throws OrmException {
       if (testAgainst == null) {
         testAgainst =
             ObjectId.fromString(
@@ -230,7 +230,7 @@
       return testAgainst;
     }
 
-    ProjectState getProjectState() {
+    protected ProjectState getProjectState() {
       if (projectState == null) {
         projectState = projectCache.get(change.getProject());
         if (projectState == null) {
@@ -240,7 +240,7 @@
       return projectState;
     }
 
-    Iterable<ObjectId> getAlreadyAccepted(Repository repo) throws IOException {
+    protected Iterable<ObjectId> getAlreadyAccepted(Repository repo) throws IOException {
       if (alreadyAccepted == null) {
         alreadyAccepted = SubmitDryRun.getAlreadyAccepted(repo);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeletedPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeletedPredicate.java
index 9e49269..9c46da8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeletedPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeletedPredicate.java
@@ -19,7 +19,7 @@
 import com.google.gwtorm.server.OrmException;
 
 public class DeletedPredicate extends IntegerRangeChangePredicate {
-  DeletedPredicate(String value) throws QueryParseException {
+  public DeletedPredicate(String value) throws QueryParseException {
     super(ChangeField.DELETED, value);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeltaPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeltaPredicate.java
index ce33225..68a4b84 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeltaPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeltaPredicate.java
@@ -19,7 +19,7 @@
 import com.google.gwtorm.server.OrmException;
 
 public class DeltaPredicate extends IntegerRangeChangePredicate {
-  DeltaPredicate(String value) throws QueryParseException {
+  public DeltaPredicate(String value) throws QueryParseException {
     super(ChangeField.DELTA, value);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java
index 809e7a1..4e8d30d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java
@@ -19,10 +19,10 @@
 import com.google.gwtorm.server.OrmException;
 import java.util.Set;
 
-class DestinationPredicate extends ChangeOperatorPredicate {
-  Set<Branch.NameKey> destinations;
+public class DestinationPredicate extends ChangeOperatorPredicate {
+  protected Set<Branch.NameKey> destinations;
 
-  DestinationPredicate(Set<Branch.NameKey> destinations, String value) {
+  public DestinationPredicate(Set<Branch.NameKey> destinations, String value) {
     super(ChangeQueryBuilder.FIELD_DESTINATION, value);
     this.destinations = destinations;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EditByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EditByPredicate.java
index 8be5235..3238dc9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EditByPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EditByPredicate.java
@@ -18,10 +18,10 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class EditByPredicate extends ChangeIndexPredicate {
-  private final Account.Id id;
+public class EditByPredicate extends ChangeIndexPredicate {
+  protected final Account.Id id;
 
-  EditByPredicate(Account.Id id) {
+  public EditByPredicate(Account.Id id) {
     super(ChangeField.EDITBY, id.toString());
     this.id = id;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
index fb6c56b..66958695 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
@@ -19,8 +19,8 @@
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
 import com.google.gwtorm.server.OrmException;
 
-class EqualsFilePredicate extends ChangeIndexPredicate {
-  static Predicate<ChangeData> create(Arguments args, String value) {
+public class EqualsFilePredicate extends ChangeIndexPredicate {
+  public static Predicate<ChangeData> create(Arguments args, String value) {
     Predicate<ChangeData> eqPath = new EqualsPathPredicate(ChangeQueryBuilder.FIELD_FILE, value);
     if (!args.getSchema().hasField(ChangeField.FILE_PART)) {
       return eqPath;
@@ -28,11 +28,8 @@
     return Predicate.or(eqPath, new EqualsFilePredicate(value));
   }
 
-  private final String value;
-
   private EqualsFilePredicate(String value) {
     super(ChangeField.FILE_PART, ChangeQueryBuilder.FIELD_FILE, value);
-    this.value = value;
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index 1189e87..1917d6f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
@@ -24,26 +23,28 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
 
-class EqualsLabelPredicate extends ChangeIndexPredicate {
-  private final ProjectCache projectCache;
-  private final ChangeControl.GenericFactory ccFactory;
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final Provider<ReviewDb> dbProvider;
-  private final String label;
-  private final int expVal;
-  private final Account.Id account;
-  private final AccountGroup.UUID group;
+public class EqualsLabelPredicate extends ChangeIndexPredicate {
+  protected final ProjectCache projectCache;
+  protected final PermissionBackend permissionBackend;
+  protected final IdentifiedUser.GenericFactory userFactory;
+  protected final Provider<ReviewDb> dbProvider;
+  protected final String label;
+  protected final int expVal;
+  protected final Account.Id account;
+  protected final AccountGroup.UUID group;
 
-  EqualsLabelPredicate(LabelPredicate.Args args, String label, int expVal, Account.Id account) {
-    super(args.field, ChangeField.formatLabel(label, expVal, account));
-    this.ccFactory = args.ccFactory;
+  public EqualsLabelPredicate(
+      LabelPredicate.Args args, String label, int expVal, Account.Id account) {
+    super(ChangeField.LABEL, ChangeField.formatLabel(label, expVal, account));
+    this.permissionBackend = args.permissionBackend;
     this.projectCache = args.projectCache;
     this.userFactory = args.userFactory;
     this.dbProvider = args.dbProvider;
@@ -78,7 +79,7 @@
     for (PatchSetApproval p : object.currentApprovals()) {
       if (labelType.matches(p)) {
         hasVote = true;
-        if (match(c, p.getValue(), p.getAccountId(), labelType)) {
+        if (match(object, p.getValue(), p.getAccountId(), labelType)) {
           return true;
         }
       }
@@ -91,7 +92,7 @@
     return false;
   }
 
-  private static LabelType type(LabelTypes types, String toFind) {
+  protected static LabelType type(LabelTypes types, String toFind) {
     if (types.byLabel(toFind) != null) {
       return types.byLabel(toFind);
     }
@@ -104,40 +105,28 @@
     return null;
   }
 
-  private boolean match(Change change, int value, Account.Id approver, LabelType type)
-      throws OrmException {
-    int psVal = value;
-    if (psVal == expVal) {
-      // Double check the value is still permitted for the user.
-      //
-      IdentifiedUser reviewer = userFactory.create(approver);
-      try {
-        ChangeControl cc = ccFactory.controlFor(dbProvider.get(), change, reviewer);
-        if (!cc.isVisible(dbProvider.get())) {
-          // The user can't see the change anymore.
-          //
-          return false;
-        }
-        psVal = cc.getRange(Permission.forLabel(type.getName())).squash(psVal);
-      } catch (NoSuchChangeException e) {
-        // The project has disappeared.
-        //
-        return false;
-      }
-
-      if (account != null && !account.equals(approver)) {
-        return false;
-      }
-
-      if (group != null && !reviewer.getEffectiveGroups().contains(group)) {
-        return false;
-      }
-
-      if (psVal == expVal) {
-        return true;
-      }
+  protected boolean match(ChangeData cd, short value, Account.Id approver, LabelType type) {
+    if (value != expVal) {
+      return false;
     }
-    return false;
+
+    if (account != null && !account.equals(approver)) {
+      return false;
+    }
+
+    IdentifiedUser reviewer = userFactory.create(approver);
+    if (group != null && !reviewer.getEffectiveGroups().contains(group)) {
+      return false;
+    }
+
+    // Double check the value is still permitted for the user.
+    try {
+      PermissionBackend.ForChange perm =
+          permissionBackend.user(reviewer).database(dbProvider).change(cd);
+      return perm.test(ChangePermission.READ) && expVal == perm.squashByTest(type, value);
+    } catch (PermissionBackendException e) {
+      return false;
+    }
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
index 9d841f3..56ed797 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
@@ -19,12 +19,9 @@
 import java.util.Collections;
 import java.util.List;
 
-class EqualsPathPredicate extends ChangeIndexPredicate {
-  private final String value;
-
-  EqualsPathPredicate(String fieldName, String value) {
+public class EqualsPathPredicate extends ChangeIndexPredicate {
+  public EqualsPathPredicate(String fieldName, String value) {
     super(ChangeField.PATH, fieldName, value);
-    this.value = value;
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
index 510910e..dc85ece 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
@@ -19,8 +19,8 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwtorm.server.OrmException;
 
-class ExactTopicPredicate extends ChangeIndexPredicate {
-  ExactTopicPredicate(String topic) {
+public class ExactTopicPredicate extends ChangeIndexPredicate {
+  public ExactTopicPredicate(String topic) {
     super(EXACT_TOPIC, topic);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
index 5651544..5f3b621 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
@@ -24,10 +24,10 @@
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
-class FuzzyTopicPredicate extends ChangeIndexPredicate {
-  private final ChangeIndex index;
+public class FuzzyTopicPredicate extends ChangeIndexPredicate {
+  protected final ChangeIndex index;
 
-  FuzzyTopicPredicate(String topic, ChangeIndex index) {
+  public FuzzyTopicPredicate(String topic, ChangeIndex index) {
     super(FUZZY_TOPIC, topic);
     this.index = index;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java
index 54e1c97..d2645dc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java
@@ -19,8 +19,8 @@
 import com.google.gwtorm.server.OrmException;
 import java.util.List;
 
-class GroupPredicate extends ChangeIndexPredicate {
-  GroupPredicate(String group) {
+public class GroupPredicate extends ChangeIndexPredicate {
+  public GroupPredicate(String group) {
     super(ChangeField.GROUP, group);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
index 244589c..e422b74 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
@@ -18,10 +18,10 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class HasDraftByPredicate extends ChangeIndexPredicate {
-  private final Account.Id accountId;
+public class HasDraftByPredicate extends ChangeIndexPredicate {
+  protected final Account.Id accountId;
 
-  HasDraftByPredicate(Account.Id accountId) {
+  public HasDraftByPredicate(Account.Id accountId) {
     super(ChangeField.DRAFTBY, accountId.toString());
     this.accountId = accountId;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasStarsPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
index eb3a137..b17fffd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
@@ -19,9 +19,9 @@
 import com.google.gwtorm.server.OrmException;
 
 public class HasStarsPredicate extends ChangeIndexPredicate {
-  private final Account.Id accountId;
+  protected final Account.Id accountId;
 
-  HasStarsPredicate(Account.Id accountId) {
+  public HasStarsPredicate(Account.Id accountId) {
     super(ChangeField.STARBY, accountId.toString());
     this.accountId = accountId;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java
index 4fd4156..a348d48 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java
@@ -18,8 +18,8 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class HashtagPredicate extends ChangeIndexPredicate {
-  HashtagPredicate(String hashtag) {
+public class HashtagPredicate extends ChangeIndexPredicate {
+  public HashtagPredicate(String hashtag) {
     super(ChangeField.HASHTAG, HashtagsUtil.cleanupHashtag(hashtag));
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergePredicate.java
index 50e5bd9..28fb7cf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergePredicate.java
@@ -24,7 +24,7 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 
 public class IsMergePredicate extends ChangeOperatorPredicate {
-  private final Arguments args;
+  protected final Arguments args;
 
   public IsMergePredicate(Arguments args, String value) {
     super(ChangeQueryBuilder.FIELD_MERGE, value);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
index 92de09a..8b6c8e6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
@@ -25,14 +25,14 @@
 import java.util.List;
 import java.util.Set;
 
-class IsReviewedPredicate extends ChangeIndexPredicate {
-  private static final Account.Id NOT_REVIEWED = new Account.Id(ChangeField.NOT_REVIEWED);
+public class IsReviewedPredicate extends ChangeIndexPredicate {
+  protected static final Account.Id NOT_REVIEWED = new Account.Id(ChangeField.NOT_REVIEWED);
 
-  static Predicate<ChangeData> create() {
+  public static Predicate<ChangeData> create() {
     return Predicate.not(new IsReviewedPredicate(NOT_REVIEWED));
   }
 
-  static Predicate<ChangeData> create(Collection<Account.Id> ids) {
+  public static Predicate<ChangeData> create(Collection<Account.Id> ids) {
     List<Predicate<ChangeData>> predicates = new ArrayList<>(ids.size());
     for (Account.Id id : ids) {
       predicates.add(new IsReviewedPredicate(id));
@@ -40,7 +40,7 @@
     return Predicate.or(predicates);
   }
 
-  private final Account.Id id;
+  protected final Account.Id id;
 
   private IsReviewedPredicate(Account.Id id) {
     super(REVIEWEDBY, Integer.toString(id.get()));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
index 17a6347..e9b2899 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
@@ -19,11 +19,11 @@
 import com.google.gwtorm.server.OrmException;
 
 public class IsUnresolvedPredicate extends IntegerRangeChangePredicate {
-  IsUnresolvedPredicate() throws QueryParseException {
+  public IsUnresolvedPredicate() throws QueryParseException {
     this(">0");
   }
 
-  IsUnresolvedPredicate(String value) throws QueryParseException {
+  public IsUnresolvedPredicate(String value) throws QueryParseException {
     super(ChangeField.UNRESOLVED_COMMENT_COUNT, value);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
index dda834b..a1a5070 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
@@ -26,23 +26,23 @@
 import java.util.Collections;
 import java.util.List;
 
-class IsWatchedByPredicate extends AndPredicate<ChangeData> {
-  private static String describe(CurrentUser user) {
+public class IsWatchedByPredicate extends AndPredicate<ChangeData> {
+  protected static String describe(CurrentUser user) {
     if (user.isIdentifiedUser()) {
       return user.getAccountId().toString();
     }
     return user.toString();
   }
 
-  private final CurrentUser user;
+  protected final CurrentUser user;
 
-  IsWatchedByPredicate(ChangeQueryBuilder.Arguments args, boolean checkIsVisible)
+  public IsWatchedByPredicate(ChangeQueryBuilder.Arguments args, boolean checkIsVisible)
       throws QueryParseException {
     super(filters(args, checkIsVisible));
     this.user = args.getUser();
   }
 
-  private static List<Predicate<ChangeData>> filters(
+  protected static List<Predicate<ChangeData>> filters(
       ChangeQueryBuilder.Arguments args, boolean checkIsVisible) throws QueryParseException {
     List<Predicate<ChangeData>> r = new ArrayList<>();
     ChangeQueryBuilder builder = new ChangeQueryBuilder(args);
@@ -89,7 +89,7 @@
     }
   }
 
-  private static Collection<ProjectWatchKey> getWatches(ChangeQueryBuilder.Arguments args)
+  protected static Collection<ProjectWatchKey> getWatches(ChangeQueryBuilder.Arguments args)
       throws QueryParseException {
     CurrentUser user = args.getUser();
     if (user.isIdentifiedUser()) {
@@ -98,7 +98,7 @@
     return Collections.<ProjectWatchKey>emptySet();
   }
 
-  private static List<Predicate<ChangeData>> none() {
+  protected static List<Predicate<ChangeData>> none() {
     Predicate<ChangeData> any = any();
     return ImmutableList.of(not(any));
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java
index 2fbaa1e..bd342d7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java
@@ -19,8 +19,7 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.OrPredicate;
@@ -34,29 +33,29 @@
 import java.util.Set;
 
 public class LabelPredicate extends OrPredicate<ChangeData> {
-  private static final int MAX_LABEL_VALUE = 4;
+  protected static final int MAX_LABEL_VALUE = 4;
 
-  static class Args {
-    final FieldDef<ChangeData, ?> field;
-    final ProjectCache projectCache;
-    final ChangeControl.GenericFactory ccFactory;
-    final IdentifiedUser.GenericFactory userFactory;
-    final Provider<ReviewDb> dbProvider;
-    final String value;
-    final Set<Account.Id> accounts;
-    final AccountGroup.UUID group;
+  protected static class Args {
+    protected final ProjectCache projectCache;
+    protected final PermissionBackend permissionBackend;
+    protected final ChangeControl.GenericFactory ccFactory;
+    protected final IdentifiedUser.GenericFactory userFactory;
+    protected final Provider<ReviewDb> dbProvider;
+    protected final String value;
+    protected final Set<Account.Id> accounts;
+    protected final AccountGroup.UUID group;
 
-    private Args(
-        FieldDef<ChangeData, ?> field,
+    protected Args(
         ProjectCache projectCache,
+        PermissionBackend permissionBackend,
         ChangeControl.GenericFactory ccFactory,
         IdentifiedUser.GenericFactory userFactory,
         Provider<ReviewDb> dbProvider,
         String value,
         Set<Account.Id> accounts,
         AccountGroup.UUID group) {
-      this.field = field;
       this.projectCache = projectCache;
+      this.permissionBackend = permissionBackend;
       this.ccFactory = ccFactory;
       this.userFactory = userFactory;
       this.dbProvider = dbProvider;
@@ -66,22 +65,21 @@
     }
   }
 
-  private static class Parsed {
-    private final String label;
-    private final String test;
-    private final int expVal;
+  protected static class Parsed {
+    protected final String label;
+    protected final String test;
+    protected final int expVal;
 
-    private Parsed(String label, String test, int expVal) {
+    protected Parsed(String label, String test, int expVal) {
       this.label = label;
       this.test = test;
       this.expVal = expVal;
     }
   }
 
-  private final String value;
+  protected final String value;
 
-  @SuppressWarnings("deprecation")
-  LabelPredicate(
+  public LabelPredicate(
       ChangeQueryBuilder.Arguments a,
       String value,
       Set<Account.Id> accounts,
@@ -89,8 +87,8 @@
     super(
         predicates(
             new Args(
-                a.getSchema().getField(ChangeField.LABEL2, ChangeField.LABEL).get(),
                 a.projectCache,
+                a.permissionBackend,
                 a.changeControlGenericFactory,
                 a.userFactory,
                 a.db,
@@ -100,7 +98,7 @@
     this.value = value;
   }
 
-  private static List<Predicate<ChangeData>> predicates(Args args) {
+  protected static List<Predicate<ChangeData>> predicates(Args args) {
     String v = args.value;
     Parsed parsed = null;
 
@@ -140,14 +138,14 @@
     return r;
   }
 
-  private static Predicate<ChangeData> onePredicate(Args args, String label, int expVal) {
+  protected static Predicate<ChangeData> onePredicate(Args args, String label, int expVal) {
     if (expVal != 0) {
       return equalsLabelPredicate(args, label, expVal);
     }
     return noLabelQuery(args, label);
   }
 
-  private static Predicate<ChangeData> noLabelQuery(Args args, String label) {
+  protected static Predicate<ChangeData> noLabelQuery(Args args, String label) {
     List<Predicate<ChangeData>> r = Lists.newArrayListWithCapacity(2 * MAX_LABEL_VALUE);
     for (int i = 1; i <= MAX_LABEL_VALUE; i++) {
       r.add(equalsLabelPredicate(args, label, i));
@@ -156,7 +154,7 @@
     return not(or(r));
   }
 
-  private static Predicate<ChangeData> equalsLabelPredicate(Args args, String label, int expVal) {
+  protected static Predicate<ChangeData> equalsLabelPredicate(Args args, String label, int expVal) {
     if (args.accounts == null || args.accounts.isEmpty()) {
       return new EqualsLabelPredicate(args, label, expVal, null);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
index f7f98d5..7cc8b31 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
@@ -20,7 +20,7 @@
 
 /** Predicate over change number (aka legacy ID or Change.Id). */
 public class LegacyChangeIdPredicate extends ChangeIndexPredicate {
-  private final Change.Id id;
+  protected final Change.Id id;
 
   public LegacyChangeIdPredicate(Change.Id id) {
     super(LEGACY_ID, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java
index 9e525c2..92d1ed3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java
@@ -22,10 +22,10 @@
 import com.google.gwtorm.server.OrmException;
 
 /** Predicate to match changes that contains specified text in commit messages body. */
-class MessagePredicate extends ChangeIndexPredicate {
-  private final ChangeIndex index;
+public class MessagePredicate extends ChangeIndexPredicate {
+  protected final ChangeIndex index;
 
-  MessagePredicate(ChangeIndex index, String value) {
+  public MessagePredicate(ChangeIndex index, String value) {
     super(ChangeField.COMMIT_MESSAGE, value);
     this.index = index;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index cd98087..0d12132 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -313,6 +313,7 @@
       eventFactory.addDependencies(rw, c, d.change(), d.currentPatchSet());
     }
 
+    c.plugins = queryProcessor.create(d);
     return c;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerPredicate.java
index dfaac08..5fd1ca0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerPredicate.java
@@ -19,15 +19,15 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class OwnerPredicate extends ChangeIndexPredicate {
-  private final Account.Id id;
+public class OwnerPredicate extends ChangeIndexPredicate {
+  protected final Account.Id id;
 
-  OwnerPredicate(Account.Id id) {
+  public OwnerPredicate(Account.Id id) {
     super(ChangeField.OWNER, id.toString());
     this.id = id;
   }
 
-  Account.Id getAccountId() {
+  protected Account.Id getAccountId() {
     return id;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
index f3239af..f828970 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
@@ -19,17 +19,17 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gwtorm.server.OrmException;
 
-class OwnerinPredicate extends ChangeOperatorPredicate {
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final AccountGroup.UUID uuid;
+public class OwnerinPredicate extends ChangeOperatorPredicate {
+  protected final IdentifiedUser.GenericFactory userFactory;
+  protected final AccountGroup.UUID uuid;
 
-  OwnerinPredicate(IdentifiedUser.GenericFactory userFactory, AccountGroup.UUID uuid) {
+  public OwnerinPredicate(IdentifiedUser.GenericFactory userFactory, AccountGroup.UUID uuid) {
     super(ChangeQueryBuilder.FIELD_OWNERIN, uuid.toString());
     this.userFactory = userFactory;
     this.uuid = uuid;
   }
 
-  AccountGroup.UUID getAccountGroupUUID() {
+  protected AccountGroup.UUID getAccountGroupUUID() {
     return uuid;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
index d3a3f20..3b00c0a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ListChildProjects;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectResource;
@@ -27,11 +28,15 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
-class ParentProjectPredicate extends OrPredicate<ChangeData> {
-  private final String value;
+public class ParentProjectPredicate extends OrPredicate<ChangeData> {
+  private static final Logger log = LoggerFactory.getLogger(ParentProjectPredicate.class);
 
-  ParentProjectPredicate(
+  protected final String value;
+
+  public ParentProjectPredicate(
       ProjectCache projectCache,
       Provider<ListChildProjects> listChildProjects,
       Provider<CurrentUser> self,
@@ -40,7 +45,7 @@
     this.value = value;
   }
 
-  private static List<Predicate<ChangeData>> predicates(
+  protected static List<Predicate<ChangeData>> predicates(
       ProjectCache projectCache,
       Provider<ListChildProjects> listChildProjects,
       Provider<CurrentUser> self,
@@ -52,10 +57,15 @@
 
     List<Predicate<ChangeData>> r = new ArrayList<>();
     r.add(new ProjectPredicate(projectState.getProject().getName()));
-    ListChildProjects children = listChildProjects.get();
-    children.setRecursive(true);
-    for (ProjectInfo p : children.apply(new ProjectResource(projectState.controlFor(self.get())))) {
-      r.add(new ProjectPredicate(p.name));
+    try {
+      ProjectResource proj = new ProjectResource(projectState.controlFor(self.get()));
+      ListChildProjects children = listChildProjects.get();
+      children.setRecursive(true);
+      for (ProjectInfo p : children.apply(proj)) {
+        r.add(new ProjectPredicate(p.name));
+      }
+    } catch (PermissionBackendException e) {
+      log.warn("cannot check permissions to expand child projects", e);
     }
     return r;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/PluginDefinedAttributesFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/PluginDefinedAttributesFactory.java
new file mode 100644
index 0000000..a795025
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/PluginDefinedAttributesFactory.java
@@ -0,0 +1,22 @@
+// 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.query.change;
+
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import java.util.List;
+
+public interface PluginDefinedAttributesFactory {
+  List<PluginDefinedInfo> create(ChangeData cd);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPredicate.java
index 644870d..ef25ddb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPredicate.java
@@ -19,12 +19,12 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class ProjectPredicate extends ChangeIndexPredicate {
-  ProjectPredicate(String id) {
+public class ProjectPredicate extends ChangeIndexPredicate {
+  public ProjectPredicate(String id) {
     super(ChangeField.PROJECT, id);
   }
 
-  Project.NameKey getValueKey() {
+  protected Project.NameKey getValueKey() {
     return new Project.NameKey(getValue());
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
index 4c06d1b..28b1302 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
@@ -18,8 +18,8 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class ProjectPrefixPredicate extends ChangeIndexPredicate {
-  ProjectPrefixPredicate(String prefix) {
+public class ProjectPrefixPredicate extends ChangeIndexPredicate {
+  public ProjectPrefixPredicate(String prefix) {
     super(ChangeField.PROJECTS, prefix);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java
index 7eccf45..f0ef40d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java
@@ -137,13 +137,18 @@
 
     int cnt = queries.size();
     List<QueryResult<ChangeData>> results = imp.query(qb.parse(queries));
+
     boolean requireLazyLoad =
         containsAnyOf(options, ImmutableSet.of(DETAILED_LABELS, LABELS))
             && !qb.getArgs().getSchema().hasField(ChangeField.STORED_SUBMIT_RECORD_LENIENT);
+
+    ChangeJson cjson = json.create(options);
+    cjson.setPluginDefinedAttributesFactory(this.imp);
     List<List<ChangeInfo>> res =
-        json.create(options)
+        cjson
             .lazyLoad(requireLazyLoad || containsAnyOf(options, ChangeJson.REQUIRE_LAZY_LOAD))
             .formatQueryResults(results);
+
     for (int n = 0; n < cnt; n++) {
       List<ChangeInfo> info = res.get(n);
       if (results.get(n).more()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RefPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RefPredicate.java
index 491aed9..b8bece9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RefPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RefPredicate.java
@@ -18,8 +18,8 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class RefPredicate extends ChangeIndexPredicate {
-  RefPredicate(String ref) {
+public class RefPredicate extends ChangeIndexPredicate {
+  public RefPredicate(String ref) {
     super(ChangeField.REF, ref);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
index 5b9774c..ca21247 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
@@ -19,8 +19,8 @@
 import com.google.gwtorm.server.OrmException;
 import java.util.List;
 
-class RegexPathPredicate extends ChangeRegexPredicate {
-  RegexPathPredicate(String re) {
+public class RegexPathPredicate extends ChangeRegexPredicate {
+  public RegexPathPredicate(String re) {
     super(ChangeField.PATH, re);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
index 1284e88..cf78c57 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
@@ -21,10 +21,10 @@
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
-class RegexProjectPredicate extends ChangeRegexPredicate {
-  private final RunAutomaton pattern;
+public class RegexProjectPredicate extends ChangeRegexPredicate {
+  protected final RunAutomaton pattern;
 
-  RegexProjectPredicate(String re) {
+  public RegexProjectPredicate(String re) {
     super(ChangeField.PROJECT, re);
 
     if (re.startsWith("^")) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
index 671d4cc..ac7af9b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
@@ -20,10 +20,10 @@
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
-class RegexRefPredicate extends ChangeRegexPredicate {
-  private final RunAutomaton pattern;
+public class RegexRefPredicate extends ChangeRegexPredicate {
+  protected final RunAutomaton pattern;
 
-  RegexRefPredicate(String re) {
+  public RegexRefPredicate(String re) {
     super(ChangeField.REF, re);
 
     if (re.startsWith("^")) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
index a4ba059..8a9f8cd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
@@ -21,10 +21,10 @@
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
-class RegexTopicPredicate extends ChangeRegexPredicate {
-  private final RunAutomaton pattern;
+public class RegexTopicPredicate extends ChangeRegexPredicate {
+  protected final RunAutomaton pattern;
 
-  RegexTopicPredicate(String re) {
+  public RegexTopicPredicate(String re) {
     super(EXACT_TOPIC, re);
 
     if (re.startsWith("^")) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
new file mode 100644
index 0000000..a040e18
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2017 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.query.change;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
+import com.google.gwtorm.server.OrmException;
+
+class ReviewerByEmailPredicate extends ChangeIndexPredicate {
+
+  static Predicate<ChangeData> forState(Arguments args, Address adr, ReviewerStateInternal state) {
+    checkArgument(state != ReviewerStateInternal.REMOVED, "can't query by removed reviewer");
+    return create(args, new ReviewerByEmailPredicate(state, adr));
+  }
+
+  private final ReviewerStateInternal state;
+  private final Address adr;
+
+  private ReviewerByEmailPredicate(ReviewerStateInternal state, Address adr) {
+    super(ChangeField.REVIEWER_BY_EMAIL, ChangeField.getReviewerByEmailFieldValue(state, adr));
+    this.state = state;
+    this.adr = adr;
+  }
+
+  Address getAddress() {
+    return adr;
+  }
+
+  @Override
+  public boolean match(ChangeData cd) throws OrmException {
+    return cd.reviewersByEmail().asTable().get(state, adr) != null;
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
index 6ce02fb..f3a8619 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static java.util.stream.Collectors.toList;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.query.Predicate;
@@ -25,8 +25,14 @@
 import com.google.gwtorm.server.OrmException;
 import java.util.stream.Stream;
 
-class ReviewerPredicate extends ChangeIndexPredicate {
-  static Predicate<ChangeData> reviewer(Arguments args, Account.Id id) {
+public class ReviewerPredicate extends ChangeIndexPredicate {
+  protected static Predicate<ChangeData> forState(
+      Arguments args, Account.Id id, ReviewerStateInternal state) {
+    checkArgument(state != ReviewerStateInternal.REMOVED, "can't query by removed reviewer");
+    return create(args, new ReviewerPredicate(state, id));
+  }
+
+  protected static Predicate<ChangeData> reviewer(Arguments args, Account.Id id) {
     Predicate<ChangeData> p;
     if (args.notesMigration.readChanges()) {
       // With NoteDb, Reviewer/CC are clearly distinct states, so only choose reviewer.
@@ -39,14 +45,14 @@
     return create(args, p);
   }
 
-  static Predicate<ChangeData> cc(Arguments args, Account.Id id) {
+  protected static Predicate<ChangeData> cc(Arguments args, Account.Id id) {
     // As noted above, CC is nebulous without NoteDb, but it certainly doesn't make sense to return
     // Reviewers for cc:foo. Most likely this will just not match anything, but let the index sort
     // it out.
     return create(args, new ReviewerPredicate(ReviewerStateInternal.CC, id));
   }
 
-  private static Predicate<ChangeData> anyReviewerState(Account.Id id) {
+  protected static Predicate<ChangeData> anyReviewerState(Account.Id id) {
     return Predicate.or(
         Stream.of(ReviewerStateInternal.values())
             .filter(s -> s != ReviewerStateInternal.REMOVED)
@@ -54,17 +60,8 @@
             .collect(toList()));
   }
 
-  private static Predicate<ChangeData> create(Arguments args, Predicate<ChangeData> p) {
-    if (!args.allowsDrafts) {
-      // TODO(dborowitz): This really belongs much higher up e.g. QueryProcessor. Also, why are we
-      // even doing this?
-      return Predicate.and(p, Predicate.not(new ChangeStatusPredicate(Change.Status.DRAFT)));
-    }
-    return p;
-  }
-
-  private final ReviewerStateInternal state;
-  private final Account.Id id;
+  protected final ReviewerStateInternal state;
+  protected final Account.Id id;
 
   private ReviewerPredicate(ReviewerStateInternal state, Account.Id id) {
     super(ChangeField.REVIEWER, ChangeField.getReviewerFieldValue(state, id));
@@ -72,7 +69,7 @@
     this.id = id;
   }
 
-  Account.Id getAccountId() {
+  protected Account.Id getAccountId() {
     return id;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
index 63e7859..df28de3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
@@ -19,17 +19,17 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gwtorm.server.OrmException;
 
-class ReviewerinPredicate extends ChangeOperatorPredicate {
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final AccountGroup.UUID uuid;
+public class ReviewerinPredicate extends ChangeOperatorPredicate {
+  protected final IdentifiedUser.GenericFactory userFactory;
+  protected final AccountGroup.UUID uuid;
 
-  ReviewerinPredicate(IdentifiedUser.GenericFactory userFactory, AccountGroup.UUID uuid) {
+  public ReviewerinPredicate(IdentifiedUser.GenericFactory userFactory, AccountGroup.UUID uuid) {
     super(ChangeQueryBuilder.FIELD_REVIEWERIN, uuid.toString());
     this.userFactory = userFactory;
     this.uuid = uuid;
   }
 
-  AccountGroup.UUID getAccountGroupUUID() {
+  protected AccountGroup.UUID getAccountGroupUUID() {
     return uuid;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java
index 98965bf..12d4753 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java
@@ -20,10 +20,10 @@
 import com.google.gwtorm.server.OrmException;
 
 public class StarPredicate extends ChangeIndexPredicate {
-  private final Account.Id accountId;
-  private final String label;
+  protected final Account.Id accountId;
+  protected final String label;
 
-  StarPredicate(Account.Id accountId, String label) {
+  public StarPredicate(Account.Id accountId, String label) {
     super(ChangeField.STAR, StarredChangesUtil.StarField.create(accountId, label).toString());
     this.accountId = accountId;
     this.label = label;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
index d8d5258..5fdeb68 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
@@ -18,9 +18,8 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class SubmissionIdPredicate extends ChangeIndexPredicate {
-
-  SubmissionIdPredicate(String changeSet) {
+public class SubmissionIdPredicate extends ChangeIndexPredicate {
+  public SubmissionIdPredicate(String changeSet) {
     super(ChangeField.SUBMISSIONID, changeSet);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
index 5b01ea2..81d64e0e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
@@ -23,8 +23,8 @@
 import com.google.gwtorm.server.OrmException;
 import java.util.Set;
 
-class SubmitRecordPredicate extends ChangeIndexPredicate {
-  static Predicate<ChangeData> create(
+public class SubmitRecordPredicate extends ChangeIndexPredicate {
+  public static Predicate<ChangeData> create(
       String label, SubmitRecord.Label.Status status, Set<Account.Id> accounts) {
     String lowerLabel = label.toLowerCase();
     if (accounts == null || accounts.isEmpty()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmittablePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
index 0812c6a..df78315 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
@@ -18,10 +18,10 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class SubmittablePredicate extends ChangeIndexPredicate {
-  private final SubmitRecord.Status status;
+public class SubmittablePredicate extends ChangeIndexPredicate {
+  protected final SubmitRecord.Status status;
 
-  SubmittablePredicate(SubmitRecord.Status status) {
+  public SubmittablePredicate(SubmitRecord.Status status) {
     super(ChangeField.SUBMIT_RECORD, status.name());
     this.status = status;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
index afaea5c..6a5f260 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
@@ -24,12 +24,12 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-class TrackingIdPredicate extends ChangeIndexPredicate {
+public class TrackingIdPredicate extends ChangeIndexPredicate {
   private static final Logger log = LoggerFactory.getLogger(TrackingIdPredicate.class);
 
-  private final TrackingFooters trackingFooters;
+  protected final TrackingFooters trackingFooters;
 
-  TrackingIdPredicate(TrackingFooters trackingFooters, String trackingId) {
+  public TrackingIdPredicate(TrackingFooters trackingFooters, String trackingId) {
     super(ChangeField.TR, trackingId);
     this.trackingFooters = trackingFooters;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
index 8f72945..3ac9c39 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
@@ -23,10 +23,11 @@
 import com.google.gwtorm.server.OrmException;
 
 public class GroupIsVisibleToPredicate extends IsVisibleToPredicate<AccountGroup> {
-  private final GroupControl.GenericFactory groupControlFactory;
-  private final CurrentUser user;
+  protected final GroupControl.GenericFactory groupControlFactory;
+  protected final CurrentUser user;
 
-  GroupIsVisibleToPredicate(GroupControl.GenericFactory groupControlFactory, CurrentUser user) {
+  public GroupIsVisibleToPredicate(
+      GroupControl.GenericFactory groupControlFactory, CurrentUser user) {
     super(AccountQueryBuilder.FIELD_VISIBLETO, describe(user));
     this.groupControlFactory = groupControlFactory;
     this.user = user;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
index 170a5fa..c5bd140 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
@@ -161,8 +161,8 @@
     }
   }
 
-  private void exportPoolMetrics(final BasicDataSource pool) {
-    final CallbackMetric1<Boolean, Integer> cnt =
+  private void exportPoolMetrics(BasicDataSource pool) {
+    CallbackMetric1<Boolean, Integer> cnt =
         metrics.newCallbackMetric(
             "sql/connection_pool/connections",
             Integer.class,
@@ -170,13 +170,10 @@
             Field.ofBoolean("active"));
     metrics.newTrigger(
         cnt,
-        new Runnable() {
-          @Override
-          public void run() {
-            synchronized (pool) {
-              cnt.set(true, pool.getNumActive());
-              cnt.set(false, pool.getNumIdle());
-            }
+        () -> {
+          synchronized (pool) {
+            cnt.set(true, pool.getNumActive());
+            cnt.set(false, pool.getNumIdle());
           }
         });
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java
index 43e9a3a..af55b00 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java
@@ -73,6 +73,11 @@
   }
 
   @Override
+  public boolean changesTablesEnabled() {
+    return false;
+  }
+
+  @Override
   public ChangeAccess changes() {
     return changes;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
index b60b1f7..084d63b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
@@ -52,7 +52,10 @@
 
   @Inject
   SchemaUpdater(
-      SchemaFactory<ReviewDb> schema, SitePaths site, SchemaCreator creator, Injector parent) {
+      @ReviewDbFactory SchemaFactory<ReviewDb> schema,
+      SitePaths site,
+      SchemaCreator creator,
+      Injector parent) {
     this.schema = schema;
     this.site = site;
     this.creator = creator;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
index a67a8a9..be41a0a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
@@ -35,7 +35,7 @@
 /** A version of the database schema. */
 public abstract class SchemaVersion {
   /** The current schema version. */
-  public static final Class<Schema_142> C = Schema_142.class;
+  public static final Class<Schema_149> C = Schema_149.class;
 
   public static int getBinaryVersion() {
     return guessVersion(C);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_143.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_143.java
new file mode 100644
index 0000000..b190b29
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_143.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2017 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.schema;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+/** Add isPrivate field to change. */
+public class Schema_143 extends SchemaVersion {
+  @Inject
+  Schema_143(Provider<Schema_142> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_144.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_144.java
new file mode 100644
index 0000000..70e55cf
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_144.java
@@ -0,0 +1,78 @@
+// 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.schema;
+
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdReader;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.SQLException;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class Schema_144 extends SchemaVersion {
+  private static final String COMMIT_MSG = "Import external IDs from ReviewDb";
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverIdent;
+
+  @Inject
+  Schema_144(
+      Provider<Schema_143> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent serverIdent) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.serverIdent = serverIdent;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    Set<ExternalId> toAdd = ExternalId.from(db.accountExternalIds().all().toList());
+    try {
+      try (Repository repo = repoManager.openRepository(allUsersName);
+          RevWalk rw = new RevWalk(repo);
+          ObjectInserter ins = repo.newObjectInserter()) {
+        ObjectId rev = ExternalIdReader.readRevision(repo);
+
+        NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+
+        for (ExternalId extId : toAdd) {
+          ExternalIdsUpdate.upsert(rw, ins, noteMap, extId);
+        }
+
+        ExternalIdsUpdate.commit(repo, rw, ins, rev, noteMap, COMMIT_MSG, serverIdent, serverIdent);
+      }
+    } catch (IOException | ConfigInvalidException e) {
+      throw new OrmException("Failed to migrate external IDs to NoteDb", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_145.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_145.java
new file mode 100644
index 0000000..6ccb5d8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_145.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2017 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.schema;
+
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.schema.sql.SqlDialect;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.StatementExecutor;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.sql.SQLException;
+
+/** Create account_external_ids_byEmail index. */
+public class Schema_145 extends SchemaVersion {
+
+  @Inject
+  Schema_145(Provider<Schema_144> prior) {
+    super(prior);
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    JdbcSchema schema = (JdbcSchema) db;
+    SqlDialect dialect = schema.getDialect();
+    try (StatementExecutor e = newExecutor(db)) {
+      try {
+        dialect.dropIndex(e, "account_external_ids", "account_external_ids_byEmail");
+      } catch (OrmException ex) {
+        // Ignore.  The index did not exist.
+      }
+      e.execute(
+          "CREATE INDEX account_external_ids_byEmail"
+              + " ON account_external_ids"
+              + " (email_address)");
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_146.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_146.java
new file mode 100644
index 0000000..dd11396
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_146.java
@@ -0,0 +1,156 @@
+// Copyright (C) 2017 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.schema;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Make sure that for every account a user branch exists that has an initial empty commit with the
+ * registration date as commit time.
+ *
+ * <p>For accounts that don't have a user branch yet the user branch is created with an initial
+ * empty commit that has the registration date as commit time.
+ *
+ * <p>For accounts that already have a user branch the user branch is rewritten and an initial empty
+ * commit with the registration date as commit time is inserted (if such a commit doesn't exist
+ * yet).
+ */
+public class Schema_146 extends SchemaVersion {
+  private static final String CREATE_ACCOUNT_MSG = "Create Account";
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverIdent;
+
+  @Inject
+  Schema_146(
+      Provider<Schema_145> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent serverIdent) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.serverIdent = serverIdent;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    try (Repository repo = repoManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(repo);
+        ObjectInserter oi = repo.newObjectInserter()) {
+      ObjectId emptyTree = emptyTree(oi);
+
+      for (Account account : db.accounts().all()) {
+        String refName = RefNames.refsUsers(account.getId());
+        Ref ref = repo.exactRef(refName);
+        if (ref != null) {
+          rewriteUserBranch(repo, rw, oi, emptyTree, ref, account);
+        } else {
+          AccountsUpdate.createUserBranch(repo, oi, serverIdent, serverIdent, account);
+        }
+      }
+    } catch (IOException e) {
+      throw new OrmException("Failed to rewrite user branches.", e);
+    }
+  }
+
+  private void rewriteUserBranch(
+      Repository repo, RevWalk rw, ObjectInserter oi, ObjectId emptyTree, Ref ref, Account account)
+      throws IOException {
+    ObjectId current = createInitialEmptyCommit(oi, emptyTree, account.getRegisteredOn());
+
+    rw.reset();
+    rw.sort(RevSort.TOPO);
+    rw.sort(RevSort.REVERSE, true);
+    rw.markStart(rw.parseCommit(ref.getObjectId()));
+
+    RevCommit c;
+    while ((c = rw.next()) != null) {
+      if (isInitialEmptyCommit(emptyTree, c)) {
+        return;
+      }
+
+      CommitBuilder cb = new CommitBuilder();
+      cb.setParentId(current);
+      cb.setTreeId(c.getTree());
+      cb.setAuthor(c.getAuthorIdent());
+      cb.setCommitter(c.getCommitterIdent());
+      cb.setMessage(c.getFullMessage());
+      cb.setEncoding(c.getEncoding());
+      current = oi.insert(cb);
+    }
+
+    oi.flush();
+
+    RefUpdate ru = repo.updateRef(ref.getName());
+    ru.setExpectedOldObjectId(ref.getObjectId());
+    ru.setNewObjectId(current);
+    ru.setForceUpdate(true);
+    ru.setRefLogIdent(serverIdent);
+    ru.setRefLogMessage(getClass().getSimpleName(), true);
+    Result result = ru.update();
+    if (result != Result.FORCED) {
+      throw new IOException(
+          String.format("Failed to update ref %s: %s", ref.getName(), result.name()));
+    }
+  }
+
+  private ObjectId createInitialEmptyCommit(
+      ObjectInserter oi, ObjectId emptyTree, Timestamp registrationDate) throws IOException {
+    PersonIdent ident = new PersonIdent(serverIdent, registrationDate);
+
+    CommitBuilder cb = new CommitBuilder();
+    cb.setTreeId(emptyTree);
+    cb.setCommitter(ident);
+    cb.setAuthor(ident);
+    cb.setMessage(CREATE_ACCOUNT_MSG);
+    return oi.insert(cb);
+  }
+
+  private boolean isInitialEmptyCommit(ObjectId emptyTree, RevCommit c) {
+    return c.getParentCount() == 0
+        && c.getTree().equals(emptyTree)
+        && c.getShortMessage().equals(CREATE_ACCOUNT_MSG);
+  }
+
+  private static ObjectId emptyTree(ObjectInserter oi) throws IOException {
+    return oi.insert(Constants.OBJ_TREE, new byte[] {});
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_147.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_147.java
new file mode 100644
index 0000000..8585988
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_147.java
@@ -0,0 +1,75 @@
+// Copyright (C) 2017 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.schema;
+
+import static java.util.stream.Collectors.toSet;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.SQLException;
+import java.util.Objects;
+import java.util.Set;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+/** Delete user branches for which no account exists. */
+public class Schema_147 extends SchemaVersion {
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverIdent;
+
+  @Inject
+  Schema_147(
+      Provider<Schema_146> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent serverIdent) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.serverIdent = serverIdent;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      Set<Account.Id> accountIdsFromReviewDb =
+          db.accounts().all().toList().stream().map(a -> a.getId()).collect(toSet());
+      Set<Account.Id> accountIdsFromUserBranches =
+          repo.getRefDatabase()
+              .getRefs(RefNames.REFS_USERS)
+              .values()
+              .stream()
+              .map(r -> Account.Id.fromRef(r.getName()))
+              .filter(Objects::nonNull)
+              .collect(toSet());
+      accountIdsFromUserBranches.removeAll(accountIdsFromReviewDb);
+      for (Account.Id accountId : accountIdsFromUserBranches) {
+        AccountsUpdate.deleteUserBranch(repo, serverIdent, accountId);
+      }
+    } catch (IOException e) {
+      throw new OrmException("Failed to delete user branches for non-existing accounts.", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_148.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_148.java
new file mode 100644
index 0000000..abb3bb2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_148.java
@@ -0,0 +1,99 @@
+// Copyright (C) 2017 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.schema;
+
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.primitives.Ints;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdReader;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.SQLException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.Note;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class Schema_148 extends SchemaVersion {
+  private static final String COMMIT_MSG = "Make account IDs of external IDs human-readable";
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverUser;
+
+  @Inject
+  Schema_148(
+      Provider<Schema_147> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent serverUser) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.serverUser = serverUser;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    try (Repository repo = repoManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(repo);
+        ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId rev = ExternalIdReader.readRevision(repo);
+      NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+      boolean dirty = false;
+      for (Note note : noteMap) {
+        byte[] raw =
+            rw.getObjectReader()
+                .open(note.getData(), OBJ_BLOB)
+                .getCachedBytes(ExternalIdReader.MAX_NOTE_SZ);
+        try {
+          ExternalId extId = ExternalId.parse(note.getName(), raw);
+
+          if (needsUpdate(extId)) {
+            ExternalIdsUpdate.upsert(rw, ins, noteMap, extId);
+            dirty = true;
+          }
+        } catch (ConfigInvalidException e) {
+          ui.message(
+              String.format("Warning: Ignoring invalid external ID note %s", note.getName()));
+        }
+      }
+      if (dirty) {
+        ExternalIdsUpdate.commit(repo, rw, ins, rev, noteMap, COMMIT_MSG, serverUser, serverUser);
+      }
+    } catch (IOException e) {
+      throw new OrmException("Failed to update external IDs", e);
+    }
+  }
+
+  private static boolean needsUpdate(ExternalId extId) {
+    Config cfg = new Config();
+    cfg.setInt("externalId", extId.key().get(), "accountId", extId.accountId().get());
+    return Ints.tryParse(cfg.getString("externalId", extId.key().get(), "accountId")) == null;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_149.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_149.java
new file mode 100644
index 0000000..f1ccaa6
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_149.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2017 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.schema;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+/** Add workInProgress field to change. */
+public class Schema_149 extends SchemaVersion {
+  @Inject
+  Schema_149(Provider<Schema_148> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java
index f34c22cb..22329fd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -17,19 +17,31 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableMultiset.toImmutableMultiset;
 
+import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Multiset;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.NoSuchRefException;
 import com.google.gerrit.server.util.RequestId;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -41,6 +53,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.TimeZone;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -81,43 +94,98 @@
       @Override
       public void configure() {
         factory(ReviewDbBatchUpdate.AssistedFactory.class);
+        factory(FusedNoteDbBatchUpdate.AssistedFactory.class);
+        factory(UnfusedNoteDbBatchUpdate.AssistedFactory.class);
       }
     };
   }
 
   @Singleton
   public static class Factory {
+    private final NotesMigration migration;
     private final ReviewDbBatchUpdate.AssistedFactory reviewDbBatchUpdateFactory;
+    private final FusedNoteDbBatchUpdate.AssistedFactory fusedNoteDbBatchUpdateFactory;
+    private final UnfusedNoteDbBatchUpdate.AssistedFactory unfusedNoteDbBatchUpdateFactory;
 
     @Inject
-    Factory(ReviewDbBatchUpdate.AssistedFactory reviewDbBatchUpdateFactory) {
+    Factory(
+        NotesMigration migration,
+        ReviewDbBatchUpdate.AssistedFactory reviewDbBatchUpdateFactory,
+        FusedNoteDbBatchUpdate.AssistedFactory fusedNoteDbBatchUpdateFactory,
+        UnfusedNoteDbBatchUpdate.AssistedFactory unfusedNoteDbBatchUpdateFactory) {
+      this.migration = migration;
       this.reviewDbBatchUpdateFactory = reviewDbBatchUpdateFactory;
+      this.fusedNoteDbBatchUpdateFactory = fusedNoteDbBatchUpdateFactory;
+      this.unfusedNoteDbBatchUpdateFactory = unfusedNoteDbBatchUpdateFactory;
     }
 
     public BatchUpdate create(
         ReviewDb db, Project.NameKey project, CurrentUser user, Timestamp when) {
+      if (migration.disableChangeReviewDb()) {
+        if (migration.fuseUpdates()) {
+          return fusedNoteDbBatchUpdateFactory.create(db, project, user, when);
+        }
+        return unfusedNoteDbBatchUpdateFactory.create(db, project, user, when);
+      }
       return reviewDbBatchUpdateFactory.create(db, project, user, when);
     }
 
+    @SuppressWarnings({"rawtypes", "unchecked"})
     public void execute(
         Collection<BatchUpdate> updates,
         BatchUpdateListener listener,
         @Nullable RequestId requestId,
         boolean dryRun)
         throws UpdateException, RestApiException {
+      checkNotNull(listener);
+      checkDifferentProject(updates);
       // It's safe to downcast all members of the input collection in this case, because the only
       // way a caller could have gotten any BatchUpdates in the first place is to call the create
       // method above, which always returns instances of the type we expect. Just to be safe,
       // copy them into an ImmutableList so there is no chance the callee can pollute the input
       // collection.
-      @SuppressWarnings({"rawtypes", "unchecked"})
-      ImmutableList<ReviewDbBatchUpdate> reviewDbUpdates =
-          (ImmutableList) ImmutableList.copyOf(updates);
-      ReviewDbBatchUpdate.execute(reviewDbUpdates, listener, requestId, dryRun);
+      if (migration.disableChangeReviewDb()) {
+        if (migration.fuseUpdates()) {
+          ImmutableList<FusedNoteDbBatchUpdate> noteDbUpdates =
+              (ImmutableList) ImmutableList.copyOf(updates);
+          FusedNoteDbBatchUpdate.execute(noteDbUpdates, listener, requestId, dryRun);
+        } else {
+          ImmutableList<UnfusedNoteDbBatchUpdate> noteDbUpdates =
+              (ImmutableList) ImmutableList.copyOf(updates);
+          UnfusedNoteDbBatchUpdate.execute(noteDbUpdates, listener, requestId, dryRun);
+        }
+      } else {
+        ImmutableList<ReviewDbBatchUpdate> reviewDbUpdates =
+            (ImmutableList) ImmutableList.copyOf(updates);
+        ReviewDbBatchUpdate.execute(reviewDbUpdates, listener, requestId, dryRun);
+      }
+    }
+
+    private static void checkDifferentProject(Collection<BatchUpdate> updates) {
+      Multiset<Project.NameKey> projectCounts =
+          updates.stream().map(u -> u.project).collect(toImmutableMultiset());
+      checkArgument(
+          projectCounts.entrySet().size() == updates.size(),
+          "updates must all be for different projects, got: %s",
+          projectCounts);
     }
   }
 
-  protected static Order getOrder(Collection<? extends BatchUpdate> updates) {
+  static void setRequestIds(
+      Collection<? extends BatchUpdate> updates, @Nullable RequestId requestId) {
+    if (requestId != null) {
+      for (BatchUpdate u : updates) {
+        checkArgument(
+            u.requestId == null || u.requestId == requestId,
+            "refusing to overwrite RequestId %s in update with %s",
+            u.requestId,
+            requestId);
+        u.setRequestId(requestId);
+      }
+    }
+  }
+
+  static Order getOrder(Collection<? extends BatchUpdate> updates, BatchUpdateListener listener) {
     Order o = null;
     for (BatchUpdate u : updates) {
       if (o == null) {
@@ -126,10 +194,16 @@
         throw new IllegalArgumentException("cannot mix execution orders");
       }
     }
+    if (o != Order.REPO_BEFORE_DB) {
+      checkArgument(
+          listener == BatchUpdateListener.NONE,
+          "BatchUpdateListener not supported for order %s",
+          o);
+    }
     return o;
   }
 
-  protected static boolean getUpdateChangesInParallel(Collection<? extends BatchUpdate> updates) {
+  static boolean getUpdateChangesInParallel(Collection<? extends BatchUpdate> updates) {
     checkArgument(!updates.isEmpty());
     Boolean p = null;
     for (BatchUpdate u : updates) {
@@ -148,6 +222,28 @@
     return p;
   }
 
+  static void wrapAndThrowException(Exception e) throws UpdateException, RestApiException {
+    Throwables.throwIfUnchecked(e);
+
+    // Propagate REST API exceptions thrown by operations; they commonly throw exceptions like
+    // ResourceConflictException to indicate an atomic update failure.
+    Throwables.throwIfInstanceOf(e, UpdateException.class);
+    Throwables.throwIfInstanceOf(e, RestApiException.class);
+
+    // Convert other common non-REST exception types with user-visible messages to corresponding
+    // REST exception types
+    if (e instanceof InvalidChangeOperationException) {
+      throw new ResourceConflictException(e.getMessage(), e);
+    } else if (e instanceof NoSuchChangeException
+        || e instanceof NoSuchRefException
+        || e instanceof NoSuchProjectException) {
+      throw new ResourceNotFoundException(e.getMessage(), e);
+    }
+
+    // Otherwise, wrap in a generic UpdateException, which does not include a user-visible message.
+    throw new UpdateException(e);
+  }
+
   protected GitRepositoryManager repoManager;
 
   protected final Project.NameKey project;
@@ -160,17 +256,13 @@
   protected final Map<Change.Id, Change> newChanges = new HashMap<>();
   protected final List<RepoOnlyOp> repoOnlyOps = new ArrayList<>();
 
-  protected Repository repo;
-  protected ObjectInserter inserter;
-  protected RevWalk revWalk;
-  protected ChainedReceiveCommands commands;
+  protected RepoView repoView;
   protected BatchRefUpdate batchRefUpdate;
   protected Order order;
   protected OnSubmitValidators onSubmitValidators;
   protected RequestId requestId;
 
   private boolean updateChangesInParallel;
-  private boolean closeRepo;
 
   protected BatchUpdate(
       GitRepositoryManager repoManager,
@@ -188,18 +280,17 @@
 
   @Override
   public void close() {
-    if (closeRepo) {
-      revWalk.getObjectReader().close();
-      revWalk.close();
-      inserter.close();
-      repo.close();
+    if (repoView != null) {
+      repoView.close();
     }
   }
 
   public abstract void execute(BatchUpdateListener listener)
       throws UpdateException, RestApiException;
 
-  public abstract void execute() throws UpdateException, RestApiException;
+  public void execute() throws UpdateException, RestApiException {
+    execute(BatchUpdateListener.NONE);
+  }
 
   protected abstract Context newContext();
 
@@ -209,12 +300,8 @@
   }
 
   public BatchUpdate setRepository(Repository repo, RevWalk revWalk, ObjectInserter inserter) {
-    checkState(this.repo == null, "repo already set");
-    closeRepo = false;
-    this.repo = checkNotNull(repo, "repo");
-    this.revWalk = checkNotNull(revWalk, "revWalk");
-    this.inserter = checkNotNull(inserter, "inserter");
-    commands = new ChainedReceiveCommands(repo);
+    checkState(this.repoView == null, "repo already set");
+    repoView = new RepoView(repo, revWalk, inserter);
     return this;
   }
 
@@ -232,43 +319,46 @@
     return this;
   }
 
-  /** Execute {@link BatchUpdateOp#updateChange(ChangeContext)} in parallel for each change. */
+  /**
+   * Execute {@link BatchUpdateOp#updateChange(ChangeContext)} in parallel for each change.
+   *
+   * <p>This improves performance of writing to multiple changes in separate ReviewDb transactions.
+   * When only NoteDb is used, updates to all changes are written in a single batch ref update, so
+   * parallelization is not used and this option is ignored.
+   */
   public BatchUpdate updateChangesInParallel() {
     this.updateChangesInParallel = true;
     return this;
   }
 
   protected void initRepository() throws IOException {
-    if (repo == null) {
-      this.repo = repoManager.openRepository(project);
-      closeRepo = true;
-      inserter = repo.newObjectInserter();
-      revWalk = new RevWalk(inserter.newReader());
-      commands = new ChainedReceiveCommands(repo);
+    if (repoView == null) {
+      repoView = new RepoView(repoManager, project);
     }
   }
 
+  protected RepoView getRepoView() throws IOException {
+    initRepository();
+    return repoView;
+  }
+
   protected CurrentUser getUser() {
     return user;
   }
 
-  protected Repository getRepository() throws IOException {
-    initRepository();
-    return repo;
+  protected Optional<Account> getAccount() {
+    return user.isIdentifiedUser()
+        ? Optional.of(user.asIdentifiedUser().getAccount())
+        : Optional.empty();
   }
 
   protected RevWalk getRevWalk() throws IOException {
     initRepository();
-    return revWalk;
+    return repoView.getRevWalk();
   }
 
-  protected ObjectInserter getObjectInserter() throws IOException {
-    initRepository();
-    return inserter;
-  }
-
-  public Collection<ReceiveCommand> getRefUpdates() {
-    return commands.getCommands().values();
+  public Map<String, ReceiveCommand> getRefUpdates() {
+    return repoView != null ? repoView.getCommands().getCommands() : ImmutableMap.of();
   }
 
   public BatchUpdate addOp(Change.Id id, BatchUpdateOp op) {
@@ -284,7 +374,7 @@
     return this;
   }
 
-  public BatchUpdate insertChange(InsertChangeOp op) {
+  public BatchUpdate insertChange(InsertChangeOp op) throws IOException {
     Context ctx = newContext();
     Change c = op.createChange(ctx);
     checkArgument(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateListener.java
index 765bba1..847a7ca 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateListener.java
@@ -19,6 +19,8 @@
  *
  * <p>When used during execution of multiple batch updates, the {@code after*} methods are called
  * after that phase has been completed for <em>all</em> updates.
+ *
+ * <p>Listeners are only supported for the {@link Order#REPO_BEFORE_DB} order.
  */
 public interface BatchUpdateListener {
   public static final BatchUpdateListener NONE = new BatchUpdateListener() {};
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeContext.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeContext.java
index d619490..ca39763 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeContext.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeContext.java
@@ -44,17 +44,24 @@
   ChangeUpdate getUpdate(PatchSet.Id psId);
 
   /**
-   * @return control for this change. The user will be the same as {@link #getUser()}, and the
-   *     change data is read within the same transaction that {@code updateChange} is executing.
+   * Get the control for this change, encapsulating the user and up-to-date change data.
+   *
+   * <p>The user will be the same as {@link #getUser()}, and the change data is read within the same
+   * transaction that {@link BatchUpdateOp#updateChange(ChangeContext)} is executing.
+   *
+   * @return control for this change.
    */
   ChangeControl getControl();
 
   /**
-   * @param bump whether to bump the value of {@link Change#getLastUpdatedOn()} field before storing
-   *     to ReviewDb. For NoteDb, the value is always incremented (assuming the update is not
-   *     otherwise a no-op).
+   * Don't bump the value of {@link Change#getLastUpdatedOn()}.
+   *
+   * <p>If called, don't bump the timestamp before storing to ReviewDb. Only has an effect in
+   * ReviewDb, and the only usage should be to match the behavior of NoteDb. Specifically, in NoteDb
+   * the timestamp is updated if and only if the change meta graph is updated, and is not updated
+   * when only drafts are modified.
    */
-  void bumpLastUpdatedOn(boolean bump);
+  void dontBumpLastUpdatedOn();
 
   /**
    * Instruct {@link BatchUpdate} to delete this change.
@@ -63,7 +70,11 @@
    */
   void deleteChange();
 
-  /** @return notes corresponding to {@link #getControl()}. */
+  /**
+   * Get notes corresponding to {@link #getControl()}.
+   *
+   * @return loaded notes instance.
+   */
   default ChangeNotes getNotes() {
     return checkNotNull(getControl().getNotes());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/Context.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/Context.java
index 497b7ab..f33536d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/Context.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/Context.java
@@ -24,7 +24,6 @@
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.TimeZone;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
@@ -33,19 +32,22 @@
  * <p>A single update may span multiple changes, but they all belong to a single repo.
  */
 public interface Context {
-  /** @return the project name this update operates on. */
+  /**
+   * Get the project name this update operates on.
+   *
+   * @return project.
+   */
   Project.NameKey getProject();
 
   /**
-   * Get an open repository instance for this project.
+   * Get a read-only view of the open repository for this project.
    *
-   * <p>Will be opened lazily if necessary; callers should not close the repo. In some phases of the
-   * update, the repository might be read-only; see {@link BatchUpdateOp} for details.
+   * <p>Will be opened lazily if necessary.
    *
    * @return repository instance.
    * @throws IOException if an error occurred opening the repo.
    */
-  Repository getRepository() throws IOException;
+  RepoView getRepoView() throws IOException;
 
   /**
    * Get a walk for this project.
@@ -57,50 +59,80 @@
    */
   RevWalk getRevWalk() throws IOException;
 
-  /** @return the timestamp at which this update takes place. */
+  /**
+   * Get the timestamp at which this update takes place.
+   *
+   * @return timestamp.
+   */
   Timestamp getWhen();
 
   /**
-   * @return the time zone in which this update takes place. In the current implementation, this is
-   *     always the time zone of the server.
+   * Get the time zone in which this update takes place.
+   *
+   * <p>In the current implementation, this is always the time zone of the server.
+   *
+   * @return time zone.
    */
   TimeZone getTimeZone();
 
   /**
-   * @return an open ReviewDb database. Callers should not manage transactions or call mutating
-   *     methods on the Changes table. Mutations on other tables (including other entities in the
-   *     change entity group) are fine.
+   * Get the ReviewDb database.
+   *
+   * <p>Callers should not manage transactions or call mutating methods on the Changes table.
+   * Mutations on other tables (including other entities in the change entity group) are fine.
+   *
+   * @return open database instance.
    */
   ReviewDb getDb();
 
   /**
-   * @return user performing the update. In the current implementation, this is always an {@link
-   *     IdentifiedUser} or {@link com.google.gerrit.server.InternalUser}.
+   * Get the user performing the update.
+   *
+   * <p>In the current implementation, this is always an {@link IdentifiedUser} or {@link
+   * com.google.gerrit.server.InternalUser}.
+   *
+   * @return user.
    */
   CurrentUser getUser();
 
-  /** @return order in which operations are executed in this update. */
+  /**
+   * Get the order in which operations are executed in this update.
+   *
+   * @return order of operations.
+   */
   Order getOrder();
 
   /**
-   * @return identified user performing the update; throws an unchecked exception if the user is not
-   *     an {@link IdentifiedUser}
+   * Get the identified user performing the update.
+   *
+   * <p>Convenience method for {@code getUser().asIdentifiedUser()}.
+   *
+   * @see CurrentUser#asIdentifiedUser()
+   * @return user.
    */
   default IdentifiedUser getIdentifiedUser() {
     return checkNotNull(getUser()).asIdentifiedUser();
   }
 
   /**
-   * @return account of the user performing the update; throws if the user is not an {@link
-   *     IdentifiedUser}
+   * Get the account of the user performing the update.
+   *
+   * <p>Convenience method for {@code getIdentifiedUser().getAccount()}.
+   *
+   * @see CurrentUser#asIdentifiedUser()
+   * @return account.
    */
   default Account getAccount() {
     return getIdentifiedUser().getAccount();
   }
 
   /**
-   * @return account ID of the user performing the update; throws if the user is not an {@link
-   *     IdentifiedUser}
+   * Get the account ID of the user performing the update.
+   *
+   * <p>Convenience method for {@code getUser().getAccountId()}
+   *
+   * @see CurrentUser#getAccountId()
+   * @return account ID.
    */
   default Account.Id getAccountId() {
     return getIdentifiedUser().getAccountId();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/FusedNoteDbBatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/FusedNoteDbBatchUpdate.java
new file mode 100644
index 0000000..ad758484
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/FusedNoteDbBatchUpdate.java
@@ -0,0 +1,459 @@
+// Copyright (C) 2017 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.update;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.Comparator.comparing;
+
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.CheckedFuture;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.util.RequestId;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TimeZone;
+import java.util.TreeMap;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/**
+ * {@link BatchUpdate} implementation using only NoteDb that updates code refs and meta refs in a
+ * single {@link org.eclipse.jgit.lib.BatchRefUpdate}.
+ *
+ * <p>Used when {@code noteDb.changes.disableReviewDb=true}, at which point ReviewDb is not
+ * consulted during updates.
+ */
+class FusedNoteDbBatchUpdate extends BatchUpdate {
+  interface AssistedFactory {
+    FusedNoteDbBatchUpdate create(
+        ReviewDb db, Project.NameKey project, CurrentUser user, Timestamp when);
+  }
+
+  static void execute(
+      ImmutableList<FusedNoteDbBatchUpdate> updates,
+      BatchUpdateListener listener,
+      @Nullable RequestId requestId,
+      boolean dryrun)
+      throws UpdateException, RestApiException {
+    if (updates.isEmpty()) {
+      return;
+    }
+    setRequestIds(updates, requestId);
+
+    try {
+      List<CheckedFuture<?, IOException>> indexFutures = new ArrayList<>();
+      List<ChangesHandle> handles = new ArrayList<>(updates.size());
+      Order order = getOrder(updates, listener);
+      try {
+        switch (order) {
+          case REPO_BEFORE_DB:
+            for (FusedNoteDbBatchUpdate u : updates) {
+              u.executeUpdateRepo();
+            }
+            listener.afterUpdateRepos();
+            for (FusedNoteDbBatchUpdate u : updates) {
+              handles.add(u.executeChangeOps(dryrun));
+            }
+            for (ChangesHandle h : handles) {
+              h.execute();
+              indexFutures.addAll(h.startIndexFutures());
+            }
+            listener.afterUpdateRefs();
+            listener.afterUpdateChanges();
+            break;
+
+          case DB_BEFORE_REPO:
+            // Call updateChange for each op before updateRepo, but defer executing the
+            // NoteDbUpdateManager until after calling updateRepo. They share an inserter and
+            // BatchRefUpdate, so it will all execute as a single batch. But we have to let
+            // NoteDbUpdateManager actually execute the update, since it has to interleave it
+            // properly with All-Users updates.
+            //
+            // TODO(dborowitz): This may still result in multiple updates to All-Users, but that's
+            // currently not a big deal because multi-change batches generally aren't affecting
+            // drafts anyway.
+            for (FusedNoteDbBatchUpdate u : updates) {
+              handles.add(u.executeChangeOps(dryrun));
+            }
+            for (FusedNoteDbBatchUpdate u : updates) {
+              u.executeUpdateRepo();
+            }
+            for (ChangesHandle h : handles) {
+              // TODO(dborowitz): This isn't quite good enough: in theory updateRepo may want to
+              // see the results of change meta commands, but they aren't actually added to the
+              // BatchUpdate until the body of execute. To fix this, execute needs to be split up
+              // into a method that returns a BatchRefUpdate before execution. Not a big deal at the
+              // moment, because this order is only used for deleting changes, and those updateRepo
+              // implementations definitely don't need to observe the updated change meta refs.
+              h.execute();
+              indexFutures.addAll(h.startIndexFutures());
+            }
+            break;
+          default:
+            throw new IllegalStateException("invalid execution order: " + order);
+        }
+      } finally {
+        for (ChangesHandle h : handles) {
+          h.close();
+        }
+      }
+
+      ChangeIndexer.allAsList(indexFutures).get();
+
+      // Fire ref update events only after all mutations are finished, since callers may assume a
+      // patch set ref being created means the change was created, or a branch advancing meaning
+      // some changes were closed.
+      updates
+          .stream()
+          .filter(u -> u.batchRefUpdate != null)
+          .forEach(
+              u -> u.gitRefUpdated.fire(u.project, u.batchRefUpdate, u.getAccount().orElse(null)));
+
+      if (!dryrun) {
+        for (FusedNoteDbBatchUpdate u : updates) {
+          u.executePostOps();
+        }
+      }
+    } catch (Exception e) {
+      wrapAndThrowException(e);
+    }
+  }
+
+  class ContextImpl implements Context {
+    @Override
+    public RepoView getRepoView() throws IOException {
+      return FusedNoteDbBatchUpdate.this.getRepoView();
+    }
+
+    @Override
+    public RevWalk getRevWalk() throws IOException {
+      return getRepoView().getRevWalk();
+    }
+
+    @Override
+    public Project.NameKey getProject() {
+      return project;
+    }
+
+    @Override
+    public Timestamp getWhen() {
+      return when;
+    }
+
+    @Override
+    public TimeZone getTimeZone() {
+      return tz;
+    }
+
+    @Override
+    public ReviewDb getDb() {
+      return db;
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return user;
+    }
+
+    @Override
+    public Order getOrder() {
+      return order;
+    }
+  }
+
+  private class RepoContextImpl extends ContextImpl implements RepoContext {
+    @Override
+    public ObjectInserter getInserter() throws IOException {
+      return getRepoView().getInserterWrapper();
+    }
+
+    @Override
+    public void addRefUpdate(ReceiveCommand cmd) throws IOException {
+      getRepoView().getCommands().add(cmd);
+    }
+  }
+
+  private class ChangeContextImpl extends ContextImpl implements ChangeContext {
+    private final ChangeControl ctl;
+    private final Map<PatchSet.Id, ChangeUpdate> updates;
+
+    private boolean deleted;
+
+    protected ChangeContextImpl(ChangeControl ctl) {
+      this.ctl = checkNotNull(ctl);
+      updates = new TreeMap<>(comparing(PatchSet.Id::get));
+    }
+
+    @Override
+    public ChangeUpdate getUpdate(PatchSet.Id psId) {
+      ChangeUpdate u = updates.get(psId);
+      if (u == null) {
+        u = changeUpdateFactory.create(ctl, when);
+        if (newChanges.containsKey(ctl.getId())) {
+          u.setAllowWriteToNewRef(true);
+        }
+        u.setPatchSetId(psId);
+        updates.put(psId, u);
+      }
+      return u;
+    }
+
+    @Override
+    public ChangeControl getControl() {
+      return ctl;
+    }
+
+    @Override
+    public void dontBumpLastUpdatedOn() {
+      // Do nothing; NoteDb effectively updates timestamp if and only if a commit was written to the
+      // change meta ref.
+    }
+
+    @Override
+    public void deleteChange() {
+      deleted = true;
+    }
+  }
+
+  /** Per-change result status from {@link #executeChangeOps}. */
+  private enum ChangeResult {
+    SKIPPED,
+    UPSERTED,
+    DELETED;
+  }
+
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final ChangeControl.GenericFactory changeControlFactory;
+  private final ChangeUpdate.Factory changeUpdateFactory;
+  private final NoteDbUpdateManager.Factory updateManagerFactory;
+  private final ChangeIndexer indexer;
+  private final GitReferenceUpdated gitRefUpdated;
+  private final ReviewDb db;
+
+  @Inject
+  FusedNoteDbBatchUpdate(
+      GitRepositoryManager repoManager,
+      @GerritPersonIdent PersonIdent serverIdent,
+      ChangeNotes.Factory changeNotesFactory,
+      ChangeControl.GenericFactory changeControlFactory,
+      ChangeUpdate.Factory changeUpdateFactory,
+      NoteDbUpdateManager.Factory updateManagerFactory,
+      ChangeIndexer indexer,
+      GitReferenceUpdated gitRefUpdated,
+      @Assisted ReviewDb db,
+      @Assisted Project.NameKey project,
+      @Assisted CurrentUser user,
+      @Assisted Timestamp when) {
+    super(repoManager, serverIdent, project, user, when);
+    checkArgument(!db.changesTablesEnabled(), "expected Change tables to be disabled on %s", db);
+    this.changeNotesFactory = changeNotesFactory;
+    this.changeControlFactory = changeControlFactory;
+    this.changeUpdateFactory = changeUpdateFactory;
+    this.updateManagerFactory = updateManagerFactory;
+    this.indexer = indexer;
+    this.gitRefUpdated = gitRefUpdated;
+    this.db = db;
+  }
+
+  @Override
+  public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
+    execute(ImmutableList.of(this), listener, requestId, false);
+  }
+
+  @Override
+  protected Context newContext() {
+    return new ContextImpl();
+  }
+
+  private void executeUpdateRepo() throws UpdateException, RestApiException {
+    try {
+      logDebug("Executing updateRepo on {} ops", ops.size());
+      RepoContextImpl ctx = new RepoContextImpl();
+      for (BatchUpdateOp op : ops.values()) {
+        op.updateRepo(ctx);
+      }
+
+      logDebug("Executing updateRepo on {} RepoOnlyOps", repoOnlyOps.size());
+      for (RepoOnlyOp op : repoOnlyOps) {
+        op.updateRepo(ctx);
+      }
+
+      if (onSubmitValidators != null && !getRefUpdates().isEmpty()) {
+        // Validation of refs has to take place here and not at the beginning of executeRefUpdates.
+        // Otherwise, failing validation in a second BatchUpdate object will happen *after* the
+        // first update's executeRefUpdates has finished, hence after first repo's refs have been
+        // updated, which is too late.
+        onSubmitValidators.validate(
+            project, ctx.getRevWalk().getObjectReader(), repoView.getCommands());
+      }
+    } catch (Exception e) {
+      Throwables.throwIfInstanceOf(e, RestApiException.class);
+      throw new UpdateException(e);
+    }
+  }
+
+  private class ChangesHandle implements AutoCloseable {
+    private final NoteDbUpdateManager manager;
+    private final boolean dryrun;
+    private final Map<Change.Id, ChangeResult> results;
+
+    ChangesHandle(NoteDbUpdateManager manager, boolean dryrun) {
+      this.manager = manager;
+      this.dryrun = dryrun;
+      results = new HashMap<>();
+    }
+
+    @Override
+    public void close() {
+      manager.close();
+    }
+
+    void setResult(Change.Id id, ChangeResult result) {
+      ChangeResult old = results.putIfAbsent(id, result);
+      checkArgument(old == null, "result for change %s already set: %s", id, old);
+    }
+
+    void execute() throws OrmException, IOException {
+      FusedNoteDbBatchUpdate.this.batchRefUpdate = manager.execute(dryrun);
+    }
+
+    List<CheckedFuture<?, IOException>> startIndexFutures() {
+      if (dryrun) {
+        return ImmutableList.of();
+      }
+      logDebug("Reindexing {} changes", results.size());
+      List<CheckedFuture<?, IOException>> indexFutures = new ArrayList<>(results.size());
+      for (Map.Entry<Change.Id, ChangeResult> e : results.entrySet()) {
+        Change.Id id = e.getKey();
+        switch (e.getValue()) {
+          case UPSERTED:
+            indexFutures.add(indexer.indexAsync(project, id));
+            break;
+          case DELETED:
+            indexFutures.add(indexer.deleteAsync(id));
+            break;
+          case SKIPPED:
+            break;
+          default:
+            throw new IllegalStateException("unexpected result: " + e.getValue());
+        }
+      }
+      return indexFutures;
+    }
+  }
+
+  private ChangesHandle executeChangeOps(boolean dryrun) throws Exception {
+    logDebug("Executing change ops");
+    initRepository();
+    Repository repo = repoView.getRepository();
+    checkState(
+        repo.getRefDatabase().performsAtomicTransactions(),
+        "cannot use noteDb.changes.fuseUpdates=true with a repository that does not support atomic"
+            + " batch ref updates: %s",
+        repo);
+
+    ChangesHandle handle =
+        new ChangesHandle(
+            updateManagerFactory
+                .create(project)
+                .setChangeRepo(
+                    repo, repoView.getRevWalk(), repoView.getInserter(), repoView.getCommands()),
+            dryrun);
+    if (user.isIdentifiedUser()) {
+      handle.manager.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, tz));
+    }
+    for (Map.Entry<Change.Id, Collection<BatchUpdateOp>> e : ops.asMap().entrySet()) {
+      Change.Id id = e.getKey();
+      ChangeContextImpl ctx = newChangeContext(id);
+      boolean dirty = false;
+      logDebug("Applying {} ops for change {}", e.getValue().size(), id);
+      for (BatchUpdateOp op : e.getValue()) {
+        dirty |= op.updateChange(ctx);
+      }
+      if (!dirty) {
+        logDebug("No ops reported dirty, short-circuiting");
+        handle.setResult(id, ChangeResult.SKIPPED);
+        continue;
+      }
+      for (ChangeUpdate u : ctx.updates.values()) {
+        handle.manager.add(u);
+      }
+      if (ctx.deleted) {
+        logDebug("Change {} was deleted", id);
+        handle.manager.deleteChange(id);
+        handle.setResult(id, ChangeResult.DELETED);
+      } else {
+        handle.setResult(id, ChangeResult.UPSERTED);
+      }
+    }
+    return handle;
+  }
+
+  private ChangeContextImpl newChangeContext(Change.Id id) throws OrmException {
+    logDebug("Opening change {} for update", id);
+    Change c = newChanges.get(id);
+    boolean isNew = c != null;
+    if (!isNew) {
+      // Pass a synthetic change into ChangeNotes.Factory, which will take care of checking for
+      // existence and populating columns from the parsed notes state.
+      // TODO(dborowitz): This dance made more sense when using Reviewdb; consider a nicer way.
+      c = ChangeNotes.Factory.newNoteDbOnlyChange(project, id);
+    } else {
+      logDebug("Change {} is new", id);
+    }
+    ChangeNotes notes = changeNotesFactory.createForBatchUpdate(c, !isNew);
+    ChangeControl ctl = changeControlFactory.controlFor(notes, user);
+    return new ChangeContextImpl(ctl);
+  }
+
+  private void executePostOps() throws Exception {
+    ContextImpl ctx = new ContextImpl();
+    for (BatchUpdateOp op : ops.values()) {
+      op.postUpdate(ctx);
+    }
+
+    for (RepoOnlyOp op : repoOnlyOps) {
+      op.postUpdate(ctx);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/InsertChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/InsertChangeOp.java
index 1a947e6..7060059 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/InsertChangeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/InsertChangeOp.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.update;
 
 import com.google.gerrit.reviewdb.client.Change;
+import java.io.IOException;
 
 /**
  * Specialization of {@link BatchUpdateOp} for creating changes.
@@ -27,5 +28,5 @@
  * first.
  */
 public interface InsertChangeOp extends BatchUpdateOp {
-  Change createChange(Context ctx);
+  Change createChange(Context ctx) throws IOException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/ReadOnlyRepository.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/ReadOnlyRepository.java
deleted file mode 100644
index 37c1d60..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/ReadOnlyRepository.java
+++ /dev/null
@@ -1,180 +0,0 @@
-// Copyright (C) 2015 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.update;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import java.io.IOException;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.attributes.AttributesNodeProvider;
-import org.eclipse.jgit.lib.BaseRepositoryBuilder;
-import org.eclipse.jgit.lib.ObjectDatabase;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
-import org.eclipse.jgit.lib.RefRename;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.ReflogReader;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.StoredConfig;
-
-class ReadOnlyRepository extends Repository {
-  private static final String MSG = "Cannot modify a " + ReadOnlyRepository.class.getSimpleName();
-
-  private static BaseRepositoryBuilder<?, ?> builder(Repository r) {
-    checkNotNull(r);
-    BaseRepositoryBuilder<?, ?> builder =
-        new BaseRepositoryBuilder<>().setFS(r.getFS()).setGitDir(r.getDirectory());
-
-    if (!r.isBare()) {
-      builder.setWorkTree(r.getWorkTree()).setIndexFile(r.getIndexFile());
-    }
-    return builder;
-  }
-
-  private final Repository delegate;
-  private final RefDb refdb;
-  private final ObjDb objdb;
-
-  ReadOnlyRepository(Repository delegate) {
-    super(builder(delegate));
-    this.delegate = delegate;
-    this.refdb = new RefDb(delegate.getRefDatabase());
-    this.objdb = new ObjDb(delegate.getObjectDatabase());
-  }
-
-  @Override
-  public void create(boolean bare) throws IOException {
-    throw new UnsupportedOperationException(MSG);
-  }
-
-  @Override
-  public ObjectDatabase getObjectDatabase() {
-    return objdb;
-  }
-
-  @Override
-  public RefDatabase getRefDatabase() {
-    return refdb;
-  }
-
-  @Override
-  public StoredConfig getConfig() {
-    return delegate.getConfig();
-  }
-
-  @Override
-  public AttributesNodeProvider createAttributesNodeProvider() {
-    return delegate.createAttributesNodeProvider();
-  }
-
-  @Override
-  public void scanForRepoChanges() throws IOException {
-    delegate.scanForRepoChanges();
-  }
-
-  @Override
-  public void notifyIndexChanged() {
-    delegate.notifyIndexChanged();
-  }
-
-  @Override
-  public ReflogReader getReflogReader(String refName) throws IOException {
-    return delegate.getReflogReader(refName);
-  }
-
-  @Override
-  public String getGitwebDescription() throws IOException {
-    return delegate.getGitwebDescription();
-  }
-
-  private static class RefDb extends RefDatabase {
-    private final RefDatabase delegate;
-
-    private RefDb(RefDatabase delegate) {
-      this.delegate = checkNotNull(delegate);
-    }
-
-    @Override
-    public void create() throws IOException {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public void close() {
-      delegate.close();
-    }
-
-    @Override
-    public boolean isNameConflicting(String name) throws IOException {
-      return delegate.isNameConflicting(name);
-    }
-
-    @Override
-    public RefUpdate newUpdate(String name, boolean detach) throws IOException {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public RefRename newRename(String fromName, String toName) throws IOException {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public Ref getRef(String name) throws IOException {
-      return delegate.getRef(name);
-    }
-
-    @Override
-    public Map<String, Ref> getRefs(String prefix) throws IOException {
-      return delegate.getRefs(prefix);
-    }
-
-    @Override
-    public List<Ref> getAdditionalRefs() throws IOException {
-      return delegate.getAdditionalRefs();
-    }
-
-    @Override
-    public Ref peel(Ref ref) throws IOException {
-      return delegate.peel(ref);
-    }
-  }
-
-  private static class ObjDb extends ObjectDatabase {
-    private final ObjectDatabase delegate;
-
-    private ObjDb(ObjectDatabase delegate) {
-      this.delegate = checkNotNull(delegate);
-    }
-
-    @Override
-    public ObjectInserter newInserter() {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ObjectReader newReader() {
-      return delegate.newReader();
-    }
-
-    @Override
-    public void close() {
-      delegate.close();
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoContext.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoContext.java
index 5009c50..9faf628 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoContext.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoContext.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.update;
 
 import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
@@ -31,11 +32,28 @@
   /**
    * Add a command to the pending list of commands.
    *
-   * <p>Callers should use this method instead of writing directly to the repository returned by
-   * {@link #getRepository()}.
+   * <p>Adding commands to the {@code RepoContext} is the only way of updating refs in the
+   * repository from a {@link BatchUpdateOp}.
    *
    * @param cmd ref update command.
    * @throws IOException if an error occurred opening the repo.
    */
   void addRefUpdate(ReceiveCommand cmd) throws IOException;
+
+  /**
+   * Add a command to the pending list of commands.
+   *
+   * <p>Adding commands to the {@code RepoContext} is the only way of updating refs in the
+   * repository from a {@link BatchUpdateOp}.
+   *
+   * @param oldId the old object ID; must not be null. Use {@link ObjectId#zeroId()} for ref
+   *     creation.
+   * @param newId the new object ID; must not be null. Use {@link ObjectId#zeroId()} for ref
+   *     deletion.
+   * @param refName the ref name.
+   * @throws IOException if an error occurred opening the repo.
+   */
+  default void addRefUpdate(ObjectId oldId, ObjectId newId, String refName) throws IOException {
+    addRefUpdate(new ReceiveCommand(oldId, newId, refName));
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoView.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoView.java
new file mode 100644
index 0000000..8839dbe
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoView.java
@@ -0,0 +1,234 @@
+// Copyright (C) 2017 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.update;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.Maps;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Restricted view of a {@link Repository} for use by {@link BatchUpdateOp} implementations.
+ *
+ * <p>This class serves two purposes in the context of {@link BatchUpdate}. First, the subset of
+ * normal Repository functionality is purely read-only, which prevents implementors from modifying
+ * the repository outside of {@link BatchUpdateOp#updateRepo}. Write operations can only be
+ * performed by calling methods on {@link RepoContext}.
+ *
+ * <p>Second, the read methods take into account any pending operations on the repository that
+ * implementations have staged using the write methods on {@link RepoContext}. Callers do not have
+ * to worry about whether operations have been performed yet, and the implementation details may
+ * differ between ReviewDb and NoteDb, but callers just don't need to care.
+ */
+public class RepoView {
+  private final Repository repo;
+  private final RevWalk rw;
+  private final ObjectInserter inserter;
+  private final ObjectInserter inserterWrapper;
+  private final ChainedReceiveCommands commands;
+  private final boolean closeRepo;
+
+  RepoView(GitRepositoryManager repoManager, Project.NameKey project) throws IOException {
+    repo = repoManager.openRepository(project);
+    inserter = repo.newObjectInserter();
+    inserterWrapper = new NonFlushingInserter(inserter);
+    rw = new RevWalk(inserter.newReader());
+    commands = new ChainedReceiveCommands(repo);
+    closeRepo = true;
+  }
+
+  RepoView(Repository repo, RevWalk rw, ObjectInserter inserter) {
+    checkArgument(
+        rw.getObjectReader().getCreatedFromInserter() == inserter,
+        "expected RevWalk %s to be created by ObjectInserter %s",
+        rw,
+        inserter);
+    this.repo = checkNotNull(repo);
+    this.rw = checkNotNull(rw);
+    this.inserter = checkNotNull(inserter);
+    inserterWrapper = new NonFlushingInserter(inserter);
+    commands = new ChainedReceiveCommands(repo);
+    closeRepo = false;
+  }
+
+  /**
+   * Get this repo's configuration.
+   *
+   * <p>This is the storage-level config you would get with {@link Repository#getConfig()}, not, for
+   * example, the Gerrit-level project config.
+   *
+   * @return a defensive copy of the config; modifications have no effect on the underlying config.
+   */
+  public Config getConfig() {
+    return new Config(repo.getConfig());
+  }
+
+  /**
+   * Get an open revwalk on the repo.
+   *
+   * <p>Guaranteed to be able to read back any objects inserted in the repository via {@link
+   * RepoContext#getInserter()}, even if objects have not been flushed to the underlying repo. In
+   * particular this includes any object returned by {@link #getRef(String)}, even taking into
+   * account not-yet-executed commands.
+   *
+   * @return revwalk.
+   */
+  public RevWalk getRevWalk() {
+    return rw;
+  }
+
+  /**
+   * Read a single ref from the repo.
+   *
+   * <p>Takes into account any ref update commands added during the course of the update using
+   * {@link RepoContext#addRefUpdate}, even if they have not yet been executed on the underlying
+   * repo.
+   *
+   * <p>The results of individual ref lookups are cached: calling this method multiple times with
+   * the same ref name will return the same result (unless a command was added in the meantime). The
+   * repo is not reread.
+   *
+   * @param name exact ref name.
+   * @return the value of the ref, if present.
+   * @throws IOException if an error occurred.
+   */
+  public Optional<ObjectId> getRef(String name) throws IOException {
+    return getCommands().get(name);
+  }
+
+  /**
+   * Look up refs by prefix.
+   *
+   * <p>Takes into account any ref update commands added during the course of the update using
+   * {@link RepoContext#addRefUpdate}, even if they have not yet been executed on the underlying
+   * repo.
+   *
+   * <p>For any ref that has previously been accessed with {@link #getRef(String)}, the value in the
+   * result map will be that same cached value. Any refs that have <em>not</em> been previously
+   * accessed are re-scanned from the repo on each call.
+   *
+   * @param prefix ref prefix; must end in '/' or else be empty.
+   * @return a map of ref suffixes to SHA-1s. The refs are all under {@code prefix} and have the
+   *     prefix stripped; this matches the behavior of {@link
+   *     org.eclipse.jgit.lib.RefDatabase#getRefs(String)}.
+   * @throws IOException if an error occurred.
+   */
+  public Map<String, ObjectId> getRefs(String prefix) throws IOException {
+    Map<String, ObjectId> result =
+        new HashMap<>(
+            Maps.transformValues(repo.getRefDatabase().getRefs(prefix), Ref::getObjectId));
+
+    // First, overwrite any cached reads from the underlying RepoRefCache. If any of these differ,
+    // it's because a ref was updated after the RepoRefCache read it. It feels a little odd to
+    // prefer the *old* value in this case, but it would be weirder to be inconsistent with getRef.
+    //
+    // Mostly this doesn't matter. If the caller was intending to write to the ref, they lost a
+    // race, and they will get a lock failure. If they just want to read, well, the JGit interface
+    // doesn't currently guarantee that any snapshot of multiple refs is consistent, so they were
+    // probably out of luck anyway.
+    commands
+        .getRepoRefCache()
+        .getCachedRefs()
+        .forEach((k, v) -> updateRefIfPrefixMatches(result, prefix, k, v));
+
+    // Second, overwrite with any pending commands.
+    commands
+        .getCommands()
+        .values()
+        .forEach(
+            c ->
+                updateRefIfPrefixMatches(result, prefix, c.getRefName(), toOptional(c.getNewId())));
+
+    return result;
+  }
+
+  private static Optional<ObjectId> toOptional(ObjectId id) {
+    return id.equals(ObjectId.zeroId()) ? Optional.empty() : Optional.of(id);
+  }
+
+  private static void updateRefIfPrefixMatches(
+      Map<String, ObjectId> map, String prefix, String fullRefName, Optional<ObjectId> maybeId) {
+    if (!fullRefName.startsWith(prefix)) {
+      return;
+    }
+    String suffix = fullRefName.substring(prefix.length());
+    if (maybeId.isPresent()) {
+      map.put(suffix, maybeId.get());
+    } else {
+      map.remove(suffix);
+    }
+  }
+
+  // Not AutoCloseable so callers can't improperly close it. Plus it's never managed with a try
+  // block anyway.
+  void close() {
+    if (closeRepo) {
+      inserter.close();
+      rw.close();
+      repo.close();
+    }
+  }
+
+  Repository getRepository() {
+    return repo;
+  }
+
+  ObjectInserter getInserter() {
+    return inserter;
+  }
+
+  ObjectInserter getInserterWrapper() {
+    return inserterWrapper;
+  }
+
+  ChainedReceiveCommands getCommands() {
+    return commands;
+  }
+
+  private static class NonFlushingInserter extends ObjectInserter.Filter {
+    private final ObjectInserter delegate;
+
+    private NonFlushingInserter(ObjectInserter delegate) {
+      this.delegate = delegate;
+    }
+
+    @Override
+    protected ObjectInserter delegate() {
+      return delegate;
+    }
+
+    @Override
+    public void flush() {
+      // Do nothing.
+    }
+
+    @Override
+    public void close() {
+      // Do nothing; the delegate is closed separately.
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
index 2b07280..95ed053 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.update;
 
-import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static java.util.Comparator.comparing;
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Stopwatch;
 import com.google.common.base.Throwables;
@@ -30,7 +30,6 @@
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
@@ -59,17 +58,12 @@
 import com.google.gerrit.server.notedb.NoteDbUpdateManager.MismatchedStateException;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.NoSuchRefException;
 import com.google.gerrit.server.util.RequestId;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -85,7 +79,6 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -118,19 +111,14 @@
   }
 
   class ContextImpl implements Context {
-    private Repository repoWrapper;
-
     @Override
-    public Repository getRepository() throws IOException {
-      if (repoWrapper == null) {
-        repoWrapper = new ReadOnlyRepository(ReviewDbBatchUpdate.this.getRepository());
-      }
-      return repoWrapper;
+    public RepoView getRepoView() throws IOException {
+      return ReviewDbBatchUpdate.this.getRepoView();
     }
 
     @Override
     public RevWalk getRevWalk() throws IOException {
-      return ReviewDbBatchUpdate.this.getRevWalk();
+      return getRepoView().getRevWalk();
     }
 
     @Override
@@ -166,19 +154,14 @@
 
   private class RepoContextImpl extends ContextImpl implements RepoContext {
     @Override
-    public Repository getRepository() throws IOException {
-      return ReviewDbBatchUpdate.this.getRepository();
-    }
-
-    @Override
     public ObjectInserter getInserter() throws IOException {
-      return ReviewDbBatchUpdate.this.getObjectInserter();
+      return getRepoView().getInserterWrapper();
     }
 
     @Override
     public void addRefUpdate(ReceiveCommand cmd) throws IOException {
       initRepository();
-      commands.add(cmd);
+      repoView.getCommands().add(cmd);
     }
   }
 
@@ -208,11 +191,6 @@
     }
 
     @Override
-    public Repository getRepository() {
-      return threadLocalRepo;
-    }
-
-    @Override
     public RevWalk getRevWalk() {
       return threadLocalRevWalk;
     }
@@ -238,8 +216,8 @@
     }
 
     @Override
-    public void bumpLastUpdatedOn(boolean bump) {
-      bumpLastUpdatedOn = bump;
+    public void dontBumpLastUpdatedOn() {
+      bumpLastUpdatedOn = false;
     }
 
     @Override
@@ -273,18 +251,9 @@
     if (updates.isEmpty()) {
       return;
     }
-    if (requestId != null) {
-      for (BatchUpdate u : updates) {
-        checkArgument(
-            u.requestId == null || u.requestId == requestId,
-            "refusing to overwrite RequestId %s in update with %s",
-            u.requestId,
-            requestId);
-        u.setRequestId(requestId);
-      }
-    }
+    setRequestIds(updates, requestId);
     try {
-      Order order = getOrder(updates);
+      Order order = getOrder(updates, listener);
       boolean updateChangesInParallel = getUpdateChangesInParallel(updates);
       switch (order) {
         case REPO_BEFORE_DB:
@@ -305,59 +274,37 @@
           for (ReviewDbBatchUpdate u : updates) {
             u.reindexChanges(u.executeChangeOps(updateChangesInParallel, dryrun));
           }
-          listener.afterUpdateChanges();
           for (ReviewDbBatchUpdate u : updates) {
             u.executeUpdateRepo();
           }
-          listener.afterUpdateRepos();
           for (ReviewDbBatchUpdate u : updates) {
             u.executeRefUpdates(dryrun);
           }
-          listener.afterUpdateRefs();
           break;
         default:
           throw new IllegalStateException("invalid execution order: " + order);
       }
 
-      List<CheckedFuture<?, IOException>> indexFutures = new ArrayList<>();
-      for (ReviewDbBatchUpdate u : updates) {
-        indexFutures.addAll(u.indexFutures);
-      }
-      ChangeIndexer.allAsList(indexFutures).get();
+      ChangeIndexer.allAsList(
+              updates.stream().flatMap(u -> u.indexFutures.stream()).collect(toList()))
+          .get();
 
-      for (ReviewDbBatchUpdate u : updates) {
-        if (u.batchRefUpdate != null) {
-          // Fire ref update events only after all mutations are finished, since
-          // callers may assume a patch set ref being created means the change
-          // was created, or a branch advancing meaning some changes were
-          // closed.
-          u.gitRefUpdated.fire(
-              u.project,
-              u.batchRefUpdate,
-              u.getUser().isIdentifiedUser() ? u.getUser().asIdentifiedUser().getAccount() : null);
-        }
-      }
+      // Fire ref update events only after all mutations are finished, since callers may assume a
+      // patch set ref being created means the change was created, or a branch advancing meaning
+      // some changes were closed.
+      updates
+          .stream()
+          .filter(u -> u.batchRefUpdate != null)
+          .forEach(
+              u -> u.gitRefUpdated.fire(u.project, u.batchRefUpdate, u.getAccount().orElse(null)));
+
       if (!dryrun) {
         for (ReviewDbBatchUpdate u : updates) {
           u.executePostOps();
         }
       }
-    } catch (UpdateException | RestApiException e) {
-      // Propagate REST API exceptions thrown by operations; they commonly throw
-      // exceptions like ResourceConflictException to indicate an atomic update
-      // failure.
-      throw e;
-
-      // Convert other common non-REST exception types with user-visible
-      // messages to corresponding REST exception types
-    } catch (InvalidChangeOperationException e) {
-      throw new ResourceConflictException(e.getMessage(), e);
-    } catch (NoSuchChangeException | NoSuchRefException | NoSuchProjectException e) {
-      throw new ResourceNotFoundException(e.getMessage(), e);
-
     } catch (Exception e) {
-      Throwables.throwIfUnchecked(e);
-      throw new UpdateException(e);
+      wrapAndThrowException(e);
     }
   }
 
@@ -376,7 +323,7 @@
   private final long skewMs;
   private final List<CheckedFuture<?, IOException>> indexFutures = new ArrayList<>();
 
-  @AssistedInject
+  @Inject
   ReviewDbBatchUpdate(
       @GerritServerConfig Config cfg,
       AllUsersName allUsers,
@@ -413,11 +360,6 @@
   }
 
   @Override
-  public void execute() throws UpdateException, RestApiException {
-    execute(BatchUpdateListener.NONE);
-  }
-
-  @Override
   public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
     execute(ImmutableList.of(this), listener, requestId, false);
   }
@@ -440,20 +382,18 @@
         op.updateRepo(ctx);
       }
 
-      if (onSubmitValidators != null && commands != null && !commands.isEmpty()) {
-        try (ObjectReader reader = ctx.getInserter().newReader()) {
-          // Validation of refs has to take place here and not at the beginning
-          // executeRefUpdates. Otherwise failing validation in a second BatchUpdate object will
-          // happen *after* first object's executeRefUpdates has finished, hence after first repo's
-          // refs have been updated, which is too late.
-          onSubmitValidators.validate(
-              project, new ReadOnlyRepository(getRepository()), reader, commands.getCommands());
-        }
+      if (onSubmitValidators != null && !getRefUpdates().isEmpty()) {
+        // Validation of refs has to take place here and not at the beginning of executeRefUpdates.
+        // Otherwise, failing validation in a second BatchUpdate object will happen *after* the
+        // first update's executeRefUpdates has finished, hence after first repo's refs have been
+        // updated, which is too late.
+        onSubmitValidators.validate(
+            project, ctx.getRevWalk().getObjectReader(), repoView.getCommands());
       }
 
-      if (inserter != null) {
+      if (repoView != null) {
         logDebug("Flushing inserter");
-        inserter.flush();
+        repoView.getInserter().flush();
       } else {
         logDebug("No objects to flush");
       }
@@ -464,20 +404,25 @@
   }
 
   private void executeRefUpdates(boolean dryrun) throws IOException, RestApiException {
-    if (commands == null || commands.isEmpty()) {
+    if (getRefUpdates().isEmpty()) {
       logDebug("No ref updates to execute");
       return;
     }
     // May not be opened if the caller added ref updates but no new objects.
+    // TODO(dborowitz): Really?
     initRepository();
-    batchRefUpdate = repo.getRefDatabase().newBatchUpdate();
-    commands.addTo(batchRefUpdate);
+    batchRefUpdate = repoView.getRepository().getRefDatabase().newBatchUpdate();
+    repoView.getCommands().addTo(batchRefUpdate);
     logDebug("Executing batch of {} ref updates", batchRefUpdate.getCommands().size());
     if (dryrun) {
       return;
     }
 
-    batchRefUpdate.execute(revWalk, NullProgressMonitor.INSTANCE);
+    // Force BatchRefUpdate to read newly referenced objects using a new RevWalk, rather than one
+    // that might have access to unflushed objects.
+    try (RevWalk updateRw = new RevWalk(repoView.getRepository())) {
+      batchRefUpdate.execute(updateRw, NullProgressMonitor.INSTANCE);
+    }
     boolean ok = true;
     for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
       if (cmd.getResult() != ReceiveCommand.Result.OK) {
@@ -502,11 +447,11 @@
 
       tasks = new ArrayList<>(ops.keySet().size());
       try {
-        if (notesMigration.commitChangeWrites() && repo != null) {
+        if (notesMigration.commitChangeWrites() && repoView != null) {
           // A NoteDb change may have been rebuilt since the repo was originally
           // opened, so make sure we see that.
           logDebug("Preemptively scanning for repo changes");
-          repo.scanForRepoChanges();
+          repoView.getRepository().scanForRepoChanges();
         }
         if (!ops.isEmpty() && notesMigration.failChangeWrites()) {
           // Fail fast before attempting any writes if changes are read-only, as
@@ -574,9 +519,10 @@
     // updates on the change repo first.
     logDebug("Executing NoteDb updates for {} changes", tasks.size());
     try {
-      BatchRefUpdate changeRefUpdate = getRepository().getRefDatabase().newBatchUpdate();
+      initRepository();
+      BatchRefUpdate changeRefUpdate = repoView.getRepository().getRefDatabase().newBatchUpdate();
       boolean hasAllUsersCommands = false;
-      try (ObjectInserter ins = getRepository().newObjectInserter()) {
+      try (ObjectInserter ins = repoView.getRepository().newObjectInserter()) {
         int objs = 0;
         for (ChangeTask task : tasks) {
           if (task.noteDbResult == null) {
@@ -683,7 +629,8 @@
     public Void call() throws Exception {
       taskId = id.toString() + "-" + Thread.currentThread().getId();
       if (Thread.currentThread() == mainThread) {
-        Repository repo = getRepository();
+        initRepository();
+        Repository repo = repoView.getRepository();
         try (RevWalk rw = new RevWalk(repo)) {
           call(ReviewDbBatchUpdate.this.db, repo, rw);
         }
@@ -842,7 +789,10 @@
           updateManagerFactory
               .create(ctx.getProject())
               .setChangeRepo(
-                  ctx.getRepository(), ctx.getRevWalk(), null, new ChainedReceiveCommands(repo));
+                  ctx.threadLocalRepo,
+                  ctx.threadLocalRevWalk,
+                  null,
+                  new ChainedReceiveCommands(ctx.threadLocalRepo));
       if (ctx.getUser().isIdentifiedUser()) {
         updateManager.setRefLogIdent(
             ctx.getUser().asIdentifiedUser().newRefLogIdent(ctx.getWhen(), tz));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/UnfusedNoteDbBatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/UnfusedNoteDbBatchUpdate.java
new file mode 100644
index 0000000..2f7de46
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/UnfusedNoteDbBatchUpdate.java
@@ -0,0 +1,458 @@
+// Copyright (C) 2017 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.update;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Maps;
+import com.google.common.util.concurrent.CheckedFuture;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.util.RequestId;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.TimeZone;
+import java.util.TreeMap;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/**
+ * {@link BatchUpdate} implementation that only supports NoteDb.
+ *
+ * <p>Used when {@code noteDb.changes.disableReviewDb=true}, at which point ReviewDb is not
+ * consulted during updates.
+ */
+class UnfusedNoteDbBatchUpdate extends BatchUpdate {
+  interface AssistedFactory {
+    UnfusedNoteDbBatchUpdate create(
+        ReviewDb db, Project.NameKey project, CurrentUser user, Timestamp when);
+  }
+
+  static void execute(
+      ImmutableList<UnfusedNoteDbBatchUpdate> updates,
+      BatchUpdateListener listener,
+      @Nullable RequestId requestId,
+      boolean dryrun)
+      throws UpdateException, RestApiException {
+    if (updates.isEmpty()) {
+      return;
+    }
+    setRequestIds(updates, requestId);
+
+    try {
+      Order order = getOrder(updates, listener);
+      // TODO(dborowitz): Fuse implementations to use a single BatchRefUpdate between phases. Note
+      // that we may still need to respect the order, since op implementations may make assumptions
+      // about the order in which their methods are called.
+      switch (order) {
+        case REPO_BEFORE_DB:
+          for (UnfusedNoteDbBatchUpdate u : updates) {
+            u.executeUpdateRepo();
+          }
+          listener.afterUpdateRepos();
+          for (UnfusedNoteDbBatchUpdate u : updates) {
+            u.executeRefUpdates(dryrun);
+          }
+          listener.afterUpdateRefs();
+          for (UnfusedNoteDbBatchUpdate u : updates) {
+            u.reindexChanges(u.executeChangeOps(dryrun), dryrun);
+          }
+          listener.afterUpdateChanges();
+          break;
+        case DB_BEFORE_REPO:
+          for (UnfusedNoteDbBatchUpdate u : updates) {
+            u.reindexChanges(u.executeChangeOps(dryrun), dryrun);
+          }
+          for (UnfusedNoteDbBatchUpdate u : updates) {
+            u.executeUpdateRepo();
+          }
+          for (UnfusedNoteDbBatchUpdate u : updates) {
+            u.executeRefUpdates(dryrun);
+          }
+          break;
+        default:
+          throw new IllegalStateException("invalid execution order: " + order);
+      }
+
+      ChangeIndexer.allAsList(
+              updates.stream().flatMap(u -> u.indexFutures.stream()).collect(toList()))
+          .get();
+
+      // Fire ref update events only after all mutations are finished, since callers may assume a
+      // patch set ref being created means the change was created, or a branch advancing meaning
+      // some changes were closed.
+      updates
+          .stream()
+          .filter(u -> u.batchRefUpdate != null)
+          .forEach(
+              u -> u.gitRefUpdated.fire(u.project, u.batchRefUpdate, u.getAccount().orElse(null)));
+
+      if (!dryrun) {
+        for (UnfusedNoteDbBatchUpdate u : updates) {
+          u.executePostOps();
+        }
+      }
+    } catch (Exception e) {
+      wrapAndThrowException(e);
+    }
+  }
+
+  class ContextImpl implements Context {
+    @Override
+    public RepoView getRepoView() throws IOException {
+      return UnfusedNoteDbBatchUpdate.this.getRepoView();
+    }
+
+    @Override
+    public RevWalk getRevWalk() throws IOException {
+      return getRepoView().getRevWalk();
+    }
+
+    @Override
+    public Project.NameKey getProject() {
+      return project;
+    }
+
+    @Override
+    public Timestamp getWhen() {
+      return when;
+    }
+
+    @Override
+    public TimeZone getTimeZone() {
+      return tz;
+    }
+
+    @Override
+    public ReviewDb getDb() {
+      return db;
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return user;
+    }
+
+    @Override
+    public Order getOrder() {
+      return order;
+    }
+  }
+
+  private class RepoContextImpl extends ContextImpl implements RepoContext {
+    @Override
+    public ObjectInserter getInserter() throws IOException {
+      return getRepoView().getInserterWrapper();
+    }
+
+    @Override
+    public void addRefUpdate(ReceiveCommand cmd) throws IOException {
+      getRepoView().getCommands().add(cmd);
+    }
+  }
+
+  private class ChangeContextImpl extends ContextImpl implements ChangeContext {
+    private final ChangeControl ctl;
+    private final Map<PatchSet.Id, ChangeUpdate> updates;
+
+    private boolean deleted;
+
+    protected ChangeContextImpl(ChangeControl ctl) {
+      this.ctl = checkNotNull(ctl);
+      updates = new TreeMap<>(comparing(PatchSet.Id::get));
+    }
+
+    @Override
+    public ChangeUpdate getUpdate(PatchSet.Id psId) {
+      ChangeUpdate u = updates.get(psId);
+      if (u == null) {
+        u = changeUpdateFactory.create(ctl, when);
+        if (newChanges.containsKey(ctl.getId())) {
+          u.setAllowWriteToNewRef(true);
+        }
+        u.setPatchSetId(psId);
+        updates.put(psId, u);
+      }
+      return u;
+    }
+
+    @Override
+    public ChangeControl getControl() {
+      return ctl;
+    }
+
+    @Override
+    public void dontBumpLastUpdatedOn() {
+      // Do nothing; NoteDb effectively updates timestamp if and only if a commit was written to the
+      // change meta ref.
+    }
+
+    @Override
+    public void deleteChange() {
+      deleted = true;
+    }
+  }
+
+  /** Per-change result status from {@link #executeChangeOps}. */
+  private enum ChangeResult {
+    SKIPPED,
+    UPSERTED,
+    DELETED;
+  }
+
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final ChangeControl.GenericFactory changeControlFactory;
+  private final ChangeUpdate.Factory changeUpdateFactory;
+  private final NoteDbUpdateManager.Factory updateManagerFactory;
+  private final ChangeIndexer indexer;
+  private final GitReferenceUpdated gitRefUpdated;
+  private final ReviewDb db;
+
+  private List<CheckedFuture<?, IOException>> indexFutures;
+
+  @Inject
+  UnfusedNoteDbBatchUpdate(
+      GitRepositoryManager repoManager,
+      @GerritPersonIdent PersonIdent serverIdent,
+      ChangeNotes.Factory changeNotesFactory,
+      ChangeControl.GenericFactory changeControlFactory,
+      ChangeUpdate.Factory changeUpdateFactory,
+      NoteDbUpdateManager.Factory updateManagerFactory,
+      ChangeIndexer indexer,
+      GitReferenceUpdated gitRefUpdated,
+      @Assisted ReviewDb db,
+      @Assisted Project.NameKey project,
+      @Assisted CurrentUser user,
+      @Assisted Timestamp when) {
+    super(repoManager, serverIdent, project, user, when);
+    checkArgument(!db.changesTablesEnabled(), "expected Change tables to be disabled on %s", db);
+    this.changeNotesFactory = changeNotesFactory;
+    this.changeControlFactory = changeControlFactory;
+    this.changeUpdateFactory = changeUpdateFactory;
+    this.updateManagerFactory = updateManagerFactory;
+    this.indexer = indexer;
+    this.gitRefUpdated = gitRefUpdated;
+    this.db = db;
+    this.indexFutures = new ArrayList<>();
+  }
+
+  @Override
+  public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
+    execute(ImmutableList.of(this), listener, requestId, false);
+  }
+
+  @Override
+  protected Context newContext() {
+    return new ContextImpl();
+  }
+
+  private void executeUpdateRepo() throws UpdateException, RestApiException {
+    try {
+      logDebug("Executing updateRepo on {} ops", ops.size());
+      RepoContextImpl ctx = new RepoContextImpl();
+      for (BatchUpdateOp op : ops.values()) {
+        op.updateRepo(ctx);
+      }
+
+      logDebug("Executing updateRepo on {} RepoOnlyOps", repoOnlyOps.size());
+      for (RepoOnlyOp op : repoOnlyOps) {
+        op.updateRepo(ctx);
+      }
+
+      if (onSubmitValidators != null && !getRefUpdates().isEmpty()) {
+        // Validation of refs has to take place here and not at the beginning of executeRefUpdates.
+        // Otherwise, failing validation in a second BatchUpdate object will happen *after* the
+        // first update's executeRefUpdates has finished, hence after first repo's refs have been
+        // updated, which is too late.
+        onSubmitValidators.validate(
+            project, ctx.getRevWalk().getObjectReader(), repoView.getCommands());
+      }
+
+      // TODO(dborowitz): Don't flush when fusing phases.
+      if (repoView != null) {
+        logDebug("Flushing inserter");
+        repoView.getInserter().flush();
+      } else {
+        logDebug("No objects to flush");
+      }
+    } catch (Exception e) {
+      Throwables.throwIfInstanceOf(e, RestApiException.class);
+      throw new UpdateException(e);
+    }
+  }
+
+  // TODO(dborowitz): Don't execute non-change ref updates separately when fusing phases.
+  private void executeRefUpdates(boolean dryrun) throws IOException, RestApiException {
+    if (getRefUpdates().isEmpty()) {
+      logDebug("No ref updates to execute");
+      return;
+    }
+    // May not be opened if the caller added ref updates but no new objects.
+    initRepository();
+    batchRefUpdate = repoView.getRepository().getRefDatabase().newBatchUpdate();
+    repoView.getCommands().addTo(batchRefUpdate);
+    logDebug("Executing batch of {} ref updates", batchRefUpdate.getCommands().size());
+    if (dryrun) {
+      return;
+    }
+
+    // Force BatchRefUpdate to read newly referenced objects using a new RevWalk, rather than one
+    // that might have access to unflushed objects.
+    try (RevWalk updateRw = new RevWalk(repoView.getRepository())) {
+      batchRefUpdate.execute(updateRw, NullProgressMonitor.INSTANCE);
+    }
+    boolean ok = true;
+    for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
+      if (cmd.getResult() != ReceiveCommand.Result.OK) {
+        ok = false;
+        break;
+      }
+    }
+    if (!ok) {
+      throw new RestApiException("BatchRefUpdate failed: " + batchRefUpdate);
+    }
+  }
+
+  private Map<Change.Id, ChangeResult> executeChangeOps(boolean dryrun) throws Exception {
+    logDebug("Executing change ops");
+    Map<Change.Id, ChangeResult> result =
+        Maps.newLinkedHashMapWithExpectedSize(ops.keySet().size());
+    initRepository();
+    Repository repo = repoView.getRepository();
+    // TODO(dborowitz): Teach NoteDbUpdateManager to allow reusing the same inserter and batch ref
+    // update as in executeUpdateRepo.
+    try (ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader);
+        NoteDbUpdateManager updateManager =
+            updateManagerFactory
+                .create(project)
+                .setChangeRepo(repo, rw, ins, new ChainedReceiveCommands(repo))) {
+      if (user.isIdentifiedUser()) {
+        updateManager.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, tz));
+      }
+      for (Map.Entry<Change.Id, Collection<BatchUpdateOp>> e : ops.asMap().entrySet()) {
+        Change.Id id = e.getKey();
+        ChangeContextImpl ctx = newChangeContext(id);
+        boolean dirty = false;
+        logDebug("Applying {} ops for change {}", e.getValue().size(), id);
+        for (BatchUpdateOp op : e.getValue()) {
+          dirty |= op.updateChange(ctx);
+        }
+        if (!dirty) {
+          logDebug("No ops reported dirty, short-circuiting");
+          result.put(id, ChangeResult.SKIPPED);
+          continue;
+        }
+        for (ChangeUpdate u : ctx.updates.values()) {
+          updateManager.add(u);
+        }
+        if (ctx.deleted) {
+          logDebug("Change {} was deleted", id);
+          updateManager.deleteChange(id);
+          result.put(id, ChangeResult.DELETED);
+        } else {
+          result.put(id, ChangeResult.UPSERTED);
+        }
+      }
+
+      if (!dryrun) {
+        logDebug("Executing NoteDb updates");
+        updateManager.execute();
+      }
+    }
+    return result;
+  }
+
+  private ChangeContextImpl newChangeContext(Change.Id id) throws OrmException {
+    logDebug("Opening change {} for update", id);
+    Change c = newChanges.get(id);
+    boolean isNew = c != null;
+    if (!isNew) {
+      // Pass a synthetic change into ChangeNotes.Factory, which will take care of checking for
+      // existence and populating columns from the parsed notes state.
+      // TODO(dborowitz): This dance made more sense when using Reviewdb; consider a nicer way.
+      c = ChangeNotes.Factory.newNoteDbOnlyChange(project, id);
+    } else {
+      logDebug("Change {} is new", id);
+    }
+    ChangeNotes notes = changeNotesFactory.createForBatchUpdate(c, !isNew);
+    ChangeControl ctl = changeControlFactory.controlFor(notes, user);
+    return new ChangeContextImpl(ctl);
+  }
+
+  private void reindexChanges(Map<Change.Id, ChangeResult> updateResults, boolean dryrun) {
+    if (dryrun) {
+      return;
+    }
+    logDebug("Reindexing {} changes", updateResults.size());
+    for (Map.Entry<Change.Id, ChangeResult> e : updateResults.entrySet()) {
+      Change.Id id = e.getKey();
+      switch (e.getValue()) {
+        case UPSERTED:
+          indexFutures.add(indexer.indexAsync(project, id));
+          break;
+        case DELETED:
+          indexFutures.add(indexer.deleteAsync(id));
+          break;
+        case SKIPPED:
+          break;
+        default:
+          throw new IllegalStateException("unexpected result: " + e.getValue());
+      }
+    }
+  }
+
+  private void executePostOps() throws Exception {
+    ContextImpl ctx = new ContextImpl();
+    for (BatchUpdateOp op : ops.values()) {
+      op.postUpdate(ctx);
+    }
+
+    for (RepoOnlyOp op : repoOnlyOps) {
+      op.postUpdate(ctx);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java
index 4d66809e..a1333c6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java
@@ -172,52 +172,46 @@
   /** @see #wrap(Callable) */
   protected abstract <T> Callable<T> wrapImpl(Callable<T> callable);
 
-  protected <T> Callable<T> context(final RequestContext context, final Callable<T> callable) {
-    return new Callable<T>() {
-      @Override
-      public T call() throws Exception {
-        RequestContext old =
-            local.setContext(
-                new RequestContext() {
-                  @Override
-                  public CurrentUser getUser() {
-                    return context.getUser();
-                  }
+  protected <T> Callable<T> context(RequestContext context, Callable<T> callable) {
+    return () -> {
+      RequestContext old =
+          local.setContext(
+              new RequestContext() {
+                @Override
+                public CurrentUser getUser() {
+                  return context.getUser();
+                }
 
-                  @Override
-                  public Provider<ReviewDb> getReviewDbProvider() {
-                    return dbProviderProvider.get();
-                  }
-                });
-        try {
-          return callable.call();
-        } finally {
-          local.setContext(old);
-        }
+                @Override
+                public Provider<ReviewDb> getReviewDbProvider() {
+                  return dbProviderProvider.get();
+                }
+              });
+      try {
+        return callable.call();
+      } finally {
+        local.setContext(old);
       }
     };
   }
 
-  protected <T> Callable<T> cleanup(final Callable<T> callable) {
-    return new Callable<T>() {
-      @Override
-      public T call() throws Exception {
-        RequestCleanup cleanup =
-            scope
-                .scope(
-                    Key.get(RequestCleanup.class),
-                    new Provider<RequestCleanup>() {
-                      @Override
-                      public RequestCleanup get() {
-                        return new RequestCleanup();
-                      }
-                    })
-                .get();
-        try {
-          return callable.call();
-        } finally {
-          cleanup.run();
-        }
+  protected <T> Callable<T> cleanup(Callable<T> callable) {
+    return () -> {
+      RequestCleanup cleanup =
+          scope
+              .scope(
+                  Key.get(RequestCleanup.class),
+                  new Provider<RequestCleanup>() {
+                    @Override
+                    public RequestCleanup get() {
+                      return new RequestCleanup();
+                    }
+                  })
+              .get();
+      try {
+        return callable.call();
+      } finally {
+        cleanup.run();
       }
     };
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/ThreadLocalRequestScopePropagator.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/ThreadLocalRequestScopePropagator.java
index 4b27208..90fb994 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/ThreadLocalRequestScopePropagator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/ThreadLocalRequestScopePropagator.java
@@ -41,21 +41,18 @@
 
   /** @see RequestScopePropagator#wrap(Callable) */
   @Override
-  protected final <T> Callable<T> wrapImpl(final Callable<T> callable) {
-    final C ctx = continuingContext(requireContext());
-    return new Callable<T>() {
-      @Override
-      public T call() throws Exception {
-        C old = threadLocal.get();
-        threadLocal.set(ctx);
-        try {
-          return callable.call();
-        } finally {
-          if (old != null) {
-            threadLocal.set(old);
-          } else {
-            threadLocal.remove();
-          }
+  protected final <T> Callable<T> wrapImpl(Callable<T> callable) {
+    C ctx = continuingContext(requireContext());
+    return () -> {
+      C old = threadLocal.get();
+      threadLocal.set(ctx);
+      try {
+        return callable.call();
+      } finally {
+        if (old != null) {
+          threadLocal.set(old);
+        } else {
+          threadLocal.remove();
         }
       }
     };
diff --git a/gerrit-server/src/main/java/gerrit/PRED__check_user_label_3.java b/gerrit-server/src/main/java/gerrit/PRED__check_user_label_3.java
new file mode 100644
index 0000000..e84b3ac
--- /dev/null
+++ b/gerrit-server/src/main/java/gerrit/PRED__check_user_label_3.java
@@ -0,0 +1,107 @@
+// Copyright (C) 2017 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 gerrit;
+
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.rules.StoredValues;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
+import com.googlecode.prolog_cafe.exceptions.JavaException;
+import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.exceptions.SystemException;
+import com.googlecode.prolog_cafe.lang.IntegerTerm;
+import com.googlecode.prolog_cafe.lang.JavaObjectTerm;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+import com.googlecode.prolog_cafe.lang.VariableTerm;
+
+/**
+ * Checks user can set label to val.
+ *
+ * <pre>
+ *   '_check_user_label'(+Label, +CurrentUser, +Val)
+ * </pre>
+ */
+class PRED__check_user_label_3 extends Predicate.P3 {
+  PRED__check_user_label_3(Term a1, Term a2, Term a3, Operation n) {
+    arg1 = a1;
+    arg2 = a2;
+    arg3 = a3;
+    cont = n;
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.setB0();
+    Term a1 = arg1.dereference();
+    Term a2 = arg2.dereference();
+    Term a3 = arg3.dereference();
+
+    if (a1 instanceof VariableTerm) {
+      throw new PInstantiationException(this, 1);
+    }
+    if (!(a1 instanceof SymbolTerm)) {
+      throw new IllegalTypeException(this, 1, "atom", a1);
+    }
+    String label = a1.name();
+
+    if (a2 instanceof VariableTerm) {
+      throw new PInstantiationException(this, 2);
+    }
+    if (!(a2 instanceof JavaObjectTerm) || !a2.convertible(CurrentUser.class)) {
+      throw new IllegalTypeException(this, 2, "CurrentUser)", a2);
+    }
+    CurrentUser user = (CurrentUser) ((JavaObjectTerm) a2).object();
+
+    if (a3 instanceof VariableTerm) {
+      throw new PInstantiationException(this, 3);
+    }
+    if (!(a3 instanceof IntegerTerm)) {
+      throw new IllegalTypeException(this, 3, "integer", a3);
+    }
+    short val = (short) ((IntegerTerm) a3).intValue();
+
+    try {
+      ChangeData cd = StoredValues.CHANGE_DATA.get(engine);
+      LabelType type = cd.getLabelTypes().byLabel(label);
+      if (type == null) {
+        return engine.fail();
+      }
+      StoredValues.PERMISSION_BACKEND
+          .get(engine)
+          .user(user)
+          .change(cd)
+          .check(new LabelPermission.WithValue(type, val));
+      return cont;
+    } catch (OrmException err) {
+      throw new JavaException(this, 1, err);
+    } catch (AuthException err) {
+      return engine.fail();
+    } catch (PermissionBackendException err) {
+      SystemException se = new SystemException(err.getMessage());
+      se.initCause(err);
+      throw se;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/gerrit/PRED__load_commit_labels_1.java b/gerrit-server/src/main/java/gerrit/PRED__load_commit_labels_1.java
index 8b5a33d..5a3d656 100644
--- a/gerrit-server/src/main/java/gerrit/PRED__load_commit_labels_1.java
+++ b/gerrit-server/src/main/java/gerrit/PRED__load_commit_labels_1.java
@@ -38,7 +38,7 @@
     Term listHead = Prolog.Nil;
     try {
       ChangeData cd = StoredValues.CHANGE_DATA.get(engine);
-      LabelTypes types = StoredValues.CHANGE_CONTROL.get(engine).getLabelTypes();
+      LabelTypes types = cd.getLabelTypes();
 
       for (PatchSetApproval a : cd.currentApprovals()) {
         LabelType t = types.byLabel(a.getLabelId());
diff --git a/gerrit-server/src/main/java/gerrit/PRED__user_label_range_4.java b/gerrit-server/src/main/java/gerrit/PRED__user_label_range_4.java
index d06664e..f7f39da 100644
--- a/gerrit-server/src/main/java/gerrit/PRED__user_label_range_4.java
+++ b/gerrit-server/src/main/java/gerrit/PRED__user_label_range_4.java
@@ -14,14 +14,18 @@
 
 package gerrit;
 
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
+import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.rules.StoredValues;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
 import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
+import com.googlecode.prolog_cafe.exceptions.JavaException;
 import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.exceptions.SystemException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.JavaObjectTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
@@ -30,12 +34,13 @@
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
 import com.googlecode.prolog_cafe.lang.VariableTerm;
+import java.util.Set;
 
 /**
  * Resolves the valid range for a label on a CurrentUser.
  *
  * <pre>
- *   '$user_label_range'(+Label, +CurrentUser, -Min, -Max)
+ *   '_user_label_range'(+Label, +CurrentUser, -Min, -Max)
  * </pre>
  */
 class PRED__user_label_range_4 extends Predicate.P4 {
@@ -71,20 +76,34 @@
     }
     CurrentUser user = (CurrentUser) ((JavaObjectTerm) a2).object();
 
-    ChangeControl ctl = StoredValues.CHANGE_CONTROL.get(engine).forUser(user);
-    PermissionRange range = ctl.getRange(Permission.LABEL + label);
-    if (range == null) {
+    Set<LabelPermission.WithValue> can;
+    try {
+      ChangeData cd = StoredValues.CHANGE_DATA.get(engine);
+      LabelType type = cd.getLabelTypes().byLabel(label);
+      if (type == null) {
+        return engine.fail();
+      }
+      can = StoredValues.PERMISSION_BACKEND.get(engine).user(user).change(cd).test(type);
+    } catch (OrmException err) {
+      throw new JavaException(this, 1, err);
+    } catch (PermissionBackendException err) {
+      SystemException se = new SystemException(err.getMessage());
+      se.initCause(err);
+      throw se;
+    }
+
+    int min = 0;
+    int max = 0;
+    for (LabelPermission.WithValue v : can) {
+      min = Math.min(min, v.value());
+      max = Math.max(max, v.value());
+    }
+
+    if (!a3.unify(new IntegerTerm(min), engine.trail)) {
       return engine.fail();
     }
 
-    IntegerTerm min = new IntegerTerm(range.getMin());
-    IntegerTerm max = new IntegerTerm(range.getMax());
-
-    if (!a3.unify(min, engine.trail)) {
-      return engine.fail();
-    }
-
-    if (!a4.unify(max, engine.trail)) {
+    if (!a4.unify(new IntegerTerm(max), engine.trail)) {
       return engine.fail();
     }
 
diff --git a/gerrit-server/src/main/java/gerrit/PRED_get_legacy_label_types_1.java b/gerrit-server/src/main/java/gerrit/PRED_get_legacy_label_types_1.java
index ea3fb17..9bfcc61 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_get_legacy_label_types_1.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_get_legacy_label_types_1.java
@@ -17,6 +17,8 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.rules.StoredValues;
+import com.google.gwtorm.server.OrmException;
+import com.googlecode.prolog_cafe.exceptions.JavaException;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.ListTerm;
@@ -51,7 +53,12 @@
   public Operation exec(Prolog engine) throws PrologException {
     engine.setB0();
     Term a1 = arg1.dereference();
-    List<LabelType> list = StoredValues.CHANGE_CONTROL.get(engine).getLabelTypes().getLabelTypes();
+    List<LabelType> list;
+    try {
+      list = StoredValues.CHANGE_DATA.get(engine).getLabelTypes().getLabelTypes();
+    } catch (OrmException err) {
+      throw new JavaException(this, 1, err);
+    }
     Term head = Prolog.Nil;
     for (int idx = list.size() - 1; 0 <= idx; idx--) {
       head = new ListTerm(export(list.get(idx)), head);
diff --git a/gerrit-server/src/main/prolog/gerrit_common.pl b/gerrit-server/src/main/prolog/gerrit_common.pl
index 59c926f..4671e0d 100644
--- a/gerrit-server/src/main/prolog/gerrit_common.pl
+++ b/gerrit-server/src/main/prolog/gerrit_common.pl
@@ -92,6 +92,27 @@
 
 %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
 %%
+%% check_user_label/3:
+%%
+%%   Check Who can set Label to Val.
+%%
+check_user_label(Label, Who, Val) :-
+  hash_get(commit_labels, '$fast_range', true), !,
+  atom(Label),
+  assume_range_from_label(Label, Who, Min, Max),
+  Min @=< Val, Val @=< Max.
+check_user_label(Label, Who, Val) :-
+  Who = user(_), !,
+  atom(Label),
+  current_user(Who, User),
+  '_check_user_label'(Label, User, Val).
+check_user_label(Label, test_user(Name), Val) :-
+  clause(user:test_grant(Label, test_user(Name), range(Min, Max)), _),
+  Min @=< Val, Val @=< Max
+  .
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
 %% user_label_range/4:
 %%
 %%   Lookup the range allowed to be used.
@@ -319,8 +340,7 @@
 %%
 check_label_range_permission(Label, ExpValue, ok(Who)) :-
   commit_label(label(Label, ExpValue), Who),
-  user_label_range(Label, Who, Min, Max),
-  Min @=< ExpValue, ExpValue @=< Max
+  check_user_label(Label, Who, ExpValue)
   .
 %TODO Uncomment this clause when group suggesting is possible.
 %check_label_range_permission(Label, ExpValue, ask(Group)) :-
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/change/ChangeMessages.properties b/gerrit-server/src/main/resources/com/google/gerrit/server/change/ChangeMessages.properties
index f34c992..b2bcde3 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/change/ChangeMessages.properties
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/change/ChangeMessages.properties
@@ -1,9 +1,13 @@
 # Changes to this file should also be made in
 # gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties
 revertChangeDefaultMessage = Revert \"{0}\"\n\nThis reverts commit {1}.
+
+reviewerCantSeeChange = {0} does not have permission to see this change
+reviewerInactive = {0} identifies an inactive account
+reviewerInvalid = {0} is not a valid user identifier
 reviewerNotFoundUser = {0} does not identify a registered user
 reviewerNotFoundUserOrGroup = {0} does not identify a registered user or group
 
-groupIsNotAllowed =  The group {0} cannot be added as reviewer.
+groupIsNotAllowed = The group {0} cannot be added as reviewer.
 groupHasTooManyMembers = The group {0} has too many members to add them all as reviewers.
 groupManyMembersConfirmation = The group {0} has {1} members. Do you want to add them all as reviewers?
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
index 59790dc..9adff05 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
@@ -38,7 +38,17 @@
 
   {let $ulStyle kind="css"}
     list-style: none;
-    padding-left: 20px;
+    padding: 0;
+  {/let}
+
+  {let $fileLiStyle kind="css"}
+    margin: 0;
+    padding: 0;
+  {/let}
+
+  {let $commentLiStyle kind="css"}
+    margin: 0;
+    padding: 0 0 0 16px;
   {/let}
 
   {let $voteStyle kind="css"}
@@ -104,14 +114,14 @@
 
   <ul style="{$ulStyle}">
     {foreach $group in $commentFiles}
-      <li>
+      <li style="{$fileLiStyle}">
         <p>
           <a href="{$group.link}">{$group.title}:</a>
         </p>
 
         <ul style="{$ulStyle}">
           {foreach $comment in $group.comments}
-            <li>
+            <li style="{$commentLiStyle}">
               {if $comment.isRobotComment}
                 <p style="{$commentHeaderStyle}">
                   Robot Comment from{sp}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy
index fa2b44d..927601b 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy
@@ -17,6 +17,7 @@
 {namespace com.google.gerrit.server.mail.template}
 
 /**
+ * @param diffLines
  * @param email
  * @param fromName
  */
@@ -36,6 +37,6 @@
   {call .Pre}{param content: $email.changeDetail /}{/call}
 
   {if $email.includeDiff}
-    {call .Pre}{param content: $email.unifiedDiff /}{/call}
+    {call .UnifiedDiff}{param diffLines: $diffLines /}{/call}
   {/if}
-{/template}
\ No newline at end of file
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
index 559bb26..8026666 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
@@ -17,6 +17,7 @@
 {namespace com.google.gerrit.server.mail.template}
 
 /**
+ * @param diffLines
  * @param email
  * @param fromName
  * @param ownerName
@@ -55,6 +56,6 @@
   {/if}
 
   {if $email.includeDiff}
-    {call .Pre}{param content: $email.unifiedDiff /}{/call}
+    {call .UnifiedDiff}{param diffLines: $diffLines /}{/call}
   {/if}
 {/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
index 93353d7..556191d 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
@@ -86,3 +86,36 @@
     {/if}
   {/foreach}
 {/template}
+
+/**
+ * @param diffLines
+ */
+{template .UnifiedDiff private="true" autoescape="strict" kind="html"}
+  {let $addStyle kind="css"}
+    color: green;
+  {/let}
+
+  {let $removeStyle kind="css"}
+    color: red;
+  {/let}
+
+  {let $preStyle kind="css"}
+    font-family: monospace,monospace; // Use this to avoid browsers scaling down
+                                      // monospace text.
+    white-space: pre-wrap;
+  {/let}
+
+  <pre style="{$preStyle}">
+    {foreach $line in $diffLines}
+      {if $line.type == 'add'}
+        <span style="{$addStyle}">
+      {elseif $line.type == 'remove'}
+        <span style="{$removeStyle}">
+      {else}
+        <span>
+      {/if}
+        {$line.text}
+      </span><br>
+    {/foreach}
+  </pre>
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
index bbf16d6..31cfbd6 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
@@ -17,6 +17,7 @@
 {namespace com.google.gerrit.server.mail.template}
 
 /**
+ * @param diffLines
  * @param email
  * @param fromName
  * @param patchSet
@@ -44,6 +45,6 @@
   {/if}
 
   {if $email.includeDiff}
-    {call .Pre}{param content: $email.unifiedDiff /}{/call}
+    {call .UnifiedDiff}{param diffLines: $diffLines /}{/call}
   {/if}
 {/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties b/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties
index 5a937b6..996e8a4 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -26,6 +26,7 @@
 cob = text/x-cobol
 coffee = text/x-coffeescript
 conf = text/plain
+config = text/x-ini
 cpy = text/x-cobol
 cr = text/x-crystal
 cs = text/x-csharp
@@ -156,7 +157,6 @@
 pm = text/x-perl
 pp = text/x-puppet
 pro = text/x-idl
-project.config = text/x-ini
 properties = text/x-ini
 proto = text/x-protobuf
 protobuf = text/x-protobuf
diff --git a/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java b/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
index 9974bc6..91b01f6 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
@@ -126,19 +126,16 @@
 
   @Test
   public void callbackMetric0() {
-    final CallbackMetric0<Long> cntr =
+    CallbackMetric0<Long> cntr =
         metrics.newCallbackMetric(
             "test/count", Long.class, new Description("simple test").setCumulative());
 
-    final AtomicInteger invocations = new AtomicInteger(0);
+    AtomicInteger invocations = new AtomicInteger(0);
     metrics.newTrigger(
         cntr,
-        new Runnable() {
-          @Override
-          public void run() {
-            invocations.getAndIncrement();
-            cntr.set(42L);
-          }
+        () -> {
+          invocations.getAndIncrement();
+          cntr.set(42L);
         });
 
     // Triggers run immediately with DropWizard binding.
diff --git a/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java b/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java
index fa4a951..40596e8 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java
@@ -17,8 +17,8 @@
 import static org.easymock.EasyMock.expect;
 
 import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.Util;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.AbstractModule;
 import com.googlecode.prolog_cafe.exceptions.CompileException;
 import com.googlecode.prolog_cafe.exceptions.ReductionLimitException;
@@ -47,27 +47,28 @@
             cfg.setInt("rules", null, "reductionLimit", 1300);
             cfg.setInt("rules", null, "compileReductionLimit", (int) 1e6);
             bind(PrologEnvironment.Args.class)
-                .toInstance(new PrologEnvironment.Args(null, null, null, null, null, null, cfg));
+                .toInstance(
+                    new PrologEnvironment.Args(null, null, null, null, null, null, null, cfg));
           }
         });
   }
 
   @Override
-  protected void setUpEnvironment(PrologEnvironment env) {
+  protected void setUpEnvironment(PrologEnvironment env) throws Exception {
     LabelTypes labelTypes = new LabelTypes(Arrays.asList(Util.codeReview(), Util.verified()));
-    ChangeControl ctl = EasyMock.createMock(ChangeControl.class);
-    expect(ctl.getLabelTypes()).andStubReturn(labelTypes);
-    EasyMock.replay(ctl);
-    env.set(StoredValues.CHANGE_CONTROL, ctl);
+    ChangeData cd = EasyMock.createMock(ChangeData.class);
+    expect(cd.getLabelTypes()).andStubReturn(labelTypes);
+    EasyMock.replay(cd);
+    env.set(StoredValues.CHANGE_DATA, cd);
   }
 
   @Test
-  public void gerritCommon() {
+  public void gerritCommon() throws Exception {
     runPrologBasedTests();
   }
 
   @Test
-  public void reductionLimit() throws CompileException {
+  public void reductionLimit() throws Exception {
     PrologEnvironment env = envFactory.create(machine);
     setUpEnvironment(env);
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java b/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java
index 6f6d189..7b2b388 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java
@@ -84,7 +84,7 @@
    *
    * @param env Prolog environment.
    */
-  protected void setUpEnvironment(PrologEnvironment env) {}
+  protected void setUpEnvironment(PrologEnvironment env) throws Exception {}
 
   private PrologMachineCopy newMachine() {
     BufferingPrologControl ctl = new BufferingPrologControl();
@@ -115,7 +115,7 @@
     return env.execute(Prolog.BUILTIN, "clause", head, new VariableTerm());
   }
 
-  public void runPrologBasedTests() {
+  public void runPrologBasedTests() throws Exception {
     int errors = 0;
     long start = TimeUtil.nowMs();
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java b/gerrit-server/src/test/java/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java
new file mode 100644
index 0000000..801b2b0
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2017 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.edit.tree;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.io.CharStreams;
+import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.common.truth.SubjectFactory;
+import com.google.common.truth.Truth;
+import com.google.gerrit.extensions.restapi.RawInput;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+
+public class ChangeFileContentModificationSubject
+    extends Subject<ChangeFileContentModificationSubject, ChangeFileContentModification> {
+
+  private static final SubjectFactory<
+          ChangeFileContentModificationSubject, ChangeFileContentModification>
+      MODIFICATION_SUBJECT_FACTORY =
+          new SubjectFactory<
+              ChangeFileContentModificationSubject, ChangeFileContentModification>() {
+            @Override
+            public ChangeFileContentModificationSubject getSubject(
+                FailureStrategy failureStrategy, ChangeFileContentModification modification) {
+              return new ChangeFileContentModificationSubject(failureStrategy, modification);
+            }
+          };
+
+  public static ChangeFileContentModificationSubject assertThat(
+      ChangeFileContentModification modification) {
+    return assertAbout(MODIFICATION_SUBJECT_FACTORY).that(modification);
+  }
+
+  private ChangeFileContentModificationSubject(
+      FailureStrategy failureStrategy, ChangeFileContentModification modification) {
+    super(failureStrategy, modification);
+  }
+
+  public StringSubject filePath() {
+    isNotNull();
+    return Truth.assertThat(actual().getFilePath()).named("filePath");
+  }
+
+  public StringSubject newContent() throws IOException {
+    isNotNull();
+    RawInput newContent = actual().getNewContent();
+    Truth.assertThat(newContent).named("newContent").isNotNull();
+    String contentString =
+        CharStreams.toString(
+            new InputStreamReader(newContent.getInputStream(), StandardCharsets.UTF_8));
+    return Truth.assertThat(contentString).named("newContent");
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/edit/tree/TreeModificationSubject.java b/gerrit-server/src/test/java/com/google/gerrit/server/edit/tree/TreeModificationSubject.java
new file mode 100644
index 0000000..ac4ebb8
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/edit/tree/TreeModificationSubject.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2017 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.edit.tree;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.Subject;
+import com.google.common.truth.SubjectFactory;
+import com.google.gerrit.truth.ListSubject;
+import java.util.List;
+
+public class TreeModificationSubject extends Subject<TreeModificationSubject, TreeModification> {
+
+  private static final SubjectFactory<TreeModificationSubject, TreeModification>
+      TREE_MODIFICATION_SUBJECT_FACTORY =
+          new SubjectFactory<TreeModificationSubject, TreeModification>() {
+            @Override
+            public TreeModificationSubject getSubject(
+                FailureStrategy failureStrategy, TreeModification treeModification) {
+              return new TreeModificationSubject(failureStrategy, treeModification);
+            }
+          };
+
+  public static TreeModificationSubject assertThat(TreeModification treeModification) {
+    return assertAbout(TREE_MODIFICATION_SUBJECT_FACTORY).that(treeModification);
+  }
+
+  public static ListSubject<TreeModificationSubject, TreeModification> assertThatList(
+      List<TreeModification> treeModifications) {
+    return ListSubject.assertThat(treeModifications, TreeModificationSubject::assertThat)
+        .named("treeModifications");
+  }
+
+  private TreeModificationSubject(
+      FailureStrategy failureStrategy, TreeModification treeModification) {
+    super(failureStrategy, treeModification);
+  }
+
+  public ChangeFileContentModificationSubject asChangeFileContentModification() {
+    isInstanceOf(ChangeFileContentModification.class);
+    return ChangeFileContentModificationSubject.assertThat(
+        (ChangeFileContentModification) actual());
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java
new file mode 100644
index 0000000..c1a65bb
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java
@@ -0,0 +1,333 @@
+// Copyright (C) 2017 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.fixes;
+
+import static com.google.gerrit.server.edit.tree.TreeModificationSubject.assertThatList;
+import static org.easymock.EasyMock.createMock;
+import static org.easymock.EasyMock.replay;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Comment.Range;
+import com.google.gerrit.reviewdb.client.FixReplacement;
+import com.google.gerrit.server.change.FileContentUtil;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import com.google.gerrit.server.project.ProjectState;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import org.easymock.EasyMock;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class FixReplacementInterpreterTest {
+
+  @Rule public ExpectedException expectedException = ExpectedException.none();
+
+  private final FileContentUtil fileContentUtil = createMock(FileContentUtil.class);
+  private final Repository repository = createMock(Repository.class);
+  private final ProjectState projectState = createMock(ProjectState.class);
+  private final ObjectId patchSetCommitId = createMock(ObjectId.class);
+  private final String filePath1 = "an/arbitrary/file.txt";
+  private final String filePath2 = "another/arbitrary/file.txt";
+
+  private FixReplacementInterpreter fixReplacementInterpreter;
+
+  @Before
+  public void setUp() {
+    fixReplacementInterpreter = new FixReplacementInterpreter(fileContentUtil);
+  }
+
+  @Test
+  public void noReplacementsResultInNoTreeModifications() throws Exception {
+    List<TreeModification> treeModifications = toTreeModifications();
+    assertThatList(treeModifications).isEmpty();
+  }
+
+  @Test
+  public void treeModificationsTargetCorrectFiles() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(1, 6, 3, 2), "Modified content");
+    FixReplacement fixReplacement2 =
+        new FixReplacement(filePath1, new Range(3, 5, 3, 5), "Second modification");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+    FixReplacement fixReplacement3 =
+        new FixReplacement(filePath2, new Range(2, 0, 3, 0), "Another modified content");
+    mockFileContent(filePath2, "1st line\n2nd line\n3rd line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications =
+        toTreeModifications(fixReplacement, fixReplacement3, fixReplacement2);
+    List<TreeModification> sortedTreeModifications = getSortedCopy(treeModifications);
+    assertThatList(sortedTreeModifications)
+        .element(0)
+        .asChangeFileContentModification()
+        .filePath()
+        .isEqualTo(filePath1);
+    assertThatList(sortedTreeModifications)
+        .element(0)
+        .asChangeFileContentModification()
+        .newContent()
+        .startsWith("First");
+    assertThatList(sortedTreeModifications)
+        .element(1)
+        .asChangeFileContentModification()
+        .filePath()
+        .isEqualTo(filePath2);
+    assertThatList(sortedTreeModifications)
+        .element(1)
+        .asChangeFileContentModification()
+        .newContent()
+        .startsWith("1st");
+  }
+
+  @Test
+  public void replacementsCanDeleteALine() throws Exception {
+    FixReplacement fixReplacement = new FixReplacement(filePath1, new Range(2, 0, 3, 0), "");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("First line\nThird line\n");
+  }
+
+  @Test
+  public void replacementsCanAddALine() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(2, 0, 2, 0), "A new line\n");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("First line\nA new line\nSecond line\nThird line\n");
+  }
+
+  @Test
+  public void replacementsMaySpanMultipleLines() throws Exception {
+    FixReplacement fixReplacement = new FixReplacement(filePath1, new Range(1, 6, 3, 1), "and t");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("First and third line\n");
+  }
+
+  @Test
+  public void replacementsMayOccurOnSameLine() throws Exception {
+    FixReplacement fixReplacement1 = new FixReplacement(filePath1, new Range(2, 0, 2, 6), "A");
+    FixReplacement fixReplacement2 =
+        new FixReplacement(filePath1, new Range(2, 7, 2, 11), "modification");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications =
+        toTreeModifications(fixReplacement1, fixReplacement2);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("First line\nA modification\nThird line\n");
+  }
+
+  @Test
+  public void replacementsMayTouch() throws Exception {
+    FixReplacement fixReplacement1 =
+        new FixReplacement(filePath1, new Range(1, 6, 2, 7), "modified ");
+    FixReplacement fixReplacement2 =
+        new FixReplacement(filePath1, new Range(2, 7, 3, 5), "content");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications =
+        toTreeModifications(fixReplacement1, fixReplacement2);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("First modified content line\n");
+  }
+
+  @Test
+  public void replacementsCanAddContentAtEndOfFile() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(4, 0, 4, 0), "New content");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("First line\nSecond line\nThird line\nNew content");
+  }
+
+  @Test
+  public void replacementsCanModifySeveralFilesInAnyOrder() throws Exception {
+    FixReplacement fixReplacement1 =
+        new FixReplacement(filePath1, new Range(1, 1, 3, 2), "Modified content");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+    FixReplacement fixReplacement2 =
+        new FixReplacement(filePath2, new Range(2, 0, 3, 0), "First modification\n");
+    FixReplacement fixReplacement3 =
+        new FixReplacement(filePath2, new Range(3, 0, 4, 0), "Second modification\n");
+    mockFileContent(filePath2, "1st line\n2nd line\n3rd line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications =
+        toTreeModifications(fixReplacement3, fixReplacement1, fixReplacement2);
+    List<TreeModification> sortedTreeModifications = getSortedCopy(treeModifications);
+    assertThatList(sortedTreeModifications)
+        .element(0)
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("FModified contentird line\n");
+    assertThatList(sortedTreeModifications)
+        .element(1)
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("1st line\nFirst modification\nSecond modification\n");
+  }
+
+  @Test
+  public void lineSeparatorCanBeChanged() throws Exception {
+    FixReplacement fixReplacement = new FixReplacement(filePath1, new Range(2, 11, 3, 0), "\r");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("First line\nSecond line\rThird line\n");
+  }
+
+  @Test
+  public void replacementsDoNotNeedToBeOrderedAccordingToRange() throws Exception {
+    FixReplacement fixReplacement1 =
+        new FixReplacement(filePath1, new Range(1, 0, 2, 0), "1st modification\n");
+    FixReplacement fixReplacement2 =
+        new FixReplacement(filePath1, new Range(3, 0, 4, 0), "2nd modification\n");
+    FixReplacement fixReplacement3 =
+        new FixReplacement(filePath1, new Range(4, 0, 5, 0), "3rd modification\n");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\nFourth line\nFifth line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications =
+        toTreeModifications(fixReplacement2, fixReplacement1, fixReplacement3);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo(
+            "1st modification\nSecond line\n2nd modification\n3rd modification\nFifth line\n");
+  }
+
+  @Test
+  public void replacementsMustNotReferToNotExistingLine() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(5, 0, 5, 0), "A new line\n");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+
+    expectedException.expect(ResourceConflictException.class);
+    toTreeModifications(fixReplacement);
+  }
+
+  @Test
+  public void replacementsMustNotReferToZeroLine() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(0, 0, 0, 0), "A new line\n");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+
+    expectedException.expect(ResourceConflictException.class);
+    toTreeModifications(fixReplacement);
+  }
+
+  @Test
+  public void replacementsMustNotReferToNotExistingOffsetOfIntermediateLine() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(1, 0, 1, 11), "modified");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+
+    expectedException.expect(ResourceConflictException.class);
+    toTreeModifications(fixReplacement);
+  }
+
+  @Test
+  public void replacementsMustNotReferToNotExistingOffsetOfLastLine() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(3, 0, 3, 11), "modified");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+
+    expectedException.expect(ResourceConflictException.class);
+    toTreeModifications(fixReplacement);
+  }
+
+  @Test
+  public void replacementsMustNotReferToNegativeOffset() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(1, -1, 1, 5), "modified");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+
+    expectedException.expect(ResourceConflictException.class);
+    toTreeModifications(fixReplacement);
+  }
+
+  private void mockFileContent(String filePath, String fileContent) throws Exception {
+    EasyMock.expect(
+            fileContentUtil.getContent(repository, projectState, patchSetCommitId, filePath))
+        .andReturn(BinaryResult.create(fileContent));
+  }
+
+  private List<TreeModification> toTreeModifications(FixReplacement... fixReplacements)
+      throws Exception {
+    return fixReplacementInterpreter.toTreeModifications(
+        repository, projectState, patchSetCommitId, ImmutableList.copyOf(fixReplacements));
+  }
+
+  private static List<TreeModification> getSortedCopy(List<TreeModification> treeModifications) {
+    List<TreeModification> sortedTreeModifications = new ArrayList<>(treeModifications);
+    sortedTreeModifications.sort(Comparator.comparing(TreeModification::getFilePath));
+    return sortedTreeModifications;
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/fixes/LineIdentifierTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/fixes/LineIdentifierTest.java
new file mode 100644
index 0000000..f638346
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/fixes/LineIdentifierTest.java
@@ -0,0 +1,257 @@
+// Copyright (C) 2017 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.fixes;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class LineIdentifierTest {
+
+  @Rule public ExpectedException expectedException = ExpectedException.none();
+
+  @Test
+  public void lineNumberMustBePositive() {
+    LineIdentifier lineIdentifier = new LineIdentifier("First line\nSecond line");
+    expectedException.expect(StringIndexOutOfBoundsException.class);
+    expectedException.expectMessage("positive");
+    lineIdentifier.getStartIndexOfLine(0);
+  }
+
+  @Test
+  public void lineNumberMustIndicateAnAvailableLine() {
+    LineIdentifier lineIdentifier = new LineIdentifier("First line\nSecond line");
+    expectedException.expect(StringIndexOutOfBoundsException.class);
+    expectedException.expectMessage("Line 3 isn't available");
+    lineIdentifier.getStartIndexOfLine(3);
+  }
+
+  @Test
+  public void startIndexOfFirstLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567");
+    int startIndex = lineIdentifier.getStartIndexOfLine(1);
+    assertThat(startIndex).isEqualTo(0);
+  }
+
+  @Test
+  public void lengthOfFirstLineIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567");
+    int lineLength = lineIdentifier.getLengthOfLine(1);
+    assertThat(lineLength).isEqualTo(8);
+  }
+
+  @Test
+  public void startIndexOfSecondLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567");
+    int startIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(startIndex).isEqualTo(9);
+  }
+
+  @Test
+  public void lengthOfSecondLineIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567");
+    int lineLength = lineIdentifier.getLengthOfLine(2);
+    assertThat(lineLength).isEqualTo(3);
+  }
+
+  @Test
+  public void startIndexOfLastLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567");
+    int startIndex = lineIdentifier.getStartIndexOfLine(3);
+    assertThat(startIndex).isEqualTo(13);
+  }
+
+  @Test
+  public void lengthOfLastLineIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567");
+    int lineLength = lineIdentifier.getLengthOfLine(3);
+    assertThat(lineLength).isEqualTo(7);
+  }
+
+  @Test
+  public void emptyFirstLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("\n123\n1234567");
+    int startIndex = lineIdentifier.getStartIndexOfLine(1);
+    assertThat(startIndex).isEqualTo(0);
+  }
+
+  @Test
+  public void lengthOfEmptyFirstLineIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("\n123\n1234567");
+    int lineLength = lineIdentifier.getLengthOfLine(1);
+    assertThat(lineLength).isEqualTo(0);
+  }
+
+  @Test
+  public void emptyIntermediaryLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n\n1234567");
+    int startIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(startIndex).isEqualTo(9);
+  }
+
+  @Test
+  public void lengthOfEmptyIntermediaryLineIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n\n1234567");
+    int lineLength = lineIdentifier.getLengthOfLine(2);
+    assertThat(lineLength).isEqualTo(0);
+  }
+
+  @Test
+  public void lineAfterIntermediaryLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n\n1234567");
+    int startIndex = lineIdentifier.getStartIndexOfLine(3);
+    assertThat(startIndex).isEqualTo(10);
+  }
+
+  @Test
+  public void emptyLastLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n");
+    int startIndex = lineIdentifier.getStartIndexOfLine(3);
+    assertThat(startIndex).isEqualTo(13);
+  }
+
+  @Test
+  public void lengthOfEmptyLastLineIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n");
+    int lineLength = lineIdentifier.getLengthOfLine(3);
+    assertThat(lineLength).isEqualTo(0);
+  }
+
+  @Test
+  public void startIndexOfSingleLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678");
+    int startIndex = lineIdentifier.getStartIndexOfLine(1);
+    assertThat(startIndex).isEqualTo(0);
+  }
+
+  @Test
+  public void lengthOfSingleLineIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678");
+    int lineLength = lineIdentifier.getLengthOfLine(1);
+    assertThat(lineLength).isEqualTo(8);
+  }
+
+  @Test
+  public void startIndexOfSingleEmptyLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("");
+    int startIndex = lineIdentifier.getStartIndexOfLine(1);
+    assertThat(startIndex).isEqualTo(0);
+  }
+
+  @Test
+  public void lengthOfSingleEmptyLineIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("");
+    int lineLength = lineIdentifier.getLengthOfLine(1);
+    assertThat(lineLength).isEqualTo(0);
+  }
+
+  @Test
+  public void lookingUpSubsequentLinesIsPossible() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567\n12");
+
+    int firstLineStartIndex = lineIdentifier.getStartIndexOfLine(1);
+    assertThat(firstLineStartIndex).isEqualTo(0);
+
+    int secondLineStartIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(secondLineStartIndex).isEqualTo(9);
+  }
+
+  @Test
+  public void lookingUpNotSubsequentLinesInAscendingOrderIsPossible() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567\n12");
+
+    int firstLineStartIndex = lineIdentifier.getStartIndexOfLine(1);
+    assertThat(firstLineStartIndex).isEqualTo(0);
+
+    int fourthLineStartIndex = lineIdentifier.getStartIndexOfLine(4);
+    assertThat(fourthLineStartIndex).isEqualTo(21);
+  }
+
+  @Test
+  public void lookingUpNotSubsequentLinesInDescendingOrderIsPossible() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567\n12");
+
+    int fourthLineStartIndex = lineIdentifier.getStartIndexOfLine(4);
+    assertThat(fourthLineStartIndex).isEqualTo(21);
+
+    int secondLineStartIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(secondLineStartIndex).isEqualTo(9);
+  }
+
+  @Test
+  public void linesSeparatedByOnlyCarriageReturnAreRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\r123\r12");
+    int startIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(startIndex).isEqualTo(9);
+  }
+
+  @Test
+  public void lengthOfLinesSeparatedByOnlyCarriageReturnIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\r123\r12");
+    int lineLength = lineIdentifier.getLengthOfLine(2);
+    assertThat(lineLength).isEqualTo(3);
+  }
+
+  @Test
+  public void linesSeparatedByLineFeedAndCarriageReturnAreRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\r\n123\r\n12");
+    int startIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(startIndex).isEqualTo(10);
+  }
+
+  @Test
+  public void lengthOfLinesSeparatedByLineFeedAndCarriageReturnIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\r\n123\r\n12");
+    int lineLength = lineIdentifier.getLengthOfLine(2);
+    assertThat(lineLength).isEqualTo(3);
+  }
+
+  @Test
+  public void linesSeparatedByMixtureOfCarriageReturnAndLineFeedAreRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\r123\r\n12\n123456\r\n1234");
+    int startIndex = lineIdentifier.getStartIndexOfLine(5);
+    assertThat(startIndex).isEqualTo(25);
+  }
+
+  @Test
+  public void linesSeparatedBySomeUnicodeLinebreakCharacterAreRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\u2029123\u202912");
+    int startIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(startIndex).isEqualTo(9);
+  }
+
+  @Test
+  public void lengthOfLinesSeparatedBySomeUnicodeLinebreakCharacterIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\u2029123\u202912");
+    int lineLength = lineIdentifier.getLengthOfLine(2);
+    assertThat(lineLength).isEqualTo(3);
+  }
+
+  @Test
+  public void blanksAreNotInterpretedAsLineSeparators() {
+    LineIdentifier lineIdentifier = new LineIdentifier("1 2345678\n123\n12");
+    int startIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(startIndex).isEqualTo(10);
+  }
+
+  @Test
+  public void tabsAreNotInterpretedAsLineSeparators() {
+    LineIdentifier lineIdentifier = new LineIdentifier("123\t45678\n123\n12");
+    int startIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(startIndex).isEqualTo(10);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/fixes/StringModifierTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/fixes/StringModifierTest.java
new file mode 100644
index 0000000..d23e928
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/fixes/StringModifierTest.java
@@ -0,0 +1,106 @@
+// Copyright (C) 2017 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.fixes;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class StringModifierTest {
+
+  @Rule public ExpectedException expectedException = ExpectedException.none();
+
+  private final String originalString = "This is the original, unmodified string.";
+  private StringModifier stringModifier;
+
+  @Before
+  public void setUp() {
+    stringModifier = new StringModifier(originalString);
+  }
+
+  @Test
+  public void singlePartIsReplaced() {
+    stringModifier.replace(0, 11, "An");
+    String modifiedString = stringModifier.getResult();
+    assertThat(modifiedString).isEqualTo("An original, unmodified string.");
+  }
+
+  @Test
+  public void twoPartsCanBeReplacedWithInsertionFirst() {
+    stringModifier.replace(5, 5, "string ");
+    stringModifier.replace(8, 39, "a modified version");
+    String modifiedString = stringModifier.getResult();
+    assertThat(modifiedString).isEqualTo("This string is a modified version.");
+  }
+
+  @Test
+  public void twoPartsCanBeReplacedWithDeletionFirst() {
+    stringModifier.replace(0, 8, "");
+    stringModifier.replace(12, 32, "modified");
+    String modifiedString = stringModifier.getResult();
+    assertThat(modifiedString).isEqualTo("the modified string.");
+  }
+
+  @Test
+  public void replacedPartsMayTouch() {
+    stringModifier.replace(0, 8, "");
+    stringModifier.replace(8, 32, "The modified");
+    String modifiedString = stringModifier.getResult();
+    assertThat(modifiedString).isEqualTo("The modified string.");
+  }
+
+  @Test
+  public void replacedPartsMustNotOverlap() {
+    stringModifier.replace(0, 9, "");
+    expectedException.expect(StringIndexOutOfBoundsException.class);
+    stringModifier.replace(8, 32, "The modified");
+  }
+
+  @Test
+  public void startIndexMustNotBeGreaterThanEndIndex() {
+    expectedException.expect(StringIndexOutOfBoundsException.class);
+    stringModifier.replace(10, 9, "something");
+  }
+
+  @Test
+  public void startIndexMustNotBeNegative() {
+    expectedException.expect(StringIndexOutOfBoundsException.class);
+    stringModifier.replace(-1, 9, "something");
+  }
+
+  @Test
+  public void newContentCanBeInsertedAtEndOfString() {
+    stringModifier.replace(
+        originalString.length(), originalString.length(), " And this an addition.");
+    String modifiedString = stringModifier.getResult();
+    assertThat(modifiedString)
+        .isEqualTo("This is the original, unmodified string. And this an addition.");
+  }
+
+  @Test
+  public void startIndexMustNotBeGreaterThanLengthOfString() {
+    expectedException.expect(StringIndexOutOfBoundsException.class);
+    stringModifier.replace(originalString.length() + 1, originalString.length() + 1, "something");
+  }
+
+  @Test
+  public void endIndexMustNotBeGreaterThanLengthOfString() {
+    expectedException.expect(StringIndexOutOfBoundsException.class);
+    stringModifier.replace(8, originalString.length() + 1, "something");
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
index 1724c51..cb1d97b 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
@@ -58,7 +58,7 @@
     indexes = new ChangeIndexCollection();
     indexes.setSearchIndex(index);
     queryBuilder = new FakeQueryBuilder(indexes);
-    rewrite = new ChangeIndexRewriter(indexes, IndexConfig.create(0, 0, 3));
+    rewrite = new ChangeIndexRewriter(indexes, IndexConfig.builder().maxTerms(3).build());
   }
 
   @Test
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java
index 6fda100..3bbd335 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java
@@ -27,8 +27,8 @@
         new FakeQueryBuilder.Definition<>(FakeQueryBuilder.class),
         new ChangeQueryBuilder.Arguments(
             null, null, null, null, null, null, null, null, null, null, null, null, null, null,
-            null, null, null, null, null, null, null, indexes, null, null, null, null, null, null,
-            null, null, null));
+            null, null, null, null, null, null, null, null, indexes, null, null, null, null, null,
+            null, null, null, null));
   }
 
   @Operator
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java
index 4eef629..0af642d 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java
@@ -321,11 +321,13 @@
     Change indexChange = newChange(P1, new Account.Id(1));
     indexChange.setNoteDbState(SHA1);
 
-    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, null)).isFalse();
+    // Change is missing from ReviewDb but present in index.
+    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, null)).isTrue();
 
+    // Change differs only in primary storage.
     Change noteDbPrimary = clone(indexChange);
     noteDbPrimary.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
-    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, noteDbPrimary)).isFalse();
+    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, noteDbPrimary)).isTrue();
 
     assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, clone(indexChange))).isFalse();
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/ValidatorTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/ValidatorTest.java
deleted file mode 100644
index 8cf1097..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/ValidatorTest.java
+++ /dev/null
@@ -1,62 +0,0 @@
-// 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.mail.send;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
-
-import java.io.BufferedReader;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import org.junit.Test;
-
-public class ValidatorTest {
-  private static final String UNSUPPORTED_PREFIX = "#! ";
-
-  @Test
-  public void validateLocalDomain() throws Exception {
-    assertThat(OutgoingEmailValidator.isValid("foo@bar.local")).isTrue();
-  }
-
-  @Test
-  public void validateTopLevelDomains() throws Exception {
-    try (InputStream in = this.getClass().getResourceAsStream("tlds-alpha-by-domain.txt")) {
-      if (in == null) {
-        throw new Exception("TLD list not found");
-      }
-      BufferedReader r = new BufferedReader(new InputStreamReader(in));
-      String tld;
-      while ((tld = r.readLine()) != null) {
-        if (tld.startsWith("# ") || tld.startsWith("XN--")) {
-          // Ignore comments and non-latin domains
-          continue;
-        }
-        if (tld.startsWith(UNSUPPORTED_PREFIX)) {
-          String test = "test@example." + tld.toLowerCase().substring(UNSUPPORTED_PREFIX.length());
-          assert_()
-              .withFailureMessage("expected invalid TLD \"" + test + "\"")
-              .that(OutgoingEmailValidator.isValid(test))
-              .isFalse();
-        } else {
-          String test = "test@example." + tld.toLowerCase();
-          assert_()
-              .withFailureMessage("failed to validate TLD \"" + test + "\"")
-              .that(OutgoingEmailValidator.isValid(test))
-              .isTrue();
-        }
-      }
-    }
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 9d6cb60..5a1d10c 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -49,6 +49,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.RequestId;
 import com.google.gerrit.testutil.TestChanges;
@@ -751,7 +752,7 @@
     try (RevWalk walk = new RevWalk(repo)) {
       RevCommit commit = walk.parseCommit(update.getResult());
       walk.parseBody(commit);
-      assertThat(commit.getFullMessage()).endsWith("Hashtags: tag1,tag2\n");
+      assertThat(commit.getFullMessage()).contains("Hashtags: tag1,tag2\n");
     }
   }
 
@@ -3265,6 +3266,138 @@
     assertThat(notes.getReadOnlyUntil()).isEqualTo(new Timestamp(0));
   }
 
+  @Test
+  public void privateDefault() throws Exception {
+    Change c = newChange();
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.isPrivate()).isFalse();
+  }
+
+  @Test
+  public void privateSetPrivate() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setPrivate(true);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.isPrivate()).isTrue();
+  }
+
+  @Test
+  public void privateSetPrivateMultipleTimes() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setPrivate(true);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.setPrivate(false);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.isPrivate()).isFalse();
+  }
+
+  @Test
+  public void defaultReviewersByEmailIsEmpty() throws Exception {
+    Change c = newChange();
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().all()).isEmpty();
+  }
+
+  @Test
+  public void putReviewerByEmail() throws Exception {
+    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().all()).containsExactly(adr);
+  }
+
+  @Test
+  public void putAndRemoveReviewerByEmail() throws Exception {
+    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.removeReviewerByEmail(adr);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().all()).isEmpty();
+  }
+
+  @Test
+  public void putRemoveAndAddBackReviewerByEmail() throws Exception {
+    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.removeReviewerByEmail(adr);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.CC);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().all()).containsExactly(adr);
+  }
+
+  @Test
+  public void putReviewerByEmailAndCcByEmail() throws Exception {
+    Address adrReviewer = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+    Address adrCc = new Address("Foo Bor", "foo.bar.2@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adrReviewer, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adrCc, ReviewerStateInternal.CC);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.REVIEWER))
+        .containsExactly(adrReviewer);
+    assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.CC))
+        .containsExactly(adrCc);
+    assertThat(notes.getReviewersByEmail().all()).containsExactly(adrReviewer, adrCc);
+  }
+
+  @Test
+  public void putReviewerByEmailAndChangeToCc() throws Exception {
+    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.CC);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.REVIEWER)).isEmpty();
+    assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.CC)).containsExactly(adr);
+    assertThat(notes.getReviewersByEmail().all()).containsExactly(adr);
+  }
+
   private boolean testJson() {
     return noteUtil.getWriteJson();
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index 25b5168..83dcf61 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.util.RequestId;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.TestChanges;
@@ -382,6 +383,32 @@
     assertBodyEquals("Update patch set 1\n\nPatch-set: 1\nCurrent: true\n", update.getResult());
   }
 
+  @Test
+  public void reviewerByEmail() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(
+        new Address("John Doe", "j.doe@gerritcodereview.com"), ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    assertBodyEquals(
+        "Update patch set 1\n\nPatch-set: 1\n"
+            + "Reviewer-email: John Doe <j.doe@gerritcodereview.com>\n",
+        update.getResult());
+  }
+
+  @Test
+  public void ccByEmail() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(new Address("j.doe@gerritcodereview.com"), ReviewerStateInternal.CC);
+    update.commit();
+
+    assertBodyEquals(
+        "Update patch set 1\n\nPatch-set: 1\nCC-email: j.doe@gerritcodereview.com\n",
+        update.getResult());
+  }
+
   private RevCommit parseCommit(ObjectId id) throws Exception {
     if (id instanceof RevCommit) {
       return (RevCommit) id;
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbUpdateManagerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbUpdateManagerTest.java
new file mode 100644
index 0000000..12eb39d
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbUpdateManagerTest.java
@@ -0,0 +1,119 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.git.LockFailureException;
+import java.io.IOException;
+import java.util.function.Consumer;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for {@link NoteDbUpdateManager}. */
+@RunWith(JUnit4.class)
+public class NoteDbUpdateManagerTest {
+  private static final Consumer<ReceiveCommand> OK = c -> c.setResult(ReceiveCommand.Result.OK);
+  private static final Consumer<ReceiveCommand> LOCK_FAILURE =
+      c -> c.setResult(ReceiveCommand.Result.LOCK_FAILURE);
+  private static final Consumer<ReceiveCommand> REJECTED =
+      c -> c.setResult(ReceiveCommand.Result.REJECTED_OTHER_REASON);
+  private static final Consumer<ReceiveCommand> ABORTED =
+      c -> {
+        c.setResult(ReceiveCommand.Result.NOT_ATTEMPTED);
+        ReceiveCommand.abort(ImmutableList.of(c));
+        checkState(
+            c.getResult() != ReceiveCommand.Result.NOT_ATTEMPTED
+                && c.getResult() != ReceiveCommand.Result.LOCK_FAILURE
+                && c.getResult() != ReceiveCommand.Result.OK,
+            "unexpected state after abort: %s",
+            c);
+      };
+
+  @Test
+  public void checkBatchRefUpdateResults() throws Exception {
+    checkResults(OK);
+    checkResults(OK, OK);
+
+    assertIoException(REJECTED);
+    assertIoException(OK, REJECTED);
+    assertIoException(LOCK_FAILURE, REJECTED);
+    assertIoException(LOCK_FAILURE, OK);
+    assertIoException(LOCK_FAILURE, REJECTED, OK);
+    assertIoException(LOCK_FAILURE, LOCK_FAILURE, REJECTED);
+    assertIoException(LOCK_FAILURE, ABORTED, REJECTED);
+    assertIoException(LOCK_FAILURE, ABORTED, OK);
+
+    assertLockFailureException(LOCK_FAILURE);
+    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE);
+    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE, ABORTED);
+    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE, ABORTED, ABORTED);
+    assertLockFailureException(ABORTED);
+    assertLockFailureException(ABORTED, ABORTED);
+  }
+
+  @SafeVarargs
+  private static void checkResults(Consumer<ReceiveCommand>... resultSetters) throws Exception {
+    NoteDbUpdateManager.checkResults(newBatchRefUpdate(resultSetters));
+  }
+
+  @SafeVarargs
+  private static void assertIoException(Consumer<ReceiveCommand>... resultSetters) {
+    try {
+      NoteDbUpdateManager.checkResults(newBatchRefUpdate(resultSetters));
+      assert_().fail("expected IOException");
+    } catch (IOException e) {
+      assertThat(e).isNotInstanceOf(LockFailureException.class);
+    }
+  }
+
+  @SafeVarargs
+  private static void assertLockFailureException(Consumer<ReceiveCommand>... resultSetters)
+      throws Exception {
+    try {
+      NoteDbUpdateManager.checkResults(newBatchRefUpdate(resultSetters));
+      assert_().fail("expected LockFailureException");
+    } catch (LockFailureException e) {
+      // Expected.
+    }
+  }
+
+  @SafeVarargs
+  private static BatchRefUpdate newBatchRefUpdate(Consumer<ReceiveCommand>... resultSetters) {
+    try (Repository repo = new InMemoryRepository(new DfsRepositoryDescription("repo"))) {
+      BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+      for (int i = 0; i < resultSetters.length; i++) {
+        ReceiveCommand cmd =
+            new ReceiveCommand(
+                ObjectId.fromString(String.format("%039x1", i)),
+                ObjectId.fromString(String.format("%039x2", i)),
+                "refs/heads/branch" + i);
+        bru.addCommand(cmd);
+        resultSetters[i].accept(cmd);
+      }
+      return bru;
+    }
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java
index df3e405..6977ce2 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java
@@ -159,14 +159,11 @@
     // Seed existing ref value.
     writeBlob("id", "1");
 
-    final AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
+    AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
     Runnable bgUpdate =
-        new Runnable() {
-          @Override
-          public void run() {
-            if (!doneBgUpdate.getAndSet(true)) {
-              writeBlob("id", "1234");
-            }
+        () -> {
+          if (!doneBgUpdate.getAndSet(true)) {
+            writeBlob("id", "1234");
           }
         };
 
@@ -203,20 +200,13 @@
 
   @Test
   public void failAfterRetryerGivesUp() throws Exception {
-    final AtomicInteger bgCounter = new AtomicInteger(1234);
-    Runnable bgUpdate =
-        new Runnable() {
-          @Override
-          public void run() {
-            writeBlob("id", Integer.toString(bgCounter.getAndAdd(1000)));
-          }
-        };
+    AtomicInteger bgCounter = new AtomicInteger(1234);
     RepoSequence s =
         newSequence(
             "id",
             1,
             10,
-            bgUpdate,
+            () -> writeBlob("id", Integer.toString(bgCounter.getAndAdd(1000))),
             RetryerBuilder.<RefUpdate.Result>newBuilder()
                 .withStopStrategy(StopStrategies.stopAfterAttempt(3))
                 .build());
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
index 6390e1f6..ebd5b49 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
@@ -58,6 +58,8 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.index.SingleVersionModule.SingleVersionListener;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.RequestContext;
@@ -109,12 +111,14 @@
     assertThat(u.controlForRef(ref).isOwner()).named("NOT OWN " + ref).isFalse();
   }
 
-  private void assertCanRead(ProjectControl u) {
-    assertThat(u.isVisible()).named("can read").isTrue();
+  private void assertCanAccess(ProjectControl u) {
+    boolean access = u.asForProject().testOrFalse(ProjectPermission.ACCESS);
+    assertThat(access).named("can access").isTrue();
   }
 
-  private void assertCannotRead(ProjectControl u) {
-    assertThat(u.isVisible()).named("cannot read").isFalse();
+  private void assertAccessDenied(ProjectControl u) {
+    boolean access = u.asForProject().testOrFalse(ProjectPermission.ACCESS);
+    assertThat(access).named("cannot access").isFalse();
   }
 
   private void assertCanRead(String ref, ProjectControl u) {
@@ -158,19 +162,23 @@
   }
 
   private void assertCanUpdate(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canUpdate()).named("can update " + ref).isTrue();
+    boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.UPDATE);
+    assertThat(update).named("can update " + ref).isTrue();
   }
 
   private void assertCannotUpdate(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canUpdate()).named("cannot update " + ref).isFalse();
+    boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.UPDATE);
+    assertThat(update).named("cannot update " + ref).isFalse();
   }
 
   private void assertCanForceUpdate(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canForceUpdate()).named("can force push " + ref).isTrue();
+    boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.FORCE_UPDATE);
+    assertThat(update).named("can force push " + ref).isTrue();
   }
 
   private void assertCannotForceUpdate(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canForceUpdate()).named("cannot force push " + ref).isFalse();
+    boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.FORCE_UPDATE);
+    assertThat(update).named("cannot force push " + ref).isFalse();
   }
 
   private void assertCanVote(int score, PermissionRange range) {
@@ -438,13 +446,13 @@
   public void inheritDuplicateSections() throws Exception {
     allow(parent, READ, ADMIN, "refs/*");
     allow(local, READ, DEVS, "refs/heads/*");
-    assertCanRead(user(local, "a", ADMIN));
+    assertCanAccess(user(local, "a", ADMIN));
 
     local = new ProjectConfig(localKey);
     local.load(newRepository(localKey));
     local.getProject().setParentName(parentKey);
     allow(local, READ, DEVS, "refs/*");
-    assertCanRead(user(local, "d", DEVS));
+    assertCanAccess(user(local, "d", DEVS));
   }
 
   @Test
@@ -452,7 +460,7 @@
     allow(parent, READ, REGISTERED_USERS, "refs/*");
     deny(local, READ, REGISTERED_USERS, "refs/*");
 
-    assertCannotRead(user(local));
+    assertAccessDenied(user(local));
   }
 
   @Test
@@ -461,7 +469,7 @@
     deny(local, READ, REGISTERED_USERS, "refs/heads/*");
 
     ProjectControl u = user(local);
-    assertCanRead(u);
+    assertCanAccess(u);
     assertCanRead("refs/master", u);
     assertCanRead("refs/tags/foobar", u);
     assertCanRead("refs/heads/master", u);
@@ -474,7 +482,7 @@
     allow(local, READ, REGISTERED_USERS, "refs/heads/*");
 
     ProjectControl u = user(local);
-    assertCanRead(u);
+    assertCanAccess(u);
     assertCannotRead("refs/foobar", u);
     assertCannotRead("refs/tags/foobar", u);
     assertCanRead("refs/heads/foobar", u);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index cd179b5..34ef0b5 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -44,6 +44,8 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
@@ -71,7 +73,7 @@
 import com.google.gerrit.server.change.ChangeTriplet;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.index.IndexConfig;
 import com.google.gerrit.server.index.QueryOptions;
 import com.google.gerrit.server.index.change.ChangeField;
@@ -83,6 +85,7 @@
 import com.google.gerrit.server.notedb.NoteDbChangeState;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.RequestContext;
@@ -94,6 +97,7 @@
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
 import com.google.gerrit.testutil.InMemoryRepositoryManager.Repo;
 import com.google.gerrit.testutil.TestTimeUtil;
+import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Provider;
@@ -101,6 +105,7 @@
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -111,10 +116,13 @@
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.SystemReader;
 import org.junit.After;
 import org.junit.Before;
@@ -140,7 +148,6 @@
   @Inject protected ChangeIndexCollection indexes;
   @Inject protected ChangeIndexer indexer;
   @Inject protected IndexConfig indexConfig;
-  @Inject protected InMemoryDatabase schemaFactory;
   @Inject protected InMemoryRepositoryManager repoManager;
   @Inject protected InternalChangeQuery internalChangeQuery;
   @Inject protected ChangeNotes.Factory notesFactory;
@@ -149,8 +156,14 @@
   @Inject protected ChangeControl.GenericFactory changeControlFactory;
   @Inject protected ChangeQueryProcessor queryProcessor;
   @Inject protected SchemaCreator schemaCreator;
+  @Inject protected SchemaFactory<ReviewDb> schemaFactory;
   @Inject protected Sequences seq;
   @Inject protected ThreadLocalRequestContext requestContext;
+  @Inject protected ProjectCache projectCache;
+  @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
+
+  // Only for use in setting up/tearing down injector; other users should use schemaFactory.
+  @Inject private InMemoryDatabase inMemoryDatabase;
 
   protected Injector injector;
   protected LifecycleManager lifecycle;
@@ -173,8 +186,10 @@
   }
 
   protected void setUpDatabase() throws Exception {
+    try (ReviewDb underlyingDb = inMemoryDatabase.getDatabase().open()) {
+      schemaCreator.create(underlyingDb);
+    }
     db = schemaFactory.open();
-    schemaCreator.create(db);
 
     userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
     Account userAccount = db.accounts().get(userId);
@@ -208,7 +223,7 @@
     if (db != null) {
       db.close();
     }
-    InMemoryDatabase.drop(schemaFactory);
+    InMemoryDatabase.drop(inMemoryDatabase);
   }
 
   @Before
@@ -373,11 +388,77 @@
   }
 
   @Test
+  public void byPrivate() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo), userId);
+    Account.Id user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+    Change change2 = insert(repo, newChange(repo), user2);
+
+    // No private changes.
+    assertQuery("is:open", change2, change1);
+    assertQuery("is:private");
+
+    gApi.changes().id(change1.getChangeId()).setPrivate(true);
+
+    // Change1 is not private, but should be still visible to its owner.
+    assertQuery("is:open", change1, change2);
+    assertQuery("is:private", change1);
+
+    // Switch request context to user2.
+    requestContext.setContext(newRequestContext(user2));
+    assertQuery("is:open", change2);
+    assertQuery("is:private");
+  }
+
+  @Test
+  public void byWip() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo), userId);
+
+    assertQuery("is:open", change1);
+    assertQuery("is:wip");
+
+    gApi.changes().id(change1.getChangeId()).setWorkInProgress();
+
+    assertQuery("is:wip", change1);
+
+    gApi.changes().id(change1.getChangeId()).setReadyForReview();
+
+    assertQuery("is:wip");
+  }
+
+  @Test
+  public void excludeWipChangeFromReviewersDashboards() throws Exception {
+    Account.Id user1 = createAccount("user1");
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo), userId);
+
+    AddReviewerInput rin = new AddReviewerInput();
+    rin.reviewer = user1.toString();
+    rin.state = ReviewerState.REVIEWER;
+    gApi.changes().id(change1.getId().get()).addReviewer(rin);
+
+    assertQuery("is:wip");
+    assertQuery("reviewer:" + user1, change1);
+
+    gApi.changes().id(change1.getChangeId()).setWorkInProgress();
+
+    assertQuery("is:wip", change1);
+    assertQuery("reviewer:" + user1);
+
+    gApi.changes().id(change1.getChangeId()).setReadyForReview();
+
+    assertQuery("is:wip");
+    assertQuery("reviewer:" + user1, change1);
+  }
+
+  @Test
   public void byCommit() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     ChangeInserter ins = newChange(repo);
     insert(repo, ins);
-    String sha = ins.getCommit().name();
+    String sha = ins.getCommitId().name();
 
     assertQuery("0000000000000000000000000000000000000000");
     for (int i = 0; i <= 36; i++) {
@@ -536,8 +617,23 @@
     assertQuery("intopic:fixup", change4);
     assertQuery("topic:\"\"", change5);
     assertQuery("intopic:\"\"", change5);
-    assertQuery("intopic:^feature2.*", change4, change2);
-    assertQuery("intopic:{^.*feature2$}", change3, change2);
+  }
+
+  @Test
+  public void byTopicRegex() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+
+    ChangeInserter ins1 = newChangeWithTopic(repo, "feature1");
+    Change change1 = insert(repo, ins1);
+
+    ChangeInserter ins2 = newChangeWithTopic(repo, "Cherrypick-feature1");
+    Change change2 = insert(repo, ins2);
+
+    ChangeInserter ins3 = newChangeWithTopic(repo, "feature1-fixup");
+    Change change3 = insert(repo, ins3);
+
+    assertQuery("intopic:^feature1.*", change3, change1);
+    assertQuery("intopic:{^.*feature1$}", change2, change1);
   }
 
   @Test
@@ -822,38 +918,14 @@
   }
 
   @Test
-  public void updatedOrderWithMinuteResolution() throws Exception {
-    resetTimeWithClockStep(2, MINUTES);
-    TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins1 = newChange(repo);
-    Change change1 = insert(repo, ins1);
-    Change change2 = insert(repo, newChange(repo));
-
-    assertThat(lastUpdatedMs(change1)).isLessThan(lastUpdatedMs(change2));
-    assertQuery("status:new", change2, change1);
-
-    gApi.changes().id(change1.getId().get()).topic("new-topic");
-    change1 = notesFactory.create(db, change1.getProject(), change1.getId()).getChange();
-
-    assertThat(lastUpdatedMs(change1)).isGreaterThan(lastUpdatedMs(change2));
-    assertThat(lastUpdatedMs(change1) - lastUpdatedMs(change2))
-        .isGreaterThan(MILLISECONDS.convert(1, MINUTES));
-
-    // change1 moved to the top.
-    assertQuery("status:new", change1, change2);
-  }
-
-  @Test
-  public void updatedOrderWithSubMinuteResolution() throws Exception {
+  public void updatedOrder() throws Exception {
     resetTimeWithClockStep(1, SECONDS);
-
     TestRepository<Repo> repo = createProject("repo");
     ChangeInserter ins1 = newChange(repo);
     Change change1 = insert(repo, ins1);
     Change change2 = insert(repo, newChange(repo));
 
     assertThat(lastUpdatedMs(change1)).isLessThan(lastUpdatedMs(change2));
-
     assertQuery("status:new", change2, change1);
 
     gApi.changes().id(change1.getId().get()).topic("new-topic");
@@ -861,7 +933,7 @@
 
     assertThat(lastUpdatedMs(change1)).isGreaterThan(lastUpdatedMs(change2));
     assertThat(lastUpdatedMs(change1) - lastUpdatedMs(change2))
-        .isLessThan(MILLISECONDS.convert(1, MINUTES));
+        .isAtLeast(MILLISECONDS.convert(1, SECONDS));
 
     // change1 moved to the top.
     assertQuery("status:new", change1, change2);
@@ -1343,7 +1415,8 @@
     allUsers.update(draftsRef.getName(), draftsRef.getObjectId());
     assertThat(allUsers.getRepository().exactRef(draftsRef.getName())).isNotNull();
 
-    if (PrimaryStorage.of(change) == PrimaryStorage.REVIEW_DB) {
+    if (PrimaryStorage.of(change) == PrimaryStorage.REVIEW_DB
+        && !notesMigration.disableChangeReviewDb()) {
       // Record draft ref in noteDbState as well.
       ReviewDb db = ReviewDbUtil.unwrapDb(this.db);
       change = db.changes().get(id);
@@ -1380,7 +1453,8 @@
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
-    insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change3 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change4 = insert(repo, newChange(repo));
 
     gApi.accounts()
         .self()
@@ -1394,16 +1468,42 @@
             new StarsInput(
                 new HashSet<>(Arrays.asList(StarredChangesUtil.DEFAULT_LABEL, "green", "blue"))));
 
+    gApi.accounts()
+        .self()
+        .setStars(
+            change4.getId().toString(), new StarsInput(new HashSet<>(Arrays.asList("ignore"))));
+
     // check labeled stars
     assertQuery("star:red", change1);
     assertQuery("star:blue", change2, change1);
-    assertQuery("has:stars", change2, change1);
+    assertQuery("has:stars", change4, change2, change1);
 
     // check default star
     assertQuery("has:star", change2);
     assertQuery("is:starred", change2);
     assertQuery("starredby:self", change2);
     assertQuery("star:" + StarredChangesUtil.DEFAULT_LABEL, change2);
+
+    // check ignored
+    assertQuery("is:ignored", change4);
+    assertQuery("-is:ignored", change3, change2, change1);
+  }
+
+  @Test
+  public void byIgnore() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Account.Id user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+    Change change1 = insert(repo, newChange(repo), user2);
+    Change change2 = insert(repo, newChange(repo), user2);
+
+    gApi.changes().id(change1.getId().toString()).ignore(true);
+    assertQuery("is:ignored", change1);
+    assertQuery("-is:ignored", change2);
+
+    gApi.changes().id(change1.getId().toString()).ignore(false);
+    assertQuery("is:ignored");
+    assertQuery("-is:ignored", change2, change1);
   }
 
   @Test
@@ -1523,6 +1623,71 @@
   }
 
   @Test
+  public void reviewerAndCcByEmail() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    Project.NameKey project = new Project.NameKey("repo");
+    TestRepository<Repo> repo = createProject(project.get());
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+
+    String userByEmail = "un.registered@reviewer.com";
+    String userByEmailWithName = "John Doe <" + userByEmail + ">";
+
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    insert(repo, newChange(repo));
+
+    AddReviewerInput rin = new AddReviewerInput();
+    rin.reviewer = userByEmailWithName;
+    rin.state = ReviewerState.REVIEWER;
+    gApi.changes().id(change1.getId().get()).addReviewer(rin);
+
+    rin = new AddReviewerInput();
+    rin.reviewer = userByEmailWithName;
+    rin.state = ReviewerState.CC;
+    gApi.changes().id(change2.getId().get()).addReviewer(rin);
+
+    assertQuery("reviewer:\"" + userByEmailWithName + "\"", change1);
+    assertQuery("cc:\"" + userByEmailWithName + "\"", change2);
+
+    // Omitting the name:
+    assertQuery("reviewer:\"" + userByEmail + "\"", change1);
+    assertQuery("cc:\"" + userByEmail + "\"", change2);
+  }
+
+  @Test
+  public void reviewerAndCcByEmailWithQueryForDifferentUser() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    Project.NameKey project = new Project.NameKey("repo");
+    TestRepository<Repo> repo = createProject(project.get());
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+
+    String userByEmail = "John Doe <un.registered@reviewer.com>";
+
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    insert(repo, newChange(repo));
+
+    AddReviewerInput rin = new AddReviewerInput();
+    rin.reviewer = userByEmail;
+    rin.state = ReviewerState.REVIEWER;
+    gApi.changes().id(change1.getId().get()).addReviewer(rin);
+
+    rin = new AddReviewerInput();
+    rin.reviewer = userByEmail;
+    rin.state = ReviewerState.CC;
+    gApi.changes().id(change2.getId().get()).addReviewer(rin);
+
+    assertQuery("reviewer:\"someone@example.com\"");
+    assertQuery("cc:\"someone@example.com\"");
+  }
+
+  @Test
   public void submitRecords() throws Exception {
     Account.Id user1 = createAccount("user1");
     TestRepository<Repo> repo = createProject("repo");
@@ -1610,9 +1775,28 @@
 
   @Test
   public void byCommitsOnBranchNotMerged() throws Exception {
+    TestRepository<Repo> tr = createProject("repo");
+    testByCommitsOnBranchNotMerged(tr, ImmutableSet.of());
+  }
+
+  @Test
+  public void byCommitsOnBranchNotMergedSkipsMissingChanges() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
+    ObjectId missing =
+        repo.branch(new PatchSet.Id(new Change.Id(987654), 1).toRefName())
+            .commit()
+            .message("No change for this commit")
+            .insertChangeId()
+            .create()
+            .copy();
+    testByCommitsOnBranchNotMerged(repo, ImmutableSet.of(missing));
+  }
+
+  private void testByCommitsOnBranchNotMerged(TestRepository<Repo> repo, Collection<ObjectId> extra)
+      throws Exception {
     int n = 10;
-    List<String> shas = new ArrayList<>(n);
+    List<String> shas = new ArrayList<>(n + extra.size());
+    extra.forEach(i -> shas.add(i.name()));
     List<Integer> expectedIds = new ArrayList<>(n);
     Branch.NameKey dest = null;
     for (int i = 0; i < n; i++) {
@@ -1621,7 +1805,7 @@
       if (dest == null) {
         dest = ins.getChange().getDest();
       }
-      shas.add(ins.getCommit().name());
+      shas.add(ins.getCommitId().name());
       expectedIds.add(ins.getChange().getId().get());
     }
 
@@ -1848,7 +2032,7 @@
     ChangeInserter ins =
         changeFactory
             .create(id, commit, branch)
-            .setValidatePolicy(CommitValidators.Policy.NONE)
+            .setValidate(false)
             .setStatus(status)
             .setTopic(topic);
     return ins;
@@ -1893,10 +2077,12 @@
             .create(ctl, new PatchSet.Id(c.getId(), n), commit)
             .setNotify(NotifyHandling.NONE)
             .setFireRevisionCreated(false)
-            .setValidatePolicy(CommitValidators.Policy.NONE);
+            .setValidate(false);
     try (BatchUpdate bu = updateFactory.create(db, c.getProject(), user, TimeUtil.nowTs());
-        ObjectInserter oi = repo.getRepository().newObjectInserter()) {
-      bu.setRepository(repo.getRepository(), repo.getRevWalk(), oi);
+        ObjectInserter oi = repo.getRepository().newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      bu.setRepository(repo.getRepository(), rw, oi);
       bu.addOp(c.getId(), inserter);
       bu.execute();
     }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java
index 9a32365..74eb1d2 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java
@@ -38,6 +38,7 @@
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.gwtorm.server.StatementExecutor;
 import com.google.inject.Guice;
+import com.google.inject.Key;
 import com.google.inject.ProvisionException;
 import com.google.inject.TypeLiteral;
 import java.io.FileNotFoundException;
@@ -82,7 +83,10 @@
                 new FactoryModule() {
                   @Override
                   protected void configure() {
-                    bind(new TypeLiteral<SchemaFactory<ReviewDb>>() {}).toInstance(db);
+                    TypeLiteral<SchemaFactory<ReviewDb>> schemaFactory =
+                        new TypeLiteral<SchemaFactory<ReviewDb>>() {};
+                    bind(schemaFactory).to(NotesMigrationSchemaFactory.class);
+                    bind(Key.get(schemaFactory, ReviewDbFactory.class)).toInstance(db);
                     bind(SitePaths.class).toInstance(paths);
 
                     Config cfg = new Config();
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/update/BatchUpdateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/update/BatchUpdateTest.java
index 892d037..dba3b3d 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2017 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.update;
 
 import static org.junit.Assert.assertEquals;
@@ -17,6 +31,7 @@
 import com.google.gerrit.testutil.InMemoryDatabase;
 import com.google.gerrit.testutil.InMemoryModule;
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
+import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
@@ -25,26 +40,22 @@
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.ReceiveCommand;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
 public class BatchUpdateTest {
   @Inject private AccountManager accountManager;
-
   @Inject private IdentifiedUser.GenericFactory userFactory;
-
-  @Inject private InMemoryDatabase schemaFactory;
-
+  @Inject private SchemaFactory<ReviewDb> schemaFactory;
   @Inject private InMemoryRepositoryManager repoManager;
-
   @Inject private SchemaCreator schemaCreator;
-
   @Inject private ThreadLocalRequestContext requestContext;
-
   @Inject private BatchUpdate.Factory batchUpdateFactory;
 
+  // Only for use in setting up/tearing down injector; other users should use schemaFactory.
+  @Inject private InMemoryDatabase inMemoryDatabase;
+
   private LifecycleManager lifecycle;
   private ReviewDb db;
   private TestRepository<InMemoryRepository> repo;
@@ -59,8 +70,10 @@
     lifecycle.add(injector);
     lifecycle.start();
 
+    try (ReviewDb underlyingDb = inMemoryDatabase.getDatabase().open()) {
+      schemaCreator.create(underlyingDb);
+    }
     db = schemaFactory.open();
-    schemaCreator.create(db);
     Account.Id userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
     user = userFactory.create(userId);
 
@@ -95,7 +108,7 @@
     if (db != null) {
       db.close();
     }
-    InMemoryDatabase.drop(schemaFactory);
+    InMemoryDatabase.drop(inMemoryDatabase);
   }
 
   @Test
@@ -108,9 +121,7 @@
           new RepoOnlyOp() {
             @Override
             public void updateRepo(RepoContext ctx) throws Exception {
-              ctx.addRefUpdate(
-                  new ReceiveCommand(
-                      masterCommit.getId(), branchCommit.getId(), "refs/heads/master"));
+              ctx.addRefUpdate(masterCommit.getId(), branchCommit.getId(), "refs/heads/master");
             }
           });
       bu.execute();
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/update/RepoViewTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/update/RepoViewTest.java
new file mode 100644
index 0000000..0ea9f83
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/update/RepoViewTest.java
@@ -0,0 +1,154 @@
+// Copyright (C) 2017 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.update;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static org.eclipse.jgit.lib.Constants.R_HEADS;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.testutil.InMemoryRepositoryManager;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class RepoViewTest {
+  private static final String MASTER = "refs/heads/master";
+  private static final String BRANCH = "refs/heads/branch";
+
+  private Repository repo;
+  private TestRepository<?> tr;
+  private RepoView view;
+
+  @Before
+  public void setUp() throws Exception {
+    InMemoryRepositoryManager repoManager = new InMemoryRepositoryManager();
+    Project.NameKey project = new Project.NameKey("project");
+    repo = repoManager.createRepository(project);
+    tr = new TestRepository<>(repo);
+    tr.branch(MASTER).commit().create();
+    view = new RepoView(repoManager, project);
+  }
+
+  @After
+  public void tearDown() {
+    view.close();
+    repo.close();
+  }
+
+  @Test
+  public void getConfigIsDefensiveCopy() throws Exception {
+    StoredConfig orig = repo.getConfig();
+    orig.setString("a", "config", "option", "yes");
+    orig.save();
+
+    Config copy = view.getConfig();
+    copy.setString("a", "config", "option", "no");
+
+    assertThat(orig.getString("a", "config", "option")).isEqualTo("yes");
+    assertThat(repo.getConfig().getString("a", "config", "option")).isEqualTo("yes");
+  }
+
+  @Test
+  public void getRef() throws Exception {
+    ObjectId oldMaster = repo.exactRef(MASTER).getObjectId();
+    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(oldMaster);
+    assertThat(repo.exactRef(BRANCH)).isNull();
+    assertThat(view.getRef(MASTER)).hasValue(oldMaster);
+    assertThat(view.getRef(BRANCH)).isEmpty();
+
+    tr.branch(MASTER).commit().create();
+    tr.branch(BRANCH).commit().create();
+    assertThat(repo.exactRef(MASTER).getObjectId()).isNotEqualTo(oldMaster);
+    assertThat(repo.exactRef(BRANCH)).isNotNull();
+    assertThat(view.getRef(MASTER)).hasValue(oldMaster);
+    assertThat(view.getRef(BRANCH)).isEmpty();
+  }
+
+  @Test
+  public void getRefsRescansWhenNotCaching() throws Exception {
+    ObjectId oldMaster = repo.exactRef(MASTER).getObjectId();
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", oldMaster);
+
+    ObjectId newBranch = tr.branch(BRANCH).commit().create();
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", oldMaster, "branch", newBranch);
+  }
+
+  @Test
+  public void getRefsUsesCachedValueMatchingGetRef() throws Exception {
+    ObjectId master1 = repo.exactRef(MASTER).getObjectId();
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1);
+    assertThat(view.getRef(MASTER)).hasValue(master1);
+
+    // Doesn't reflect new value for master.
+    ObjectId master2 = tr.branch(MASTER).commit().create();
+    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master2);
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1);
+
+    // Branch wasn't previously cached, so does reflect new value.
+    ObjectId branch1 = tr.branch(BRANCH).commit().create();
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1, "branch", branch1);
+
+    // Looking up branch causes it to be cached.
+    assertThat(view.getRef(BRANCH)).hasValue(branch1);
+    ObjectId branch2 = tr.branch(BRANCH).commit().create();
+    assertThat(repo.exactRef(BRANCH).getObjectId()).isEqualTo(branch2);
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1, "branch", branch1);
+  }
+
+  @Test
+  public void getRefsReflectsCommands() throws Exception {
+    ObjectId master1 = repo.exactRef(MASTER).getObjectId();
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1);
+
+    ObjectId master2 = tr.commit().create();
+    view.getCommands().add(new ReceiveCommand(master1, master2, MASTER));
+
+    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master1);
+    assertThat(view.getRef(MASTER)).hasValue(master2);
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master2);
+
+    view.getCommands().add(new ReceiveCommand(master2, ObjectId.zeroId(), MASTER));
+
+    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master1);
+    assertThat(view.getRef(MASTER)).isEmpty();
+    assertThat(view.getRefs(R_HEADS)).isEmpty();
+  }
+
+  @Test
+  public void getRefsOverwritesCachedValueWithCommand() throws Exception {
+    ObjectId master1 = repo.exactRef(MASTER).getObjectId();
+    assertThat(view.getRef(MASTER)).hasValue(master1);
+
+    ObjectId master2 = tr.commit().create();
+    view.getCommands().add(new ReceiveCommand(master1, master2, MASTER));
+
+    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master1);
+    assertThat(view.getRef(MASTER)).hasValue(master2);
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master2);
+
+    view.getCommands().add(new ReceiveCommand(master2, ObjectId.zeroId(), MASTER));
+
+    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master1);
+    assertThat(view.getRef(MASTER)).isEmpty();
+    assertThat(view.getRefs(R_HEADS)).isEmpty();
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java
index 038baac..dd42b67 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java
@@ -49,8 +49,10 @@
       };
 
   public void beforeTest() throws Exception {
-    notesMigration = new TestNotesMigration().setFromEnv();
+    notesMigration = new TestNotesMigration();
   }
 
-  public void afterTest() {}
+  public void afterTest() {
+    notesMigration.resetFromEnv();
+  }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
index 9944508..bce0a0f 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
@@ -57,8 +57,11 @@
 import com.google.gerrit.server.notedb.GwtormChangeBundleReader;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.DiffExecutor;
+import com.google.gerrit.server.project.DefaultPermissionBackendModule;
 import com.google.gerrit.server.schema.DataSourceType;
 import com.google.gerrit.server.schema.H2AccountPatchReviewStore;
+import com.google.gerrit.server.schema.NotesMigrationSchemaFactory;
+import com.google.gerrit.server.schema.ReviewDbFactory;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.securestore.DefaultSecureStore;
 import com.google.gerrit.server.securestore.SecureStore;
@@ -69,6 +72,7 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
+import com.google.inject.Key;
 import com.google.inject.Module;
 import com.google.inject.Provider;
 import com.google.inject.Provides;
@@ -146,6 +150,7 @@
             });
     bind(MetricMaker.class).to(DisabledMetricMaker.class);
     install(cfgInjector.getInstance(GerritGlobalModule.class));
+    install(new DefaultPermissionBackendModule());
     install(new SearchingChangeCacheImpl.Module());
     factory(GarbageCollection.Factory.class);
 
@@ -171,13 +176,15 @@
     bind(ListeningExecutorService.class)
         .annotatedWith(ChangeUpdateExecutor.class)
         .toInstance(MoreExecutors.newDirectExecutorService());
-
     bind(DataSourceType.class).to(InMemoryH2Type.class);
-    bind(new TypeLiteral<SchemaFactory<ReviewDb>>() {}).to(InMemoryDatabase.class);
     bind(ChangeBundleReader.class).to(GwtormChangeBundleReader.class);
-
     bind(SecureStore.class).to(DefaultSecureStore.class);
 
+    TypeLiteral<SchemaFactory<ReviewDb>> schemaFactory =
+        new TypeLiteral<SchemaFactory<ReviewDb>>() {};
+    bind(schemaFactory).to(NotesMigrationSchemaFactory.class);
+    bind(Key.get(schemaFactory, ReviewDbFactory.class)).to(InMemoryDatabase.class);
+
     install(NoSshKeyCache.module());
     install(
         new CanonicalWebUrlModule() {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java
index 4826d9e..a90cbb9 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java
@@ -19,6 +19,8 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.RepositoryCaseMismatchException;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.inject.Inject;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.SortedSet;
@@ -30,7 +32,7 @@
 /** Repository manager that uses in-memory repositories. */
 public class InMemoryRepositoryManager implements GitRepositoryManager {
   public static InMemoryRepository newRepository(Project.NameKey name) {
-    return new Repo(name);
+    return new Repo(new TestNotesMigration(), name);
   }
 
   public static class Description extends DfsRepositoryDescription {
@@ -49,11 +51,15 @@
   public static class Repo extends InMemoryRepository {
     private String description;
 
-    private Repo(Project.NameKey name) {
+    private Repo(NotesMigration notesMigration, Project.NameKey name) {
       super(new Description(name));
-      // TODO(dborowitz): Allow atomic transactions when this is supported:
-      // https://git.eclipse.org/r/#/c/61841/2/org.eclipse.jgit/src/org/eclipse/jgit/internal/storage/dfs/InMemoryRepository.java@313
-      setPerformsAtomicTransactions(false);
+      // Normally, mimic the behavior of JGit FileRepository, the standard Gerrit repository
+      // backend, and don't support atomic ref updates. The exception is when we're testing with
+      // fused ref updates, which requires atomic ref updates to function.
+      //
+      // TODO(dborowitz): Change to match the behavior of JGit FileRepository after fixing
+      // https://bugs.eclipse.org/bugs/show_bug.cgi?id=515678
+      setPerformsAtomicTransactions(notesMigration.fuseUpdates());
     }
 
     @Override
@@ -72,7 +78,18 @@
     }
   }
 
-  private Map<String, Repo> repos = new HashMap<>();
+  private final NotesMigration notesMigration;
+  private final Map<String, Repo> repos;
+
+  public InMemoryRepositoryManager() {
+    this(new TestNotesMigration());
+  }
+
+  @Inject
+  InMemoryRepositoryManager(NotesMigration notesMigration) {
+    this.notesMigration = notesMigration;
+    this.repos = new HashMap<>();
+  }
 
   @Override
   public synchronized Repo openRepository(Project.NameKey name) throws RepositoryNotFoundException {
@@ -89,7 +106,7 @@
         throw new RepositoryCaseMismatchException(name);
       }
     } catch (RepositoryNotFoundException e) {
-      repo = new Repo(name);
+      repo = new Repo(notesMigration, name);
       repos.put(normalize(name), repo);
     }
     return repo;
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
index aeaaa47..ad876ce 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
@@ -89,7 +89,7 @@
 
     List<ChangeBundle> allExpected = readExpected(changeIds);
 
-    boolean oldWrite = notesMigration.writeChanges();
+    boolean oldWrite = notesMigration.rawWriteChangesSetting();
     boolean oldRead = notesMigration.readChanges();
     try {
       notesMigration.setWriteChanges(true);
@@ -162,7 +162,7 @@
   private void checkActual(List<ChangeBundle> allExpected, List<String> msgs) throws Exception {
     ReviewDb db = getUnwrappedDb();
     boolean oldRead = notesMigration.readChanges();
-    boolean oldWrite = notesMigration.writeChanges();
+    boolean oldWrite = notesMigration.rawWriteChangesSetting();
     try {
       notesMigration.setWriteChanges(true);
       notesMigration.setReadChanges(true);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java
index 552f6f1..e8446a2 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java
@@ -35,6 +35,9 @@
   /** All change tables are entirely disabled. */
   DISABLE_CHANGE_REVIEW_DB(true),
 
+  /** All change tables are entirely disabled, and code/meta ref updates are fused. */
+  FUSED(true),
+
   /**
    * Run tests with NoteDb disabled, then convert ReviewDb to NoteDb and check that the results
    * match.
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestNotesMigration.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestNotesMigration.java
index e6a72fc..c05dd01 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestNotesMigration.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestNotesMigration.java
@@ -27,8 +27,13 @@
   private volatile boolean writeChanges;
   private volatile PrimaryStorage changePrimaryStorage = PrimaryStorage.REVIEW_DB;
   private volatile boolean disableChangeReviewDb;
+  private volatile boolean fuseUpdates;
   private volatile boolean failOnLoad;
 
+  public TestNotesMigration() {
+    resetFromEnv();
+  }
+
   @Override
   public boolean readChanges() {
     return readChanges;
@@ -51,10 +56,15 @@
     return disableChangeReviewDb;
   }
 
+  @Override
+  public boolean fuseUpdates() {
+    return fuseUpdates;
+  }
+
   // Increase visbility from superclass, as tests may want to check whether
   // NoteDb data is written in specific migration scenarios.
   @Override
-  public boolean writeChanges() {
+  public boolean rawWriteChangesSetting() {
     return writeChanges;
   }
 
@@ -83,6 +93,11 @@
     return this;
   }
 
+  public TestNotesMigration setFuseUpdates(boolean fuseUpdates) {
+    this.fuseUpdates = fuseUpdates;
+    return this;
+  }
+
   public TestNotesMigration setFailOnLoad(boolean failOnLoad) {
     this.failOnLoad = failOnLoad;
     return this;
@@ -92,31 +107,42 @@
     return setReadChanges(enabled).setWriteChanges(enabled);
   }
 
-  public TestNotesMigration setFromEnv() {
+  public TestNotesMigration resetFromEnv() {
     switch (NoteDbMode.get()) {
       case READ_WRITE:
         setWriteChanges(true);
         setReadChanges(true);
         setChangePrimaryStorage(PrimaryStorage.REVIEW_DB);
         setDisableChangeReviewDb(false);
+        setFuseUpdates(false);
         break;
       case WRITE:
         setWriteChanges(true);
         setReadChanges(false);
         setChangePrimaryStorage(PrimaryStorage.REVIEW_DB);
         setDisableChangeReviewDb(false);
+        setFuseUpdates(false);
         break;
       case PRIMARY:
         setWriteChanges(true);
         setReadChanges(true);
         setChangePrimaryStorage(PrimaryStorage.NOTE_DB);
         setDisableChangeReviewDb(false);
+        setFuseUpdates(false);
         break;
       case DISABLE_CHANGE_REVIEW_DB:
         setWriteChanges(true);
         setReadChanges(true);
         setChangePrimaryStorage(PrimaryStorage.NOTE_DB);
         setDisableChangeReviewDb(true);
+        setFuseUpdates(false);
+        break;
+      case FUSED:
+        setWriteChanges(true);
+        setReadChanges(true);
+        setChangePrimaryStorage(PrimaryStorage.NOTE_DB);
+        setDisableChangeReviewDb(true);
+        setFuseUpdates(true);
         break;
       case CHECK:
       case OFF:
@@ -125,8 +151,18 @@
         setReadChanges(false);
         setChangePrimaryStorage(PrimaryStorage.REVIEW_DB);
         setDisableChangeReviewDb(false);
+        setFuseUpdates(false);
         break;
     }
     return this;
   }
+
+  public TestNotesMigration setFrom(NotesMigration other) {
+    setWriteChanges(other.rawWriteChangesSetting());
+    setReadChanges(other.readChanges());
+    setChangePrimaryStorage(other.changePrimaryStorage());
+    setDisableChangeReviewDb(other.disableChangeReviewDb());
+    setFuseUpdates(other.fuseUpdates());
+    return this;
+  }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java
index 4144ed2..dc96e18 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.sshd.SshScope.Context;
 import com.google.inject.Inject;
@@ -84,7 +85,7 @@
     return n;
   }
 
-  private void service() throws IOException, Failure {
+  private void service() throws IOException, PermissionBackendException, Failure {
     project = projectControl.getProjectState().getProject();
 
     try {
@@ -100,5 +101,5 @@
     }
   }
 
-  protected abstract void runImpl() throws IOException, Failure;
+  protected abstract void runImpl() throws IOException, PermissionBackendException, Failure;
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java
index 45835d9..0ac7765 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java
@@ -16,12 +16,16 @@
 
 import com.google.common.base.Throwables;
 import com.google.common.util.concurrent.Atomics;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.CapabilityControl;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import java.io.IOException;
 import java.util.LinkedList;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.atomic.AtomicReference;
 import org.apache.sshd.server.Command;
 import org.apache.sshd.server.Environment;
@@ -30,14 +34,17 @@
 public class AliasCommand extends BaseCommand {
   private final DispatchCommandProvider root;
   private final CurrentUser currentUser;
+  private final PermissionBackend permissionBackend;
   private final CommandName command;
   private final AtomicReference<Command> atomicCmd;
 
   AliasCommand(
       @CommandName(Commands.ROOT) DispatchCommandProvider root,
+      PermissionBackend permissionBackend,
       CurrentUser currentUser,
       CommandName command) {
     this.root = root;
+    this.permissionBackend = permissionBackend;
     this.currentUser = currentUser;
     this.command = command;
     this.atomicCmd = Atomics.newReference();
@@ -47,7 +54,7 @@
   public void start(Environment env) throws IOException {
     try {
       begin(env);
-    } catch (UnloggedFailure e) {
+    } catch (Failure e) {
       String msg = e.getMessage();
       if (!msg.endsWith("\n")) {
         msg += "\n";
@@ -58,7 +65,7 @@
     }
   }
 
-  private void begin(Environment env) throws UnloggedFailure, IOException {
+  private void begin(Environment env) throws IOException, Failure {
     Map<String, CommandProvider> map = root.getMap();
     for (String name : chain(command)) {
       CommandProvider p = map.get(name);
@@ -103,17 +110,16 @@
     }
   }
 
-  private void checkRequiresCapability(Command cmd) throws UnloggedFailure {
-    RequiresCapability rc = cmd.getClass().getAnnotation(RequiresCapability.class);
-    if (rc != null) {
-      CapabilityControl ctl = currentUser.getCapabilities();
-      if (!ctl.canPerform(rc.value()) && !ctl.canAdministrateServer()) {
-        String msg =
-            String.format(
-                "fatal: %s does not have \"%s\" capability.",
-                currentUser.getUserName(), rc.value());
-        throw new UnloggedFailure(BaseCommand.STATUS_NOT_ADMIN, msg);
+  private void checkRequiresCapability(Command cmd) throws Failure {
+    try {
+      Set<GlobalOrPluginPermission> check = GlobalPermission.fromAnnotation(cmd.getClass());
+      try {
+        permissionBackend.user(currentUser).checkAny(check);
+      } catch (AuthException err) {
+        throw new UnloggedFailure(BaseCommand.STATUS_NOT_ADMIN, "fatal: " + err.getMessage());
       }
+    } catch (PermissionBackendException err) {
+      throw new Failure(1, "fatal: permissions unavailable", err);
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommandProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommandProvider.java
index 10beb40..0ef0473 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommandProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommandProvider.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.sshd;
 
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import org.apache.sshd.server.Command;
@@ -27,6 +28,7 @@
   @CommandName(Commands.ROOT)
   private DispatchCommandProvider root;
 
+  @Inject private PermissionBackend permissionBackend;
   @Inject private CurrentUser currentUser;
 
   public AliasCommandProvider(CommandName command) {
@@ -35,6 +37,6 @@
 
   @Override
   public Command get() {
-    return new AliasCommand(root, currentUser, command);
+    return new AliasCommand(root, permissionBackend, currentUser, command);
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java
index bc465ec..dbf5b10 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java
@@ -20,19 +20,26 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.RequestCleanup;
 import com.google.gerrit.server.git.ProjectRunnable;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.CancelableRunnable;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.sshd.SshScope.Context;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.gerrit.util.cli.EndOfOptionsHandler;
 import com.google.inject.Inject;
+import com.google.inject.Injector;
 import java.io.BufferedWriter;
 import java.io.IOException;
 import java.io.InputStream;
@@ -80,6 +87,7 @@
 
   @Inject @CommandExecutor private WorkQueue.Executor executor;
 
+  @Inject private PermissionBackend permissionBackend;
   @Inject private CurrentUser user;
 
   @Inject private SshScope.Context context;
@@ -89,6 +97,10 @@
   @PluginName
   private String pluginName;
 
+  @Inject private Injector injector;
+
+  @Inject private DynamicMap<DynamicOptions.DynamicBean> dynamicBeans = null;
+
   /** The task, as scheduled on a worker thread. */
   private final AtomicReference<Future<?>> task;
 
@@ -193,6 +205,10 @@
    */
   protected void parseCommandLine(Object options) throws UnloggedFailure {
     final CmdLineParser clp = newCmdLineParser(options);
+    DynamicOptions pluginOptions = new DynamicOptions(options, injector, dynamicBeans);
+    pluginOptions.parseDynamicBeans(clp);
+    pluginOptions.setDynamicBeans();
+    pluginOptions.onBeanParseStart();
     try {
       clp.parseArgument(argv);
     } catch (IllegalArgumentException | CmdLineException err) {
@@ -207,6 +223,7 @@
       msg.write(usage());
       throw new UnloggedFailure(1, msg.toString());
     }
+    pluginOptions.onBeanParseEnd();
   }
 
   protected String usage() {
@@ -224,31 +241,6 @@
    * <p>Typically this should be invoked within {@link Command#start(Environment)}, such as:
    *
    * <pre>
-   * startThread(new Runnable() {
-   *   public void run() {
-   *     runImp();
-   *   }
-   * });
-   * </pre>
-   *
-   * @param thunk the runnable to execute on the thread, performing the command's logic.
-   */
-  protected void startThread(final Runnable thunk) {
-    startThread(
-        new CommandRunnable() {
-          @Override
-          public void run() throws Exception {
-            thunk.run();
-          }
-        });
-  }
-
-  /**
-   * Spawn a function into its own thread.
-   *
-   * <p>Typically this should be invoked within {@link Command#start(Environment)}, such as:
-   *
-   * <pre>
    * startThread(new CommandRunnable() {
    *   public void run() throws Exception {
    *     runImp();
@@ -264,7 +256,7 @@
   protected void startThread(final CommandRunnable thunk) {
     final TaskThunk tt = new TaskThunk(thunk);
 
-    if (isAdminHighPriorityCommand() && user.getCapabilities().canAdministrateServer()) {
+    if (isAdminHighPriorityCommand()) {
       // Admin commands should not block the main work threads (there
       // might be an interactive shell there), nor should they wait
       // for the main work threads.
@@ -276,7 +268,15 @@
   }
 
   private boolean isAdminHighPriorityCommand() {
-    return getClass().getAnnotation(AdminHighPriorityCommand.class) != null;
+    if (getClass().getAnnotation(AdminHighPriorityCommand.class) != null) {
+      try {
+        permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+        return true;
+      } catch (AuthException | PermissionBackendException e) {
+        return false;
+      }
+    }
+    return false;
   }
 
   /**
@@ -469,6 +469,7 @@
   }
 
   /** Runnable function which can throw an exception. */
+  @FunctionalInterface
   public interface CommandRunnable {
     void run() throws Exception;
   }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java
index 4488c71..e64ab0e 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java
@@ -16,6 +16,7 @@
 
 import static java.util.stream.Collectors.toList;
 
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -24,6 +25,9 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ChangesCollection;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectControl;
@@ -43,6 +47,7 @@
   private final ReviewDb db;
   private final ChangeNotes.Factory changeNotesFactory;
   private final ChangeControl.GenericFactory changeControlFactory;
+  private final PermissionBackend permissionBackend;
 
   @Inject
   ChangeArgumentParser(
@@ -51,13 +56,15 @@
       ChangeFinder changeFinder,
       ReviewDb db,
       ChangeNotes.Factory changeNotesFactory,
-      ChangeControl.GenericFactory changeControlFactory) {
+      ChangeControl.GenericFactory changeControlFactory,
+      PermissionBackend permissionBackend) {
     this.currentUser = currentUser;
     this.changesCollection = changesCollection;
     this.changeFinder = changeFinder;
     this.db = db;
     this.changeNotesFactory = changeNotesFactory;
     this.changeControlFactory = changeControlFactory;
+    this.permissionBackend = permissionBackend;
   }
 
   public void addChange(String id, Map<Change.Id, ChangeResource> changes)
@@ -80,9 +87,13 @@
     List<ChangeControl> matched =
         useIndex ? changeFinder.find(id, currentUser) : changeFromNotesFactory(id, currentUser);
     List<ChangeControl> toAdd = new ArrayList<>(changes.size());
-    boolean canMaintainServer =
-        currentUser.isIdentifiedUser()
-            && currentUser.asIdentifiedUser().getCapabilities().canMaintainServer();
+    boolean canMaintainServer;
+    try {
+      permissionBackend.user(currentUser).check(GlobalPermission.MAINTAIN_SERVER);
+      canMaintainServer = true;
+    } catch (AuthException | PermissionBackendException e) {
+      canMaintainServer = false;
+    }
     for (ChangeControl ctl : matched) {
       if (!changes.containsKey(ctl.getId())
           && inProject(projectControl, ctl.getProject())
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java
index c6d750c..66b8fe6 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java
@@ -230,13 +230,7 @@
       Future<?> future = task.getAndSet(null);
       if (future != null) {
         future.cancel(true);
-        destroyExecutor.execute(
-            new Runnable() {
-              @Override
-              public void run() {
-                onDestroy();
-              }
-            });
+        destroyExecutor.execute(this::onDestroy);
       }
     }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java
index 2f3d10f6..87e90f4 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java
@@ -20,8 +20,10 @@
 import com.google.common.util.concurrent.Atomics;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.CapabilityUtils;
 import com.google.gerrit.server.args4j.SubcommandHandler;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -41,6 +43,7 @@
   }
 
   private final CurrentUser currentUser;
+  private final PermissionBackend permissionBackend;
   private final Map<String, CommandProvider> commands;
   private final AtomicReference<Command> atomicCmd;
 
@@ -51,8 +54,12 @@
   private List<String> args = new ArrayList<>();
 
   @Inject
-  DispatchCommand(CurrentUser cu, @Assisted final Map<String, CommandProvider> all) {
-    currentUser = cu;
+  DispatchCommand(
+      CurrentUser user,
+      PermissionBackend permissionBackend,
+      @Assisted Map<String, CommandProvider> all) {
+    this.currentUser = user;
+    this.permissionBackend = permissionBackend;
     commands = all;
     atomicCmd = Atomics.newReference();
   }
@@ -117,9 +124,13 @@
       pluginName = ((BaseCommand) cmd).getPluginName();
     }
     try {
-      CapabilityUtils.checkRequiresCapability(currentUser, pluginName, cmd.getClass());
+      permissionBackend
+          .user(currentUser)
+          .checkAny(GlobalPermission.fromAnnotation(pluginName, cmd.getClass()));
     } catch (AuthException e) {
       throw new UnloggedFailure(BaseCommand.STATUS_NOT_ADMIN, e.getMessage());
+    } catch (PermissionBackendException e) {
+      throw new UnloggedFailure(1, "fatal: permission check unavailable", e);
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
index 837865e..a3cf7c1 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
@@ -14,14 +14,15 @@
 
 package com.google.gerrit.sshd;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gerrit.server.ssh.SshKeyCreator;
@@ -92,22 +93,23 @@
 
   static class Loader extends CacheLoader<String, Iterable<SshKeyCacheEntry>> {
     private final SchemaFactory<ReviewDb> schema;
+    private final ExternalIds externalIds;
     private final VersionedAuthorizedKeys.Accessor authorizedKeys;
 
     @Inject
-    Loader(SchemaFactory<ReviewDb> schema, VersionedAuthorizedKeys.Accessor authorizedKeys) {
+    Loader(
+        SchemaFactory<ReviewDb> schema,
+        ExternalIds externalIds,
+        VersionedAuthorizedKeys.Accessor authorizedKeys) {
       this.schema = schema;
+      this.externalIds = externalIds;
       this.authorizedKeys = authorizedKeys;
     }
 
     @Override
     public Iterable<SshKeyCacheEntry> load(String username) throws Exception {
       try (ReviewDb db = schema.open()) {
-        ExternalId user =
-            ExternalId.from(
-                db.accountExternalIds()
-                    .get(
-                        ExternalId.Key.create(SCHEME_USERNAME, username).asAccountExternalIdKey()));
+        ExternalId user = externalIds.get(db, ExternalId.Key.create(SCHEME_USERNAME, username));
         if (user == null) {
           return NO_SUCH_USER;
         }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java
index d25c58b..789a630 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java
@@ -17,7 +17,9 @@
 import static com.google.gerrit.extensions.registration.PrivateInternals_DynamicTypes.registerInParentInjectors;
 import static com.google.inject.Scopes.SINGLETON;
 
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.RemotePeer;
 import com.google.gerrit.server.config.GerritRequestModule;
@@ -94,6 +96,8 @@
         .annotatedWith(UniqueAnnotations.create())
         .to(SshPluginStarterCallback.class);
 
+    DynamicMap.mapOf(binder(), DynamicOptions.DynamicBean.class);
+
     listener().toInstance(registerInParentInjectors());
     listener().to(SshLog.class);
     listener().to(SshDaemon.class);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshPluginStarterCallback.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
index 7f76ec6..9ae1814 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.sshd;
 
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.plugins.Plugin;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
 import com.google.gerrit.server.plugins.StartPluginListener;
@@ -30,10 +32,14 @@
   private static final Logger log = LoggerFactory.getLogger(SshPluginStarterCallback.class);
 
   private final DispatchCommandProvider root;
+  private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
   @Inject
-  SshPluginStarterCallback(@CommandName(Commands.ROOT) DispatchCommandProvider root) {
+  SshPluginStarterCallback(
+      @CommandName(Commands.ROOT) DispatchCommandProvider root,
+      DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
     this.root = root;
+    this.dynamicBeans = dynamicBeans;
   }
 
   @Override
@@ -58,10 +64,19 @@
       try {
         return plugin.getSshInjector().getProvider(key);
       } catch (RuntimeException err) {
-        log.warn(
-            String.format("Plugin %s did not define its top-level command", plugin.getName()), err);
+        if (!providesDynamicOptions(plugin)) {
+          log.warn(
+              String.format(
+                  "Plugin %s did not define its top-level command nor any DynamicOptions",
+                  plugin.getName()),
+              err);
+        }
       }
     }
     return null;
   }
+
+  private boolean providesDynamicOptions(Plugin plugin) {
+    return dynamicBeans.plugins().contains(plugin.getName());
+  }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java
index 53a98eb..54371c1 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java
@@ -18,11 +18,15 @@
 
 import com.google.common.base.Throwables;
 import com.google.common.util.concurrent.Atomics;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.sshd.SshScope.Context;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -45,6 +49,7 @@
 public final class SuExec extends BaseCommand {
   private final SshScope sshScope;
   private final DispatchCommandProvider dispatcher;
+  private final PermissionBackend permissionBackend;
 
   private boolean enableRunAs;
   private CurrentUser caller;
@@ -67,6 +72,7 @@
   SuExec(
       final SshScope sshScope,
       @CommandName(Commands.ROOT) final DispatchCommandProvider dispatcher,
+      PermissionBackend permissionBackend,
       final CurrentUser caller,
       final SshSession session,
       final IdentifiedUser.GenericFactory userFactory,
@@ -74,6 +80,7 @@
       AuthConfig config) {
     this.sshScope = sshScope;
     this.dispatcher = dispatcher;
+    this.permissionBackend = permissionBackend;
     this.caller = caller;
     this.session = session;
     this.userFactory = userFactory;
@@ -115,8 +122,14 @@
       // OK.
     } else if (!enableRunAs) {
       throw die("suexec disabled by auth.enableRunAs = false");
-    } else if (!caller.getCapabilities().canRunAs()) {
-      throw die("suexec not permitted");
+    } else {
+      try {
+        permissionBackend.user(caller).check(GlobalPermission.RUN_AS);
+      } catch (AuthException e) {
+        throw die("suexec not permitted");
+      } catch (PermissionBackendException e) {
+        throw die("suexec not available: " + e);
+      }
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminQueryShell.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminQueryShell.java
index 42c7578..ef1cd81 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminQueryShell.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminQueryShell.java
@@ -15,9 +15,12 @@
 package com.google.gerrit.sshd.commands;
 
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.errors.PermissionDeniedException;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.sshd.AdminHighPriorityCommand;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
@@ -29,8 +32,8 @@
 @RequiresCapability(GlobalCapability.ACCESS_DATABASE)
 @CommandMetaData(name = "gsql", description = "Administrative interface to active database")
 final class AdminQueryShell extends SshCommand {
+  @Inject private PermissionBackend permissionBackend;
   @Inject private QueryShell.Factory factory;
-
   @Inject private IdentifiedUser currentUser;
 
   @Option(name = "--format", usage = "Set output format")
@@ -42,9 +45,11 @@
   @Override
   protected void run() throws Failure {
     try {
-      checkPermission();
-    } catch (PermissionDeniedException err) {
+      permissionBackend.user(currentUser).check(GlobalPermission.ACCESS_DATABASE);
+    } catch (AuthException err) {
       throw die(err.getMessage());
+    } catch (PermissionBackendException e) {
+      throw new Failure(1, "unavailable", e);
     }
 
     QueryShell shell = factory.create(in, out);
@@ -55,22 +60,4 @@
       shell.run();
     }
   }
-
-  /**
-   * Assert that the current user is permitted to perform raw queries.
-   *
-   * <p>As the @RequireCapability guards at various entry points of internal commands implicitly add
-   * administrators (which we want to avoid), we also check permissions within QueryShell and grant
-   * access only to those who can access the database, regardless of whether they are administrators
-   * or not.
-   *
-   * @throws PermissionDeniedException
-   */
-  private void checkPermission() throws PermissionDeniedException {
-    if (!currentUser.getCapabilities().canAccessDatabase()) {
-      throw new PermissionDeniedException(
-          String.format(
-              "%s does not have \"Access Database\" capability.", currentUser.getUserName()));
-    }
-  }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
index 35ddc2a..4434694 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ListChildProjects;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectControl;
@@ -129,7 +130,11 @@
       childProjects.add(pc.getProject().getNameKey());
     }
     if (oldParent != null) {
-      childProjects.addAll(getChildrenForReparenting(oldParent));
+      try {
+        childProjects.addAll(getChildrenForReparenting(oldParent));
+      } catch (PermissionBackendException e) {
+        throw new Failure(1, "permissions unavailable", e);
+      }
     }
 
     for (final Project.NameKey nameKey : childProjects) {
@@ -185,7 +190,8 @@
    * list of child projects does not contain projects that were specified to be excluded from
    * reparenting.
    */
-  private List<Project.NameKey> getChildrenForReparenting(final ProjectControl parent) {
+  private List<Project.NameKey> getChildrenForReparenting(final ProjectControl parent)
+      throws PermissionBackendException {
     final List<Project.NameKey> childProjects = new ArrayList<>();
     final List<Project.NameKey> excluded = new ArrayList<>(excludedChildren.size());
     for (final ProjectControl excludedChild : excludedChildren) {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
index 8b323dc..0df2a80 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.SuggestParentCandidates;
 import com.google.gerrit.sshd.CommandMetaData;
@@ -175,7 +175,7 @@
   @Inject private SuggestParentCandidates suggestParentCandidates;
 
   @Override
-  protected void run() throws UnloggedFailure {
+  protected void run() throws Failure {
     try {
       if (!suggestParent) {
         if (projectName == null) {
@@ -207,14 +207,14 @@
 
         gApi.projects().create(input);
       } else {
-        List<Project.NameKey> parentCandidates = suggestParentCandidates.getNameKeys();
-
-        for (Project.NameKey parent : parentCandidates) {
-          stdout.print(parent + "\n");
+        for (Project.NameKey parent : suggestParentCandidates.getNameKeys()) {
+          stdout.print(parent.get() + '\n');
         }
       }
-    } catch (RestApiException | NoSuchProjectException err) {
+    } catch (RestApiException err) {
       throw die(err);
+    } catch (PermissionBackendException err) {
+      throw new Failure(1, "permissions unavailable", err);
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java
index 21bfe9b..392fd29 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.config.ListCaches;
 import com.google.gerrit.server.config.ListCaches.OutputFormat;
 import com.google.gerrit.server.config.PostCaches;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
@@ -81,6 +82,8 @@
       }
     } catch (RestApiException e) {
       throw die(e.getMessage());
+    } catch (PermissionBackendException e) {
+      throw new Failure(1, "unavailable", e);
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
index fc65cf3..0ee1c28 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.Index;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.sshd.ChangeArgumentParser;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
@@ -57,7 +58,7 @@
     for (ChangeResource rsrc : changes.values()) {
       try {
         index.apply(rsrc, new Index.Input());
-      } catch (IOException | RestApiException | OrmException e) {
+      } catch (IOException | RestApiException | OrmException | PermissionBackendException e) {
         ok = false;
         writeError(
             "error", String.format("failed to index change %s: %s", rsrc.getId(), e.getMessage()));
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/KillCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/KillCommand.java
index 4ebc568..3465a9c 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/KillCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/KillCommand.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.server.config.DeleteTask;
 import com.google.gerrit.server.config.TaskResource;
 import com.google.gerrit.server.config.TasksCollection;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.sshd.AdminHighPriorityCommand;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
@@ -50,7 +51,7 @@
       try {
         TaskResource taskRsrc = tasksCollection.parse(cfgRsrc, IdString.fromDecoded(id));
         deleteTask.apply(taskRsrc, null);
-      } catch (AuthException | ResourceNotFoundException e) {
+      } catch (AuthException | ResourceNotFoundException | PermissionBackendException e) {
         stderr.print("kill: " + id + ": No such task\n");
       }
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Query.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Query.java
index 1192eb5..2e5bf71 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Query.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Query.java
@@ -24,7 +24,7 @@
 import org.kohsuke.args4j.Option;
 
 @CommandMetaData(name = "query", description = "Query the change database")
-class Query extends SshCommand {
+public class Query extends SshCommand {
   @Inject private OutputStreamQuery processor;
 
   @Option(name = "--format", metaVar = "FMT", usage = "Output display format")
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java
index 1ed0bb0..0c8c74a 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java
@@ -82,13 +82,7 @@
 
   @Override
   public void start(final Environment env) {
-    startThread(
-        new Runnable() {
-          @Override
-          public void run() {
-            runImp();
-          }
-        });
+    startThread(this::runImp);
   }
 
   private void runImp() {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
index 21591dd..8c6db83 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
@@ -42,6 +42,7 @@
 import com.google.gerrit.server.account.PutHttpPassword;
 import com.google.gerrit.server.account.PutName;
 import com.google.gerrit.server.account.PutPreferred;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gwtorm.server.OrmException;
@@ -174,7 +175,8 @@
   }
 
   private void setAccount()
-      throws OrmException, IOException, UnloggedFailure, ConfigInvalidException {
+      throws OrmException, IOException, UnloggedFailure, ConfigInvalidException,
+          PermissionBackendException {
     user = genericUserFactory.create(id);
     rsrc = new AccountResource(user);
     try {
@@ -227,7 +229,8 @@
   }
 
   private void addSshKeys(List<String> sshKeys)
-      throws RestApiException, OrmException, IOException, ConfigInvalidException {
+      throws RestApiException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     for (final String sshKey : sshKeys) {
       AddSshKey.Input in = new AddSshKey.Input();
       in.raw = RawInputUtil.create(sshKey.getBytes(UTF_8), "plain/text");
@@ -237,7 +240,7 @@
 
   private void deleteSshKeys(List<String> sshKeys)
       throws RestApiException, OrmException, RepositoryNotFoundException, IOException,
-          ConfigInvalidException {
+          ConfigInvalidException, PermissionBackendException {
     List<SshKeyInfo> infos = getSshKeys.apply(rsrc);
     if (sshKeys.contains("ALL")) {
       for (SshKeyInfo i : infos) {
@@ -256,14 +259,15 @@
 
   private void deleteSshKey(SshKeyInfo i)
       throws AuthException, OrmException, RepositoryNotFoundException, IOException,
-          ConfigInvalidException {
+          ConfigInvalidException, PermissionBackendException {
     AccountSshKey sshKey =
         new AccountSshKey(new AccountSshKey.Id(user.getAccountId(), i.seq), i.sshPublicKey);
     deleteSshKey.apply(new AccountResource.SshKey(user, sshKey), null);
   }
 
   private void addEmail(String email)
-      throws UnloggedFailure, RestApiException, OrmException, IOException, ConfigInvalidException {
+      throws UnloggedFailure, RestApiException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     EmailInput in = new EmailInput();
     in.email = email;
     in.noConfirmation = true;
@@ -275,7 +279,8 @@
   }
 
   private void deleteEmail(String email)
-      throws RestApiException, OrmException, IOException, ConfigInvalidException {
+      throws RestApiException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     if (email.equals("ALL")) {
       List<EmailInfo> emails = getEmails.apply(rsrc);
       for (EmailInfo e : emails) {
@@ -286,7 +291,8 @@
     }
   }
 
-  private void putPreferred(String email) throws RestApiException, OrmException, IOException {
+  private void putPreferred(String email)
+      throws RestApiException, OrmException, IOException, PermissionBackendException {
     for (EmailInfo e : getEmails.apply(rsrc)) {
       if (e.email.equals(email)) {
         putPreferred.apply(new AccountResource.Email(user, email), null);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetProjectCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
index 20f65ad..c275af8 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
@@ -14,29 +14,23 @@
 
 package com.google.gerrit.sshd.commands;
 
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.PutConfig;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 @CommandMetaData(name = "set-project", description = "Change a project's settings")
 final class SetProjectCommand extends SshCommand {
-  private static final Logger log = LoggerFactory.getLogger(SetProjectCommand.class);
-
   @Argument(index = 0, required = true, metaVar = "NAME", usage = "name of the project")
   private ProjectControl projectControl;
 
@@ -144,64 +138,30 @@
   @Option(name = "--max-object-size-limit", usage = "max Git object size for this project")
   private String maxObjectSizeLimit;
 
-  @Inject private MetaDataUpdate.User metaDataUpdateFactory;
-
-  @Inject private ProjectCache projectCache;
+  @Inject private PutConfig putConfig;
 
   @Override
   protected void run() throws Failure {
-    if (!projectControl.isOwner()) {
-      throw new UnloggedFailure(1, "restricted to project owner");
+    ConfigInput configInput = new ConfigInput();
+    configInput.requireChangeId = requireChangeID;
+    configInput.submitType = submitType;
+    configInput.useContentMerge = contentMerge;
+    configInput.useContributorAgreements = contributorAgreements;
+    configInput.useSignedOffBy = signedOffBy;
+    configInput.state = state;
+    configInput.maxObjectSizeLimit = maxObjectSizeLimit;
+    // Description is different to other parameters, null won't result in
+    // keeping the existing description, it would delete it.
+    if (Strings.emptyToNull(projectDescription) != null) {
+      configInput.description = projectDescription;
+    } else {
+      configInput.description = projectControl.getProject().getDescription();
     }
-    Project ctlProject = projectControl.getProject();
-    Project.NameKey nameKey = ctlProject.getNameKey();
-    String name = ctlProject.getName();
-    final StringBuilder err = new StringBuilder();
 
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(nameKey)) {
-      ProjectConfig config = ProjectConfig.read(md);
-      Project project = config.getProject();
-
-      if (requireChangeID != null) {
-        project.setRequireChangeID(requireChangeID);
-      }
-      if (submitType != null) {
-        project.setSubmitType(submitType);
-      }
-      if (contentMerge != null) {
-        project.setUseContentMerge(contentMerge);
-      }
-      if (contributorAgreements != null) {
-        project.setUseContributorAgreements(contributorAgreements);
-      }
-      if (signedOffBy != null) {
-        project.setUseSignedOffBy(signedOffBy);
-      }
-      if (projectDescription != null) {
-        project.setDescription(projectDescription);
-      }
-      if (state != null) {
-        project.setState(state);
-      }
-      if (maxObjectSizeLimit != null) {
-        project.setMaxObjectSizeLimit(maxObjectSizeLimit);
-      }
-      md.setMessage("Project settings updated");
-      config.commit(md);
-    } catch (RepositoryNotFoundException notFound) {
-      err.append("Project ").append(name).append(" not found\n");
-    } catch (IOException | ConfigInvalidException e) {
-      final String msg = "Cannot update project " + name;
-      log.error(msg, e);
-      err.append("error: ").append(msg).append("\n");
-    }
-    projectCache.evict(ctlProject);
-
-    if (err.length() > 0) {
-      while (err.charAt(err.length() - 1) == '\n') {
-        err.setLength(err.length() - 1);
-      }
-      throw die(err.toString());
+    try {
+      putConfig.apply(new ProjectResource(projectControl), configInput);
+    } catch (RestApiException e) {
+      throw die(e);
     }
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowCaches.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowCaches.java
index e16f270..1ed7db3 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.common.Version;
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.config.GetSummary;
@@ -34,6 +35,9 @@
 import com.google.gerrit.server.config.ListCaches;
 import com.google.gerrit.server.config.ListCaches.CacheInfo;
 import com.google.gerrit.server.config.ListCaches.CacheType;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gerrit.sshd.SshDaemon;
@@ -80,12 +84,10 @@
   private boolean showThreads;
 
   @Inject private SshDaemon daemon;
-
   @Inject private ListCaches listCaches;
-
   @Inject private GetSummary getSummary;
-
   @Inject private CurrentUser self;
+  @Inject private PermissionBackend permissionBackend;
 
   @Option(
     name = "--width",
@@ -168,7 +170,15 @@
     printDiskCaches(caches);
     stdout.print('\n');
 
-    if (self.getCapabilities().canMaintainServer()) {
+    boolean showJvm;
+    try {
+      permissionBackend.user(self).check(GlobalPermission.MAINTAIN_SERVER);
+      showJvm = true;
+    } catch (AuthException | PermissionBackendException e) {
+      // Silently ignore and do not display detailed JVM information.
+      showJvm = false;
+    }
+    if (showJvm) {
       sshSummary();
 
       SummaryInfo summary = getSummary.setGc(gc).setJvm(showJVM).apply(new ConfigResource());
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowQueue.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowQueue.java
index 13db697..dfb9c9c 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowQueue.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowQueue.java
@@ -27,6 +27,9 @@
 import com.google.gerrit.server.config.ListTasks.TaskInfo;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.Task;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.sshd.AdminHighPriorityCommand;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
@@ -60,10 +63,9 @@
   )
   private boolean groupByQueue;
 
+  @Inject private PermissionBackend permissionBackend;
   @Inject private ListTasks listTasks;
-
   @Inject private IdentifiedUser currentUser;
-
   @Inject private WorkQueue workQueue;
 
   private int columns = 80;
@@ -83,7 +85,7 @@
   }
 
   @Override
-  protected void run() throws UnloggedFailure {
+  protected void run() throws Failure {
     maxCommandWidth = wide ? Integer.MAX_VALUE : columns - 8 - 12 - 12 - 4 - 4;
     stdout.print(
         String.format(
@@ -97,10 +99,12 @@
       tasks = listTasks.apply(new ConfigResource());
     } catch (AuthException e) {
       throw die(e);
+    } catch (PermissionBackendException e) {
+      throw new Failure(1, "permission backend unavailable", e);
     }
-    boolean viewAll = currentUser.getCapabilities().canViewQueue();
-    long now = TimeUtil.nowMs();
 
+    boolean viewAll = permissionBackend.user(currentUser).testOrFalse(GlobalPermission.VIEW_QUEUE);
+    long now = TimeUtil.nowMs();
     if (groupByQueue) {
       ListMultimap<String, TaskInfo> byQueue = byQueue(tasks);
       for (String queueName : byQueue.keySet()) {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java
index 4b8771a..093808c 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java
@@ -17,9 +17,14 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.AllowedFormats;
 import com.google.gerrit.server.change.ArchiveFormat;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.sshd.AbstractGitCommand;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -115,6 +120,8 @@
     private List<String> path;
   }
 
+  @Inject private PermissionBackend permissionBackend;
+  @Inject private IdentifiedUser user;
   @Inject private AllowedFormats allowedFormats;
   @Inject private ReviewDb db;
   private Options options = new Options();
@@ -156,7 +163,7 @@
   }
 
   @Override
-  protected void runImpl() throws IOException, Failure {
+  protected void runImpl() throws IOException, PermissionBackendException, Failure {
     PacketLineOut packetOut = new PacketLineOut(out);
     packetOut.setFlushOnEnd(true);
     packetOut.writeString("ACK");
@@ -177,8 +184,8 @@
         throw new Failure(4, "fatal: reference not found");
       }
 
-      // Verify the user has permissions to read the specified reference
-      if (!projectControl.allRefsAreVisible() && !canRead(treeId)) {
+      // Verify the user has permissions to read the specified tree.
+      if (!canRead(treeId)) {
         throw new Failure(5, "fatal: cannot perform upload-archive operation");
       }
 
@@ -235,10 +242,16 @@
     return Collections.emptyMap();
   }
 
-  private boolean canRead(ObjectId revId) throws IOException {
-    try (RevWalk rw = new RevWalk(repo)) {
-      RevCommit commit = rw.parseCommit(revId);
-      return projectControl.canReadCommit(db, repo, commit);
+  private boolean canRead(ObjectId revId) throws IOException, PermissionBackendException {
+    try {
+      permissionBackend.user(user).project(project.getNameKey()).check(ProjectPermission.READ);
+      return true;
+    } catch (AuthException e) {
+      // Check reachability of the specific revision.
+      try (RevWalk rw = new RevWalk(repo)) {
+        RevCommit commit = rw.parseCommit(revId);
+        return projectControl.canReadCommit(db, repo, commit);
+      }
     }
   }
 }
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/restapi/BinaryResultSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/restapi/BinaryResultSubject.java
index 989ab0f..30ac496 100644
--- a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/restapi/BinaryResultSubject.java
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/restapi/BinaryResultSubject.java
@@ -18,6 +18,7 @@
 
 import com.google.common.truth.FailureStrategy;
 import com.google.common.truth.PrimitiveByteArraySubject;
+import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
 import com.google.common.truth.SubjectFactory;
 import com.google.common.truth.Truth;
@@ -51,6 +52,15 @@
     super(failureStrategy, binaryResult);
   }
 
+  public StringSubject asString() throws IOException {
+    isNotNull();
+    // We shouldn't close the BinaryResult within this method as it might still
+    // be used afterwards. Besides, closing it doesn't have an effect for most
+    // implementations of a BinaryResult.
+    BinaryResult binaryResult = actual();
+    return Truth.assertThat(binaryResult.asString());
+  }
+
   public PrimitiveByteArraySubject bytes() throws IOException {
     isNotNull();
     // We shouldn't close the BinaryResult within this method as it might still
diff --git a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java
index 11f380d..8aa5b9e 100644
--- a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java
+++ b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java
@@ -44,6 +44,8 @@
 import java.io.Writer;
 import java.lang.annotation.Annotation;
 import java.lang.reflect.AnnotatedElement;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -58,8 +60,10 @@
 import org.kohsuke.args4j.spi.BooleanOptionHandler;
 import org.kohsuke.args4j.spi.EnumOptionHandler;
 import org.kohsuke.args4j.spi.FieldSetter;
+import org.kohsuke.args4j.spi.MethodSetter;
 import org.kohsuke.args4j.spi.OptionHandler;
 import org.kohsuke.args4j.spi.Setter;
+import org.kohsuke.args4j.spi.Setters;
 
 /**
  * Extended command line parser which handles --foo=value arguments.
@@ -253,6 +257,10 @@
     return findHandler(makeOption(name)) instanceof BooleanOptionHandler;
   }
 
+  public void parseWithPrefix(String prefix, Object bean) {
+    parser.parseWithPrefix(prefix, bean);
+  }
+
   private String makeOption(String name) {
     if (!name.startsWith("-")) {
       if (name.length() == 1) {
@@ -313,6 +321,70 @@
     throw new CmdLineException(parser, String.format("invalid boolean \"%s=%s\"", name, value));
   }
 
+  private static class PrefixedOption implements Option {
+    String prefix;
+    Option o;
+
+    PrefixedOption(String prefix, Option o) {
+      this.prefix = prefix;
+      this.o = o;
+    }
+
+    @Override
+    public String name() {
+      return getPrefixedName(prefix, o.name());
+    }
+
+    @Override
+    public String[] aliases() {
+      String[] prefixedAliases = new String[o.aliases().length];
+      for (int i = 0; i < prefixedAliases.length; i++) {
+        prefixedAliases[i] = getPrefixedName(prefix, o.aliases()[i]);
+      }
+      return prefixedAliases;
+    }
+
+    @Override
+    public String usage() {
+      return o.usage();
+    }
+
+    @Override
+    public String metaVar() {
+      return o.metaVar();
+    }
+
+    @Override
+    public boolean required() {
+      return o.required();
+    }
+
+    @Override
+    public boolean hidden() {
+      return o.hidden();
+    }
+
+    @SuppressWarnings("rawtypes")
+    @Override
+    public Class<? extends OptionHandler> handler() {
+      return o.handler();
+    }
+
+    @Override
+    public String[] depends() {
+      return o.depends();
+    }
+
+    @Override
+    public Class<? extends Annotation> annotationType() {
+      return o.annotationType();
+    }
+
+    private static String getPrefixedName(String prefix, String name) {
+      return "--" + prefix + name;
+    }
+  }
+
   private class MyParser extends org.kohsuke.args4j.CmdLineParser {
     @SuppressWarnings("rawtypes")
     private List<OptionHandler> optionsList;
@@ -324,6 +396,25 @@
       ensureOptionsInitialized();
     }
 
+    // NOTE: Argument annotations on bean are ignored.
+    public void parseWithPrefix(String prefix, Object bean) {
+      // recursively process all the methods/fields.
+      for (Class<?> c = bean.getClass(); c != null; c = c.getSuperclass()) {
+        for (Method m : c.getDeclaredMethods()) {
+          Option o = m.getAnnotation(Option.class);
+          if (o != null) {
+            addOption(new MethodSetter(this, bean, m), new PrefixedOption(prefix, o));
+          }
+        }
+        for (Field f : c.getDeclaredFields()) {
+          Option o = f.getAnnotation(Option.class);
+          if (o != null) {
+            addOption(Setters.create(f, bean), new PrefixedOption(prefix, o));
+          }
+        }
+      }
+    }
+
     @SuppressWarnings({"unchecked", "rawtypes"})
     @Override
     protected OptionHandler createOptionHandler(final OptionDef option, final Setter setter) {
diff --git a/gerrit-war/pom.xml b/gerrit-war/pom.xml
index 1236122..0357e6c4 100644
--- a/gerrit-war/pom.xml
+++ b/gerrit-war/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>2.14</version>
+  <version>2.15-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java b/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
index a4e8e3c..f5cd9fc 100644
--- a/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
+++ b/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
@@ -62,6 +62,7 @@
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.gerrit.server.plugins.PluginRestApiModule;
+import com.google.gerrit.server.project.DefaultPermissionBackendModule;
 import com.google.gerrit.server.schema.DataSourceModule;
 import com.google.gerrit.server.schema.DataSourceProvider;
 import com.google.gerrit.server.schema.DataSourceType;
@@ -317,6 +318,7 @@
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new SearchingChangeCacheImpl.Module());
     modules.add(new InternalAccountDirectory.Module());
+    modules.add(new DefaultPermissionBackendModule());
     modules.add(new DefaultCacheFactory.Module());
     modules.add(cfgInjector.getInstance(MailReceiver.Module.class));
     modules.add(new SmtpEmailSender.Module());
diff --git a/lib/elasticsearch/BUILD b/lib/elasticsearch/BUILD
index 8dc4bce..c40925e 100644
--- a/lib/elasticsearch/BUILD
+++ b/lib/elasticsearch/BUILD
@@ -26,9 +26,6 @@
     ],
 )
 
-# Java REST client for Elasticsearch.
-VERSION = "0.1.7"
-
 java_library(
     name = "jest-common",
     data = ["//lib:LICENSE-Apache2.0"],
diff --git a/lib/jgit/jgit.bzl b/lib/jgit/jgit.bzl
index 502c060..38eb791 100644
--- a/lib/jgit/jgit.bzl
+++ b/lib/jgit/jgit.bzl
@@ -1,12 +1,12 @@
 load("//tools/bzl:maven_jar.bzl", "GERRIT", "MAVEN_LOCAL", "MAVEN_CENTRAL", "maven_jar")
 
-_JGIT_VERS = "4.7.0.201704051617-r"
+_JGIT_VERS = "4.7.0.201704051617-r.37-gc80d8c590"
 
-_DOC_VERS = _JGIT_VERS # Set to _JGIT_VERS unless using a snapshot
+_DOC_VERS = "4.7.0.201704051617-r" # Set to _JGIT_VERS unless using a snapshot
 
 JGIT_DOC_URL = "http://download.eclipse.org/jgit/site/" + _DOC_VERS + "/apidocs"
 
-_JGIT_REPO = MAVEN_CENTRAL # Leave here even if set to MAVEN_CENTRAL.
+_JGIT_REPO = GERRIT # Leave here even if set to MAVEN_CENTRAL.
 
 # set this to use a local version.
 # "/home/<user>/projects/jgit"
@@ -26,28 +26,28 @@
         name = "jgit_lib",
         artifact = "org.eclipse.jgit:org.eclipse.jgit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "99be65d1827276b97d4f51668b60f4a38f282bda",
-        src_sha1 = "de519d6f352aaf12e4c65f7590591326ac24d2e8",
+        sha1 = "edb739cd1e7c72dab361a8f6011807ae7fae35e2",
+        src_sha1 = "ddf922143dd88ec8fbd2c44f48f203340e6b4d54",
         unsign = True,
     )
     maven_jar(
         name = "jgit_servlet",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.http.server:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "72fa98ebf001aadd3dcb99ca8f7fcd90983da56b",
+        sha1 = "e864cb9f7e16d77ff75805708cd82e6f82a73246",
         unsign = True,
     )
     maven_jar(
         name = "jgit_archive",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.archive:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "f825504a903dfe8d3daa61d6ab5c26fbad92c954",
+        sha1 = "cc944356eb8ca74446341729d539f5b9faccb698",
     )
     maven_jar(
         name = "jgit_junit",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.junit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "e0dbc6d3568b2ba65c9421af2f06e4158a624bcb",
+        sha1 = "eae23cc952d8b9d332287f7a4d4200c17ae78411",
         unsign = True,
     )
 
diff --git a/lib/js/bower_archives.bzl b/lib/js/bower_archives.bzl
index 67cc7c0..9b96521 100644
--- a/lib/js/bower_archives.bzl
+++ b/lib/js/bower_archives.bzl
@@ -10,8 +10,8 @@
   bower_archive(
     name = "accessibility-developer-tools",
     package = "accessibility-developer-tools",
-    version = "2.11.0",
-    sha1 = "792cb24b649dafb316e7e536f8ae65d0d7b52bab")
+    version = "2.12.0",
+    sha1 = "88ae82dcdeb6c658f76eff509d0ee425cae14d49")
   bower_archive(
     name = "async",
     package = "async",
@@ -40,13 +40,13 @@
   bower_archive(
     name = "iron-fit-behavior",
     package = "iron-fit-behavior",
-    version = "1.2.5",
-    sha1 = "5938815cd227843fc77ebeac480b999600a76157")
+    version = "1.2.6",
+    sha1 = "59daa8526aac59aa72b8edcbbd24d9eed555a0f5")
   bower_archive(
     name = "iron-flex-layout",
     package = "iron-flex-layout",
-    version = "1.3.1",
-    sha1 = "ba696394abff5e799fc06eb11bff4720129a1b52")
+    version = "1.3.2",
+    sha1 = "b896041aad049a5e889a0165828d7b1262e32612")
   bower_archive(
     name = "iron-form-element-behavior",
     package = "iron-form-element-behavior",
@@ -105,5 +105,5 @@
   bower_archive(
     name = "webcomponentsjs",
     package = "webcomponentsjs",
-    version = "0.7.23",
-    sha1 = "3d62269e614175573b0a0f3039aab05d40f0a763")
+    version = "0.7.24",
+    sha1 = "559227f8ee9db9bfbd81989f24510cc0c1bfc65c")
diff --git a/lib/polymer_externs/BUILD b/lib/polymer_externs/BUILD
new file mode 100644
index 0000000..2f1bdbd
--- /dev/null
+++ b/lib/polymer_externs/BUILD
@@ -0,0 +1,26 @@
+# Copyright (C) 2017 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(
+    default_visibility = ["//visibility:public"],
+)
+
+load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_library")
+
+closure_js_library(
+    name = "polymer_closure",
+    srcs = ["@polymer_closure//file"],
+    data = ["//lib:LICENSE-Apache2.0"],
+    no_closure_library = True,
+)
diff --git a/plugins/download-commands b/plugins/download-commands
index 8357e94..6ee2462 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit 8357e942dd9da82884a4e1b6e4697479153d0496
+Subproject commit 6ee246245b9200062e753d1c6943d5782cb7fee0
diff --git a/plugins/replication b/plugins/replication
index dc18cb6..8fcaee0 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit dc18cb665eb452d71602e1a980d7669a67265dfc
+Subproject commit 8fcaee07b6d457b3fc6ed44d9e9d441e3cd174ac
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index 76979f1..77d5781 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -68,9 +68,10 @@
 that serves PolyGerrit:
 
 ```sh
-bazel build polygerrit && \
-java -jar bazel-bin/polygerrit.war daemon --polygerrit-dev \
--d ../gerrit_testsite --console-log --show-stack-trace
+bazel build polygerrit &&
+  $(bazel info output_base)/external/local_jdk/bin/java \
+  -jar bazel-bin/polygerrit.war daemon --polygerrit-dev \
+  -d ../gerrit_testsite --console-log --show-stack-trace
 ```
 
 ## Running Tests
@@ -94,6 +95,9 @@
 ./polygerrit-ui/app/run_test.sh
 ```
 
+To allow the tests to run in Safari it is necessary to enable the
+"Allow Remote Automation" option under the "Develop" menu.
+
 If you need to pass additional arguments to `wct`:
 
 ```sh
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 94f9bc8..7c12fa2 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -3,6 +3,7 @@
 )
 
 load("//tools/bzl:genrule2.bzl", "genrule2")
+load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_library", "closure_js_binary")
 load(
     "//tools/bzl:js.bzl",
     "bower_component_bundle",
@@ -29,6 +30,33 @@
     deps = ["//polygerrit-ui:polygerrit_components.bower_components"],
 )
 
+closure_js_library(
+    name = "closure_lib",
+    srcs = ["gr-app.js"],
+    convention = "GOOGLE",
+    language = "ECMASCRIPT6",
+    suppress = [
+        "JSC_BAD_JSDOC_ANNOTATION",
+    ],
+    deps = [
+        "//lib/polymer_externs:polymer_closure",
+        "@io_bazel_rules_closure//closure/library",
+    ],
+)
+
+closure_js_binary(
+    name = "closure_bin",
+    # Known issue: Closure compilation not compatible with Polymer behaviors.
+    # See: https://github.com/google/closure-compiler/issues/2042
+    compilation_level = "WHITESPACE_ONLY",
+    defs = [
+        "--polymer_pass",
+        "--jscomp_off=duplicate",
+    ],
+    language = "ECMASCRIPT5",
+    deps = [":closure_lib"],
+)
+
 filegroup(
     name = "top_sources",
     srcs = [
@@ -42,6 +70,14 @@
     srcs = glob(["styles/**/*.css"]),
 )
 
+filegroup(
+    name = "app_sources",
+    srcs = [
+        "closure_bin.js",
+        "gr-app.html",
+    ],
+)
+
 genrule2(
     name = "polygerrit_ui",
     srcs = [
@@ -49,7 +85,7 @@
         "//lib/js:highlightjs_files",
         ":top_sources",
         ":css_sources",
-        ":gr-app",
+        ":app_sources",
         # we extract from the zip, but depend on the component for license checking.
         "@webcomponentsjs//:zipfile",
         "//lib/js:webcomponentsjs",
@@ -57,7 +93,7 @@
     outs = ["polygerrit_ui.zip"],
     cmd = " && ".join([
         "mkdir -p $$TMP/polygerrit_ui/{styles,fonts,bower_components/{highlightjs,webcomponentsjs},elements}",
-        "cp $(locations :gr-app) $$TMP/polygerrit_ui/elements/",
+        "for f in $(locations :app_sources); do ext=$${f##*.}; cp -p $$f $$TMP/polygerrit_ui/elements/gr-app.$$ext; done",
         "cp $(locations //lib/fonts:sourcecodepro) $$TMP/polygerrit_ui/fonts/",
         "for f in $(locations :top_sources); do cp $$f $$TMP/polygerrit_ui/; done",
         "for f in $(locations :css_sources); do cp $$f $$TMP/polygerrit_ui/styles; done",
diff --git a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
index 1e277bc..0f38f36 100644
--- a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
@@ -23,6 +23,7 @@
 
 <link rel="import" href="../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <script>
+  /** @type {String} */
   window.CANONICAL_PATH = '/r';
 </script>
 <link rel="import" href="base-url-behavior.html">
@@ -72,4 +73,4 @@
       );
     });
   });
-</script>
\ No newline at end of file
+</script>
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
index acf3a62..8807917 100644
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
@@ -36,6 +36,40 @@
         }
       }
     },
+
+    computeAllPatchSets: function(change) {
+      var patchNums = [];
+      for (var commit in change.revisions) {
+        if (change.revisions.hasOwnProperty(commit)) {
+          patchNums.push({
+            num: change.revisions[commit]._number,
+            desc: change.revisions[commit].description,
+          });
+        }
+      }
+      return patchNums.sort(function(a, b) { return a.num - b.num; });
+    },
+
+    computeLatestPatchNum: function(allPatchSets) {
+      return allPatchSets[allPatchSets.length - 1].num;
+    },
+
+    /**
+     * Check whether there is no newer patch than the latest patch that was
+     * available when this change was loaded.
+     * @return {Promise} A promise that yields true if the latest patch has been
+     *     loaded, and false if a newer patch has been uploaded in the meantime.
+     */
+    fetchIsLatestKnown: function(change, restAPI) {
+      var knownLatest = this.computeLatestPatchNum(
+          this.computeAllPatchSets(change));
+      return restAPI.getChangeDetail(change._number)
+          .then(function(detail) {
+            var actualLatest = this.computeLatestPatchNum(
+                this.computeAllPatchSets(detail));
+            return actualLatest <= knownLatest;
+          }.bind(this));
+    },
   };
 
   window.Gerrit = window.Gerrit || {};
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
index 7ff9371..892d94b 100644
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
@@ -34,5 +34,50 @@
       assert.deepEqual(get(revisions, 2), revisions[2]);
       assert.equal(get(revisions, '3'), undefined);
     });
+
+    test('fetchIsLatestKnown on latest', function(done) {
+      var knownChange = {
+        revisions: {
+          sha1: {description: 'patch 1', _number: 1},
+          sha2: {description: 'patch 2', _number: 2},
+        },
+      };
+      var mockRestApi = {
+        getChangeDetail: function() {
+          return Promise.resolve(knownChange);
+        },
+      };
+      Gerrit.PatchSetBehavior.fetchIsLatestKnown(knownChange, mockRestApi)
+          .then(function(isLatest) {
+            assert.isTrue(isLatest);
+            done();
+          });
+    });
+
+    test('fetchIsLatestKnown not on latest', function(done) {
+      var knownChange = {
+        revisions: {
+          sha1: {description: 'patch 1', _number: 1},
+          sha2: {description: 'patch 2', _number: 2},
+        },
+      };
+      var actualChange = {
+        revisions: {
+          sha1: {description: 'patch 1', _number: 1},
+          sha2: {description: 'patch 2', _number: 2},
+          sha3: {description: 'patch 3', _number: 3},
+        },
+      };
+      var mockRestApi = {
+        getChangeDetail: function() {
+          return Promise.resolve(actualChange);
+        },
+      };
+      Gerrit.PatchSetBehavior.fetchIsLatestKnown(knownChange, mockRestApi)
+          .then(function(isLatest) {
+            assert.isFalse(isLatest);
+            done();
+          });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
index e4c4d11..8eb63a4 100644
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
@@ -20,7 +20,10 @@
   var TooltipBehavior = {
 
     properties: {
-      hasTooltip: Boolean,
+      hasTooltip: {
+        type: Boolean,
+        observer: '_setupTooltipListeners',
+      },
 
       _isTouchDevice: {
         type: Boolean,
@@ -30,16 +33,10 @@
       },
       _tooltip: Element,
       _titleText: String,
-    },
-
-    attached: function() {
-      if (!this.hasTooltip) { return; }
-
-      this.addEventListener('mouseenter', this._handleShowTooltip.bind(this));
-      this.addEventListener('mouseleave', this._handleHideTooltip.bind(this));
-      this.addEventListener('tap', this._handleHideTooltip.bind(this));
-
-      this.listen(window, 'scroll', '_handleWindowScroll');
+      _hasSetupTooltipListeners: {
+        type: Boolean,
+        value: false,
+      },
     },
 
     detached: function() {
@@ -47,6 +44,16 @@
       this.unlisten(window, 'scroll', '_handleWindowScroll');
     },
 
+    _setupTooltipListeners: function() {
+      if (this._hasSetupTooltipListeners || !this.hasTooltip) { return; }
+      this._hasSetupTooltipListeners = true;
+
+      this.addEventListener('mouseenter', this._handleShowTooltip.bind(this));
+      this.addEventListener('mouseleave', this._handleHideTooltip.bind(this));
+      this.addEventListener('tap', this._handleHideTooltip.bind(this));
+      this.listen(window, 'scroll', '_handleWindowScroll');
+    },
+
     _handleShowTooltip: function(e) {
       if (this._isTouchDevice) { return; }
 
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
index 99bfc03..580bb4b 100644
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
@@ -118,5 +118,11 @@
       flushAsynchronousOperations();
       assert.isTrue(element._handleHideTooltip.called);
     });
+
+    test('sets up listeners when has-tooltip is changed', function() {
+      var addListenerStub = sandbox.stub(element, 'addEventListener');
+      element.hasTooltip = true;
+      assert.isTrue(addListenerStub.called);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
index 3d99cec..6003bc4 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
@@ -32,6 +32,13 @@
       return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
     },
 
+    isModifierPressed: function(e, modifier) {
+      e = getKeyboardEvent(e);
+      // When e is a keyboardEvent, e.event is not null.
+      if (e.event) { e = e.event; }
+      return e[modifier];
+    },
+
     shouldSuppressKeyboardShortcut: function(e) {
       e = getKeyboardEvent(e);
       if (e.path[0].tagName === 'INPUT' || e.path[0].tagName === 'TEXTAREA') {
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
index a72eb75..840998c 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
@@ -127,5 +127,26 @@
       MockInteractions.keyDownOn(element, 75, 'alt', 'k');
       assert.isTrue(spy.lastCall.returnValue);
     });
+
+    test('isModifierPressed returns accurate value', function() {
+      var spy = sandbox.spy(element, 'isModifierPressed');
+      element._handleKey = function(e) {
+        element.isModifierPressed(e, 'shiftKey');
+      };
+      MockInteractions.keyDownOn(element, 75, 'shift', 'k');
+      assert.isTrue(spy.lastCall.returnValue);
+      MockInteractions.keyDownOn(element, 75, null, 'k');
+      assert.isFalse(spy.lastCall.returnValue);
+      MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
+      assert.isFalse(spy.lastCall.returnValue);
+      MockInteractions.keyDownOn(element, 75, null, 'k');
+      assert.isFalse(spy.lastCall.returnValue);
+      MockInteractions.keyDownOn(element, 75, 'meta', 'k');
+      assert.isFalse(spy.lastCall.returnValue);
+      MockInteractions.keyDownOn(element, 75, null, 'k');
+      assert.isFalse(spy.lastCall.returnValue);
+      MockInteractions.keyDownOn(element, 75, 'alt', 'k');
+      assert.isFalse(spy.lastCall.returnValue);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
index f71fe8f..e862cba 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
@@ -100,7 +100,7 @@
     },
 
     changeBaseURL: function(changeNum, patchNum) {
-      var v =  this.getBaseUrl() + '/changes/' + changeNum;
+      var v = this.getBaseUrl() + '/changes/' + changeNum;
       if (patchNum) {
         v += '/revisions/' + patchNum;
       }
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
index 2b3e858..f3c44eb 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
@@ -21,6 +21,7 @@
 <script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
 <script src="../../bower_components/web-component-tester/browser.js"></script>
 <script>
+  /** @type {String} */
   window.CANONICAL_PATH = '/r';
 </script>
 
@@ -74,4 +75,4 @@
       assert.deepEqual(element.changePath('1'), '/r/c/1');
     });
   });
-</script>
\ No newline at end of file
+</script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
index 8a95fa8..362f101 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
@@ -35,6 +35,13 @@
       th {
         text-align: left;
       }
+      .groupHeader {
+        background-color: #eee;
+        border-top: 1em solid #fff;
+      }
+      .headerRow + tr {
+        border: none;
+      }
     </style>
     <style include="gr-change-list-styles"></style>
     <table id="changeList">
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
index 46f2ed2..9a0b115 100644
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
@@ -22,7 +22,6 @@
      *
      * @event add
      */
-
     properties: {
       borderless: Boolean,
       change: Object,
@@ -64,6 +63,10 @@
       this.$.input.clear();
     },
 
+    setText: function(text) {
+      this.$.input.setText(text);
+    },
+
     _handleInputCommit: function(e) {
       this.fire('add', {value: e.detail.value});
     },
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
index 2948b4b..8797d32 100644
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
@@ -171,5 +171,15 @@
         });
       });
     });
+
+    test('setText', function() {
+      // Spy on query, as that is called when _updateSuggestions proceeds.
+      var suggestSpy = sandbox.spy(element.$.input, 'query');
+      element.setText('test text');
+      flushAsynchronousOperations();
+
+      assert.equal(element.$.input.$.input.value, 'test text');
+      assert.isFalse(suggestSpy.called);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
index 35311f9..bf063b3 100644
--- a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
@@ -14,9 +14,17 @@
 (function() {
   'use strict';
 
+  var VALID_EMAIL_ALERT = 'Please input a valid email.';
+
   Polymer({
     is: 'gr-account-list',
 
+    /**
+     * Fired when user inputs an invalid email address.
+     *
+     * @event show-alert
+     */
+
     properties: {
       accounts: {
         type: Array,
@@ -35,6 +43,7 @@
         type: Boolean,
         value: false,
       },
+
       /**
        * When true, the account-entry autocomplete uses the account suggest API
        * endpoint, which suggests any account in that Gerrit instance (and does
@@ -48,6 +57,15 @@
         type: Boolean,
         value: false,
       },
+
+      /**
+       * When true, allows for non-suggested inputs to be added.
+       */
+      allowAnyInput: {
+        type: Boolean,
+        value: false,
+      },
+
       /**
        * Array of values (groups/accounts) that are removable. When this prop is
        * undefined, all values are removable.
@@ -88,6 +106,17 @@
         var group = Object.assign({}, reviewer.group,
             {_pendingAdd: true, _group: true});
         this.push('accounts', group);
+      } else if (this.allowAnyInput) {
+        if (reviewer.indexOf('@') === -1) {
+          // Repopulate the input with what the user tried to enter and have
+          // a toast tell them why they can't enter it.
+          this.$.entry.setText(reviewer);
+          this.dispatchEvent(new CustomEvent('show-alert',
+            {detail: {message: VALID_EMAIL_ALERT}, bubbles: true}));
+        } else {
+          var account = {email: reviewer, _pendingAdd: true};
+          this.push('accounts', account);
+        }
       }
       this.pendingConfirmation = null;
     },
@@ -110,11 +139,23 @@
       return classes.join(' ');
     },
 
+    _accountMatches: function(a, b) {
+      if (a && b) {
+        if (a._account_id) {
+          return a._account_id === b._account_id;
+        }
+        if (a.email) {
+          return a.email === b.email;
+        }
+      }
+      return a === b;
+    },
+
     _computeRemovable: function(account) {
       if (this.readonly) { return false; }
       if (this.removableValues) {
         for (var i = 0; i < this.removableValues.length; i++) {
-          if (this.removableValues[i]._account_id === account._account_id) {
+          if (this._accountMatches(this.removableValues[i], account)) {
             return true;
           }
         }
@@ -137,7 +178,7 @@
         if (toRemove._group) {
           matches = toRemove.id === account.id;
         } else {
-          matches = toRemove._account_id === account._account_id;
+          matches = this._accountMatches(toRemove, account);
         }
         if (matches) {
           this.splice('accounts', i, 1);
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
index b3c9e9e..cb4a845 100644
--- a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
@@ -45,6 +45,7 @@
       var groupId = 'group' + (++_nextAccountId);
       return {
         id: groupId,
+        _group: true,
       };
     };
 
@@ -266,6 +267,44 @@
       assert.isTrue(element.$.entry.hasAttribute('hidden'));
     });
 
+    suite('allowAnyInput', function() {
+      var entry;
+
+      setup(function() {
+        entry = element.$.entry;
+        sandbox.stub(entry, '_getReviewerSuggestions');
+        sandbox.stub(entry.$.input, '_updateSuggestions');
+        element.allowAnyInput = true;
+      });
+
+      test('adds emails', function() {
+        var accountLen = element.accounts.length;
+        element._handleAdd({detail: {value: 'test@test'}});
+        assert.equal(element.accounts.length, accountLen + 1);
+        assert.equal(element.accounts[accountLen].email, 'test@test');
+      });
+
+      test('toasts on invalid email', function() {
+        var toastHandler = sandbox.stub();
+        element.addEventListener('show-alert', toastHandler);
+        element._handleAdd({detail: {value: 'test'}});
+        assert.isTrue(toastHandler.called);
+      });
+    });
+
+    test('_accountMatches', function() {
+      var acct = makeAccount();
+
+      assert.isTrue(element._accountMatches(acct, acct));
+      acct.email = 'test';
+      assert.isTrue(element._accountMatches(acct, acct));
+      assert.isTrue(element._accountMatches({email: 'test'}, acct));
+
+      assert.isFalse(element._accountMatches({}, acct));
+      assert.isFalse(element._accountMatches({email: 'test2'}, acct));
+      assert.isFalse(element._accountMatches({_account_id: -1}, acct));
+    });
+
     suite('keyboard interactions', function() {
 
       test('backspace at text input start removes last account', function() {
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
index 71ccb04..894ca63 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
@@ -14,6 +14,7 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
@@ -81,8 +82,7 @@
           down-arrow
           vertical-offset="32"
           horizontal-align="right"
-          on-tap-item-cherrypick="_handleCherrypickTap"
-          on-tap-item-delete="_handleDeleteTap"
+          on-tap-item="_handleOveflowItemTap"
           hidden$="[[_shouldHideActions(_menuActions.*, _loading)]]"
           disabled-ids="[[_disabledMenuActions]]"
           items="[[_menuActions]]">More</gr-dropdown>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
index 2b0916d..a51632a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -58,6 +58,7 @@
     PUBLISH: 'publish',
     REBASE: 'rebase',
     SUBMIT: 'submit',
+    DOWNLOAD: 'download',
   };
 
   var ActionLoadingLabels = {
@@ -87,14 +88,22 @@
     method: 'POST',
   };
 
-  /**
-   * Keys for actions to appear in the overflow menu rather than the top-level
-   * set of action buttons.
-   */
-  var MENU_ACTION_KEYS = [
-    'cherrypick',
-    '/', // '/' is the key for the delete action.
-  ];
+  var ActionPriority = {
+    CHANGE: 2,
+    DEFAULT: 0,
+    PRIMARY: 3,
+    REVIEW: -3,
+    REVISION: 1,
+  };
+
+  var DOWNLOAD_ACTION = {
+    enabled: true,
+    label: 'Download patch',
+    title: 'Open download dialog',
+    __key: 'download',
+    __primary: false,
+    __type: 'revision',
+  };
 
   Polymer({
     is: 'gr-change-actions',
@@ -111,6 +120,12 @@
      * @event <action key>-tap
      */
 
+    /**
+     * Fires to show an alert when a send is attempted on the non-latest patch.
+     *
+     * @event show-alert
+     */
+
     properties: {
       change: Object,
       actions: {
@@ -158,16 +173,46 @@
       _allActionValues: {
         type: Array,
         computed: '_computeAllActions(actions.*, revisionActions.*,' +
-            'primaryActionKeys.*, _additionalActions.*, change)',
+            'primaryActionKeys.*, _additionalActions.*, change, ' +
+            '_actionPriorityOverrides.*)',
       },
       _topLevelActions: {
         type: Array,
         computed: '_computeTopLevelActions(_allActionValues.*, ' +
-            '_hiddenActions.*)',
+            '_hiddenActions.*, _overflowActions.*)',
       },
       _menuActions: {
         type: Array,
-        computed: '_computeMenuActions(_allActionValues.*, _hiddenActions.*)',
+        computed: '_computeMenuActions(_allActionValues.*, _hiddenActions.*, ' +
+            '_overflowActions.*)',
+      },
+      _overflowActions: {
+        type: Array,
+        value: function() {
+          var value = [
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.DELETE,
+            },
+            {
+              type: ActionType.REVISION,
+              key: RevisionActions.DELETE,
+            },
+            {
+              type: ActionType.REVISION,
+              key: RevisionActions.CHERRYPICK,
+            },
+            {
+              type: ActionType.REVISION,
+              key: RevisionActions.DOWNLOAD,
+            },
+          ];
+          return value;
+        },
+      },
+      _actionPriorityOverrides: {
+        type: Array,
+        value: function() { return []; },
       },
       _additionalActions: {
         type: Array,
@@ -188,6 +233,7 @@
     RevisionActions: RevisionActions,
 
     behaviors: [
+      Gerrit.PatchSetBehavior,
       Gerrit.RESTClientBehavior,
     ],
 
@@ -227,7 +273,8 @@
         enabled: true,
         label: label,
         __type: type,
-        __key: ADDITIONAL_ACTION_KEY_PREFIX + Math.random().toString(36),
+        __key: ADDITIONAL_ACTION_KEY_PREFIX +
+            Math.random().toString(36).substr(2),
       };
       this.push('_additionalActions', action);
       return action.__key;
@@ -249,6 +296,42 @@
       ], value);
     },
 
+    setActionOverflow: function(type, key, overflow) {
+      if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+        throw Error('Invalid action type given: ' + type);
+      }
+      var index = this._getActionOverflowIndex(type, key);
+      var action = {
+        type: type,
+        key: key,
+        overflow: overflow,
+      };
+      if (!overflow && index !== -1) {
+        this.splice('_overflowActions', index, 1);
+      } else if (overflow) {
+        this.push('_overflowActions', action);
+      }
+    },
+
+    setActionPriority: function(type, key, priority) {
+      if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+        throw Error('Invalid action type given: ' + type);
+      }
+      var index = this._actionPriorityOverrides.findIndex(function(action) {
+        return action.type === type && action.key === key;
+      });
+      var action = {
+        type: type,
+        key: key,
+        priority: priority,
+      };
+      if (index !== -1) {
+        this.set('_actionPriorityOverrides', index, action);
+      } else {
+        this.push('_actionPriorityOverrides', action);
+      }
+    },
+
     setActionHidden: function(type, key, hidden) {
       if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
         throw Error('Invalid action type given: ' + type);
@@ -293,6 +376,12 @@
               additionalActions.length === 0;
       this._actionLoadingMessage = null;
       this._disabledMenuActions = [];
+
+      var revisionActions = revisionActionsChangeRecord.base || {};
+      if (Object.keys(revisionActions).length !== 0 &&
+          !revisionActions.download) {
+        this.set('revisionActions.download', DOWNLOAD_ACTION);
+      }
     },
 
     _getValuesFor: function(obj) {
@@ -459,20 +548,46 @@
         return;
       }
       var type = el.getAttribute('data-action-type');
-      if (type === ActionType.REVISION) {
-        this._handleRevisionAction(key);
-      } else if (key === ChangeActions.REVERT) {
-        this.showRevertDialog();
-      } else if (key === ChangeActions.ABANDON) {
-        this._showActionDialog(this.$.confirmAbandonDialog);
-      } else if (key === QUICK_APPROVE_ACTION.key) {
-        var action = this._allActionValues.find(function(o) {
-          return o.key === key;
-        });
-        this._fireAction(
-            this._prependSlash(key), action, true, action.payload);
-      } else {
-        this._fireAction(this._prependSlash(key), this.actions[key], false);
+      this._handleAction(type, key);
+    },
+
+    _handleOveflowItemTap: function(e) {
+      this._handleAction(e.detail.action.__type, e.detail.action.__key);
+    },
+
+    _handleAction: function(type, key) {
+      switch (type) {
+        case ActionType.REVISION:
+          this._handleRevisionAction(key);
+          break;
+        case ActionType.CHANGE:
+          this._handleChangeAction(key);
+          break;
+        default:
+          this._fireAction(this._prependSlash(key), this.actions[key], false);
+      }
+    },
+
+    _handleChangeAction: function(key) {
+      switch (key) {
+        case ChangeActions.REVERT:
+          this.showRevertDialog();
+          break;
+        case ChangeActions.ABANDON:
+          this._showActionDialog(this.$.confirmAbandonDialog);
+          break;
+        case QUICK_APPROVE_ACTION.key:
+          var action = this._allActionValues.find(function(o) {
+            return o.key === key;
+          });
+          this._fireAction(
+              this._prependSlash(key), action, true, action.payload);
+          break;
+        case ChangeActions.DELETE:
+          this._handleDeleteTap();
+          break;
+        default:
+          this._fireAction(this._prependSlash(key), this.actions[key], false);
       }
     },
 
@@ -481,6 +596,15 @@
         case RevisionActions.REBASE:
           this._showActionDialog(this.$.confirmRebase);
           break;
+        case RevisionActions.DELETE:
+          this._handleDeleteConfirm();
+          break;
+        case RevisionActions.CHERRYPICK:
+          this._handleCherrypickTap();
+          break;
+        case RevisionActions.DOWNLOAD:
+          this._handleDownloadTap();
+          break;
         case RevisionActions.SUBMIT:
           if (!this._canSubmitChange()) {
             return;
@@ -577,11 +701,17 @@
       this._fireAction('/', this.actions[ChangeActions.DELETE], false);
     },
 
-    _setLoadingOnButtonWithKey: function(key) {
+    _getActionOverflowIndex: function(type, key) {
+      return this._overflowActions.findIndex(function(action) {
+        return action.type === type && action.key === key;
+      });
+    },
+
+    _setLoadingOnButtonWithKey: function(type, key) {
       this._actionLoadingMessage = this._computeLoadingLabel(key);
 
       // If the action appears in the overflow menu.
-      if (MENU_ACTION_KEYS.indexOf(key) !== -1) {
+      if (this._getActionOverflowIndex(type, key) !== -1) {
         this.push('_disabledMenuActions', key === '/' ? 'delete' : key);
         return function() {
           this._actionLoadingMessage = null;
@@ -601,8 +731,8 @@
     },
 
     _fireAction: function(endpoint, action, revAction, opt_payload) {
-      var cleanupFn = this._setLoadingOnButtonWithKey(action.__key);
-
+      var cleanupFn =
+          this._setLoadingOnButtonWithKey(action.__type, action.__key);
       this._send(action.method, opt_payload, endpoint, revAction, cleanupFn)
           .then(this._handleResponse.bind(this, action));
     },
@@ -631,26 +761,26 @@
     _handleResponse: function(action, response) {
       if (!response) { return; }
       return this.$.restAPI.getResponseObject(response).then(function(obj) {
-          switch (action.__key) {
-            case ChangeActions.REVERT:
-              this._setLabelValuesOnRevert(obj.change_id);
-              /* falls through */
-            case RevisionActions.CHERRYPICK:
-              page.show(this.changePath(obj._number));
-              break;
-            case ChangeActions.DELETE:
-            case RevisionActions.DELETE:
-              if (action.__type === ActionType.CHANGE) {
-                page.show('/');
-              } else {
-                page.show(this.changePath(this.changeNum));
-              }
-              break;
-            default:
-              this.dispatchEvent(new CustomEvent('reload-change',
-                  {detail: {action: action.__key}, bubbles: false}));
-              break;
-          }
+        switch (action.__key) {
+          case ChangeActions.REVERT:
+            this._setLabelValuesOnRevert(obj.change_id);
+            /* falls through */
+          case RevisionActions.CHERRYPICK:
+            page.show(this.changePath(obj._number));
+            break;
+          case ChangeActions.DELETE:
+          case RevisionActions.DELETE:
+            if (action.__type === ActionType.CHANGE) {
+              page.show('/');
+            } else {
+              page.show(this.changePath(this.changeNum));
+            }
+            break;
+          default:
+            this.dispatchEvent(new CustomEvent('reload-change',
+                {detail: {action: action.__key}, bubbles: false}));
+            break;
+        }
       }.bind(this));
     },
 
@@ -664,15 +794,33 @@
       }.bind(this));
     },
 
-    _send: function(method, payload, actionEndpoint, revisionAction,
-        cleanupFn, opt_errorFn) {
-      var url = this.$.restAPI.getChangeActionURL(this.changeNum,
-          revisionAction ? this.patchNum : null, actionEndpoint);
-      return this.$.restAPI.send(method, url, payload,
-          this._handleResponseError, this).then(function(response) {
-            cleanupFn.call(this);
-            return response;
-      }.bind(this));
+    _send: function(method, payload, actionEndpoint, revisionAction, cleanupFn,
+        opt_errorFn) {
+      return this.fetchIsLatestKnown(this.change, this.$.restAPI)
+          .then(function(isLatest) {
+            if (!isLatest) {
+              this.fire('show-alert', {
+                  message: 'Cannot set label: a newer patch has been ' +
+                      'uploaded to this change.',
+                  action: 'Reload',
+                  callback: function() {
+                    // Load the current change without any patch range.
+                    location.href = this.getBaseUrl() + '/c/' +
+                        this.change._number;
+                  }.bind(this),
+              });
+              cleanupFn();
+              return Promise.resolve();
+            }
+
+            var url = this.$.restAPI.getChangeActionURL(this.changeNum,
+                revisionAction ? this.patchNum : null, actionEndpoint);
+            return this.$.restAPI.send(method, url, payload,
+                this._handleResponseError, this).then(function(response) {
+                  cleanupFn.call(this);
+                  return response;
+            }.bind(this));
+        }.bind(this));
     },
 
     _handleAbandonTap: function() {
@@ -684,6 +832,10 @@
       this._showActionDialog(this.$.confirmCherrypick);
     },
 
+    _handleDownloadTap: function() {
+      this.fire('download-tap', null, {bubbles: false});
+    },
+
     _handleDeleteTap: function() {
       this._showActionDialog(this.$.confirmDeleteDialog);
     },
@@ -710,58 +862,67 @@
       }
       return revisionActionValues
           .concat(changeActionValues)
-          .sort(this._actionComparator);
+          .sort(this._actionComparator.bind(this));
+    },
+
+    _getActionPriority: function(action) {
+      if (action.__type && action.__key) {
+        var overrideAction = this._actionPriorityOverrides.find(function(i) {
+          return i.type === action.__type && i.key === action.__key;
+        });
+
+        if (overrideAction !== undefined) {
+          return overrideAction.priority;
+        }
+      }
+      if (action.__key === 'review') {
+        return ActionPriority.REVIEW;
+      } else if (action.__primary) {
+        return ActionPriority.PRIMARY;
+      } else if (action.__type === ActionType.CHANGE) {
+        return ActionPriority.CHANGE;
+      } else if (action.__type === ActionType.REVISION) {
+        return ActionPriority.REVISION;
+      }
+      return ActionPriority.DEFAULT;
     },
 
     /**
      * Sort comparator to define the order of change actions.
      */
     _actionComparator: function(actionA, actionB) {
-      // The code review action always appears first.
-      if (actionA.__key === 'review') {
-        return -1;
-      } else if (actionB.__key === 'review') {
-        return 1;
+      var priorityDelta = this._getActionPriority(actionA) -
+          this._getActionPriority(actionB);
+      // Sort by the button label if same priority.
+      if (priorityDelta === 0) {
+        return actionA.label > actionB.label ? 1 : -1;
+      } else {
+        return priorityDelta;
       }
-
-      // Primary actions always appear last.
-      if (actionA.__primary) {
-        return 1;
-      } else if (actionB.__primary) {
-        return -1;
-      }
-
-      // Change actions appear before revision actions.
-     if (actionA.__type === 'change' && actionB.__type === 'revision') {
-        return 1;
-      } else if (actionA.__type === 'revision' && actionB.__type === 'change') {
-        return -1;
-      }
-
-      // Otherwise, sort by the button label.
-      return actionA.label > actionB.label ? 1 : -1;
     },
 
     _computeTopLevelActions: function(actionRecord, hiddenActionsRecord) {
       var hiddenActions = hiddenActionsRecord.base || [];
       return actionRecord.base.filter(function(a) {
-        return MENU_ACTION_KEYS.indexOf(a.__key) === -1 &&
-                hiddenActions.indexOf(a.__key) === -1;
-      });
+        var overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
+        return !overflow && hiddenActions.indexOf(a.__key) === -1;
+      }.bind(this));
     },
 
     _computeMenuActions: function(actionRecord, hiddenActionsRecord) {
       var hiddenActions = hiddenActionsRecord.base || [];
-      return actionRecord.base
-          .filter(function(a) {
-            return MENU_ACTION_KEYS.indexOf(a.__key) !== -1 &&
-                hiddenActions.indexOf(a.__key) === -1;
-          })
-          .map(function(action) {
-            var key = action.__key;
-            if (key === '/') { key = 'delete'; }
-            return {name: action.label, id: key, };
-          });
+      return actionRecord.base.filter(function(a) {
+        var overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
+        return overflow && hiddenActions.indexOf(a.__key) === -1;
+      }.bind(this)).map(function(action) {
+        var key = action.__key;
+        if (key === '/') { key = 'delete'; }
+        return {
+          name: action.label,
+          id: key + '-' + action.__type,
+          action: action,
+        };
+      });
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
index 83a1c7e..fa56bb5 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
@@ -36,6 +36,8 @@
 <script>
   suite('gr-change-actions tests', function() {
     var element;
+    var sandbox;
+
     setup(function() {
       stub('gr-rest-api-interface', {
         getChangeRevisionActions: function() {
@@ -97,9 +99,15 @@
           enabled: true,
         },
       };
+      sandbox = sinon.sandbox.create();
+
       return element.reload();
     });
 
+    teardown(function() {
+      sandbox.restore();
+    });
+
     test('_shouldHideActions', function() {
       assert.isTrue(element._shouldHideActions(undefined, true));
       assert.isTrue(element._shouldHideActions({base: {}}, false));
@@ -122,7 +130,7 @@
           assert.isNotOk(buttonEl);
 
           element.setActionHidden(element.ActionType.REVISION,
-            element.RevisionActions.SUBMIT, false);
+              element.RevisionActions.SUBMIT, false);
           flush(function() {
             var buttonEl = element.$$('[data-action-key="submit"]');
             assert.isOk(buttonEl);
@@ -135,7 +143,8 @@
 
     test('hide menu action', function(done) {
       flush(function() {
-        var buttonEl = element.$.moreActions.$$('span[data-id="delete"]');
+        var buttonEl =
+            element.$.moreActions.$$('span[data-id="delete-revision"]');
         assert.isOk(buttonEl);
         assert.throws(element.setActionHidden.bind(element, 'invalid type'));
         element.setActionHidden(element.ActionType.CHANGE,
@@ -145,13 +154,15 @@
             element.ChangeActions.DELETE, true);
         assert.lengthOf(element._hiddenActions, 1);
         flush(function() {
-          var buttonEl = element.$.moreActions.$$('span[data-id="delete"]');
+          var buttonEl =
+              element.$.moreActions.$$('span[data-id="delete-revision"]');
           assert.isNotOk(buttonEl);
 
           element.setActionHidden(element.ActionType.CHANGE,
-            element.RevisionActions.DELETE, false);
+              element.RevisionActions.DELETE, false);
           flush(function() {
-            var buttonEl = element.$.moreActions.$$('span[data-id="delete"]');
+            var buttonEl =
+                element.$.moreActions.$$('span[data-id="delete-revision"]');
             assert.isOk(buttonEl);
             done();
           });
@@ -165,7 +176,7 @@
         var buttonEls = Polymer.dom(element.root)
             .querySelectorAll('gr-button');
         var menuItems = element.$.moreActions.items;
-        assert.equal(buttonEls.length + menuItems.length, 6);
+        assert.equal(buttonEls.length + menuItems.length, 7);
         assert.isFalse(element.hidden);
         done();
       });
@@ -174,7 +185,7 @@
     test('delete buttons have explicit labels', function(done) {
       flush(function() {
         var deleteItems = element.$.moreActions.items.filter(function(item) {
-          return item.id === 'delete';
+          return item.id.indexOf('delete') === 0;
         });
         assert.equal(deleteItems.length, 2);
         assert.notEqual(deleteItems[0].name, deleteItems[1].name);
@@ -204,20 +215,21 @@
     test('_actionComparator sort order', function() {
       var actions = [
         {label: '123', __type: 'change', __key: 'review'},
-        {label: 'abc', __type: 'revision'},
+        {label: 'abc-ro', __type: 'revision'},
         {label: 'abc', __type: 'change'},
         {label: 'def', __type: 'change'},
-        {label: 'def', __type: 'change', __primary: true},
+        {label: 'def-p', __type: 'change', __primary: true},
       ];
 
       var result = actions.slice();
       result.reverse();
-      result.sort(element._actionComparator);
-
+      result.sort(element._actionComparator.bind(element));
       assert.deepEqual(result, actions);
     });
 
     test('submit change', function(done) {
+      sandbox.stub(element, 'fetchIsLatestKnown',
+          function() { return Promise.resolve(true); });
       element.change = {
         revisions: {
           rev1: {_number: 1},
@@ -232,24 +244,22 @@
         MockInteractions.tap(submitButton);
 
         // Upon success it should fire the reload-change event.
-        element.addEventListener('reload-change', function(e) {
+        element.addEventListener('reload-change', function() {
           done();
         });
       });
     });
 
     test('submit change with plugin hook', function(done) {
-      var canSubmitStub = sinon.stub(element, '_canSubmitChange',
+      sandbox.stub(element, '_canSubmitChange',
           function() { return false; });
-      var fireActionStub = sinon.stub(element, '_fireAction');
+      var fireActionStub = sandbox.stub(element, '_fireAction');
       flush(function() {
         var submitButton = element.$$('gr-button[data-action-key="submit"]');
         assert.ok(submitButton);
         MockInteractions.tap(submitButton);
         assert.equal(fireActionStub.callCount, 0);
 
-        canSubmitStub.restore();
-        fireActionStub.restore();
         done();
       });
     });
@@ -282,7 +292,7 @@
     });
 
     test('rebase change', function(done) {
-      var fireActionStub = sinon.stub(element, '_fireAction');
+      var fireActionStub = sandbox.stub(element, '_fireAction');
       flush(function() {
         var rebaseButton = element.$$('gr-button[data-action-key="rebase"]');
         MockInteractions.tap(rebaseButton);
@@ -313,7 +323,6 @@
         assert.deepEqual(fireActionStub.lastCall.args,
           ['/rebase', rebaseAction, true, {base: ''}]);
 
-        fireActionStub.restore();
         done();
       });
     });
@@ -337,16 +346,10 @@
 
     suite('cherry-pick', function() {
       var fireActionStub;
-      var alertStub;
 
       setup(function() {
-        fireActionStub = sinon.stub(element, '_fireAction');
-        alertStub = sinon.stub(window, 'alert');
-      });
-
-      teardown(function() {
-        alertStub.restore();
-        fireActionStub.restore();
+        fireActionStub = sandbox.stub(element, '_fireAction');
+        sandbox.stub(window, 'alert');
       });
 
       test('works', function() {
@@ -414,7 +417,8 @@
 
     test('_setLoadingOnButtonWithKey top-level', function() {
       var key = 'rebase';
-      var cleanup = element._setLoadingOnButtonWithKey(key);
+      var type = 'revision';
+      var cleanup = element._setLoadingOnButtonWithKey(type, key);
       assert.equal(element._actionLoadingMessage, 'Rebasing...');
 
       var button = element.$$('[data-action-key="' + key + '"]');
@@ -432,7 +436,8 @@
 
     test('_setLoadingOnButtonWithKey overflow menu', function() {
       var key = 'cherrypick';
-      var cleanup = element._setLoadingOnButtonWithKey(key);
+      var type = 'revision';
+      var cleanup = element._setLoadingOnButtonWithKey(type, key);
       assert.equal(element._actionLoadingMessage, 'Cherry-Picking...');
       assert.include(element._disabledMenuActions, 'cherrypick');
       assert.isFunction(cleanup);
@@ -448,8 +453,8 @@
       var fireActionStub;
 
       setup(function() {
-        fireActionStub = sinon.stub(element, '_fireAction');
-        alertStub = sinon.stub(window, 'alert');
+        fireActionStub = sandbox.stub(element, '_fireAction');
+        alertStub = sandbox.stub(window, 'alert');
         element.actions = {
           revert: {
             method: 'POST',
@@ -461,29 +466,20 @@
         return element.reload();
       });
 
-      teardown(function() {
-        alertStub.restore();
-        fireActionStub.restore();
-      });
-
       test('revert change with plugin hook', function(done) {
         element.change = {
           current_revision: 'abc1234',
         };
         var newRevertMsg = 'Modified revert msg';
-        var modifyRevertMsgStub = sinon.stub(element, '_modifyRevertMsg',
+        sandbox.stub(element, '_modifyRevertMsg',
             function() { return newRevertMsg; });
-        var populateRevertMsgStub = sinon.stub(
-            element.$.confirmRevertDialog, 'populateRevertMessage',
+        sandbox.stub(element.$.confirmRevertDialog, 'populateRevertMessage',
             function() { return 'original msg'; });
         flush(function() {
           var revertButton = element.$$('gr-button[data-action-key="revert"]');
           MockInteractions.tap(revertButton);
 
           assert.equal(element.$.confirmRevertDialog.message, newRevertMsg);
-
-          populateRevertMsgStub.restore();
-          modifyRevertMsgStub.restore();
           done();
         });
       });
@@ -492,8 +488,7 @@
         element.change = {
           current_revision: 'abc1234',
         };
-        var populateRevertMsgStub = sinon.stub(
-            element.$.confirmRevertDialog, 'populateRevertMessage',
+        sandbox.stub(element.$.confirmRevertDialog, 'populateRevertMessage',
             function() { return 'original msg'; });
         var revertButton = element.$$('gr-button[data-action-key="revert"]');
         MockInteractions.tap(revertButton);
@@ -515,7 +510,6 @@
           '/revert', action, false, {
             message: 'foo message',
           }]);
-        populateRevertMsgStub.restore();
       });
     });
 
@@ -524,7 +518,7 @@
       var deleteAction;
 
       setup(function() {
-        fireActionStub = sinon.stub(element, '_fireAction');
+        fireActionStub = sandbox.stub(element, '_fireAction');
         element.change = {
           current_revision: 'abc1234',
         };
@@ -539,10 +533,6 @@
         };
       });
 
-      teardown(function() {
-        fireActionStub.restore();
-      });
-
       test('does not delete on action', function() {
         element._handleDeleteTap();
         assert.isFalse(fireActionStub.called);
@@ -634,7 +624,7 @@
       });
 
       test('approves when taped', function() {
-        var fireActionStub = sinon.stub(element, '_fireAction');
+        var fireActionStub = sandbox.stub(element, '_fireAction');
         MockInteractions.tap(
             element.$$('gr-button[data-action-key=\'review\']'));
         flushAsynchronousOperations();
@@ -642,7 +632,6 @@
         assert.isTrue(fireActionStub.calledWith('/review'));
         var payload = fireActionStub.lastCall.args[3];
         assert.deepEqual(payload.labels, {foo: '+1'});
-        fireActionStub.restore();
       });
 
       test('not added when multiple labels are required', function() {
@@ -728,5 +717,37 @@
         assert.equal(approveButton.getAttribute('data-label'), 'bar+2');
       });
     });
+
+    test('adds download revision action', function() {
+      var handler = sandbox.stub();
+      element.addEventListener('download-tap', handler);
+      assert.ok(element.revisionActions.download);
+      element._handleDownloadTap();
+      flushAsynchronousOperations();
+
+      assert.isTrue(handler.called);
+    });
+
+    suite('setActionOverflow', function() {
+      test('move action from overflow', function() {
+        assert.isNotOk(element.$$('[data-action-key="cherrypick"]'));
+        assert.strictEqual(
+            element.$.moreActions.items[0].id, 'cherrypick-revision');
+        element.setActionOverflow('revision', 'cherrypick', false);
+        flushAsynchronousOperations();
+        assert.isOk(element.$$('[data-action-key="cherrypick"]'));
+        assert.notEqual(
+            element.$.moreActions.items[0].id, 'cherrypick-revision');
+      });
+
+      test('move action to overflow', function() {
+        assert.isOk(element.$$('[data-action-key="submit"]'));
+        element.setActionOverflow('revision', 'submit', true);
+        flushAsynchronousOperations();
+        assert.isNotOk(element.$$('[data-action-key="submit"]'));
+        assert.strictEqual(
+            element.$.moreActions.items[4].id, 'submit-revision');
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
new file mode 100644
index 0000000..32b0a4d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
@@ -0,0 +1,133 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-change-metadata</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-change-metadata.html">
+
+<test-fixture id="plugin">
+  <template>
+    <script>
+      Gerrit.install(function(plugin) {
+        plugin.registerStyleModule('change-metadata', 'some-style');
+      }, '', 'http://x/plugins/foo.js');
+    </script>
+  </template>
+</test-fixture>
+
+<test-fixture id="some-style">
+  <template>
+    <dom-module id="some-style">
+      <style>
+        :root {
+          --change-metadata-assignee: {
+            display: none;
+          }
+          --change-metadata-label-status: {
+            display: none;
+          }
+          --change-metadata-strategy: {
+            display: none;
+          }
+          --change-metadata-topic: {
+            display: none;
+          }
+        }
+      </style>
+    </dom-module>
+  </template>
+</test-fixture>
+
+<test-fixture id="element">
+  <template>
+    <gr-change-metadata></gr-change-metadata>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-change-metadata integration tests', function() {
+    var sandbox;
+    var plugin;
+    var element;
+
+    var sectionSelectors = [
+      'section.assignee',
+      'section.labelStatus',
+      'section.strategy',
+      'section.topic',
+    ];
+
+    var getStyle = function(selector, name) {
+      return window.getComputedStyle(
+          Polymer.dom(element.root).querySelector(selector))[name];
+    };
+
+    setup(function() {
+      Gerrit._pluginsPending = 0;
+      sandbox = sinon.sandbox.create();
+      stub('gr-change-metadata', {
+        _computeShowLabelStatus: function() { return true; },
+        _computeShowReviewersByState: function() { return true; },
+        ready: function() {
+          this.change = {labels:[]};
+          this.serverConfig = {};
+        },
+      });
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    suite('by default', function() {
+      setup(function(done) {
+        element = fixture('element');
+        flush(done);
+      });
+
+      sectionSelectors.forEach(function(sectionSelector) {
+        test(sectionSelector + ' does not have display: none', function() {
+          assert.notEqual(
+              getStyle(sectionSelector, 'display'), 'none');
+          });
+      });
+    });
+
+    suite('with plugin style', function() {
+      var styleEl;
+
+      setup(function(done) {
+        styleEl = fixture('some-style');
+        element = fixture('element');
+        plugin = fixture('plugin');
+        flush(done);
+      });
+
+      sectionSelectors.forEach(function(sectionSelector) {
+        test(sectionSelector + ' may have display: none', function() {
+          assert.equal(
+              getStyle(sectionSelector, 'display'), 'none');
+        });
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
index 78bcb9a..2feab27 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
@@ -15,12 +15,14 @@
 -->
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../plugins/gr-external-style/gr-external-style.html">
 <link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
-<link rel="import" href="../../shared/gr-label/gr-label.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
+<link rel="import" href="../../shared/gr-label/gr-label.html">
 <link rel="import" href="../../shared/gr-linked-chip/gr-linked-chip.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-reviewer-list/gr-reviewer-list.html">
@@ -38,7 +40,8 @@
       .title {
         color: #666;
         font-weight: bold;
-        white-space: nowrap;
+        max-width: 20em;
+        word-break: break-word;
       }
       gr-account-link {
         max-width: 20ch;
@@ -69,12 +72,27 @@
       .notApproved {
         background-color: #ffd4d4;
       }
-      .labelStatus {
+      .labelStatus .value {
         max-width: 9em;
       }
       .webLink {
         display: block;
       }
+      section.assignee {
+        @apply(--change-metadata-assignee);
+      }
+      section.labelStatus {
+        @apply(--change-metadata-label-status);
+      }
+      section.strategy {
+        @apply(--change-metadata-strategy);
+      }
+      section.topic {
+        @apply(--change-metadata-topic);
+      }
+      #missingLabels {
+        padding-left: 1.5em;
+      }
       @media screen and (max-width: 50em), screen and (min-width: 75em) {
         :host {
           display: table;
@@ -96,155 +114,173 @@
         }
       }
     </style>
-    <section>
-      <span class="title">Updated</span>
-      <span class="value">
-        <gr-date-formatter
-            has-tooltip
-            date-str="[[change.updated]]"></gr-date-formatter>
-      </span>
-    </section>
-    <section>
-      <span class="title">Owner</span>
-      <span class="value">
-        <gr-account-link account="[[change.owner]]"></gr-account-link>
-      </span>
-    </section>
-    <template is="dom-if" if="[[_showReviewersByState]]">
+    <gr-external-style name="change-metadata">
       <section>
-        <span class="title">Assignee</span>
+        <span class="title">Updated</span>
         <span class="value">
-          <gr-account-list
-              max-count="1"
-              id="assigneeValue"
-              placeholder="Add assignee..."
-              accounts="{{_assignee}}"
-              change="[[change]]"
-              readonly="[[!mutable]]"
-              allow-any-user></gr-account-list>
+          <gr-date-formatter
+              has-tooltip
+              date-str="[[change.updated]]"></gr-date-formatter>
         </span>
       </section>
       <section>
-        <span class="title">Reviewers</span>
+        <span class="title">Owner</span>
         <span class="value">
-          <gr-reviewer-list
-              change="{{change}}"
-              mutable="[[mutable]]"
-              reviewers-only></gr-reviewer-list>
+          <gr-account-link account="[[change.owner]]"></gr-account-link>
+        </span>
+      </section>
+      <template is="dom-if" if="[[_showReviewersByState]]">
+        <section class="assignee">
+          <span class="title">Assignee</span>
+          <span class="value">
+            <gr-account-list
+                max-count="1"
+                id="assigneeValue"
+                placeholder="Add assignee..."
+                accounts="{{_assignee}}"
+                change="[[change]]"
+                readonly="[[_computeAssigneeReadOnly(mutable, change)]]"
+                allow-any-user></gr-account-list>
+          </span>
+        </section>
+        <section>
+          <span class="title">Reviewers</span>
+          <span class="value">
+            <gr-reviewer-list
+                change="{{change}}"
+                mutable="[[mutable]]"
+                reviewers-only></gr-reviewer-list>
+          </span>
+        </section>
+        <section>
+          <span class="title">CC</span>
+          <span class="value">
+            <gr-reviewer-list
+                change="{{change}}"
+                mutable="[[mutable]]"
+                ccs-only></gr-reviewer-list>
+          </span>
+        </section>
+      </template>
+      <template is="dom-if" if="[[!_showReviewersByState]]">
+        <section>
+          <span class="title">Assignee</span>
+          <span class="value">
+            <gr-account-list
+                max-count="1"
+                id="assigneeValue"
+                placeholder="Add assignee..."
+                accounts="{{_assignee}}"
+                change="[[change]]"
+                readonly="[[!mutable]]"
+                allow-any-user></gr-account-list>
+          </span>
+        </section>
+        <section>
+          <span class="title">Reviewers</span>
+          <span class="value">
+            <gr-reviewer-list
+                change="{{change}}"
+                mutable="[[mutable]]"></gr-reviewer-list>
+          </span>
+        </section>
+      </template>
+      <section>
+        <span class="title">Project</span>
+        <span class="value">
+          <a href$="[[_computeProjectURL(change.project)]]">[[change.project]]</a>
         </span>
       </section>
       <section>
-        <span class="title">CC</span>
+        <span class="title">Branch</span>
         <span class="value">
-          <gr-reviewer-list
-              change="{{change}}"
-              mutable="[[mutable]]"
-              ccs-only></gr-reviewer-list>
+          <a href$="[[_computeBranchURL(change.project, change.branch)]]">[[change.branch]]</a>
         </span>
       </section>
-    </template>
-    <template is="dom-if" if="[[!_showReviewersByState]]">
-      <section>
-        <span class="title">Assignee</span>
+      <section class="topic">
+        <span class="title">Topic</span>
         <span class="value">
-          <gr-account-list
-              max-count="1"
-              id="assigneeValue"
-              placeholder="Add assignee..."
-              accounts="{{_assignee}}"
-              change="[[change]]"
-              readonly="[[!mutable]]"
-              allow-any-user></gr-account-list>
-        </span>
-      </section>
-      <section>
-        <span class="title">Reviewers</span>
-        <span class="value">
-          <gr-reviewer-list
-              change="{{change}}"
-              mutable="[[mutable]]"></gr-reviewer-list>
-        </span>
-      </section>
-    </template>
-    <section>
-      <span class="title">Project</span>
-      <span class="value">[[change.project]]</span>
-    </section>
-    <section>
-      <span class="title">Branch</span>
-      <span class="value">[[change.branch]]</span>
-    </section>
-    <section>
-      <span class="title">Topic</span>
-      <span class="value">
-        <template is="dom-if" if="[[change.topic]]">
-          <gr-linked-chip
-              text="[[change.topic]]"
-              href="[[_computeTopicHref(change.topic)]]"
-              removable="[[!_topicReadOnly]]"
-              on-remove="_handleTopicRemoved"></gr-linked-chip>
-        </template>
-        <template is="dom-if" if="[[!change.topic]]">
-          <gr-editable-label
-              value="{{change.topic}}"
-              placeholder="[[_computeTopicPlaceholder(_topicReadOnly)]]"
-              read-only="[[_topicReadOnly]]"
-              on-changed="_handleTopicChanged"></gr-editable-label>
-        </template>
-      </span>
-    </section>
-    <section class="strategy" hidden$="[[_computeHideStrategy(change)]]" hidden>
-      <span class="title">Strategy</span>
-      <span class="value">[[_computeStrategy(change)]]</span>
-    </section>
-    <template is="dom-repeat"
-        items="[[_computeLabelNames(change.labels)]]" as="labelName">
-      <section>
-        <span class="title">[[labelName]]</span>
-        <span class="value">
-          <template is="dom-repeat"
-              items="[[_computeLabelValues(labelName, change.labels.*)]]"
-              as="label">
-            <div class="labelValueContainer">
-              <span class$="[[label.className]]">
-                <gr-label
-                    has-tooltip
-                    title="[[_computeValueTooltip(label.value, labelName)]]"
-                    class="labelValue">
-                  [[label.value]]
-                </gr-label>
-                <gr-account-chip
-                    account="[[label.account]]"
-                    data-account-id$="[[label.account._account_id]]"
-                    label-name="[[labelName]]"
-                    removable="[[_computeCanDeleteVote(label.account, mutable)]]"
-                    transparent-background
-                    on-remove="_onDeleteVote"></gr-account-chip>
-              </span>
-            </div>
+          <template is="dom-if" if="[[change.topic]]">
+            <gr-linked-chip
+                text="[[change.topic]]"
+                href="[[_computeTopicHref(change.topic)]]"
+                removable="[[!_topicReadOnly]]"
+                on-remove="_handleTopicRemoved"></gr-linked-chip>
+          </template>
+          <template is="dom-if" if="[[!change.topic]]">
+            <gr-editable-label
+                value="{{change.topic}}"
+                placeholder="[[_computeTopicPlaceholder(_topicReadOnly)]]"
+                read-only="[[_topicReadOnly]]"
+                on-changed="_handleTopicChanged"></gr-editable-label>
           </template>
         </span>
       </section>
-    </template>
-    <template is="dom-if" if="[[_showLabelStatus]]">
-      <section>
-        <span class="title">Label Status</span>
-        <span class="value labelStatus">
-          [[_computeSubmitStatus(change.labels)]]
+      <section class="strategy" hidden$="[[_computeHideStrategy(change)]]" hidden>
+        <span class="title">Strategy</span>
+        <span class="value">[[_computeStrategy(change)]]</span>
+      </section>
+      <template is="dom-repeat"
+          items="[[_computeLabelNames(change.labels)]]" as="labelName">
+        <section>
+          <span class="title">[[labelName]]</span>
+          <span class="value">
+            <template is="dom-repeat"
+                items="[[_computeLabelValues(labelName, change.labels.*)]]"
+                as="label">
+              <div class="labelValueContainer">
+                <span class$="[[label.className]]">
+                  <gr-label
+                      has-tooltip
+                      title="[[_computeValueTooltip(label.value, labelName)]]"
+                      class="labelValue">
+                    [[label.value]]
+                  </gr-label>
+                  <gr-account-chip
+                      account="[[label.account]]"
+                      data-account-id$="[[label.account._account_id]]"
+                      label-name="[[labelName]]"
+                      removable="[[_computeCanDeleteVote(label.account, mutable)]]"
+                      transparent-background
+                      on-remove="_onDeleteVote"></gr-account-chip>
+                </span>
+              </div>
+            </template>
+          </span>
+        </section>
+      </template>
+      <template is="dom-if" if="[[_showLabelStatus]]">
+        <section class="labelStatus">
+          <span class="title">Label Status</span>
+          <span class="value">
+            <div hidden$="[[!_showMissingLabels(change.labels)]]">
+              [[_computeMissingLabelsHeader(change.labels)]]
+              <ul id="missingLabels">
+                <template
+                    is="dom-repeat"
+                    items="[[_computeMissingLabels(change.labels)]]">
+                  <li>[[item]]</li>
+                </template>
+              </ul>
+            </div>
+            <div hidden$="[[_showMissingLabels(change.labels)]]">
+              Ready to submit
+            </div>
+          </span>
+        </section>
+      </template>
+      <section id="webLinks" hidden$="[[!_computeWebLinks(commitInfo)]]">
+        <span class="title">Links</span>
+        <span class="value">
+          <template is="dom-repeat"
+              items="[[_computeWebLinks(commitInfo)]]" as="link">
+            <a href="[[link.url]]" class="webLink" rel="noopener" target="_blank">
+              [[link.name]]
+            </a>
+          </template>
         </span>
       </section>
-    </template>
-    <section id="webLinks" hidden$="[[!_computeWebLinks(commitInfo)]]">
-      <span class="title">Links</span>
-      <span class="value">
-        <template is="dom-repeat"
-            items="[[_computeWebLinks(commitInfo)]]" as="link">
-          <a href="[[link.url]]" class="webLink" rel="noopener" target="_blank">
-            [[link.name]]
-          </a>
-        </template>
-      </span>
-    </section>
+    </gr-external-style>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-change-metadata.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
index 57e25f8..58938af 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
@@ -49,6 +49,7 @@
     behaviors: [
       Gerrit.BaseUrlBehavior,
       Gerrit.RESTClientBehavior,
+      Gerrit.URLEncodingBehavior,
     ],
 
     observers: [
@@ -152,6 +153,12 @@
       return !mutable || !change.actions.topic || !change.actions.topic.enabled;
     },
 
+    _computeAssigneeReadOnly: function(mutable, change) {
+      return !mutable ||
+          !change.actions.assignee ||
+          !change.actions.assignee.enabled;
+    },
+
     _computeTopicPlaceholder: function(_topicReadOnly) {
       return _topicReadOnly ? 'No Topic' : 'Click to add topic';
     },
@@ -189,16 +196,24 @@
       this._xhrPromise =
           this.$.restAPI.deleteVote(this.change.id, accountID, labelName)
           .then(function(response) {
-        if (!response.ok) { return response; }
-
-        var labels = this.change.labels[labelName].all || [];
-        for (var i = 0; i < labels.length; i++) {
-          if (labels[i]._account_id === accountID) {
-            this.splice(['change.labels', labelName, 'all'], i, 1);
-            break;
-          }
-        }
-      }.bind(this));
+            if (!response.ok) { return response; }
+            var label = this.change.labels[labelName];
+            var labels = label.all || [];
+            for (var i = 0; i < labels.length; i++) {
+              if (labels[i]._account_id === accountID) {
+                for (var key in label) {
+                  if (label.hasOwnProperty(key) &&
+                      label[key]._account_id === accountID) {
+                    // Remove special label field, keeping change label values
+                    // in sync with the backend.
+                    this.set(['change.labels', labelName, key], null);
+                  }
+                }
+                this.splice(['change.labels', labelName, 'all'], i, 1);
+                break;
+              }
+            }
+          }.bind(this));
     },
 
     _computeShowLabelStatus: function(change) {
@@ -207,29 +222,48 @@
       return isNewChange && hasLabels;
     },
 
-    _computeSubmitStatus: function(labels) {
+    _computeMissingLabels: function(labels) {
       var missingLabels = [];
-      var output = '';
       for (var label in labels) {
         var obj = labels[label];
         if (!obj.optional && !obj.approved) {
           missingLabels.push(label);
         }
       }
-      if (missingLabels.length) {
-        output += 'Needs ';
-        output += missingLabels.join(' and ');
-        output += missingLabels.length > 1 ? ' labels' : ' label';
+      return missingLabels;
+    },
+
+    _computeMissingLabelsHeader: function(labels) {
+      return 'Needs label' +
+          (this._computeMissingLabels(labels).length > 1 ? 's' : '') + ':';
+    },
+
+    _showMissingLabels: function(labels) {
+      return !!this._computeMissingLabels(labels).length;
+    },
+
+    _computeProjectURL: function(project) {
+      return this.getBaseUrl() + '/q/project:' +
+        this.encodeURL(project, false);
+    },
+
+    _computeBranchURL: function(project, branch) {
+      var status;
+      if (this.change.status == this.ChangeStatus.NEW) {
+        status = 'open';
       } else {
-        output = 'Ready to submit';
+        status = this.change.status.toLowerCase();
       }
-      return output;
+      return this.getBaseUrl() + '/q/project:' +
+        this.encodeURL(project, false) +
+          ' branch:' + this.encodeURL(branch, false) +
+              ' status:' + this.encodeURL(status, false);
     },
 
     _computeTopicHref: function(topic) {
       var encodedTopic = encodeURIComponent('\"' + topic + '\"');
-      return this.getBaseUrl() + '/q/topic:' + encodeURIComponent(encodedTopic) +
-          '+(status:open OR status:merged)';
+      return this.getBaseUrl() + '/q/topic:' +
+        encodeURIComponent(encodedTopic) + '+(status:open OR status:merged)';
     },
 
     _handleTopicRemoved: function() {
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
index 4eda281..c531c79 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
@@ -89,18 +89,20 @@
 
     test('computes submit status', function() {
       var labels = {};
-      assert.equal(element._computeSubmitStatus(labels), 'Ready to submit');
+      assert.isFalse(element._showMissingLabels(labels));
       labels = {test: {}};
-      assert.equal(element._computeSubmitStatus(labels), 'Needs test label');
+      assert.isTrue(element._showMissingLabels(labels));
+      assert.deepEqual(element._computeMissingLabels(labels), ['test']);
       labels.test.approved = true;
-      assert.equal(element._computeSubmitStatus(labels), 'Ready to submit');
+      assert.isFalse(element._showMissingLabels(labels));
       labels.test.approved = false;
       labels.test.optional = true;
-      assert.equal(element._computeSubmitStatus(labels), 'Ready to submit');
+      assert.isFalse(element._showMissingLabels(labels));
       labels.test.optional = false;
       labels.test2 = {};
-      assert.equal(element._computeSubmitStatus(labels),
-          'Needs test and test2 labels');
+      assert.isTrue(element._showMissingLabels(labels));
+      assert.deepEqual(element._computeMissingLabels(labels),
+          ['test', 'test2']);
     });
 
     test('weblinks hidden when no weblinks', function() {
@@ -199,6 +201,7 @@
         element.change = {
           _number: 'the number',
           change_id: 'the id',
+          actions: [],
           topic: 'the topic',
           status: 'NEW',
           submit_type: 'CHERRY_PICK',
@@ -241,8 +244,9 @@
           {
             _account_id: 1,
             name: 'bojack',
-          }
+          },
         ];
+        element.change.labels.test.recommended = {_account_id: 1};
         element.mutable = true;
         flushAsynchronousOperations();
         var button = element.$$('gr-account-chip').$$('gr-button');
@@ -253,6 +257,7 @@
           assert.deepEqual(path, ['change.labels', 'test', 'all']);
           assert.equal(index, 0);
           assert.equal(length, 1);
+          assert.notOk(element.change.labels.test.recommended);
           spliceStub.restore();
           done();
         });
@@ -285,8 +290,15 @@
           _account_id: 1,
           name: 'bojack',
         };
+        var change = {
+          actions: {
+            assignee: {enabled: false},
+          },
+          assignee: dummyAccount,
+        };
         var deleteStub;
         var setStub;
+
         setup(function() {
           deleteStub = sandbox.stub(element.$.restAPI, 'deleteAssignee');
           setStub = sandbox.stub(element.$.restAPI, 'setAssignee');
@@ -313,6 +325,17 @@
           element.set('_assignee', []);
           assert.isTrue(deleteStub.calledOnce);
         });
+
+        test('_computeAssigneeReadOnly', function() {
+          var mutable = false;
+          assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+          mutable = true;
+          assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+          change.actions.assignee.enabled = true;
+          assert.isFalse(element._computeAssigneeReadOnly(mutable, change));
+          mutable = false;
+          assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+        });
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
index 8455053..2c89e5c 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
@@ -333,9 +333,10 @@
                 change-num="[[_changeNum]]"
                 change-status="[[_change.status]]"
                 commit-num="[[_commitInfo.commit]]"
-                patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
+                patch-num="[[computeLatestPatchNum(_allPatchSets)]]"
                 commit-message="[[_latestCommitMessage]]"
-                on-reload-change="_handleReloadChange"></gr-change-actions>
+                on-reload-change="_handleReloadChange"
+                on-download-tap="_handleDownloadTap"></gr-change-actions>
           </div>
           <hr class="mobile">
           <div class="commitAndRelated">
@@ -385,7 +386,8 @@
                   change="[[_change]]"
                   has-parent="{{hasParent}}"
                   loading="{{_relatedChangesLoading}}"
-                  patch-num="[[_computeLatestPatchNum(_allPatchSets)]]">
+                  on-update="_updateRelatedChangeMaxHeight"
+                  patch-num="[[computeLatestPatchNum(_allPatchSets)]]">
               </gr-related-changes-list>
               <div
                   id="relatedChangesToggle"
@@ -422,7 +424,7 @@
                     disabled$="[[_computePatchSetDisabled(patchNum.num, _patchRange.basePatchNum)]]">
                   [[patchNum.num]]
                   /
-                  [[_computeLatestPatchNum(_allPatchSets)]]
+                  [[computeLatestPatchNum(_allPatchSets)]]
                   [[_computePatchSetDescription(_change, patchNum.num)]]
                 </option>
               </template>
@@ -463,7 +465,9 @@
             revisions="[[_change.revisions]]"
             project-config="[[_projectConfig]]"
             selected-index="{{viewState.selectedFileIndex}}"
-            diff-view-mode="{{viewState.diffMode}}"></gr-file-list>
+            diff-view-mode="{{viewState.diffMode}}"
+            num-files-shown="{{_numFilesShown}}"
+            file-list-increment="{{_numFilesShown}}"></gr-file-list>
       </section>
       <gr-messages-list id="messageList"
           change-num="[[_changeNum]]"
@@ -486,11 +490,12 @@
     <gr-overlay id="replyOverlay"
         class="scrollable"
         no-cancel-on-outside-click
+        no-cancel-on-esc-key
         on-iron-overlay-opened="_handleReplyOverlayOpen"
         with-backdrop>
       <gr-reply-dialog id="replyDialog"
           change="{{_change}}"
-          patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
+          patch-num="[[computeLatestPatchNum(_allPatchSets)]]"
           permitted-labels="[[_change.permitted_labels]]"
           diff-drafts="[[_diffDrafts]]"
           server-config="[[serverConfig]]"
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index f4892de..1a7765c 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -22,10 +22,12 @@
   var COMMENT_SAVE = 'Saving... Try again after all comments are saved.';
 
   var MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
+  var DEFAULT_NUM_FILES_SHOWN = 200;
 
   // Maximum length for patch set descriptions.
   var PATCH_DESC_MAX_LENGTH = 500;
   var REVIEWERS_REGEX = /^R=/gm;
+  var MIN_CHECK_INTERVAL_SECS = 0;
 
   Polymer({
     is: 'gr-change-view',
@@ -42,6 +44,12 @@
      * @event page-error
      */
 
+    /**
+     * Fired if being logged in is required.
+     *
+     * @event show-auth-required
+     */
+
     properties: {
       /**
        * URL params passed from the router.
@@ -57,12 +65,18 @@
       },
       backPage: String,
       hasParent: Boolean,
-      serverConfig: Object,
+      serverConfig: {
+        type: Object,
+        observer: '_startUpdateCheckTimer',
+      },
       keyEventTarget: {
         type: Object,
         value: function() { return document.body; },
       },
-
+      _numFilesShown: {
+        type: Number,
+        observer: '_numFilesShownChanged',
+      },
       _account: {
         type: Object,
         value: {},
@@ -109,7 +123,7 @@
       _currentRevisionActions: Object,
       _allPatchSets: {
         type: Array,
-        computed: '_computeAllPatchSets(_change, _change.revisions.*)',
+        computed: 'computeAllPatchSets(_change, _change.revisions.*)',
       },
       _loggedIn: {
         type: Boolean,
@@ -149,6 +163,7 @@
         type: Boolean,
         value: true,
       },
+      _updateCheckTimerHandle: Number,
     },
 
     behaviors: [
@@ -183,6 +198,9 @@
         }
       }.bind(this));
 
+      this._numFilesShown = this.viewState.numFilesShown ?
+          this.viewState.numFilesShown : DEFAULT_NUM_FILES_SHOWN;
+
       this.addEventListener('comment-save', this._handleCommentSave.bind(this));
       this.addEventListener('comment-discard',
           this._handleCommentDiscard.bind(this));
@@ -191,10 +209,16 @@
       this.addEventListener('editable-content-cancel',
           this._handleCommitMessageCancel.bind(this));
       this.listen(window, 'scroll', '_handleScroll');
+      this.listen(document, 'visibilitychange', '_handleVisibilityChange');
     },
 
     detached: function() {
       this.unlisten(window, 'scroll', '_handleScroll');
+      this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+
+      if (this._updateCheckTimerHandle) {
+        this._cancelUpdateCheckTimer();
+      }
     },
 
     _handleEditCommitMessage: function(e) {
@@ -405,7 +429,7 @@
 
       if (this._initialLoadComplete && patchChanged) {
         if (patchRange.patchNum == null) {
-          patchRange.patchNum = this._computeLatestPatchNum(this._allPatchSets);
+          patchRange.patchNum = this.computeLatestPatchNum(this._allPatchSets);
         }
         this._patchRange = patchRange;
         this._reloadPatchNumDependentResources().then(function() {
@@ -466,6 +490,10 @@
       }
     },
 
+    _numFilesShownChanged: function(numFilesShown) {
+      this.viewState.numFilesShown = numFilesShown;
+    },
+
     _maybeScrollToMessage: function() {
       var msgPrefix = '#message-';
       var hash = window.location.hash;
@@ -512,7 +540,9 @@
 
         if (this.viewState.showReplyDialog) {
           this._openReplyDialog();
-          this.async(function() { this.$.replyOverlay.center(); }, 1);
+          // TODO(kaspern@): Find a better signal for when to call center.
+          this.async(function() { this.$.replyOverlay.center(); }, 100);
+          this.async(function() { this.$.replyOverlay.center(); }, 1000);
           this.set('viewState.showReplyDialog', false);
         }
       }.bind(this));
@@ -525,6 +555,7 @@
         // Reset the diff mode to null when navigating from one change to
         // another, so that the user's preference is restored.
         this.set('viewState.diffMode', null);
+        this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN);
       }
       this.set('viewState.changeNum', this._changeNum);
       this.set('viewState.patchRange', this._patchRange);
@@ -536,7 +567,7 @@
           this._patchRange.basePatchNum || 'PARENT');
       this.set('_patchRange.patchNum',
           this._patchRange.patchNum ||
-              this._computeLatestPatchNum(this._allPatchSets));
+              this.computeLatestPatchNum(this._allPatchSets));
 
       this._updateSelected();
 
@@ -558,7 +589,7 @@
           currentPatchNum =
               this._change.revisions[this._change.current_revision]._number;
         } else {
-          currentPatchNum = this._computeLatestPatchNum(this._allPatchSets);
+          currentPatchNum = this.computeLatestPatchNum(this._allPatchSets);
         }
         if (patchNum === currentPatchNum &&
             this._patchRange.basePatchNum === 'PARENT') {
@@ -638,13 +669,9 @@
       return CHANGE_ID_ERROR.MISSING;
     },
 
-    _computeLatestPatchNum: function(allPatchSets) {
-      return allPatchSets[allPatchSets.length - 1].num;
-    },
-
     _computePatchInfoClass: function(patchNum, allPatchSets) {
       if (parseInt(patchNum, 10) ===
-          this._computeLatestPatchNum(allPatchSets)) {
+          this.computeLatestPatchNum(allPatchSets)) {
         return '';
       }
       return 'patchInfo--oldPatchSet';
@@ -662,19 +689,6 @@
       return parseInt(patchNum, 10) <= parseInt(basePatchNum, 10);
     },
 
-    _computeAllPatchSets: function(change) {
-      var patchNums = [];
-      for (var commit in change.revisions) {
-        if (change.revisions.hasOwnProperty(commit)) {
-          patchNums.push({
-            num: change.revisions[commit]._number,
-            desc: change.revisions[commit].description,
-          });
-        }
-      }
-      return patchNums.sort(function(a, b) { return a.num - b.num; });
-    },
-
     _computeLabelNames: function(labels) {
       return Object.keys(labels).sort();
     },
@@ -719,11 +733,18 @@
 
     _handleAKey: function(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e) ||
-          !this._loggedIn) { return; }
+          this.modifierPressed(e)) {
+        return;
+      }
+      this._getLoggedIn().then(function(isLoggedIn) {
+        if (!isLoggedIn) {
+          this.fire('show-auth-required');
+          return;
+        }
 
-      e.preventDefault();
-      this._openReplyDialog();
+        e.preventDefault();
+        this._openReplyDialog();
+      }.bind(this));
     },
 
     _handleDKey: function(e) {
@@ -814,6 +835,8 @@
       this.$.replyOverlay.open().then(function() {
         this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
         this.$.replyDialog.open(opt_section);
+        Polymer.dom.flush();
+        this.$.replyOverlay.center();
       }.bind(this));
     },
 
@@ -909,7 +932,7 @@
 
     _getLatestCommitMessage: function() {
       return this.$.restAPI.getChangeCommitInfo(this._changeNum,
-          this._computeLatestPatchNum(this._allPatchSets)).then(
+          this.computeLatestPatchNum(this._allPatchSets)).then(
               function(commitInfo) {
                 this._latestCommitMessage =
                     this._prepareCommitMsgForLinkify(commitInfo.message);
@@ -1095,16 +1118,16 @@
     },
 
     /**
-     * @desc get the line height of an element to the nearest integer.
-     * */
+     * Get the line height of an element to the nearest integer.
+     */
     _getLineHeight: function(element) {
       var lineHeightStr = getComputedStyle(element).lineHeight;
       return Math.round(lineHeightStr.slice(0, lineHeightStr.length - 2));
     },
 
     /**
-     * @desc new max height for the related changes section, shorter than
-     * the existing change info height.
+     * New max height for the related changes section, shorter than the existing
+     * change info height.
      */
     _updateRelatedChangeMaxHeight: function() {
       // Takes into account approximate height for the expand button and
@@ -1142,5 +1165,46 @@
       return this._getScrollHeight(this.$.relatedChanges) <=
           this._getOffsetHeight(this.$.relatedChanges);
     },
+
+    _startUpdateCheckTimer: function() {
+      if (!this.serverConfig ||
+          !this.serverConfig.change ||
+          this.serverConfig.change.update_delay === undefined ||
+          this.serverConfig.change.update_delay <= MIN_CHECK_INTERVAL_SECS) {
+        return;
+      }
+
+      this._updateCheckTimerHandle = this.async(function() {
+        this.fetchIsLatestKnown(this._change, this.$.restAPI)
+            .then(function(latest) {
+              if (!latest) {
+                this._cancelUpdateCheckTimer();
+                this.fire('show-alert', {
+                  message: 'A newer patch has been uploaded.',
+                  action: 'Reload',
+                  callback: function() {
+                    // Load the current change without any patch range.
+                    location.href = this.getBaseUrl() + '/c/' +
+                        this._change._number;
+                  }.bind(this),
+                });
+              }
+              this._startUpdateCheckTimer();
+            }.bind(this));
+      }, this.serverConfig.change.update_delay * 1000);
+    },
+
+    _cancelUpdateCheckTimer: function() {
+      this.cancelAsync(this._updateCheckTimerHandle);
+      this._updateCheckTimerHandle = null;
+    },
+
+    _handleVisibilityChange: function() {
+      if (document.hidden && this._updateCheckTimerHandle) {
+        this._cancelUpdateCheckTimer();
+      } else if (!this._updateCheckTimerHandle) {
+        this._startUpdateCheckTimer();
+      }
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
index ce91434..59b3d41 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
@@ -75,19 +75,36 @@
         assert(showStub.lastCall.calledWithExactly('/dashboard/self'));
       });
 
-      test('A should toggle overlay', function() {
+      test('A fires an error event when not logged in', function(done) {
+        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
+        var loggedInErrorSpy = sandbox.spy();
+        element.addEventListener('show-auth-required', loggedInErrorSpy);
         MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-        var overlayEl = element.$.replyOverlay;
-        assert.isFalse(overlayEl.opened);
-        element._loggedIn = true;
+        flush(function() {
+          assert.isFalse(element.$.replyOverlay.opened);
+          assert.isTrue(loggedInErrorSpy.called);
+          done();
+        });
+      });
 
+      test('shift A does not open reply overlay', function(done) {
+        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
         MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
-        assert.isFalse(overlayEl.opened);
+        flush(function() {
+          assert.isFalse(element.$.replyOverlay.opened);
+          done();
+        });
+      });
 
+      test('A toggles overlay when logged in', function(done) {
+        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
         MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-        assert.isTrue(overlayEl.opened);
-        overlayEl.close();
-        assert.isFalse(overlayEl.opened);
+        flush(function() {
+          assert.isTrue(element.$.replyOverlay.opened);
+          element.$.replyOverlay.close();
+          assert.isFalse(element.$.replyOverlay.opened);
+          done();
+        });
       });
 
       test('X should expand all messages', function() {
@@ -357,8 +374,12 @@
       };
       element.viewState.changeNum = null;
       element.viewState.diffMode = 'UNIFIED';
+      assert.equal(element.viewState.numFilesShown, 200);
+      assert.equal(element._numFilesShown, 200);
+      element._numFilesShown = 150;
       flushAsynchronousOperations();
       assert.equal(element.viewState.diffMode, 'UNIFIED');
+      assert.equal(element.viewState.numFilesShown, 150);
 
       element._changeNum = '1';
       element.params = {changeNum: '1'};
@@ -373,6 +394,8 @@
       flushAsynchronousOperations();
       assert.isNull(element.viewState.diffMode);
       assert.equal(element.viewState.changeNum, '2');
+      assert.equal(element.viewState.numFilesShown, 200);
+      assert.equal(element._numFilesShown, 200);
     });
 
     test('patch num change', function(done) {
@@ -545,7 +568,7 @@
     test('related changes are updated and new patch selected after rebase',
         function(done) {
       element._changeNum = '42';
-      sandbox.stub(element, '_computeLatestPatchNum', function() {
+      sandbox.stub(element, 'computeLatestPatchNum', function() {
         return 1;
       });
       sandbox.stub(element, '_reload',
@@ -1080,6 +1103,47 @@
         assert.equal(element.customStyle['--related-change-btn-top-padding'],
             '2px');
       });
+
+      suite('update checks', function() {
+        setup(function() {
+          sandbox.spy(element, '_startUpdateCheckTimer');
+          sandbox.stub(element, 'async', function(f) {
+            // Only fire the async callback one time.
+            if (element.async.callCount > 1) { return; }
+            f.call(element);
+          });
+        });
+
+        test('_startUpdateCheckTimer negative delay', function() {
+          sandbox.stub(element, 'fetchIsLatestKnown');
+
+          element.serverConfig = {change: {update_delay: -1}};
+
+          assert.isTrue(element._startUpdateCheckTimer.called);
+          assert.isFalse(element.fetchIsLatestKnown.called);
+        });
+
+        test('_startUpdateCheckTimer up-to-date', function() {
+          sandbox.stub(element, 'fetchIsLatestKnown',
+              function() { return Promise.resolve(true); });
+
+          element.serverConfig = {change: {update_delay: 12345}};
+
+          assert.isTrue(element._startUpdateCheckTimer.called);
+          assert.isTrue(element.fetchIsLatestKnown.called);
+          assert.equal(element.async.lastCall.args[1], 12345 * 1000);
+        });
+
+        test('_startUpdateCheckTimer out-of-date shows an alert',
+            function(done) {
+          sandbox.stub(element, 'fetchIsLatestKnown',
+              function() { return Promise.resolve(false); });
+          element.addEventListener('show-alert', function() {
+            done();
+          });
+          element.serverConfig = {change: {update_delay: 12345}};
+        });
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
index c38cf18..0e640a7 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
@@ -19,6 +19,11 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
 
+<!--
+  The custom CSS property `--gr-formatted-text-prose-max-width` controls the max
+  width of formatted text blocks that are not code.
+-->
+
 <dom-module id="gr-comment-list">
   <template>
     <style>
@@ -43,7 +48,7 @@
       }
       .message {
         flex: 1;
-        max-width: 80ch;
+        --gr-formatted-text-prose-max-width: 80ch;
       }
     </style>
     <template is="dom-repeat" items="[[_computeFilesFromComments(comments)]]" as="file">
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
index 0e97d36..d95c1f8 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
@@ -84,7 +84,7 @@
       }
       .closeButtonContainer {
         display: flex;
-        flex: 1;
+        flex: 0;
         justify-content: flex-end;
       }
       .patchFiles {
@@ -99,6 +99,10 @@
       .archives a:last-of-type {
         margin-right: 0;
       }
+      .title {
+        text-align: center;
+        flex: 1;
+      }
     </style>
     <header>
       <ul hidden$="[[!_schemes.length]]" hidden>
@@ -110,6 +114,9 @@
           </li>
         </template>
       </ul>
+      <span class="title">
+        Patch set [[patchNum]] of [[_computePatchSetQuantity(change.revisions)]]
+      </span>
       <span class="closeButtonContainer">
         <gr-button id="closeButton"
             link
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
index 41f6792..d4de034 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
@@ -137,6 +137,11 @@
       return [];
     },
 
+    _computePatchSetQuantity: function(revisions) {
+      if (!revisions) { return 0; }
+      return Object.keys(revisions).length;
+    },
+
     _computeSchemeSelected: function(scheme, selectedScheme) {
       return scheme == selectedScheme;
     },
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
index e324078..f844cfd 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
@@ -27,6 +27,7 @@
 <link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-select/gr-select.html">
+<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
 
 <dom-module id="gr-file-list">
   <template>
@@ -233,95 +234,104 @@
         </label>
       </div>
     </header>
-    <template is="dom-repeat"
-        items="[[_shownFiles]]"
-        as="file"
-        initial-count="[[_fileListIncrement]]">
-      <div class="file-row row">
-        <div class="reviewed" hidden$="[[!_loggedIn]]" hidden>
-          <input type="checkbox" checked="[[file.isReviewed]]"
-              data-path$="[[file.__path]]" on-change="_handleReviewedChange"
-              class="reviewed" aria-label="Reviewed checkbox">
-        </div>
-        <div class$="[[_computeClass('status', file.__path)]]"
-            tabindex="0"
-            aria-label$="[[_computeFileStatusLabel(file.status)]]">
-          [[_computeFileStatus(file.status)]]
-        </div>
-        <a class$="[[_computePathClass(file.__path, _expandedFilePaths.*)]]"
-            href$="[[_computeDiffURL(changeNum, patchRange, file.__path)]]"
-            on-tap="_handleFileTap">
-          <div title$="[[_computeFileDisplayName(file.__path)]]"
-              class="fullFileName">
-            [[_computeFileDisplayName(file.__path)]]
-          </div>
-          <div title$="[[_computeFileDisplayName(file.__path)]]"
-              class="truncatedFileName">
-            [[_computeTruncatedFileDisplayName(file.__path)]]
-          </div>
-          <div class="oldPath" hidden$="[[!file.old_path]]" hidden
-              title$="[[file.old_path]]">
-            [[file.old_path]]
-          </div>
-        </a>
-        <div class="comments desktop">
-          <span class="drafts">
-            [[_computeDraftsString(drafts, patchRange.patchNum, file.__path)]]
-          </span>
-          [[_computeCommentsString(comments, patchRange.patchNum, file.__path)]]
-          [[_computeUnresolvedString(comments, drafts, patchRange.patchNum, file.__path)]]
-        </div>
-        <div class="comments mobile">
-          <span class="drafts">
-            [[_computeDraftsStringMobile(drafts, patchRange.patchNum,
-                file.__path)]]
-          </span>
-          [[_computeCommentsStringMobile(comments, patchRange.patchNum,
-              file.__path)]]
-        </div>
-        <div class$="[[_computeClass('stats', file.__path)]]">
-          <span
-              class="added"
-              tabindex="0"
-              aria-label$="[[file.lines_inserted]] lines added"
-              hidden$=[[file.binary]]>
-            +[[file.lines_inserted]]
-          </span>
-          <span
-              class="removed"
-              tabindex="0"
-              aria-label$="[[file.lines_deleted]] lines removed"
-              hidden$=[[file.binary]]>
-            -[[file.lines_deleted]]
-          </span>
-          <span class$="[[_computeBinaryClass(file.size_delta)]]"
-              hidden$=[[!file.binary]]>
-            [[_formatBytes(file.size_delta)]]
-            [[_formatPercentage(file.size, file.size_delta)]]
-          </span>
-        </div>
-        <div class="show-hide" hidden$="[[_userPrefs.expand_inline_diffs]]">
-          <label class="show-hide">
-            <input type="checkbox" class="show-hide"
-                checked$="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
+    <div on-tap="_handleFileListTap">
+      <template is="dom-repeat"
+          items="[[_shownFiles]]"
+          id="files"
+          as="file"
+          initial-count="[[_fileListIncrement]]"
+          target-framerate="1">
+        <div class="file-row row">
+          <div class="reviewed" hidden$="[[!_loggedIn]]" hidden>
+            <input type="checkbox" checked="[[file.isReviewed]]"
                 data-path$="[[file.__path]]"
-                on-change="_handleHiddenChange">
-            [[_computeShowHideText(file.__path, _expandedFilePaths.*)]]
-          </label>
+                class="reviewed" aria-label="Reviewed checkbox">
+          </div>
+          <div class$="[[_computeClass('status', file.__path)]]"
+              tabindex="0"
+              aria-label$="[[_computeFileStatusLabel(file.status)]]">
+            [[_computeFileStatus(file.status)]]
+          </div>
+          <a class$="[[_computePathClass(file.__path, _expandedFilePaths.*)]]"
+              href$="[[_computeDiffURL(changeNum, patchRange, file.__path)]]"
+              data-path$="[[file.__path]]">
+            <div title$="[[_computeFileDisplayName(file.__path)]]"
+                class="fullFileName">
+              [[_computeFileDisplayName(file.__path)]]
+            </div>
+            <div title$="[[_computeFileDisplayName(file.__path)]]"
+                class="truncatedFileName">
+              [[_computeTruncatedFileDisplayName(file.__path)]]
+            </div>
+            <div class="oldPath" hidden$="[[!file.old_path]]" hidden
+                title$="[[file.old_path]]">
+              [[file.old_path]]
+            </div>
+          </a>
+          <div class="comments desktop">
+            <span class="drafts">
+              [[_computeDraftsString(drafts, patchRange.patchNum, file.__path)]]
+            </span>
+            [[_computeCommentsString(comments, patchRange.patchNum, file.__path)]]
+            [[_computeUnresolvedString(comments, drafts, patchRange.patchNum, file.__path)]]
+          </div>
+          <div class="comments mobile">
+            <span class="drafts">
+              [[_computeDraftsStringMobile(drafts, patchRange.patchNum,
+                  file.__path)]]
+            </span>
+            [[_computeCommentsStringMobile(comments, patchRange.patchNum,
+                file.__path)]]
+          </div>
+          <div class$="[[_computeClass('stats', file.__path)]]">
+            <span
+                class="added"
+                tabindex="0"
+                aria-label$="[[file.lines_inserted]] lines added"
+                hidden$=[[file.binary]]>
+              +[[file.lines_inserted]]
+            </span>
+            <span
+                class="removed"
+                tabindex="0"
+                aria-label$="[[file.lines_deleted]] lines removed"
+                hidden$=[[file.binary]]>
+              -[[file.lines_deleted]]
+            </span>
+            <span class$="[[_computeBinaryClass(file.size_delta)]]"
+                hidden$=[[!file.binary]]>
+              [[_formatBytes(file.size_delta)]]
+              [[_formatPercentage(file.size, file.size_delta)]]
+            </span>
+          </div>
+          <div class="show-hide" hidden$="[[_userPrefs.expand_inline_diffs]]">
+            <label class="show-hide" data-path$="[[file.__path]]"
+                data-expand=true>
+              <input type="checkbox" class="show-hide"
+                  checked$="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
+                  data-path$="[[file.__path]]" data-expand=true>
+              [[_computeShowHideText(file.__path, _expandedFilePaths.*)]]
+            </label>
+          </div>
         </div>
-      </div>
-      <gr-diff
-          no-auto-render
-          hidden="[[!_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
-          project="[[change.project]]"
-          commit="[[change.current_revision]]"
-          change-num="[[changeNum]]"
-          patch-range="[[patchRange]]"
-          path="[[file.__path]]"
-          prefs="[[_diffPrefs]]"
-          project-config="[[projectConfig]]"
-          view-mode="[[_getDiffViewMode(diffViewMode, _userPrefs)]]"></gr-diff>
-    </template>
+        <template is="dom-if"
+            if="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]">
+          <gr-diff
+              no-auto-render
+              inline-index=[[index]]
+              hidden="[[!_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
+              project="[[change.project]]"
+              commit="[[change.current_revision]]"
+              change-num="[[changeNum]]"
+              patch-range="[[patchRange]]"
+              path="[[file.__path]]"
+              prefs="[[_diffPrefs]]"
+              project-config="[[projectConfig]]"
+              on-line-selected="_onLineSelected"
+              view-mode="[[_getDiffViewMode(diffViewMode, _userPrefs)]]"></gr-diff>
+        </template>
+      </template>
+    </div>
     <div class$="row totalChanges [[_computeExpandInlineClass(_userPrefs)]]">
       <div class="total-stats" hidden$="[[_hideChangeTotals]]">
         <span
@@ -355,17 +365,22 @@
     <gr-button
         class="fileListButton"
         id="incrementButton"
-        hidden$="[[_computeFileListButtonHidden(_numFilesShown, _files)]]"
+        hidden$="[[_computeFileListButtonHidden(numFilesShown, _files)]]"
         link on-tap="_incrementNumFilesShown">
-      [[_computeIncrementText(_numFilesShown, _files)]]
+      [[_computeIncrementText(numFilesShown, _files)]]
     </gr-button>
-    <gr-button
-        class="fileListButton"
-        id="showAllButton"
-        hidden$="[[_computeFileListButtonHidden(_numFilesShown, _files)]]"
-        link on-tap="_showAllFiles">
-      [[_computeShowAllText(_files)]]
-    </gr-button>
+    <gr-tooltip-content
+        has-tooltip="[[_computeWarnShowAll(_files)]]"
+        show-icon="[[_computeWarnShowAll(_files)]]"
+        title$="[[_computeShowAllWarning(_files)]]">
+      <gr-button
+          class="fileListButton"
+          id="showAllButton"
+          hidden$="[[_computeFileListButtonHidden(numFilesShown, _files)]]"
+          link on-tap="_showAllFiles">
+        [[_computeShowAllText(_files)]]
+      </gr-button><!--
+ --></gr-tooltip-content>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-storage id="storage"></gr-storage>
     <gr-diff-cursor id="diffCursor"></gr-diff-cursor>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index 8660b0d..2a166ab 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -16,7 +16,7 @@
 
   // Maximum length for patch set descriptions.
   var PATCH_DESC_MAX_LENGTH = 500;
-
+  var WARN_SHOW_ALL_THRESHOLD = 1000;
   var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
 
   var FileStatus = {
@@ -72,19 +72,15 @@
       _userPrefs: Object,
       _localPrefs: Object,
       _showInlineDiffs: Boolean,
-      _numFilesShown: {
+      numFilesShown: {
         type: Number,
-        value: 75,
+        notify: true,
       },
       _patchChange: {
         type: Object,
         computed: '_calculatePatchChange(_files)',
       },
-      _fileListIncrement: {
-        type: Number,
-        readOnly: true,
-        value: 75,
-      },
+      fileListIncrement: Number,
       _hideChangeTotals: {
         type: Boolean,
         computed: '_shouldHideChangeTotals(_patchChange)',
@@ -95,7 +91,7 @@
       },
       _shownFiles: {
         type: Array,
-        computed: '_computeFilesShown(_numFilesShown, _files.*)',
+        computed: '_computeFilesShown(numFilesShown, _files.*)',
       },
       // Caps the number of files that can be shown and have the 'show diffs' /
       // 'hide diffs' buttons still be functional.
@@ -227,10 +223,6 @@
       return parseInt(patchNum, 10) >= parseInt(currentPatchNum, 10);
     },
 
-    _handleHiddenChange: function(e) {
-      this._togglePathExpanded(e.model.file.__path);
-    },
-
     _togglePathExpanded: function(path) {
       // Is the path in the list of expanded diffs? IF so remove it, otherwise
       // add it to the list.
@@ -401,15 +393,29 @@
           });
     },
 
-    _handleFileTap: function(e) {
+    /**
+     * Handle all events from the file list dom-repeat so event handleers don't
+     * have to get registered for potentially very long lists.
+     */
+    _handleFileListTap: function(e) {
+      // Handle checkbox mark as reviewed.
+      if (e.target.classList.contains('reviewed')) {
+        return this._handleReviewedChange(e);
+      }
+
+      // Check to see if the file should be expanded.
+      var path = e.target.dataset.path || e.target.parentElement.dataset.path;
+
       // If the user prefers to expand inline diffs rather than opening the diff
       // view, intercept the click event.
-      if (e.detail.sourceEvent.metaKey || e.detail.sourceEvent.ctrlKey) {
+      if (!path || e.detail.sourceEvent.metaKey ||
+          e.detail.sourceEvent.ctrlKey) {
           return;
       }
-      if (this._userPrefs && this._userPrefs.expand_inline_diffs) {
+      if (e.target.dataset.expand ||
+          this._userPrefs && this._userPrefs.expand_inline_diffs) {
         e.preventDefault();
-        this._handleHiddenChange(e);
+        this._togglePathExpanded(path);
       }
     },
 
@@ -446,7 +452,10 @@
     },
 
     _handleDownKey: function(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+        return;
+      }
+
       e.preventDefault();
       if (this._showInlineDiffs) {
         this.$.diffCursor.moveDown();
@@ -457,7 +466,9 @@
     },
 
     _handleUpKey: function(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+        return;
+      }
 
       e.preventDefault();
       if (this._showInlineDiffs) {
@@ -513,11 +524,13 @@
 
     _handleNKey: function(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
+          this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
+        return;
+      }
       if (!this._showInlineDiffs) { return; }
 
       e.preventDefault();
-      if (e.shiftKey) {
+      if (this.isModifierPressed(e, 'shiftKey')) {
         this.$.diffCursor.moveToNextCommentThread();
       } else {
         this.$.diffCursor.moveToNextChunk();
@@ -526,11 +539,13 @@
 
     _handlePKey: function(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
+          this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
+        return;
+      }
       if (!this._showInlineDiffs) { return; }
 
       e.preventDefault();
-      if (e.shiftKey) {
+      if (this.isModifierPressed(e, 'shiftKey')) {
         this.$.diffCursor.moveToPreviousCommentThread();
       } else {
         this.$.diffCursor.moveToPreviousChunk();
@@ -564,6 +579,7 @@
       if (opt_index != null) {
         this.$.fileCursor.setCursorAtIndex(opt_index);
       }
+      if (!this._files[this.$.fileCursor.index]) { return; }
       page.show(this._computeDiffURL(this.changeNum, this.patchRange,
           this._files[this.$.fileCursor.index].__path));
     },
@@ -672,22 +688,23 @@
       }
     },
 
+    _updateDiffCursor: function() {
+      var diffElements = Polymer.dom(this.root).querySelectorAll('gr-diff');
+
+      // Overwrite the cursor's list of diffs:
+      this.$.diffCursor.splice.apply(this.$.diffCursor,
+          ['diffs', 0, this.$.diffCursor.diffs.length].concat(diffElements));
+    },
+
     _filesChanged: function() {
-      this.async(function() {
-        var diffElements = Polymer.dom(this.root).querySelectorAll('gr-diff');
-
-        // Overwrite the cursor's list of diffs:
-        this.$.diffCursor.splice.apply(this.$.diffCursor,
-            ['diffs', 0, this.$.diffCursor.diffs.length].concat(diffElements));
-
-        var files = Polymer.dom(this.root).querySelectorAll('.file-row');
-        this.$.fileCursor.stops = files;
-        this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
-      }.bind(this), 1);
+      Polymer.dom.flush();
+      var files = Polymer.dom(this.root).querySelectorAll('.file-row');
+      this.$.fileCursor.stops = files;
+      this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
     },
 
     _incrementNumFilesShown: function() {
-      this._numFilesShown += this._fileListIncrement;
+      this.numFilesShown += this.fileListIncrement;
     },
 
     _computeFileListButtonHidden: function(numFilesShown, files) {
@@ -697,7 +714,7 @@
     _computeIncrementText: function(numFilesShown, files) {
       if (!files) { return ''; }
       var text =
-          Math.min(this._fileListIncrement, files.length - numFilesShown);
+          Math.min(this.fileListIncrement, files.length - numFilesShown);
       return 'Show ' + text + ' more';
     },
 
@@ -706,8 +723,18 @@
       return 'Show all ' + files.length + ' files';
     },
 
+    _computeWarnShowAll: function(files) {
+      return files.length > WARN_SHOW_ALL_THRESHOLD;
+    },
+
+    _computeShowAllWarning: function(files) {
+      if (!this._computeWarnShowAll(files)) { return ''; }
+      return 'Warning: showing all ' + files.length +
+          ' files may take several seconds.';
+    },
+
     _showAllFiles: function() {
-      this._numFilesShown = this._files.length;
+      this.numFilesShown = this._files.length;
     },
 
     _updateSelected: function(patchRange) {
@@ -755,6 +782,11 @@
       return expandedFilesRecord.base.indexOf(path) !== -1;
     },
 
+    _onLineSelected: function(e, detail) {
+      this.$.diffCursor.moveToLineNumber(detail.number, detail.side,
+          detail.path);
+    },
+
     /**
      * Handle splices to the list of expanded file paths. If there are any new
      * entries in the expanded list, then render each diff corresponding in
@@ -776,11 +808,16 @@
       var timerName = 'Expand ' + newPaths.length + ' diffs';
       this.$.reporting.time(timerName);
 
+      // Required so that the newly created diff view is included in this.diffs.
+      Polymer.dom.flush();
+
       this._renderInOrder(newPaths, this.diffs, newPaths.length)
           .then(function() {
             this.$.reporting.timeEnd(timerName);
             this.$.diffCursor.handleDiffUpdate();
           }.bind(this));
+      this._updateDiffCursor();
+      this.$.diffCursor.handleDiffUpdate();
     },
 
     /**
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
index bd4619f..4abd68a 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -23,6 +23,7 @@
 <script src="../../../bower_components/page/page.js"></script>
 <script src="../../../scripts/util.js"></script>
 
+<link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-file-list.html">
 
@@ -60,6 +61,7 @@
         reload: function() { return Promise.resolve(); },
       });
       element = fixture('basic');
+      element.numFilesShown = 200;
       saveStub = sandbox.stub(element, '_saveReviewedState',
           function() { return Promise.resolve(); });
     });
@@ -68,6 +70,15 @@
       sandbox.restore();
     });
 
+    test('correct number of files are shown', function() {
+      element._files = _.times(500, function(i) {
+          return {__path: '/file' + i, lines_inserted: 9}; });
+      flushAsynchronousOperations();
+      assert.equal(
+          Polymer.dom(element.root).querySelectorAll('.file-row').length,
+          element.numFilesShown);
+    });
+
     test('get file list', function(done) {
       var getChangeFilesStub = sandbox.stub(element.$.restAPI, 'getChangeFiles',
           function() {
@@ -308,6 +319,9 @@
         assert.isTrue(items[0].classList.contains('selected'));
         assert.isFalse(items[1].classList.contains('selected'));
         assert.isFalse(items[2].classList.contains('selected'));
+        // j with a modifier should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 74, 'shift', 'j');
+        assert.equal(element.$.fileCursor.index, 0);
         MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
         assert.equal(element.$.fileCursor.index, 1);
         assert.equal(element.selectedIndex, 1);
@@ -319,6 +333,9 @@
         MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
         assert(showStub.lastCall.calledWith('/c/42/2/myfile.txt'),
             'Should navigate to /c/42/2/myfile.txt');
+        // k with a modifier should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 75, 'shift', 'k');
+        assert.equal(element.$.fileCursor.index, 2);
 
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
         assert.equal(element.$.fileCursor.index, 1);
@@ -337,7 +354,8 @@
       test('i key shows/hides selected inline diff', function() {
         sandbox.stub(element, '_expandedPathsChanged');
         flushAsynchronousOperations();
-        element.$.fileCursor.stops = element.diffs;
+        var files = Polymer.dom(element.root).querySelectorAll('.file-row');
+        element.$.fileCursor.stops = files;
         element.$.fileCursor.setCursorAtIndex(0);
         MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
         flushAsynchronousOperations();
@@ -637,10 +655,13 @@
       flushAsynchronousOperations();
       var fileRows =
           Polymer.dom(element.root).querySelectorAll('.row:not(.header)');
+      // Because the label surrounds the input, the tap event is triggered
+      // there first.
+      var showHideLabel = fileRows[0].querySelector('label.show-hide');
       var showHideCheck = fileRows[0].querySelector(
           'input.show-hide[type="checkbox"]');
       assert.isNotOk(showHideCheck.checked);
-      MockInteractions.tap(showHideCheck);
+      MockInteractions.tap(showHideLabel);
       assert.isOk(showHideCheck.checked);
       assert.notEqual(element._expandedFilePaths.indexOf('myfile.txt'), -1);
     });
@@ -674,6 +695,13 @@
       };
       element.$.fileCursor.setCursorAtIndex(0);
       flushAsynchronousOperations();
+
+      // Tap on a file to generate the diff.
+      var row = Polymer.dom(element.root)
+          .querySelectorAll('.row:not(.header) label.show-hide')[0];
+
+      MockInteractions.tap(row);
+      flushAsynchronousOperations();
       var diffDisplay = element.diffs[0];
       element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
       assert.equal(element.diffViewMode, 'SIDE_BY_SIDE');
@@ -721,7 +749,7 @@
           arr.push({__path: 'myfile.txt'});
         });
         element._files = arr;
-        element._numFilesShown = arr.length;
+        element.numFilesShown = arr.length;
         assert.isFalse(computeSpy.lastCall.returnValue);
         done();
       });
@@ -763,18 +791,18 @@
 
       // Remove href attribute so the app doesn't route to a diff view
       commitMsgFile.removeAttribute('href');
-      var hiddenChangeSpy = sandbox.spy(element, '_handleHiddenChange');
+      var togglePathSpy = sandbox.spy(element, '_togglePathExpanded');
 
       MockInteractions.tap(commitMsgFile);
       flushAsynchronousOperations();
-      assert(hiddenChangeSpy.notCalled, 'file is opened as diff view');
+      assert(togglePathSpy.notCalled, 'file is opened as diff view');
       assert.isNotOk(element.$$('.expanded'));
 
       element._userPrefs = {expand_inline_diffs: true};
       flushAsynchronousOperations();
       MockInteractions.tap(commitMsgFile);
       flushAsynchronousOperations();
-      assert(hiddenChangeSpy.calledOnce, 'file is expanded');
+      assert(togglePathSpy.calledOnce, 'file is expanded');
       assert.isOk(element.$$('.expanded'));
     });
 
@@ -812,32 +840,37 @@
       element.push('_expandedFilePaths', path);
     });
 
-    suite('_handleFileTap', function() {
+    suite('_handleFileListTap', function() {
       function testForModifier(modifier) {
         var e = {preventDefault: function() {}};
         e.detail = {sourceEvent: {}};
+        e.target = {
+          dataset: {path: '/test'},
+          classList: element.classList,
+        };
+
         e.detail.sourceEvent[modifier] = true;
 
-        var hiddenChangeStub = sandbox.stub(element, '_handleHiddenChange');
+        var togglePathStub = sandbox.stub(element, '_togglePathExpanded');
         element._userPrefs = { expand_inline_diffs: true };
 
-        element._handleFileTap(e);
-        assert.isFalse(hiddenChangeStub.called);
+        element._handleFileListTap(e);
+        assert.isFalse(togglePathStub.called);
 
         e.detail.sourceEvent[modifier] = false;
-        element._handleFileTap(e);
-        assert.equal(hiddenChangeStub.callCount, 1);
+        element._handleFileListTap(e);
+        assert.equal(togglePathStub.callCount, 1);
 
         element._userPrefs = { expand_inline_diffs: false };
-        element._handleFileTap(e);
-        assert.equal(hiddenChangeStub.callCount, 1);
+        element._handleFileListTap(e);
+        assert.equal(togglePathStub.callCount, 1);
       }
 
-      test('_handleFileTap meta', function() {
+      test('_handleFileListTap meta', function() {
         testForModifier('metaKey');
       });
 
-      test('_handleFileTap ctrl', function() {
+      test('_handleFileListTap ctrl', function() {
         testForModifier('ctrlKey');
       });
     });
@@ -904,4 +937,251 @@
         });
     });
   });
+
+  suite('gr-file-list inline diff tests', function() {
+    var element;
+    var sandbox;
+
+    var setupDiff = function(diff) {
+      var mock = document.createElement('mock-diff-response');
+      diff._diff = mock.diffResponse;
+      diff._comments = {
+        left: [],
+        right: [],
+      };
+      diff.prefs = {
+        context: 10,
+        tab_size: 8,
+        font_size: 12,
+        line_length: 100,
+        cursor_blink_rate: 0,
+        line_wrapping: false,
+        intraline_difference: true,
+        show_line_endings: true,
+        show_tabs: true,
+        show_whitespace_errors: true,
+        syntax_highlighting: true,
+        auto_hide_diff_table_header: true,
+        theme: 'DEFAULT',
+        ignore_whitespace: 'IGNORE_NONE',
+      };
+      diff._renderDiffTable();
+    };
+
+    var renderAndGetNewDiffs = function(index) {
+      var diffs =
+          Polymer.dom(element.root).querySelectorAll('gr-diff');
+
+      for (var i = index; i < diffs.length; i++) {
+        setupDiff(diffs[i]);
+      }
+
+      element._updateDiffCursor();
+      element.$.diffCursor.handleDiffUpdate();
+      return diffs;
+    };
+
+    setup(function() {
+      sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        getLoggedIn: function() { return Promise.resolve(true); },
+        getPreferences: function() { return Promise.resolve({}); },
+      });
+      stub('gr-date-formatter', {
+        _loadTimeFormat: function() { return Promise.resolve(''); },
+      });
+      stub('gr-diff', {
+        reload: function() { return Promise.resolve(); },
+      });
+      element = fixture('basic');
+      element.numFilesShown = 75;
+      element.selectedIndex = 0;
+      element._files = [
+        {__path: '/COMMIT_MSG', lines_inserted: 9},
+        {
+          __path: 'file_added_in_rev2.txt',
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+        {
+          __path: 'myfile.txt',
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+      ];
+      element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
+      element._loggedIn = true;
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      sandbox.stub(window, 'fetch', function() {
+        return Promise.resolve();
+      });
+      flushAsynchronousOperations();
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('cursor with individually opened files', function() {
+      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
+      flushAsynchronousOperations();
+      var diffs = renderAndGetNewDiffs(0);
+      var diffStops = diffs[0].getCursorStops();
+
+      // 1 diff should be rendered.
+      assert.equal(diffs.length, 1);
+
+      // No line number is selected.
+      assert.isFalse(diffStops[10].classList.contains('target-row'));
+
+      // Tapping content on a line selects the line number.
+      MockInteractions.tap(Polymer.dom(
+          diffStops[10]).querySelectorAll('.contentText')[0]);
+      flushAsynchronousOperations();
+      assert.isTrue(diffStops[10].classList.contains('target-row'));
+
+      // Keyboard shortcuts are still moving the file cursor, not the diff
+      // cursor.
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      flushAsynchronousOperations();
+      assert.isTrue(diffStops[10].classList.contains('target-row'));
+      assert.isFalse(diffStops[11].classList.contains('target-row'));
+
+      // The file cusor is now at 1.
+      assert.equal(element.$.fileCursor.index, 1);
+      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
+      flushAsynchronousOperations();
+
+      diffs = renderAndGetNewDiffs(1);
+      // Two diffs should be rendered.
+      assert.equal(diffs.length, 2);
+      var diffStopsFirst = diffs[0].getCursorStops();
+      var diffStopsSecond = diffs[1].getCursorStops();
+
+      // The line on the first diff is stil selected
+      assert.isTrue(diffStopsFirst[10].classList.contains('target-row'));
+      assert.isFalse(diffStopsSecond[10].classList.contains('target-row'));
+    });
+
+    test('cursor with toggle all files', function() {
+      MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
+      flushAsynchronousOperations();
+
+      var diffs = renderAndGetNewDiffs(0);
+      var diffStops = diffs[0].getCursorStops();
+
+      // 1 diff should be rendered.
+      assert.equal(diffs.length, 3);
+
+      // No line number is selected.
+      assert.isFalse(diffStops[10].classList.contains('target-row'));
+
+      // Tapping content on a line selects the line number.
+      MockInteractions.tap(Polymer.dom(
+          diffStops[10]).querySelectorAll('.contentText')[0]);
+      flushAsynchronousOperations();
+      assert.isTrue(diffStops[10].classList.contains('target-row'));
+
+      // Keyboard shortcuts are still moving the file cursor, not the diff
+      // cursor.
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      flushAsynchronousOperations();
+      assert.isFalse(diffStops[10].classList.contains('target-row'));
+      assert.isTrue(diffStops[11].classList.contains('target-row'));
+
+      // The file cusor is still at 0.
+      assert.equal(element.$.fileCursor.index, 0);
+    });
+
+    suite('n key presses', function() {
+      var nKeySpy;
+      var nextCommentStub;
+      var nextChunkStub;
+      var fileRows;
+      setup(function() {
+        nKeySpy = sandbox.spy(element, '_handleNKey');
+        nextCommentStub = sandbox.stub(element.$.diffCursor,
+            'moveToNextCommentThread');
+        nextChunkStub = sandbox.stub(element.$.diffCursor,
+            'moveToNextChunk');
+        fileRows =
+            Polymer.dom(element.root).querySelectorAll('.row:not(.header)');
+      });
+      test('n key with all files expanded and no shift key', function() {
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
+        flushAsynchronousOperations();
+
+        // Handle N key should return before calling diff cursor functions.
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+        assert.isTrue(nKeySpy.called);
+        assert.isFalse(nextCommentStub.called);
+
+        // This is also called in diffCursor.moveToFirstChunk.
+        assert.equal(nextChunkStub.callCount, 1);
+        assert.isFalse(!!element._showInlineDiffs);
+      });
+
+      test('n key with all files expanded and shift key', function() {
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
+        flushAsynchronousOperations();
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+        assert.isTrue(nKeySpy.called);
+        assert.isFalse(nextCommentStub.called);
+
+        // This is also called in diffCursor.moveToFirstChunk.
+        assert.equal(nextChunkStub.callCount, 1);
+        assert.isFalse(!!element._showInlineDiffs);
+      });
+
+      test('n key without all files expanded and shift key', function() {
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, 'shift', 'i');
+        flushAsynchronousOperations();
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+        assert.isTrue(nKeySpy.called);
+        assert.isFalse(nextCommentStub.called);
+
+        // This is also called in diffCursor.moveToFirstChunk.
+        assert.equal(nextChunkStub.callCount, 2);
+        assert.isTrue(element._showInlineDiffs);
+      });
+
+      test('n key without all files expanded and no shift key', function() {
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, 'shift', 'i');
+        flushAsynchronousOperations();
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+        assert.isTrue(nKeySpy.called);
+        assert.isTrue(nextCommentStub.called);
+
+        // This is also called in diffCursor.moveToFirstChunk.
+        assert.equal(nextChunkStub.callCount, 1);
+        assert.isTrue(element._showInlineDiffs);
+      });
+    });
+
+    test('_openSelectedFile behavior', () => {
+      const _files = element._files;
+      element.set('_files', []);
+      const showStub = sandbox.stub(page, 'show');
+      // Noop when there are no files.
+      element._openSelectedFile();
+      assert.isFalse(showStub.called);
+
+      element.set('_files', _files);
+      flushAsynchronousOperations();
+       // Navigates when a file is selected.
+      element._openSelectedFile();
+      assert.isTrue(showStub.called);
+    });
+  });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html
new file mode 100644
index 0000000..4287b97
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html
@@ -0,0 +1,104 @@
+<!--
+Copyright (C) 2017 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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-selector/iron-selector.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-label-scores">
+  <template>
+    <style>
+      .labelContainer {
+        margin-bottom: .5em;
+      }
+      .labelContainer:last-child {
+        margin-bottom: 0;
+      }
+      .labelName {
+        display: inline-block;
+        margin-right: .5em;
+        min-width: 7em;
+        text-align: right;
+        white-space: nowrap;
+        width: 25%;
+      }
+      .labelMessage {
+        color: #666;
+      }
+      iron-selector > gr-button:first-of-type {
+        border-bottom-left-radius: 2px;
+        border-top-left-radius: 2px;
+      }
+      iron-selector > gr-button:last-of-type {
+        border-bottom-right-radius: 2px;
+        border-top-right-radius: 2px;
+      }
+      iron-selector > gr-button.iron-selected {
+        background-color: #ddd;
+      }
+      gr-button {
+        min-width: 40px;
+      }
+      .placeholder {
+        display: inline-block;
+        width: 40px;
+      }
+      @media only screen and (max-width: 25em) {
+        :host {
+          text-align: center;
+        }
+        .labelName {
+          margin: 0;
+          text-align: center;
+          width: 100%;
+        }
+      }
+    </style>
+    <template is="dom-repeat" items="[[_labels]]" as="label">
+      <div class="labelContainer">
+        <span class="labelName">[[label.name]]</span>
+        <span id="spaces[[index]]"></span>
+        <iron-selector data-label$="[[label.name]]"
+            selected="[[_computeIndexOfLabelValue(change.labels, permittedLabels, label)]]"
+            hidden$="[[!_computeAnyPermittedLabelValues(permittedLabels, label.name)]]">
+          <template is="dom-repeat"
+              items="[[_computeBlankItems(permittedLabels, label.name, 'start')]]"
+              as="value">
+            <span class="placeholder">&nbsp;</span>
+          </template>
+          <template is="dom-repeat"
+              items="[[_computePermittedLabelValues(permittedLabels, label.name)]]"
+              as="value">
+            <gr-button has-tooltip data-value$="[[value]]"
+              title$="[[_computeLabelValueTitle(change.labels, label.name, value)]]">
+            [[value]]</gr-button>
+          </template>
+          <template is="dom-repeat"
+              items="[[_computeBlankItems(permittedLabels, label.name, 'end')]]"
+              as="value">
+            <span class="placeholder">&nbsp;</span>
+          </template>
+        </iron-selector>
+        <span class="labelMessage"
+            hidden$="[[_computeAnyPermittedLabelValues(permittedLabels, label.name)]]">
+          You don't have permission to edit this label.
+        </span>
+      </div>
+    </template>
+  </template>
+  <script src="gr-label-scores.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
new file mode 100644
index 0000000..d0f5dd1c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
@@ -0,0 +1,142 @@
+// Copyright (C) 2017 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-label-scores',
+    properties: {
+      _labels: {
+        type: Array,
+        computed: '_computeLabels(change.labels.*, account)',
+      },
+      permittedLabels: {
+        type: Object,
+        observer: '_computeColumns',
+      },
+      change: Object,
+      _labelValues: Object,
+    },
+
+    getLabelValues: function() {
+      var labels = {};
+      for (var label in this.permittedLabels) {
+        if (!this.permittedLabels.hasOwnProperty(label)) { continue; }
+
+        var selectorEl = this.$$('iron-selector[data-label="' +
+            label + '"]');
+
+        // The user may have not voted on this label.
+        if (!selectorEl || !selectorEl.selectedItem) { continue; }
+
+        var selectedVal = selectorEl.selectedItem.getAttribute('data-value');
+        selectedVal = parseInt(selectedVal, 10);
+
+        // Only send the selection if the user changed it.
+        var prevVal = this._getVoteForAccount(this.change.labels, label,
+            this.account);
+        if (prevVal !== null) {
+          prevVal = parseInt(prevVal, 10);
+        }
+        if (selectedVal !== prevVal) {
+          labels[label] = selectedVal;
+        }
+      }
+      return labels;
+    },
+
+    _getVoteForAccount: function(labels, labelName, account) {
+      var votes = labels[labelName];
+      if (votes.all && votes.all.length > 0) {
+        for (var i = 0; i < votes.all.length; i++) {
+          if (votes.all[i]._account_id == account._account_id) {
+            return votes.all[i].value;
+          }
+        }
+      }
+      return null;
+    },
+
+    _computeLabels: function(labelRecord) {
+      var labelsObj = labelRecord.base;
+      if (!labelsObj) { return []; }
+      return Object.keys(labelsObj).sort().map(function(key) {
+        return {
+          name: key,
+          value: this._getVoteForAccount(labelsObj, key, this.account),
+        };
+      }.bind(this));
+    },
+
+    _computeColumns: function(permittedLabels) {
+      var labels = Object.keys(permittedLabels);
+      var values = {};
+
+      labels.forEach(function(label) {
+        permittedLabels[label].forEach(function(value) {
+          values[parseInt(value, 10)] = true;
+        });
+      });
+
+      var orderedValues = Object.keys(values).sort(function(a, b) {
+        return a - b;
+      });
+
+      for (var i = 0; i < orderedValues.length; i++) {
+        values[orderedValues[i]] = i;
+      }
+      this._labelValues = values;
+    },
+
+    _computeIndexOfLabelValue: function(labels, permittedLabels, label) {
+      if (!labels[label.name]) { return null; }
+      var labelValue = label.value;
+      var len = permittedLabels[label.name] != null ?
+          permittedLabels[label.name].length : 0;
+      var numberBlanks =
+          this._computeBlankItems(permittedLabels, label.name, 'start').length;
+      for (var i = 0; i < len; i++) {
+        var val = parseInt(permittedLabels[label.name][i], 10);
+        if (val == labelValue) {
+          return i + numberBlanks;
+        }
+      }
+      return null;
+    },
+
+    _computePermittedLabelValues: function(permittedLabels, label) {
+      return permittedLabels[label];
+    },
+
+    _computeBlankItems: function(permittedLabels, label, side) {
+      if (!permittedLabels[label]) { return []; }
+      var startPosition = this._labelValues[parseInt(
+          permittedLabels[label][0])];
+      if (side === 'start') {
+        return new Array(startPosition);
+      }
+      var endPosition = this._labelValues[parseInt(
+          permittedLabels[label][permittedLabels[label].length - 1])];
+      return new Array(Object.keys(this._labelValues).length - endPosition - 1);
+    },
+
+    _computeAnyPermittedLabelValues: function(permittedLabels, label) {
+      return permittedLabels.hasOwnProperty(label);
+    },
+
+     _computeLabelValueTitle: function(labels, label, value) {
+      return labels[label] && labels[label].values[value];
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html
new file mode 100644
index 0000000..c7f5a59
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html
@@ -0,0 +1,282 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-label-scores</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-label-scores.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-label-scores></gr-label-scores>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-label-scores tests', function() {
+    var element;
+    var sandbox;
+
+    setup(function(done) {
+      sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        getLoggedIn: function() { return Promise.resolve(false); },
+      });
+      element = fixture('basic');
+      element.change = {
+        _number: '123',
+        labels: {
+          'Code-Review': {
+            values: {
+              '0': 'No score',
+              '+1': 'good',
+              '+2': 'excellent',
+              '-1': 'bad',
+              '-2': 'terrible',
+            },
+            default_value: 0,
+            value: 1,
+            all: [{
+              _account_id: 123,
+              value: '+1',
+            }],
+          },
+          Verified: {
+            values: {
+              '0': 'No score',
+              '+1': 'good',
+              '+2': 'excellent',
+              '-1': 'bad',
+              '-2': 'terrible',
+            },
+            default_value: 0,
+            value: 1,
+            all: [{
+              _account_id: 123,
+              value: '+1',
+            }],
+          },
+        },
+      };
+
+      element.account = {
+        _account_id: 123,
+      };
+
+      element.permittedLabels = {
+        'Code-Review': [
+          '-2',
+          '-1',
+          ' 0',
+          '+1',
+          '+2',
+        ],
+        Verified: [
+          '-1',
+          ' 0',
+          '+1',
+        ],
+      };
+      flush(done);
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('label picker', function() {
+      for (var label in element.permittedLabels) {
+        assert.ok(element.$$('iron-selector[data-label="' + label + '"]'),
+          label);
+      }
+      element.draft = 'I wholeheartedly disapprove';
+      MockInteractions.tap(element.$$(
+          'iron-selector[data-label="Code-Review"] > ' +
+          'gr-button[data-value="-1"]'));
+      MockInteractions.tap(element.$$(
+          'iron-selector[data-label="Verified"] > ' +
+          'gr-button[data-value="-1"]'));
+      flushAsynchronousOperations();
+      assert.deepEqual(element.getLabelValues(), {
+        'Code-Review': -1,
+        Verified: -1,
+      });
+      assert.equal(element.$$('iron-selector[data-label="Code-Review"]')
+          .selected, 1);
+      assert.equal(element.$$(
+          'iron-selector[data-label="Verified"] .iron-selected')
+          .textContent.trim(), '-1');
+
+    });
+
+    test('correct item is selected', function() {
+      assert.equal(element.$$('iron-selector[data-label="Code-Review"]')
+          .selected, 3);
+      assert.equal(
+          element.$$('iron-selector[data-label="Code-Review"] .iron-selected')
+              .textContent.trim(), '+1');
+
+      // Even though there there is only a -1 0 +1 displayed, placeholders
+      // are part of the iron-selector so the selected item (+1) should
+      // still be in the 4th position / index 3.
+      assert.equal(element.$$('iron-selector[data-label="Verified"]')
+          .selected, 3);
+      assert.equal(element.$$(
+          'iron-selector[data-label="Verified"] .iron-selected')
+          .textContent.trim(), '+1');
+    });
+
+    test('do not display tooltips on touch devices', function() {
+      var verifiedBtn = element.$$(
+          'iron-selector[data-label="Verified"] > ' +
+          'gr-button[data-value="-1"]');
+
+      // On touch devices, tooltips should not be shown.
+      verifiedBtn._isTouchDevice = true;
+      verifiedBtn._handleShowTooltip();
+      assert.isNotOk(verifiedBtn._tooltip);
+      verifiedBtn._handleHideTooltip();
+      assert.isNotOk(verifiedBtn._tooltip);
+
+      // On other devices, tooltips should be shown.
+      verifiedBtn._isTouchDevice = false;
+      verifiedBtn._handleShowTooltip();
+      assert.isOk(verifiedBtn._tooltip);
+      verifiedBtn._handleHideTooltip();
+      assert.isNotOk(verifiedBtn._tooltip);
+    });
+
+    test('_getVoteForAccount', function() {
+      var labelName = 'Code-Review';
+      assert.equal(element._getVoteForAccount(element.change.labels, labelName,
+          element.account), '+1');
+    });
+
+    test('_computeColumns', function() {
+      element._computeColumns(element.permittedLabels);
+      assert.deepEqual(element._labelValues, {
+        '-2': 0,
+        '-1': 1,
+        '0': 2,
+        '1': 3,
+        '2': 4,
+      });
+    });
+
+    test('_computeIndexOfLabelValue', function() {
+      var labelName = 'Code-Review';
+
+      assert.equal(element._computeIndexOfLabelValue(element.change.labels,
+          element.permittedLabels,
+          element._labels[0]), 3);
+    });
+
+    test('_computeBlankItems', function() {
+      element._labelValues = {
+        '-2': 0,
+        '-1': 1,
+        '0': 2,
+        '1': 3,
+        '2': 4,
+      };
+
+      assert.equal(element._computeBlankItems(element.permittedLabels,
+          'Code-Review').length, 0);
+
+      assert.deepEqual(
+          element._computeBlankItems(element.permittedLabels,
+          'Verified').length, 1);
+    });
+
+    test('changes in label score are reflected in the DOM', function() {
+      element.change = {
+        _number: '123',
+        labels: {
+          'Code-Review': {
+            values: {
+              '0': 'No score',
+              '+1': 'good',
+              '+2': 'excellent',
+              '-1': 'bad',
+              '-2': 'terrible',
+            },
+            default_value: 0,
+          },
+          Verified: {
+            values: {
+              '0': 'No score',
+              '+1': 'good',
+              '+2': 'excellent',
+              '-1': 'bad',
+              '-2': 'terrible',
+            },
+            default_value: 0,
+          },
+        },
+      };
+      flushAsynchronousOperations();
+      var selector = element.$$('iron-selector[data-label="Verified"]');
+      element.set(['change', 'labels', 'Verified', 'all'],
+         [{_account_id: 123, value: 1}]);
+      flushAsynchronousOperations();
+      assert.equal(selector.selected, 3); // Index 3, value 1
+    });
+
+    test('without permitted labels', function() {
+      element.permittedLabels = {
+        Verified: [
+          '-1',
+          ' 0',
+          '+1',
+        ],
+      };
+      flushAsynchronousOperations();
+      assert.isOk(element.$$('iron-selector[data-label="Verified"]'));
+      assert.isFalse(element.$$('iron-selector[data-label="Verified"]').hidden);
+      assert.isOk(element.$$('iron-selector[data-label="Code-Review"]'));
+      assert.isTrue(
+          element.$$('iron-selector[data-label="Code-Review"]').hidden);
+    });
+
+    test('asymetrical labels', function() {
+      element.permittedLabels = {
+        'Code-Review': [
+          '-2',
+          '-1',
+          ' 0',
+          '+1',
+          '+2',
+        ],
+        Verified: [
+          ' 0',
+          '+1',
+        ],
+      };
+      flushAsynchronousOperations();
+      assert.equal(element.$$('iron-selector[data-label="Verified"]')
+          .items.length, 5);
+      assert.equal(element.$$('iron-selector[data-label="Code-Review"]')
+          .items.length, 5);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.html b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
index 831914e..6cd7fad 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
@@ -75,14 +75,14 @@
         font-weight: bold;
       }
       .message {
-        max-width: 80ch;
+        --gr-formatted-text-prose-max-width: 80ch;
       }
       .collapsed .message {
         max-width: none;
         overflow: hidden;
         text-overflow: ellipsis;
       }
-      .collapsed .name,
+      .collapsed .author,
       .collapsed .content,
       .collapsed .message,
       .collapsed .updateCategory,
@@ -108,11 +108,11 @@
       .collapsed .date {
         position: static;
       }
-      .collapsed .name {
+      .collapsed .author {
         color: var(--default-text-color);
         margin-right: .4em;
       }
-      .expanded .name {
+      .expanded .author {
         cursor: pointer;
       }
       .date {
@@ -128,7 +128,13 @@
     <div class$="[[_computeClass(_expanded, showAvatar)]]">
       <gr-avatar account="[[author]]" image-size="100"></gr-avatar>
       <div class="contentContainer">
-        <div class="name" on-tap="_handleNameTap">[[author.name]]</div>
+        <div class="author" on-tap="_handleAuthorTap">
+          <span hidden$="[[!showOnBehalfOf]]">
+            <span class="name">[[message.real_author.name]]</span>
+            on behalf of
+          </span>
+          <span class="name">[[author.name]]</span>
+        </div>
         <template is="dom-if" if="[[message.message]]">
           <div class="content">
             <div class="message hideOnOpen">[[message.message]]</div>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
index e782943..5467af9 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -62,6 +62,10 @@
         type: Boolean,
         computed: '_computeShowAvatar(author, config)',
       },
+      showOnBehalfOf: {
+        type: Boolean,
+        computed: '_computeShowOnBehalfOf(message)',
+      },
       showReplyButton: {
         type: Boolean,
         computed: '_computeShowReplyButton(message, _loggedIn)',
@@ -107,6 +111,12 @@
       return !!(author && config && config.plugin && config.plugin.has_avatars);
     },
 
+    _computeShowOnBehalfOf: function(message) {
+      var author = message.author || message.updated_by;
+      return !!(author && message.real_author &&
+          author._account_id != message.real_author._account_id);
+    },
+
     _computeShowReplyButton: function(message, loggedIn) {
       return !!message.message && loggedIn;
     },
@@ -132,7 +142,7 @@
       this.set('message.expanded', true);
     },
 
-    _handleNameTap: function(e) {
+    _handleAuthorTap: function(e) {
       if (!this.message.expanded) { return; }
       e.stopPropagation();
       this.set('message.expanded', false);
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
index 89c7173..72c09d9 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
@@ -92,7 +92,7 @@
       var content = element.$$('.contentContainer');
       assert.isOk(content);
       assert.strictEqual(element.$$('gr-account-chip').account, reviewer);
-      assert.equal(author.name, element.$$('.name').textContent);
+      assert.equal(author.name, element.$$('.author > .name').textContent);
     });
 
     test('autogenerated prefix hiding', function() {
@@ -145,5 +145,23 @@
       assert.isFalse(element._computeShowReplyButton(message, false));
       assert.isTrue(element._computeShowReplyButton(message, true));
     });
+
+    test('_computeShowOnBehalfOf', function() {
+      var message = {
+        message: '...',
+      };
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.author = {_account_id: 1115495};
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.real_author = {_account_id: 1115495};
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.real_author._account_id = 123456;
+      assert.isOk(element._computeShowOnBehalfOf(message));
+      message.updated_by = message.author;
+      delete message.author;
+      assert.isOk(element._computeShowOnBehalfOf(message));
+      delete message.updated_by;
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
index 14361f4..4449131 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
@@ -77,11 +77,14 @@
       <gr-button id="oldMessagesBtn" link on-tap="_handleShowAllTap">
           [[_computeNumMessagesText(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]
       </gr-button>
-      /
-      <gr-button id="incrementMessagesBtn" link
-          on-tap="_handleIncrementShownMessages">
-        [[_computeIncrementText(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]
-      </gr-button>
+      <span
+          hidden$="[[_computeIncrementHidden(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]">
+        /
+        <gr-button id="incrementMessagesBtn" link
+            on-tap="_handleIncrementShownMessages">
+          [[_computeIncrementText(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]
+        </gr-button>
+      </span>
     </span>
     <template
         is="dom-repeat"
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
index 0d58d96..8750e9d 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
@@ -327,5 +327,11 @@
       var total = this._numRemaining(visibleMessages, messages, hideAutomated);
       return total === 1 ? 'Show 1 message' : 'Show all ' + total + ' messages';
     },
+
+    _computeIncrementHidden: function(visibleMessages, messages,
+        hideAutomated) {
+      var total = this._numRemaining(visibleMessages, messages, hideAutomated);
+      return total <= this._getDelta(visibleMessages, messages, hideAutomated);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
index cdca365..23fc5aa 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
@@ -87,13 +87,13 @@
 
       assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
       assert.equal(getMessages().length, 20);
-      assert.equal(element.$.incrementMessagesBtn.innerText,
+      assert.equal(element.$.incrementMessagesBtn.innerText.trim(),
           'Show 5 more');
       MockInteractions.tap(element.$.incrementMessagesBtn);
       flushAsynchronousOperations();
 
       assert.equal(getMessages().length, 25);
-      assert.equal(element.$.incrementMessagesBtn.innerText,
+      assert.equal(element.$.incrementMessagesBtn.innerText.trim(),
           'Show 1 more');
       MockInteractions.tap(element.$.incrementMessagesBtn);
       flushAsynchronousOperations();
@@ -400,6 +400,18 @@
       assert.equal(messageEls.length, 1);
       assert.equal(messageEls[0].message.message, messages[0].message);
     });
+
+    test('hide increment text if increment >= total remaining', function() {
+      // Test with stubbed return values, as _numRemaining and _getDelta have
+      // their own tests.
+      sandbox.stub(element, '_getDelta').returns(5);
+      var remainingStub = sandbox.stub(element, '_numRemaining').returns(6);
+      assert.isFalse(element._computeIncrementHidden(null, null, null));
+      remainingStub.restore();
+
+      sandbox.stub(element, '_numRemaining').returns(4);
+      assert.isTrue(element._computeIncrementHidden(null, null, null));
+    });
   });
 
   suite('gr-messages-list automate tests', function() {
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
index 8eacd48..c9d5cd8 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
@@ -94,8 +94,7 @@
         }
       }
     </style>
-    <div hidden$="[[!loading]]">Loading...</div>
-    <div hidden$="[[loading]]">
+    <div>
       <hr class="mobile">
       <section class="relatedChanges" hidden$="[[!_relatedResponse.changes.length]]" hidden>
         <h4>Relation chain</h4>
@@ -159,6 +158,7 @@
         </template>
       </section>
     </div>
+    <div hidden$="[[!loading]]">Loading...</div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-related-changes-list.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
index 55a0bce..8501b20 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
@@ -39,11 +39,26 @@
         computed: '_computeConnectedRevisions(change, patchNum, ' +
             '_relatedResponse.changes)',
       },
-      _relatedResponse: Object,
-      _submittedTogether: Array,
-      _conflicts: Array,
-      _cherryPicks: Array,
-      _sameTopic: Array,
+      _relatedResponse: {
+        type: Object,
+        value: function() { return {changes: []}; },
+      },
+      _submittedTogether: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _conflicts: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _cherryPicks: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _sameTopic: {
+        type: Array,
+        value: function() { return []; },
+      },
     },
 
     behaviors: [
@@ -58,6 +73,7 @@
 
     clear: function() {
       this.loading = true;
+      this.hidden = true;
     },
 
     reload: function() {
@@ -70,7 +86,7 @@
           this._relatedResponse = response;
 
           this.hasParent = this._calculateHasParent(this.change.change_id,
-            response.changes);
+              response.changes);
 
         }.bind(this)),
         this._getSubmittedTogether().then(function(response) {
@@ -206,11 +222,12 @@
         submittedTogether,
         conflicts,
         cherryPicks,
-        sameTopic
+        sameTopic,
       ];
       for (var i = 0; i < results.length; i++) {
         if (results[i].length > 0) {
           this.hidden = false;
+          this.fire('update', null, {bubbles: false});
           return;
         }
       }
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
index 78c4cf4..171f758 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
@@ -69,8 +69,8 @@
           },
           '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6': {
             _number: 4,
-          }
-        }
+          },
+        },
       };
       var patchNum = 7;
       var relatedChanges = [
@@ -79,8 +79,8 @@
             commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
             parents: [
               {
-                commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd'
-              }
+                commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+              },
             ],
           },
         },
@@ -89,8 +89,8 @@
             commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
             parents: [
               {
-                commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb'
-              }
+                commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+              },
             ],
           },
         },
@@ -99,8 +99,8 @@
             commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
             parents: [
               {
-                commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae'
-              }
+                commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+              },
             ],
           },
         },
@@ -109,8 +109,8 @@
             commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
             parents: [
               {
-                commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907'
-              }
+                commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+              },
             ],
           },
         },
@@ -119,8 +119,8 @@
             commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
             parents: [
               {
-                commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce'
-              }
+                commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce',
+              },
             ],
           },
         },
@@ -129,11 +129,11 @@
             commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce',
             parents: [
               {
-                commit: '455ed9cd27a16bf6991f04dcc57ef575dc4d5e75'
-              }
+                commit: '455ed9cd27a16bf6991f04dcc57ef575dc4d5e75',
+              },
             ],
           },
-        }
+        },
       ];
 
       var connectedChanges =
@@ -155,8 +155,8 @@
             commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
             parents: [
               {
-                commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd'
-              }
+                commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+              },
             ],
           },
         },
@@ -165,8 +165,8 @@
             commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
             parents: [
               {
-                commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb'
-              }
+                commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+              },
             ],
           },
         },
@@ -175,8 +175,8 @@
             commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
             parents: [
               {
-                commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae'
-              }
+                commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+              },
             ],
           },
         },
@@ -185,8 +185,8 @@
             commit: 'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
             parents: [
               {
-                commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6'
-              }
+                commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+              },
             ],
           },
         },
@@ -195,8 +195,8 @@
             commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
             parents: [
               {
-                commit: 'af815dac54318826b7f1fa468acc76349ffc588e'
-              }
+                commit: 'af815dac54318826b7f1fa468acc76349ffc588e',
+              },
             ],
           },
         },
@@ -205,11 +205,11 @@
             commit: 'af815dac54318826b7f1fa468acc76349ffc588e',
             parents: [
               {
-                commit: '58f76e406e24cb8b0f5d64c7f5ac1e8616d0a22c'
-              }
+                commit: '58f76e406e24cb8b0f5d64c7f5ac1e8616d0a22c',
+              },
             ],
           },
-        }
+        },
       ];
 
       connectedChanges =
@@ -248,7 +248,7 @@
         sandbox.stub(element, '_getCherryPicks',
             function() { return Promise.resolve(); });
         conflictsStub = sandbox.stub(element, '_getConflicts',
-            function() { return Promise.resolve(); });
+            function() { return Promise.resolve(['test data']); });
       });
 
       test('request conflicts if open and mergeable', function() {
@@ -284,8 +284,7 @@
         assert.isFalse(conflictsStub.called);
       });
 
-      test('does not request conflicts if closed and not mergeable',
-          function() {
+      test('doesnt request conflicts if closed and not mergeable', function() {
         element.patchNum = 7;
         element.change = {
           change_id: 123,
@@ -313,5 +312,26 @@
           true);
 
     });
+
+    test('clear hides', function() {
+      element.loading = false;
+      element.hidden = false;
+      element.clear();
+      assert.isTrue(element.loading);
+      assert.isTrue(element.hidden);
+    });
+
+    test('update fires', function() {
+      var updateHandler = sandbox.stub();
+      element.addEventListener('update', updateHandler);
+
+      element._resultsChanged([], [], [], [], []);
+      assert.isTrue(element.hidden);
+      assert.isFalse(updateHandler.called);
+
+      element._resultsChanged([], [], [], [], ['test']);
+      assert.isFalse(element.hidden);
+      assert.isTrue(updateHandler.called);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
index b1f95e6..d08961c 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
@@ -14,10 +14,12 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/iron-selector/iron-selector.html">
 <link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
@@ -26,6 +28,7 @@
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-storage/gr-storage.html">
 <link rel="import" href="../gr-account-list/gr-account-list.html">
+<link rel="import" href="../gr-label-scores/gr-label-scores.html">
 
 <dom-module id="gr-reply-dialog">
   <template>
@@ -51,8 +54,7 @@
         width: 100%;
       }
       .peopleContainer,
-      .labelsContainer,
-      .actionsContainer {
+      .labelsContainer {
         flex-shrink: 0;
       }
       .peopleContainer {
@@ -105,38 +107,6 @@
         border: none;
         width: 100%;
       }
-      .labelContainer:not(:first-of-type) {
-        margin-top: .5em;
-      }
-      .labelName {
-        display: inline-block;
-        margin-right: .5em;
-        min-width: 7em;
-        text-align: right;
-        white-space: nowrap;
-        width: 25%;
-      }
-      .labelMessage {
-        color: #666;
-      }
-      iron-selector {
-        display: inline-flex;
-      }
-      iron-selector > gr-button {
-        margin-right: .25em;
-        min-width: 3.5em;
-      }
-      iron-selector > gr-button:first-of-type {
-        border-top-left-radius: 2px;
-        border-bottom-left-radius: 2px;
-      }
-      iron-selector > gr-button:last-of-type {
-        border-top-right-radius: 2px;
-        border-bottom-right-radius: 2px;
-      }
-      iron-selector > gr-button.iron-selected {
-        background-color: #ddd;
-      }
       .draftsContainer {
         flex: 1;
         overflow-y: auto;
@@ -144,14 +114,24 @@
       .draftsContainer h3 {
         margin-top: .25em;
       }
-      .actionsContainer {
-        display: flex;
-        justify-content: space-between;
-      }
       .action:link,
       .action:visited {
         color: #00e;
       }
+      #checkingStatusLabel,
+      #notLatestLabel {
+        margin-left: 1em;
+      }
+      #checkingStatusLabel {
+        color: #444;
+        font-style: italic;
+      }
+      #notLatestLabel {
+        color: red;
+      }
+      #cancelButton {
+        float:right;
+      }
       @media screen and (max-width: 50em) {
         :host {
           max-height: none;
@@ -191,6 +171,7 @@
                 change="[[change]]"
                 filter="[[filterReviewerSuggestion]]"
                 pending-confirmation="{{_ccPendingConfirmation}}"
+                allow-any-input
                 placeholder="Add CC...">
             </gr-account-list>
           </div>
@@ -242,36 +223,43 @@
             config="[[projectConfig.commentlinks]]"></gr-formatted-text>
       </section>
       <section class="labelsContainer">
-        <template is="dom-repeat" items="[[_labels]]" as="label">
-          <div class="labelContainer">
-            <span class="labelName">[[label.name]]</span>
-            <iron-selector data-label$="[[label.name]]"
-                selected="[[_computeIndexOfLabelValue(change.labels, permittedLabels, label)]]"
-                hidden$="[[!_computeAnyPermittedLabelValues(permittedLabels, label.name)]]">
-              <template is="dom-repeat"
-                  items="[[_computePermittedLabelValues(permittedLabels, label.name)]]"
-                  as="value">
-                <gr-button has-tooltip data-value$="[[value]]"
-                    title$="[[_computeLabelValueTitle(change.labels, label.name, value)]]">[[value]]</gr-button>
-              </template>
-            </iron-selector>
-            <span class="labelMessage"
-                hidden$="[[_computeAnyPermittedLabelValues(permittedLabels, label.name)]]">
-              You don't have permission to edit this label.
-            </span>
-          </div>
-        </template>
+        <gr-label-scores
+            id="labelScores"
+            account="[[_account]]"
+            change="[[change]]"
+            permitted-labels=[[permittedLabels]]></gr-label-scores>
       </section>
       <section class="draftsContainer" hidden$="[[_computeHideDraftList(diffDrafts)]]">
-        <h3>[[_computeDraftsTitle(diffDrafts)]]</h3>
+        <div class="includeComments">
+          <input type="checkbox" id="includeComments"
+              checked="{{_includeComments::change}}">
+          <label for="includeComments">Publish [[_computeDraftsTitle(diffDrafts)]]</label>
+        </div>
         <gr-comment-list
+            id="commentList"
             comments="[[diffDrafts]]"
             change-num="[[change._number]]"
             project-config="[[projectConfig]]"
-            patch-num="[[patchNum]]"></gr-comment-list>
+            patch-num="[[patchNum]]"
+            hidden$="[[!_includeComments]]"></gr-comment-list>
       </section>
-      <section class="actionsContainer">
-        <gr-button primary class="action send" on-tap="_sendTapHandler">Send</gr-button>
+      <section>
+        <gr-button
+            primary
+            disabled="[[!_isState(knownLatestState, 'latest')]]"
+            class="action send"
+            on-tap="_sendTapHandler">Send</gr-button>
+        <span
+            id="checkingStatusLabel"
+            hidden$="[[!_isState(knownLatestState, 'checking')]]">
+          Checking whether patch [[patchNum]] is latest...
+        </span>
+        <span
+            id="notLatestLabel"
+            hidden$="[[!_isState(knownLatestState, 'not-latest')]]">
+          Patch [[patchNum]] is not latest.
+          <gr-button link on-tap="_reload">Reload</gr-button>
+        </span>
         <gr-button
             id="cancelButton"
             class="action cancel"
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
index 4c35f38..0bb548b 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -28,6 +28,12 @@
     CC: 'CC',
   };
 
+  var LatestPatchState = {
+    LATEST: 'latest',
+    CHECKING: 'checking',
+    NOT_LATEST: 'not-latest',
+  };
+
   Polymer({
     is: 'gr-reply-dialog',
 
@@ -50,6 +56,12 @@
      * @event autogrow
      */
 
+    /**
+     * Fires to show an alert when a send is attempted on the non-latest patch.
+     *
+     * @event show-alert
+     */
+
     properties: {
       change: Object,
       patchNum: String,
@@ -77,6 +89,7 @@
       permittedLabels: Object,
       serverConfig: Object,
       projectConfig: Object,
+      knownLatestState: String,
 
       _account: Object,
       _ccs: Array,
@@ -84,12 +97,12 @@
         type: Object,
         observer: '_reviewerPendingConfirmationUpdated',
       },
-      _labels: {
-        type: Array,
-        computed: '_computeLabels(change.labels.*, _account)',
-      },
       _owner: Object,
       _pendingConfirmationDetails: Object,
+      _includeComments: {
+        type: Boolean,
+        value: true,
+      },
       _reviewers: Array,
       _reviewerPendingConfirmation: {
         type: Object,
@@ -112,9 +125,16 @@
     FocusTarget: FocusTarget,
 
     behaviors: [
+      Gerrit.BaseUrlBehavior,
+      Gerrit.KeyboardShortcutBehavior,
+      Gerrit.PatchSetBehavior,
       Gerrit.RESTClientBehavior,
     ],
 
+    keyBindings: {
+      'esc': '_handleEscKey',
+    },
+
     observers: [
       '_changeUpdated(change.reviewers.*, change.owner, serverConfig)',
       '_ccsChanged(_ccs.splices)',
@@ -132,6 +152,13 @@
     },
 
     open: function(opt_focusTarget) {
+      this.knownLatestState = LatestPatchState.CHECKING;
+      this.fetchIsLatestKnown(this.change, this.$.restAPI)
+          .then(function(isUpToDate) {
+            this.knownLatestState = isUpToDate ?
+                LatestPatchState.LATEST : LatestPatchState.NOT_LATEST;
+          }.bind(this));
+
       this._focusOn(opt_focusTarget);
       if (!this.draft || !this.draft.length) {
         this.draft = this._loadStoredDraft();
@@ -158,6 +185,10 @@
       selectorEl.selectIndex(selectorEl.indexOf(item));
     },
 
+    _handleEscKey: function(e) {
+      this.cancel();
+    },
+
     _ccsChanged: function(splices) {
       if (splices && splices.indexSplices) {
         this._processReviewerChange(splices.indexSplices, ReviewerTypes.CC);
@@ -238,7 +269,7 @@
       var reviewerId;
       var confirmed;
       if (reviewer.account) {
-        reviewerId = reviewer.account._account_id;
+        reviewerId = reviewer.account._account_id || reviewer.account.email;
       } else if (reviewer.group) {
         reviewerId = reviewer.group.id;
         confirmed = reviewer.group.confirmed;
@@ -246,33 +277,20 @@
       return {reviewer: reviewerId, confirmed: confirmed};
     },
 
-    send: function() {
+    send: function(includeComments) {
+      if (this.knownLatestState === 'not-latest') {
+        this.fire('show-alert',
+            {message: 'Cannot reply to non-latest patch.'});
+        return;
+      }
+
+      var labels = this.$.labelScores.getLabelValues();
+
       var obj = {
-        drafts: 'PUBLISH_ALL_REVISIONS',
-        labels: {},
+        drafts: includeComments ? 'PUBLISH_ALL_REVISIONS' : 'KEEP',
+        labels: labels,
       };
 
-      for (var label in this.permittedLabels) {
-        if (!this.permittedLabels.hasOwnProperty(label)) { continue; }
-
-        var selectorEl = this.$$('iron-selector[data-label="' + label + '"]');
-
-        // The user may have not voted on this label.
-        if (!selectorEl || !selectorEl.selectedItem) { continue; }
-
-        var selectedVal = selectorEl.selectedItem.getAttribute('data-value');
-        selectedVal = parseInt(selectedVal, 10);
-
-        // Only send the selection if the user changed it.
-        var prevVal = this._getVoteForAccount(this.change.labels, label,
-            this._account);
-        if (prevVal !== null) {
-          prevVal = parseInt(prevVal, 10);
-        }
-        if (selectedVal !== prevVal) {
-          obj.labels[label] = selectedVal;
-        }
-      }
       if (this.draft != null) {
         obj.message = this.draft;
       }
@@ -284,8 +302,9 @@
         }
         return this._mapReviewer(reviewer);
       }.bind(this));
-      if (this.serverConfig.note_db_enabled) {
-        this.$$('#ccs').additions().forEach(function(reviewer) {
+      var ccsEl = this.$$('#ccs');
+      if (ccsEl) {
+        ccsEl.additions().forEach(function(reviewer) {
           if (reviewer.account) {
             accountAdditions[reviewer.account._account_id] = true;
           }
@@ -304,6 +323,7 @@
         }
         this.disabled = false;
         this.draft = '';
+        this._includeComments = true;
         this.fire('send', null, {bubbles: false});
         return accountAdditions;
       }.bind(this)).catch(function(err) {
@@ -396,55 +416,6 @@
       if (total > 1) { return total + ' Drafts'; }
     },
 
-    _computeLabelValueTitle: function(labels, label, value) {
-      return labels[label] && labels[label].values[value];
-    },
-
-    _computeLabels: function(labelRecord) {
-      var labelsObj = labelRecord.base;
-      if (!labelsObj) { return []; }
-      return Object.keys(labelsObj).sort().map(function(key) {
-        return {
-          name: key,
-          value: this._getVoteForAccount(labelsObj, key, this._account),
-        };
-      }.bind(this));
-    },
-
-    _getVoteForAccount: function(labels, labelName, account) {
-      var votes = labels[labelName];
-      if (votes.all && votes.all.length > 0) {
-        for (var i = 0; i < votes.all.length; i++) {
-          if (votes.all[i]._account_id == account._account_id) {
-            return votes.all[i].value;
-          }
-        }
-      }
-      return null;
-    },
-
-    _computeIndexOfLabelValue: function(labels, permittedLabels, label) {
-      if (!labels[label.name]) { return null; }
-      var labelValue = label.value;
-      var len = permittedLabels[label.name] != null ?
-          permittedLabels[label.name].length : 0;
-      for (var i = 0; i < len; i++) {
-        var val = parseInt(permittedLabels[label.name][i], 10);
-        if (val == labelValue) {
-          return i;
-        }
-      }
-      return null;
-    },
-
-    _computePermittedLabelValues: function(permittedLabels, label) {
-      return permittedLabels[label];
-    },
-
-    _computeAnyPermittedLabelValues: function(permittedLabels, label) {
-      return permittedLabels.hasOwnProperty(label);
-    },
-
     _changeUpdated: function(changeRecord, owner, serverConfig) {
       this._rebuildReviewerArrays(changeRecord.base, owner, serverConfig);
     },
@@ -517,6 +488,10 @@
 
     _cancelTapHandler: function(e) {
       e.preventDefault();
+      this.cancel();
+    },
+
+    cancel: function() {
       this.fire('cancel', null, {bubbles: false});
       this._purgeReviewersPendingRemove(true);
       this._rebuildReviewerArrays(this.change.reviewers, this._owner,
@@ -525,8 +500,8 @@
 
     _sendTapHandler: function(e) {
       e.preventDefault();
-      this.send().then(function(keep) {
-        this._purgeReviewersPendingRemove(false, keep);
+      this.send(this._includeComments).then(function(keepReviewers) {
+        this._purgeReviewersPendingRemove(false, keepReviewers);
       }.bind(this));
     },
 
@@ -598,5 +573,14 @@
         this.fire('autogrow');
       });
     },
+
+    _isState: function(knownLatestState, value) {
+      return knownLatestState === value;
+    },
+
+    _reload: function() {
+      // Load the current change without any patch range.
+      location.href = this.getBaseUrl() + '/c/' + this.change._number;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
index 5aa5848..253f01c 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
@@ -102,6 +102,9 @@
       eraseDraftCommentStub = sandbox.stub(element.$.storage,
           'eraseDraftComment');
 
+      sandbox.stub(element, 'fetchIsLatestKnown',
+          function() { return Promise.resolve(true); });
+
       // Allow the elements created by dom-repeat to be stamped.
       flushAsynchronousOperations();
     });
@@ -110,77 +113,107 @@
       sandbox.restore();
     });
 
-    test('changes in label score are reflected in the DOM', function() {
-      element._account = {_account_id: 1};
-      element.set(['change', 'labels', 'Verified', 'all'],
-          [{_account_id: 1, value: -1}]);
-      flushAsynchronousOperations();
-      var selector = element.$$('iron-selector[data-label="Verified"]');
-      assert.equal(selector.selected, 0); // Index 0, value -1
-      element.set(['change', 'labels', 'Verified', 'all'],
-         [{_account_id: 1, value: 1}]);
-      flushAsynchronousOperations();
-      assert.equal(selector.selected, 2); // Index 2, value 1
-    });
-
-    test('cancel event', function(done) {
-      element.addEventListener('cancel', function() { done(); });
-      MockInteractions.tap(element.$$('.cancel'));
-    });
-
-    test('label picker', function(done) {
-      element.revisions = {};
-      element.patchNum = '';
-
+    test('default to publishing drafts with reply', function(done) {
       // Async tick is needed because iron-selector content is distributed and
       // distributed content requires an observer to be set up.
       // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
       flush(function() {
         flush(function() {
-          for (var label in element.permittedLabels) {
-            assert.ok(element.$$('iron-selector[data-label="' + label + '"]'),
-                label);
-          }
           element.draft = 'I wholeheartedly disapprove';
-          MockInteractions.tap(element.$$(
-              'iron-selector[data-label="Code-Review"] > ' +
-              'gr-button[data-value="-1"]'));
-          MockInteractions.tap(element.$$(
-              'iron-selector[data-label="Verified"] > ' +
-              'gr-button[data-value="-1"]'));
 
-          var saveReviewStub = sinon.stub(element, '_saveReview',
+          var saveReviewStub = sandbox.stub(element, '_saveReview',
               function(review) {
             assert.deepEqual(review, {
               drafts: 'PUBLISH_ALL_REVISIONS',
-              labels: {
-                'Code-Review': -1,
-                'Verified': -1,
-              },
+              labels: {},
               message: 'I wholeheartedly disapprove',
               reviewers: [],
             });
-            return Promise.resolve({ok: true});
-          });
-
-          element.addEventListener('send', function() {
-            assert.isFalse(element.disabled,
-                'Element should be enabled when done sending reply.');
-            assert.equal(element.draft.length, 0);
-            saveReviewStub.restore();
+            assert.isFalse(element.$.commentList.hidden);
             done();
+            return Promise.resolve({ok: true});
           });
 
           // This is needed on non-Blink engines most likely due to the ways in
           // which the dom-repeat elements are stamped.
           flush(function() {
             MockInteractions.tap(element.$$('.send'));
-            assert.isTrue(element.disabled);
           });
         });
       });
     });
 
+    test('keep drafts with reply', function(done) {
+      MockInteractions.tap(element.$$('#includeComments'));
+      assert.equal(element._includeComments, false);
+
+      // Async tick is needed because iron-selector content is distributed and
+      // distributed content requires an observer to be set up.
+      // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
+      flush(function() {
+        flush(function() {
+          element.draft = 'I wholeheartedly disapprove';
+
+          var saveReviewStub = sandbox.stub(element, '_saveReview',
+              function(review) {
+            assert.deepEqual(review, {
+              drafts: 'KEEP',
+              labels: {},
+              message: 'I wholeheartedly disapprove',
+              reviewers: [],
+            });
+            assert.isTrue(element.$.commentList.hidden);
+            done();
+            return Promise.resolve({ok: true});
+          });
+
+          // This is needed on non-Blink engines most likely due to the ways in
+          // which the dom-repeat elements are stamped.
+          flush(function() {
+            MockInteractions.tap(element.$$('.send'));
+          });
+        });
+      });
+    });
+
+    test('label picker', function(done) {
+      element.draft = 'I wholeheartedly disapprove';
+      var saveReviewStub = sandbox.stub(element, '_saveReview',
+          function(review) {
+        assert.deepEqual(review, {
+          drafts: 'PUBLISH_ALL_REVISIONS',
+          labels: {
+            'Code-Review': -1,
+            'Verified': -1,
+          },
+          message: 'I wholeheartedly disapprove',
+          reviewers: [],
+        });
+        return Promise.resolve({ok: true});
+      });
+
+      sandbox.stub(element.$.labelScores, 'getLabelValues', function() {
+        return {
+          'Code-Review': -1,
+          'Verified': -1,
+        };
+      });
+
+      element.addEventListener('send', function() {
+        assert.isFalse(element.disabled,
+            'Element should be enabled when done sending reply.');
+        assert.equal(element.draft.length, 0);
+        done();
+      });
+
+      // This is needed on non-Blink engines most likely due to the ways in
+      // which the dom-repeat elements are stamped.
+      flush(function() {
+        MockInteractions.tap(element.$$('.send'));
+        assert.isTrue(element.disabled);
+      });
+    });
+
     function getActiveElement() {
       return Polymer.IronOverlayManager.deepActiveElement;
     }
@@ -442,21 +475,21 @@
 
     test('only send labels that have changed', function(done) {
       flush(function() {
-        var saveReviewStub = sinon.stub(element, '_saveReview',
+        var saveReviewStub = sandbox.stub(element, '_saveReview',
             function(review) {
           assert.deepEqual(review.labels, {Verified: -1});
           return Promise.resolve({ok: true});
         });
 
         element.addEventListener('send', function() {
-          saveReviewStub.restore();
           done();
         });
         // Without wrapping this test in flush(), the below two calls to
         // MockInteractions.tap() cause a race in some situations in shadow DOM.
         // The send button can be tapped before the others, causing the test to
         // fail.
-        MockInteractions.tap(element.$$(
+
+        MockInteractions.tap(element.$$('gr-label-scores').$$(
             'iron-selector[data-label="Verified"] > ' +
             'gr-button[data-value="-1"]'));
         MockInteractions.tap(element.$$('.send'));
@@ -490,7 +523,7 @@
 
       flushAsynchronousOperations();
 
-      var verifiedBtn = element.$$(
+      var verifiedBtn = element.$$('gr-label-scores').$$(
           'iron-selector[data-label="Verified"] > ' +
           'gr-button[data-value="-1"]');
 
@@ -623,5 +656,14 @@
         done();
       });
     });
+
+    test('emits cancel on esc key', function() {
+      var cancelHandler = sandbox.spy();
+      element.addEventListener('cancel', cancelHandler);
+      MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
+      flushAsynchronousOperations();
+
+      assert.isTrue(cancelHandler.called);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
index 435b7de..9a84c95 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
@@ -59,7 +59,6 @@
     <template is="dom-repeat" items="[[_reviewers]]" as="reviewer">
       <gr-account-chip class="reviewer" account="[[reviewer]]"
           on-remove="_handleRemove"
-          data-account-id$="[[reviewer._account_id]]"
           removable="[[_computeCanRemoveReviewer(reviewer, mutable)]]">
       </gr-account-chip>
     </template>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
index 72a7c9b..52b90ee 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
@@ -87,9 +87,11 @@
     _computeCanRemoveReviewer: function(reviewer, mutable) {
       if (!mutable) { return false; }
 
+      var current;
       for (var i = 0; i < this.change.removable_reviewers.length; i++) {
-        if (this.change.removable_reviewers[i]._account_id ==
-            reviewer._account_id) {
+        current = this.change.removable_reviewers[i];
+        if (current._account_id === reviewer._account_id ||
+            (!reviewer._account_id && current.email === reviewer.email)) {
           return true;
         }
       }
@@ -99,7 +101,8 @@
     _handleRemove: function(e) {
       e.preventDefault();
       var target = Polymer.dom(e).rootTarget;
-      var accountID = parseInt(target.getAttribute('data-account-id'), 10);
+      if (!target.account) { return; }
+      var accountID = target.account._account_id || target.account.email;
       this.disabled = true;
       this._xhrPromise =
           this._removeReviewer(accountID).then(function(response) {
@@ -110,7 +113,8 @@
         ['REVIEWER', 'CC'].forEach(function(type) {
           reviewers[type] = reviewers[type] || [];
           for (var i = 0; i < reviewers[type].length; i++) {
-            if (reviewers[type][i]._account_id == accountID) {
+            if (reviewers[type][i]._account_id == accountID ||
+                reviewers[type][i].email == accountID) {
               this.splice('change.reviewers.' + type, i, 1);
               break;
             }
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
index 4542df8..7c8a76f 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
@@ -90,7 +90,10 @@
               name: 'Diane Nguyen',
               email: 'macarthurfellow2B@juno.com',
             },
-          ]
+            {
+              email: 'test@e.mail',
+            },
+          ],
         },
         removable_reviewers: [
           {
@@ -102,14 +105,17 @@
             name: 'Diane Nguyen',
             email: 'macarthurfellow2B@juno.com',
           },
-        ]
+          {
+            email: 'test@e.mail',
+          },
+        ],
       };
       flushAsynchronousOperations();
       var chips =
           Polymer.dom(element.root).querySelectorAll('gr-account-chip');
-      assert.equal(chips.length, 3);
+      assert.equal(chips.length, 4);
       Array.from(chips).forEach(function(el) {
-        var accountID = parseInt(el.getAttribute('data-account-id'), 10);
+        var accountID = el.account._account_id || el.account.email;
         assert.ok(accountID);
 
         var buttonEl = el.$$('gr-button');
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
index 0e06647..65c99c0 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
@@ -14,32 +14,57 @@
 (function() {
   'use strict';
 
+  var INTERPOLATE_URL_PATTERN = /\$\{([\w]+)\}/g;
+
   Polymer({
     is: 'gr-account-dropdown',
 
     properties: {
       account: Object,
-      _hasAvatars: Boolean,
       links: {
         type: Array,
-        value: [
-          {name: 'Settings', url: '/settings'},
-          {name: 'Switch account', url: '/switch-account'},
-          {name: 'Sign out', url: '/logout'},
-        ],
+        computed: '_getLinks(_switchAccountUrl, _path)',
       },
       topContent: {
         type: Array,
         computed: '_getTopContent(account)',
       },
+      _path: {
+        type: String,
+        value: '/',
+      },
+      _hasAvatars: Boolean,
+      _switchAccountUrl: String,
     },
 
     attached: function() {
+      this._handleLocationChange();
+      this.listen(window, 'location-change', '_handleLocationChange');
       this.$.restAPI.getConfig().then(function(cfg) {
+        if (cfg && cfg.auth && cfg.auth.switch_account_url) {
+          this._switchAccountUrl = cfg.auth.switch_account_url;
+        } else {
+          this._switchAccountUrl = null;
+        }
         this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
       }.bind(this));
     },
 
+    detached: function() {
+      this.unlisten(window, 'location-change', '_handleLocationChange');
+    },
+
+    _getLinks: function(switchAccountUrl, path) {
+      var links = [{name: 'Settings', url: '/settings'}];
+      if (switchAccountUrl) {
+        var replacements = {path: path};
+        var url = this._interpolateUrl(switchAccountUrl, replacements);
+        links.push({name: 'Switch account', url: url});
+      }
+      links.push({name: 'Sign out', url: '/logout'});
+      return links;
+    },
+
     _getTopContent: function(account) {
       // if (!account) { return []; }
       return [
@@ -47,5 +72,18 @@
         {text: account.email},
       ];
     },
+
+    _handleLocationChange: function() {
+      this._path =
+          window.location.pathname +
+          window.location.search +
+          window.location.hash;
+    },
+
+    _interpolateUrl: function(url, replacements) {
+      return url.replace(INTERPOLATE_URL_PATTERN, function(match, p1) {
+        return replacements[p1] || '';
+      });
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
index e833cd6..3e109a8 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
@@ -48,5 +48,38 @@
       assert.deepEqual(element.topContent,
           [{text: 'John Doe', bold: true}, {text: 'john@doe.com'}]);
     });
+
+    test('switch account', function() {
+      // No switch account link.
+      assert.equal(element._getLinks(null).length, 2);
+
+      // Unparameterized switch account link.
+      var links = element._getLinks('/switch-account');
+      assert.equal(links.length, 3);
+      assert.deepEqual(links[1],
+          {name: 'Switch account', url: '/switch-account'});
+
+      // Parameterized switch account link.
+      links = element._getLinks('/switch-account${path}', '/c/123');
+      assert.equal(links.length, 3);
+      assert.deepEqual(links[1],
+          {name: 'Switch account', url: '/switch-account/c/123'});
+    });
+
+    test('_interpolateUrl', function() {
+      var replacements = {
+        'foo': 'bar',
+        'test': 'TEST',
+      };
+      var interpolate = function(url) {
+        return element._interpolateUrl(url, replacements);
+      };
+
+      assert.equal(interpolate('test'), 'test');
+      assert.equal(interpolate('${test}'), 'TEST');
+      assert.equal(
+          interpolate('${}, ${test}, ${TEST}, ${foo}'),
+          '${}, TEST, , bar');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
index e3f2bbc..5765411 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
@@ -25,4 +25,3 @@
   </template>
   <script src="gr-error-manager.js"></script>
 </dom-module>
-
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
index d48d870..27dcd4a 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
@@ -15,11 +15,12 @@
   'use strict';
 
   var HIDE_ALERT_TIMEOUT_MS = 5000;
-  var CHECK_SIGN_IN_INTERVAL_MS = 60*1000;
-  var STALE_CREDENTIAL_THRESHOLD_MS = 10*60*1000;
+  var CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000;
+  var STALE_CREDENTIAL_THRESHOLD_MS = 10 * 60 * 1000;
   var SIGN_IN_WIDTH_PX = 690;
   var SIGN_IN_HEIGHT_PX = 500;
   var TOO_MANY_FILES = 'too many files to find conflicts';
+  var AUTHENTICATION_REQUIRED = 'Authentication required\n';
 
   Polymer({
     is: 'gr-error-manager',
@@ -56,12 +57,14 @@
       this.listen(document, 'network-error', '_handleNetworkError');
       this.listen(document, 'show-alert', '_handleShowAlert');
       this.listen(document, 'visibilitychange', '_handleVisibilityChange');
+      this.listen(document, 'show-auth-required', '_handleAuthRequired');
     },
 
     detached: function() {
       this._clearHideAlertHandle();
       this.unlisten(document, 'server-error', '_handleServerError');
       this.unlisten(document, 'network-error', '_handleNetworkError');
+      this.unlisten(document, 'show-auth-required', '_handleAuthRequired');
       this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
     },
 
@@ -69,26 +72,31 @@
       return msg.indexOf(TOO_MANY_FILES) > -1;
     },
 
+    _handleAuthRequired: function() {
+      this._showAuthErrorAlert(
+          'Log in is required to perform that action.', 'Log in.');
+    },
+
     _handleServerError: function(e) {
-      if (e.detail.response.status === 403) {
-        this._getLoggedIn().then(function(loggedIn) {
-          if (loggedIn) {
-            // The app was logged at one point and is now getting auth errors.
-            // This indicates the auth token is no longer valid.
-            this._showAuthErrorAlert();
-          }
-        }.bind(this));
-      } else {
-        e.detail.response.text().then(function(text) {
-          if (!this._shouldSuppressError(text)) {
-            this._showAlert('Server error: ' + text);
-          }
-        }.bind(this));
-      }
+      Promise.all([
+        e.detail.response.text(), this._getLoggedIn()
+      ]).then(function(values) {
+        var text = values[0];
+        var loggedIn = values[1];
+        if (e.detail.response.status === 403 &&
+            loggedIn &&
+            text === AUTHENTICATION_REQUIRED) {
+          // The app was logged at one point and is now getting auth errors.
+          // This indicates the auth token is no longer valid.
+          this._showAuthErrorAlert('Auth error', 'Refresh credentials.');
+        } else if (!this._shouldSuppressError(text)) {
+          this._showAlert('Server error: ' + text);
+        }
+      }.bind(this));
     },
 
     _handleShowAlert: function(e) {
-      this._showAlert(e.detail.message);
+      this._showAlert(e.detail.message, e.detail.action, e.detail.callback);
     },
 
     _handleNetworkError: function(e) {
@@ -100,14 +108,14 @@
       return this.$.restAPI.getLoggedIn();
     },
 
-    _showAlert: function(text) {
+    _showAlert: function(text, opt_actionText, opt_actionCallback) {
       if (this._alertElement) { return; }
 
       this._clearHideAlertHandle();
       this._hideAlertHandle =
         this.async(this._hideAlert, HIDE_ALERT_TIMEOUT_MS);
       var el = this._createToastAlert();
-      el.show(text);
+      el.show(text, opt_actionText, opt_actionCallback);
       this._alertElement = el;
     },
 
@@ -125,13 +133,13 @@
       }
     },
 
-    _showAuthErrorAlert: function() {
+    _showAuthErrorAlert: function(errorText, actionText) {
       // TODO(viktard): close alert if it's not for auth error.
       if (this._alertElement) { return; }
 
       this._alertElement = this._createToastAlert();
-      this._alertElement.show('Auth error', 'Refresh credentials.');
-      this.listen(this._alertElement, 'action', '_createLoginPopup');
+      this._alertElement.show(errorText, actionText,
+          this._createLoginPopup.bind(this));
 
       this._refreshingCredentials = true;
       this._requestCheckLoggedIn();
@@ -151,17 +159,12 @@
       // undefined).
       if (document.hidden !== false) { return; }
 
-      // If we're currently in a credential refresh, flush the debouncer so that
-      // it can be checked immediately.
-      if (this._refreshingCredentials) {
-        this.flushDebouncer('checkLoggedIn');
-        return;
-      }
-
-      // If the credentials are old, request them to confirm their validity or
-      // (display an auth toast if it fails).
+      // If not currently refreshing credentials and the credentials are old,
+      // request them to confirm their validity or (display an auth toast if it
+      // fails).
       var timeSinceLastCheck = Date.now() - this._lastCredentialCheck;
-      if (this.knownAccountId !== undefined &&
+      if (!this._refreshingCredentials &&
+          this.knownAccountId !== undefined &&
           timeSinceLastCheck > STALE_CREDENTIAL_THRESHOLD_MS) {
         this._lastCredentialCheck = Date.now();
         this.$.restAPI.checkCredentials();
@@ -210,13 +213,18 @@
       ];
       window.open(this.getBaseUrl() +
           '/login/%3FcloseAfterLogin', '_blank', options.join(','));
+      this.listen(window, 'focus', '_handleWindowFocus');
     },
 
     _handleCredentialRefreshed: function() {
+      this.unlisten(window, 'focus', '_handleWindowFocus');
       this._refreshingCredentials = false;
-      this.unlisten(this._alertElement, 'action', '_createLoginPopup');
       this._hideAlert();
       this._showAlert('Credentials refreshed.');
     },
+
+    _handleWindowFocus: function() {
+      this.flushDebouncer('checkLoggedIn');
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
index 2013686..c8743fb 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
@@ -49,22 +49,53 @@
       sandbox.restore();
     });
 
-    test('show auth error', function(done) {
+    test('does not show auth error on 403 by default', function(done) {
       var showAuthErrorStub = sandbox.stub(element, '_showAuthErrorAlert');
-      element.fire('server-error', {response: {status: 403}});
-      element.$.restAPI.getLoggedIn.lastCall.returnValue.then(function() {
+      var responseText = Promise.resolve('server says no.');
+      element.fire('server-error',
+          {response: {status: 403, text: function() { return responseText; }}}
+      );
+      Promise.all([
+        element.$.restAPI.getLoggedIn.lastCall.returnValue,
+        responseText,
+      ]).then(function() {
+          assert.isFalse(showAuthErrorStub.calledOnce);
+          done();
+      });
+    });
+
+    test('shows auth error on 403 and Authentication required', function(done) {
+      var showAuthErrorStub = sandbox.stub(element, '_showAuthErrorAlert');
+      var responseText = Promise.resolve('Authentication required\n');
+      element.fire('server-error',
+          {response: {status: 403, text: function() { return responseText; }}}
+      );
+      Promise.all([
+        element.$.restAPI.getLoggedIn.lastCall.returnValue,
+        responseText,
+      ]).then(function() {
         assert.isTrue(showAuthErrorStub.calledOnce);
         done();
       });
     });
 
+    test('show logged in error', function() {
+      sandbox.stub(element, '_showAuthErrorAlert');
+      element.fire('show-auth-required');
+      assert.isTrue(element._showAuthErrorAlert.calledWithExactly(
+          'Log in is required to perform that action.', 'Log in.'));
+    });
+
     test('show normal server error', function(done) {
       var showAlertStub = sandbox.stub(element, '_showAlert');
       var textSpy = sandbox.spy(function() { return Promise.resolve('ZOMG'); });
       element.fire('server-error', {response: {status: 500, text: textSpy}});
 
       assert.isTrue(textSpy.called);
-      textSpy.lastCall.returnValue.then(function() {
+      Promise.all([
+        element.$.restAPI.getLoggedIn.lastCall.returnValue,
+        textSpy.lastCall.returnValue,
+      ]).then(function() {
         assert.isTrue(showAlertStub.calledOnce);
         assert.isTrue(showAlertStub.lastCall.calledWithExactly(
             'Server error: ZOMG'));
@@ -80,7 +111,10 @@
       element.fire('server-error', {response: {status: 500, text: textSpy}});
 
       assert.isTrue(textSpy.called);
-      textSpy.lastCall.returnValue.then(function() {
+      Promise.all([
+        element.$.restAPI.getLoggedIn.lastCall.returnValue,
+        textSpy.lastCall.returnValue,
+      ]).then(function() {
         assert.isFalse(showAlertStub.called);
         done();
       });
@@ -105,8 +139,14 @@
           function() { return Promise.resolve(true); });
       var toastSpy = sandbox.spy(element, '_createToastAlert');
       var windowOpen = sandbox.stub(window, 'open');
-      element.fire('server-error', {response: {status: 403}});
-      element.$.restAPI.getLoggedIn.lastCall.returnValue.then(function() {
+      var responseText = Promise.resolve('Authentication required\n');
+      element.fire('server-error',
+          {response: {status: 403, text: function() { return responseText; }}}
+      );
+      Promise.all([
+        element.$.restAPI.getLoggedIn.lastCall.returnValue,
+        responseText,
+      ]).then(function() {
         assert.isTrue(toastSpy.called);
         var toast = toastSpy.lastCall.returnValue;
         assert.isOk(toast);
@@ -116,7 +156,7 @@
             Polymer.dom(toast.root).textContent, 'Refresh credentials.');
 
         assert.isFalse(windowOpen.called);
-        toast.fire('action');
+        MockInteractions.tap(toast.$$('gr-button.action'));
         assert.isTrue(windowOpen.called);
 
         // @see Issue 5822: noopener breaks closeAfterLogin
@@ -125,6 +165,7 @@
 
         var hideToastSpy = sandbox.spy(toast, 'hide');
 
+        element._handleWindowFocus();
         assert.isTrue(refreshStub.called);
         element.flushDebouncer('checkLoggedIn');
         flush(function() {
@@ -142,10 +183,13 @@
     });
 
     test('show alert', function() {
+      var alertObj = {message: 'foo'}
       sandbox.stub(element, '_showAlert');
       element.fire('show-alert', {message: 'foo'});
       assert.isTrue(element._showAlert.calledOnce);
-      assert.isTrue(element._showAlert.lastCall.calledWithExactly('foo'));
+      assert.equal(element._showAlert.lastCall.args[0], 'foo');
+      assert.isNotOk(element._showAlert.lastCall.args[1]);
+      assert.isNotOk(element._showAlert.lastCall.args[2]);
     });
 
     test('checks stale credentials on visibility change', function() {
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
index d4c8380..d4e7ae2 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
@@ -44,6 +44,7 @@
     'is:abandoned',
     'is:closed',
     'is:draft',
+    'is:ignored',
     'is:mergeable',
     'is:merged',
     'is:open',
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
index 6b1e59e..952dc67 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
@@ -17,6 +17,8 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderImage) { return; }
 
+  var IMAGE_MIME_PATTERN = /^image\/(bmp|gif|jpeg|jpg|png|tiff|webp)$/;
+
   function GrDiffBuilderImage(diff, comments, prefs, outputEl, baseImage,
       revisionImage) {
     GrDiffBuilderSideBySide.call(this, diff, comments, prefs, outputEl, []);
@@ -41,22 +43,27 @@
     var tr = this._createElement('tr');
 
     tr.appendChild(this._createElement('td'));
-    tr.appendChild(this._createImageCell(this._baseImage, 'left'));
+    tr.appendChild(this._createImageCell(this._baseImage, 'left', section));
 
     tr.appendChild(this._createElement('td'));
-    tr.appendChild(this._createImageCell(this._revisionImage, 'right'));
+    tr.appendChild(this._createImageCell(
+        this._revisionImage, 'right', section));
 
     section.appendChild(tr);
   };
 
-  GrDiffBuilderImage.prototype._createImageCell = function(image, className) {
+  GrDiffBuilderImage.prototype._createImageCell =
+      function(image, className, section) {
     var td = this._createElement('td', className);
-    if (image) {
+    if (image && IMAGE_MIME_PATTERN.test(image.type)) {
       var imageEl = this._createElement('img');
+      imageEl.onload = function() {
+        image._height = imageEl.naturalHeight;
+        image._width = imageEl.naturalWidth;
+        this._updateImageLabel(section, className, image);
+      }.bind(this);
       imageEl.src = 'data:' + image.type + ';base64, ' + image.body;
-      image._height = imageEl.naturalHeight;
-      image._width = imageEl.naturalWidth;
-      imageEl.addEventListener('error', function(e) {
+      imageEl.addEventListener('error', function() {
         imageEl.remove();
         td.textContent = '[Image failed to load]';
       });
@@ -65,20 +72,61 @@
     return td;
   };
 
+  GrDiffBuilderImage.prototype._updateImageLabel =
+      function(section, className, image) {
+    var label = Polymer.dom(section)
+        .querySelector('.' + className + ' span.label');
+    this._setLabelText(label, image);
+  };
+
+  GrDiffBuilderImage.prototype._setLabelText = function(label, image) {
+    label.textContent = this._getImageLabel(image);
+  };
+
   GrDiffBuilderImage.prototype._emitImageLabels = function(section) {
     var tr = this._createElement('tr');
 
+    var addNamesInLabel = false;
+
+    if (this._baseImage && this._revisionImage &&
+        this._baseImage._name !== this._revisionImage._name) {
+      addNamesInLabel = true;
+    }
+
     tr.appendChild(this._createElement('td'));
     var td = this._createElement('td', 'left');
     var label = this._createElement('label');
-    label.textContent = this._getImageLabel(this._baseImage);
+    var nameSpan;
+    var labelSpan = this._createElement('span', 'label');
+
+    if (addNamesInLabel) {
+      nameSpan = this._createElement('span', 'name');
+      nameSpan.textContent = this._baseImage._name;
+      label.appendChild(nameSpan);
+      label.appendChild(this._createElement('br'));
+    }
+
+    this._setLabelText(labelSpan, this._baseImage, addNamesInLabel);
+
+    label.appendChild(labelSpan);
     td.appendChild(label);
     tr.appendChild(td);
 
     tr.appendChild(this._createElement('td'));
     td = this._createElement('td', 'right');
     label = this._createElement('label');
-    label.textContent = this._getImageLabel(this._revisionImage);
+    labelSpan = this._createElement('span', 'label');
+
+    if (addNamesInLabel) {
+      nameSpan = this._createElement('span', 'name');
+      nameSpan.textContent = this._revisionImage._name;
+      label.appendChild(nameSpan);
+      label.appendChild(this._createElement('br'));
+    }
+
+    this._setLabelText(labelSpan, this._revisionImage, addNamesInLabel);
+
+    label.appendChild(labelSpan);
     td.appendChild(label);
     tr.appendChild(td);
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
index a936934..de93a69 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
@@ -145,7 +145,8 @@
           reporting.time(TimingLabel.TOTAL);
           reporting.time(TimingLabel.CONTENT);
           this.dispatchEvent(new CustomEvent('render-start', {bubbles: true}));
-          return this.$.processor.process(this.diff.content).then(function() {
+          return this.$.processor.process(this.diff.content, this.isImageDiff)
+              .then(function() {
             if (this.isImageDiff) {
               this._builder.renderDiffImages();
             }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
index e40ccf3..abc7db3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
@@ -149,8 +149,8 @@
       this._fixSide();
     },
 
-    moveToLineNumber: function(number, side) {
-      var row = this._findRowByNumber(number, side);
+    moveToLineNumber: function(number, side, opt_path) {
+      var row = this._findRowByNumberAndFile(number, side, opt_path);
       if (row) {
         this.side = side;
         this.$.cursorManager.setCursor(row);
@@ -376,8 +376,16 @@
       }
     },
 
-    _findRowByNumber: function(targetNumber, side) {
-      var stops = this.$.cursorManager.stops;
+    _findRowByNumberAndFile: function(targetNumber, side, opt_path) {
+      var stops;
+      if (opt_path) {
+        var diff = this.diffs.filter(function(diff) {
+          return diff.path === opt_path;
+        })[0];
+        stops = diff.getCursorStops();
+      } else {
+        stops = this.$.cursorManager.stops;
+      }
       var selector;
       for (var i = 0; i < stops.length; i++) {
         selector = '.lineNum.' + side + '[data-value="' + targetNumber + '"]';
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
index a77c617..16acc03 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
@@ -266,13 +266,13 @@
       assert.equal(cursorElement.getAddress(), '');
     });
 
-    test('_findRowByNumber', function() {
+    test('_findRowByNumberAndFile', function() {
       // Get the first ab row after the first chunk.
       var row = Polymer.dom(diffElement.root).querySelectorAll('tr')[8];
 
       // It should be line 8 on the right, but line 5 on the left.
-      assert.equal(cursorElement._findRowByNumber(8, 'right'), row);
-      assert.equal(cursorElement._findRowByNumber(5, 'left'), row);
+      assert.equal(cursorElement._findRowByNumberAndFile(8, 'right'), row);
+      assert.equal(cursorElement._findRowByNumberAndFile(5, 'left'), row);
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js
index 8685d7d..e870169 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js
@@ -65,8 +65,8 @@
      * Performs a synchronous in-order traversal from top to bottom of the node
      * element, counting the length of the syntax until child is found.
      *
-     * @param {!Element} The root DOM element to be searched through.
-     * @param {!Element} The child element being searched for.
+     * @param {!Element} node The root DOM element to be searched through.
+     * @param {!Element} child The child element being searched for.
      * @return {number}
      */
     _getTextOffset: function(node, child) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
index fe57c43..5a49daa 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
@@ -17,6 +17,8 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-storage/gr-storage.html">
 
 <dom-module id="gr-diff-preferences">
@@ -70,71 +72,79 @@
         color: #888;
       }
     </style>
-    <div class="header">
-      Diff View Preferences
-    </div>
-    <div class="mainContainer">
-      <div class="pref">
-        <label for="contextSelect">Context</label>
-        <select id="contextSelect" on-change="_handleContextSelectChange">
-          <option value="3">3 lines</option>
-          <option value="10">10 lines</option>
-          <option value="25">25 lines</option>
-          <option value="50">50 lines</option>
-          <option value="75">75 lines</option>
-          <option value="100">100 lines</option>
-          <option value="-1">Whole file</option>
-        </select>
+
+    <gr-overlay id="prefsOverlay" with-backdrop>
+      <div class="header">
+        Diff View Preferences
       </div>
-      <div class="pref">
-        <label for="lineWrappingInput">Fit to screen</label>
-        <input
-            is="iron-input"
-            type="checkbox"
-            id="lineWrappingInput"
-            on-tap="_handlelineWrappingTap">
+      <div class="mainContainer">
+        <div class="pref">
+          <label for="contextSelect">Context</label>
+          <select id="contextSelect" on-change="_handleContextSelectChange">
+            <option value="3">3 lines</option>
+            <option value="10">10 lines</option>
+            <option value="25">25 lines</option>
+            <option value="50">50 lines</option>
+            <option value="75">75 lines</option>
+            <option value="100">100 lines</option>
+            <option value="-1">Whole file</option>
+          </select>
+        </div>
+        <div class="pref">
+          <label for="lineWrappingInput">Fit to screen</label>
+          <input
+              is="iron-input"
+              type="checkbox"
+              id="lineWrappingInput"
+              on-tap="_handlelineWrappingTap">
+        </div>
+        <div class="pref" id="columnsPref"
+            hidden$="[[_newPrefs.line_wrapping]]">
+          <label for="columnsInput">Diff width</label>
+          <input is="iron-input" type="number" id="columnsInput"
+              prevent-invalid-input
+              allowed-pattern="[0-9]"
+              bind-value="{{_newPrefs.line_length}}">
+        </div>
+        <div class="pref">
+          <label for="tabSizeInput">Tab width</label>
+          <input is="iron-input" type="number" id="tabSizeInput"
+              prevent-invalid-input
+              allowed-pattern="[0-9]"
+              bind-value="{{_newPrefs.tab_size}}">
+        </div>
+        <div class="pref" hidden$="[[!_newPrefs.font_size]]">
+          <label for="fontSizeInput">Font size</label>
+          <input is="iron-input" type="number" id="fontSizeInput"
+                prevent-invalid-input
+                allowed-pattern="[0-9]"
+                bind-value="{{_newPrefs.font_size}}">
+        </div>
+        <div class="pref">
+          <label for="showTabsInput">Show tabs</label>
+          <input is="iron-input" type="checkbox" id="showTabsInput"
+              on-tap="_handleShowTabsTap">
+        </div>
+        <div class="pref">
+          <label for="showTrailingWhitespaceInput">
+            Show trailing whitespace</label>
+          <input is="iron-input" type="checkbox"
+              id="showTrailingWhitespaceInput"
+              on-tap="_handleShowTrailingWhitespaceTap">
+        </div>
+        <div class="pref">
+          <label for="syntaxHighlightInput">Syntax highlighting</label>
+          <input is="iron-input" type="checkbox" id="syntaxHighlightInput"
+              on-tap="_handleSyntaxHighlightTap">
+        </div>
       </div>
-      <div class="pref" id="columnsPref" hidden$="[[_newPrefs.line_wrapping]]">
-        <label for="columnsInput">Diff width</label>
-        <input is="iron-input" type="number" id="columnsInput"
-            prevent-invalid-input
-            allowed-pattern="[0-9]"
-            bind-value="{{_newPrefs.line_length}}">
+      <div class="actions">
+        <gr-button id="saveButton" primary on-tap="_handleSave">Save</gr-button>
+        <gr-button id="cancelButton" on-tap="_handleCancel">Cancel</gr-button>
       </div>
-      <div class="pref">
-        <label for="tabSizeInput">Tab width</label>
-        <input is="iron-input" type="number" id="tabSizeInput"
-            prevent-invalid-input
-            allowed-pattern="[0-9]"
-            bind-value="{{_newPrefs.tab_size}}">
-      </div>
-      <div class="pref" hidden$="[[!_newPrefs.font_size]]">
-        <label for="fontSizeInput">Font size</label>
-        <input is="iron-input" type="number" id="fontSizeInput"
-               prevent-invalid-input
-               allowed-pattern="[0-9]"
-               bind-value="{{_newPrefs.font_size}}">
-      </div>
-      <div class="pref">
-        <label for="showTabsInput">Show tabs</label>
-        <input is="iron-input" type="checkbox" id="showTabsInput"
-            on-tap="_handleShowTabsTap">
-      </div>
-      <div class="pref">
-        <label for="showTrailingWhitespaceInput">Show trailing whitespace</label>
-        <input is="iron-input" type="checkbox" id="showTrailingWhitespaceInput"
-            on-tap="_handleShowTrailingWhitespaceTap">
-      </div>
-      <div class="pref">
-        <label for="syntaxHighlightInput">Syntax highlighting</label>
-        <input is="iron-input" type="checkbox" id="syntaxHighlightInput"
-            on-tap="_handleSyntaxHighlightTap">
-      </div>
-    </div>
-    <div class="actions">
-      <gr-button id="saveButton" primary on-tap="_handleSave">Save</gr-button>
-      <gr-button id="cancelButton" on-tap="_handleCancel">Cancel</gr-button>
-    </div>
+    </overlay>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-storage id="storage"></gr-storage>
   </template>
   <script src="gr-diff-preferences.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
index fd2a6f5..e29f8ef 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
@@ -17,18 +17,6 @@
   Polymer({
     is: 'gr-diff-preferences',
 
-    /**
-     * Fired when the user presses the save button.
-     *
-     * @event save
-     */
-
-    /**
-     * Fired when the user presses the cancel button.
-     *
-     * @event cancel
-     */
-
     properties: {
       prefs: {
         type: Object,
@@ -106,14 +94,43 @@
       this.set('_newPrefs.line_wrapping', Polymer.dom(e).rootTarget.checked);
     },
 
-    _handleSave: function() {
+    _handleSave: function(e) {
+      e.stopPropagation();
       this.prefs = this._newPrefs;
       this.localPrefs = this._newLocalPrefs;
-      this.fire('save', null, {bubbles: false});
+      var el = Polymer.dom(e).rootTarget;
+      el.disabled = true;
+      this.$.storage.savePreferences(this._localPrefs);
+      this._saveDiffPreferences().then(function(response) {
+        el.disabled = false;
+        if (!response.ok) { return response; }
+
+        this.$.prefsOverlay.close();
+      }.bind(this)).catch(function(err) {
+        el.disabled = false;
+      }.bind(this));
     },
 
-    _handleCancel: function() {
-      this.fire('cancel', null, {bubbles: false});
+    _handleCancel: function(e) {
+      e.stopPropagation();
+      this.$.prefsOverlay.close();
+    },
+
+    _handlePrefsTap: function(e) {
+      e.preventDefault();
+      this._openPrefs();
+    },
+
+    open: function() {
+      this.$.prefsOverlay.open().then(function() {
+        var focusStops = this.getFocusStops();
+        this.$.prefsOverlay.setFocusStops(focusStops);
+        this.resetFocus();
+      }.bind(this));
+    },
+
+    _saveDiffPreferences: function() {
+      return this.$.restAPI.saveDiffPreferences(this.prefs);
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html
index 06f617a..b163c71 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html
@@ -35,11 +35,17 @@
 <script>
   suite('gr-diff-preferences tests', function() {
     var element;
+    var sandbox;
 
     setup(function() {
+      sandbox = sinon.sandbox.create();
       element = fixture('basic');
     });
 
+    teardown(function() {
+      sandbox.restore();
+    });
+
     test('model changes', function() {
       element.prefs = {
         context: 10,
@@ -92,18 +98,25 @@
       savePrefs.restore();
     });
 
-    test('events', function(done) {
-      var savePromise = new Promise(function(resolve) {
-        element.addEventListener('save', function() { resolve(); });
-      });
-      var cancelPromise = new Promise(function(resolve) {
-        element.addEventListener('cancel', function() { resolve(); });
-      });
-      Promise.all([savePromise, cancelPromise]).then(function() {
-        done();
-      });
+    test('save button', function() {
+      element.prefs = {
+        font_size: '11',
+      };
+      element._newPrefs = {
+        font_size: '12',
+      };
+      var saveStub = sandbox.stub(element.$.restAPI, 'saveDiffPreferences',
+          function() { return Promise.resolve(); });
+
       MockInteractions.tap(element.$$('gr-button[primary]'));
+      assert.deepEqual(element.prefs, element._newPrefs);
+      assert.deepEqual(saveStub.lastCall.args[0], element._newPrefs);
+    });
+
+    test('cancel button', function() {
+      var closeStub = sandbox.stub(element.$.prefsOverlay, 'close');
       MockInteractions.tap(element.$$('gr-button:not([primary])'));
+      assert.isTrue(closeStub.called);
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
index 95ff5b7..62e8915 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
@@ -103,11 +103,14 @@
      * @return {Promise} A promise that resolves when the diff is completely
      *     processed.
      */
-    process: function(content) {
-      return new Promise(function(resolve) {
-        this.groups = [];
-        this.push('groups', this._makeFileComments());
+    process: function(content, isImageDiff) {
+      this.groups = [];
+      this.push('groups', this._makeFileComments());
 
+      // If image diff, only render the file lines.
+      if (isImageDiff) { return Promise.resolve(); }
+
+      return new Promise(function(resolve) {
         var state = {
           lineNums: {left: 0, right: 0},
           sectionIndex: 0,
@@ -117,7 +120,6 @@
 
         var currentBatch = 0;
         var nextStep = function() {
-
           if (this._isScrolling) {
             this.async(nextStep, 100);
             return;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
index 5429f52..687b3dd 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
@@ -459,6 +459,23 @@
         assert.equal(element.groups.length, 33);
       });
 
+      test('image diffs', function() {
+        var contentRow = {
+          ab: [
+            '<!DOCTYPE html>',
+            '<meta charset="utf-8">',
+          ]
+        };
+        var content = _.times(200, _.constant(contentRow));
+        sandbox.stub(element, 'async');
+        element.process(content, true);
+        assert.equal(element.groups.length, 1);
+
+        // Image diffs don't process content, just the 'FILE' line.
+        assert.equal(element.groups[0].lines.length, 1);
+      });
+
+
       suite('gr-diff-processor helpers', function() {
         var rows;
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
index c354882a..fa1aeb2 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
@@ -191,7 +191,7 @@
      * Query the diff object for the lines from a particular side.
      *
      * @param {!string} side The side that is currently selected.
-     * @return {string[]} An array of strings indexed by line number.
+     * @return {Array.string} An array of strings indexed by line number.
      */
     _getDiffLines: function(side) {
       if (this._linesCache[side]) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
index bc4ae3e..a3ff3d1 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
@@ -21,7 +21,6 @@
 <link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-select/gr-select.html">
 <link rel="import" href="../gr-diff/gr-diff.html">
@@ -230,6 +229,9 @@
            href$="[[_computeNavLinkURL(_path, _fileList, -1, 1)]]">Prev</a>
         /
         <a class="navLink"
+           href$="[[_computeUpURL(_changeNum, _patchRange, _change, _change.revisions)]]">Up</a>
+        /
+        <a class="navLink"
            href$="[[_computeNavLinkURL(_path, _fileList, 1, 1)]]">Next</a>
       </div>
     </header>
@@ -273,21 +275,17 @@
           </span>
         </div>
       </div>
-      <gr-overlay id="prefsOverlay" with-backdrop>
-        <gr-diff-preferences
-            id="diffPreferences"
-            prefs="{{_prefs}}"
-            local-prefs="{{_localPrefs}}"
-            on-save="_handlePrefsSave"
-            on-cancel="_handlePrefsCancel"></gr-diff-preferences>
-      </gr-overlay>
+      <gr-diff-preferences
+          id="diffPreferences"
+          prefs="{{_prefs}}"
+          local-prefs="{{_localPrefs}}"></gr-diff-preferences>
       <div class="fileNav mobile">
         <a class="mobileNavLink"
-           href$="[[_computeNavLinkURL(_path, _fileList, -1, 1)]]"><</a>
+           href$="[[_computeNavLinkURL(_path, _fileList, -1, 1)]]">&lt;</a>
         <div class="fullFileName mobile">[[_computeFileDisplayName(_path)]]
         </div>
         <a class="mobileNavLink"
-            href$="[[_computeNavLinkURL(_path, _fileList, 1, 1)]]">></a>
+            href$="[[_computeNavLinkURL(_path, _fileList, 1, 1)]]">&gt;</a>
       </div>
       <gr-diff
           id="diff"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index c35a779..33f11c5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -363,7 +363,7 @@
           this.modifierPressed(e)) { return; }
 
       e.preventDefault();
-      this._openPrefs();
+      this.$.diffPreferences.open();
     },
 
     _navToChangeView: function() {
@@ -375,6 +375,13 @@
           this._change && this._change.revisions));
     },
 
+    _computeUpURL: function(changeNum, patchRange, change, changeRevisions) {
+      return this._getChangePath(
+          changeNum,
+          patchRange,
+          change && changeRevisions);
+    },
+
     _navToFile: function(path, fileList, direction) {
       var url = this._computeNavLinkURL(path, fileList, direction);
       if (!url) { return; }
@@ -382,15 +389,6 @@
       page.show(this._computeNavLinkURL(path, fileList, direction));
     },
 
-    _openPrefs: function() {
-      this.$.prefsOverlay.open().then(function() {
-        var diffPreferences = this.$.diffPreferences;
-        var focusStops = diffPreferences.getFocusStops();
-        this.$.prefsOverlay.setFocusStops(focusStops);
-        this.$.diffPreferences.resetFocus();
-      }.bind(this));
-    },
-
     /**
      * @param {?string} path The path of the current file being shown.
      * @param {Array.<string>} fileList The list of files in this change and
@@ -473,7 +471,7 @@
      * If the URL hash is a diff address then configure the diff cursor.
      */
     _loadHash: function(hash) {
-      var hash = hash.replace(/^#/, '');
+      hash = hash.replace(/^#/, '');
       if (!HASH_PATTERN.test(hash)) { return; }
       if (hash[0] === 'a' || hash[0] === 'b') {
         this.$.cursor.side = DiffSides.LEFT;
@@ -574,7 +572,11 @@
     },
 
     _handleFileTap: function(e) {
-      this.$.dropdown.close();
+      // async is needed so that that the click event is fired before the
+      // dropdown closes (This was a bug for touch devices).
+      this.async(function() {
+        this.$.dropdown.close();
+      }, 1);
     },
 
     _handleMobileSelectChange: function(e) {
@@ -588,7 +590,7 @@
 
     _handlePrefsTap: function(e) {
       e.preventDefault();
-      this._openPrefs();
+      this.$.diffPreferences.open();
     },
 
     _handlePrefsSave: function(e) {
@@ -606,15 +608,6 @@
       }.bind(this));
     },
 
-    _saveDiffPreferences: function() {
-      return this.$.restAPI.saveDiffPreferences(this._prefs);
-    },
-
-    _handlePrefsCancel: function(e) {
-      e.stopPropagation();
-      this.$.prefsOverlay.close();
-    },
-
     /**
      * _getDiffViewMode: Get the diff view (side-by-side or unified) based on
      * the current state.
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
index d7eeebf..d54f715 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
@@ -111,8 +111,8 @@
           'Should navigate to /c/42/');
       assert.equal(element.changeViewState.selectedFileIndex, 0);
 
-      var showPrefsStub = sandbox.stub(element.$.prefsOverlay, 'open',
-          function() { return Promise.resolve({}); });
+      var showPrefsStub = sandbox.stub(element.$.diffPreferences.$.prefsOverlay,
+          'open', function() { return Promise.resolve({}); });
 
       MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
       assert(showPrefsStub.calledOnce);
@@ -145,22 +145,6 @@
           false, 'SIDE_BY_SIDE', false));
     });
 
-    test('saving diff preferences', function() {
-      var savePrefs = sandbox.stub(element, '_handlePrefsSave');
-      var cancelPrefs = sandbox.stub(element, '_handlePrefsCancel');
-      element.$.diffPreferences._handleSave();
-      assert(savePrefs.calledOnce);
-      assert(cancelPrefs.notCalled);
-    });
-
-    test('cancelling diff preferences', function() {
-      var savePrefs = sandbox.stub(element, '_handlePrefsSave');
-      var cancelPrefs = sandbox.stub(element, '_handlePrefsCancel');
-      element.$.diffPreferences._handleCancel();
-      assert(cancelPrefs.calledOnce);
-      assert(savePrefs.notCalled);
-    });
-
     test('keyboard shortcuts with patch range', function() {
       element._changeNum = '42';
       element._patchRange = {
@@ -368,31 +352,40 @@
       assert.equal(linkEls[2].getAttribute('href'), '/c/42/5..10/wheatley.md');
     });
 
-    test('prev/next links', function() {
+    test('prev/up/next links', function() {
       element._changeNum = '42';
       element._patchRange = {
         basePatchNum: 'PARENT',
         patchNum: '10',
       };
+      element._change = {
+        revisions: {
+          a: {_number: 10},
+        },
+      };
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
       element._path = 'glados.txt';
       flushAsynchronousOperations();
       var linkEls = Polymer.dom(element.root).querySelectorAll('.navLink');
-      assert.equal(linkEls.length, 2);
+      assert.equal(linkEls.length, 3);
       assert.equal(linkEls[0].getAttribute('href'), '/c/42/10/chell.go');
-      assert.equal(linkEls[1].getAttribute('href'), '/c/42/10/wheatley.md');
+      assert.equal(linkEls[1].getAttribute('href'), '/c/42/');
+      assert.equal(linkEls[2].getAttribute('href'), '/c/42/10/wheatley.md');
       element._path = 'wheatley.md';
       flushAsynchronousOperations();
       assert.equal(linkEls[0].getAttribute('href'), '/c/42/10/glados.txt');
-      assert.isFalse(linkEls[1].hasAttribute('href'));
+      assert.equal(linkEls[1].getAttribute('href'), '/c/42/');
+      assert.isFalse(linkEls[2].hasAttribute('href'));
       element._path = 'chell.go';
       flushAsynchronousOperations();
       assert.isFalse(linkEls[0].hasAttribute('href'));
-      assert.equal(linkEls[1].getAttribute('href'), '/c/42/10/glados.txt');
+      assert.equal(linkEls[1].getAttribute('href'), '/c/42/');
+      assert.equal(linkEls[2].getAttribute('href'), '/c/42/10/glados.txt');
       element._path = 'not_a_real_file';
       flushAsynchronousOperations();
       assert.equal(linkEls[0].getAttribute('href'), '/c/42/10/wheatley.md');
-      assert.equal(linkEls[1].getAttribute('href'), '/c/42/10/chell.go');
+      assert.equal(linkEls[1].getAttribute('href'), '/c/42/');
+      assert.equal(linkEls[2].getAttribute('href'), '/c/42/10/chell.go');
     });
 
     test('download link', function() {
@@ -408,27 +401,36 @@
           '/changes/42/revisions/10/patch?zip&path=glados.txt');
     });
 
-    test('prev/next links with patch range', function() {
+    test('prev/up/next links with patch range', function() {
       element._changeNum = '42';
       element._patchRange = {
         basePatchNum: '5',
         patchNum: '10',
       };
+      element._change = {
+        revisions: {
+          a: {_number: 5},
+          b: {_number: 10},
+        },
+      };
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
       element._path = 'glados.txt';
       flushAsynchronousOperations();
       var linkEls = Polymer.dom(element.root).querySelectorAll('.navLink');
-      assert.equal(linkEls.length, 2);
+      assert.equal(linkEls.length, 3);
       assert.equal(linkEls[0].getAttribute('href'), '/c/42/5..10/chell.go');
-      assert.equal(linkEls[1].getAttribute('href'), '/c/42/5..10/wheatley.md');
+      assert.equal(linkEls[1].getAttribute('href'), '/c/42/5..10');
+      assert.equal(linkEls[2].getAttribute('href'), '/c/42/5..10/wheatley.md');
       element._path = 'wheatley.md';
       flushAsynchronousOperations();
       assert.equal(linkEls[0].getAttribute('href'), '/c/42/5..10/glados.txt');
-      assert.isFalse(linkEls[1].hasAttribute('href'));
+      assert.equal(linkEls[1].getAttribute('href'), '/c/42/5..10');
+      assert.isFalse(linkEls[2].hasAttribute('href'));
       element._path = 'chell.go';
       flushAsynchronousOperations();
       assert.isFalse(linkEls[0].hasAttribute('href'));
-      assert.equal(linkEls[1].getAttribute('href'), '/c/42/5..10/glados.txt');
+      assert.equal(linkEls[1].getAttribute('href'), '/c/42/5..10');
+      assert.equal(linkEls[2].getAttribute('href'), '/c/42/5..10/glados.txt');
     });
 
     test('file review status', function(done) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
index 54e9c6e..848de56 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -106,7 +106,7 @@
         display: inline-block;
         color: #666;
         content: attr(data-value);
-        padding: 0 .75em;
+        padding: 0 .5em;
         text-align: right;
         width: 100%;
       }
@@ -173,8 +173,22 @@
         border-radius: .4em;
         background-color: #FF9AD2;
       }
+      #diffHeader {
+        background-color: #F9F9F9;
+        color: #2A00FF;
+        font-family: var(--monospace-font-family);
+        font-size: var(--font-size, 12px);
+        padding: 0.5em 0 0.5em 4em;
+      }
     </style>
     <style include="gr-theme-default"></style>
+    <div id="diffHeader" hidden$="[[_computeDiffHeaderHidden(_diffHeaderItems)]]">
+      <template
+          is="dom-repeat"
+          items="[[_diffHeaderItems]]">
+        <div>[[item]]</div>
+      </template>
+    </div>
     <div class$="[[_computeContainerClass(_loggedIn, viewMode, displayLine)]]"
         on-tap="_handleTap">
       <gr-diff-selection diff="[[_diff]]">
@@ -186,6 +200,7 @@
               id="diffBuilder"
               comments="[[_comments]]"
               diff="[[_diff]]"
+              diff-path="[[path]]"
               view-mode="[[viewMode]]"
               line-wrapping="[[lineWrapping]]"
               is-image-diff="[[isImageDiff]]"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index 05a7f72..cd1931a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -32,6 +32,12 @@
      * @event line-selected
      */
 
+    /**
+     * Fired if being logged in is required.
+     *
+     * @event show-auth-required
+     */
+
     properties: {
       changeNum: String,
       noAutoRender: {
@@ -83,6 +89,11 @@
         observer: '_viewModeObserver',
       },
       _diff: Object,
+      _diffHeaderItems: {
+        type: Array,
+        value: [],
+        computed: '_computeDiffHeaderItems(_diff.*)',
+      },
       _diffTableClass: {
         type: String,
         value: '',
@@ -146,7 +157,10 @@
     addDraftAtLine: function(el) {
       this._selectLine(el);
       this._getLoggedIn().then(function(loggedIn) {
-        if (!loggedIn) { return; }
+        if (!loggedIn) {
+          this.fire('show-auth-required');
+          return;
+        }
 
         var value = el.getAttribute('data-value');
         if (value === GrDiffLine.FILE) {
@@ -218,6 +232,7 @@
       this.fire('line-selected', {
         side: el.classList.contains('left') ? DiffSide.LEFT : DiffSide.RIGHT,
         number: el.getAttribute('data-value'),
+        path: this.path,
       });
     },
 
@@ -327,7 +342,7 @@
       this._removeComment(comment, e.detail.patchNum);
     },
 
-    _removeComment: function(comment, opt_patchNum) {
+    _removeComment: function(comment) {
       var side = comment.__commentSide;
       this._removeCommentFromSide(comment, side);
     },
@@ -567,5 +582,20 @@
         threadEls[i].projectConfig = projectConfig;
       }
     },
+
+    _computeDiffHeaderItems: function(diffInfoRecord) {
+      var diffInfo = diffInfoRecord.base;
+      if (!diffInfo || !diffInfo.diff_header || diffInfo.binary) { return []; }
+      return diffInfo.diff_header.filter(function(item) {
+        return !(item.indexOf('diff --git ') === 0 ||
+            item.indexOf('index ') === 0 ||
+            item.indexOf('+++ ') === 0 ||
+            item.indexOf('--- ') === 0);
+      });
+    },
+
+    _computeDiffHeaderHidden: function(items) {
+      return items.length === 0;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
index 34d6de21c..c267eb0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
@@ -62,6 +62,17 @@
         assert.isFalse(element.classList.contains('no-left'));
       });
 
+      test('addDraftAtLine', function(done) {
+        sandbox.stub(element, '_selectLine');
+        var loggedInErrorSpy = sandbox.spy();
+        element.addEventListener('show-auth-required', loggedInErrorSpy);
+        element.addDraftAtLine();
+        flush(function() {
+          assert.isTrue(loggedInErrorSpy.called);
+          done();
+        });
+      });
+
       test('view does not start with displayLine classList', function() {
         assert.isFalse(
             element.$$('.diffContainer').classList.contains('displayLine'));
@@ -275,101 +286,333 @@
         assert.equal(threadLength, 2);
       });
 
-      test('renders image diffs', function(done) {
-        var mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index 2adc47d..f9c2f2c 100644',
-            '--- a/carrot.jpg',
-            '+++ b/carrot.jpg',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        var mockFile1 = {
-          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAEwsA' +
-              'AAAAAAAAAAAAAAAA/w==',
-          type: 'image/bmp',
-        };
-        var mockFile2 = {
-          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAEwsA' +
-              'AAAAAAAAAAAA/////w==',
-          type: 'image/bmp'
-        };
-        var mockCommit = {
-          commit: '9a1a1d10baece5efbba10bc4ccf808a67a50ac0a',
-          parents: [{
-            commit: '7338aa9adfe57909f1fdaf88975cdea467d3382f',
-            subject: 'Added a carrot',
-          }],
-          author: {
-            name: 'Wyatt Allen',
-            email: 'wyatta@google.com',
-            date: '2016-05-23 21:44:51.000000000',
-            tz: -420,
-          },
-          committer: {
-            name: 'Wyatt Allen',
-            email: 'wyatta@google.com',
-            date: '2016-05-25 00:25:41.000000000',
-            tz: -420,
-          },
-          subject: 'Updated the carrot',
-          message: 'Updated the carrot\n\nChange-Id: Iabcd123\n',
-        };
-        var mockComments = {baseComments: [], comments: []};
-
+      suite('image diffs', function() {
+        var mockFile1;
+        var mockFile2;
         var stubs = [];
-        stubs.push(sandbox.stub(element, '_getDiff',
-            function() { return Promise.resolve(mockDiff); }));
-        stubs.push(sandbox.stub(element.$.restAPI, 'getCommitInfo',
-            function() { return Promise.resolve(mockCommit); }));
-        stubs.push(sandbox.stub(element.$.restAPI,
-            'getCommitFileContents',
-            function() { return Promise.resolve(mockFile1); }));
-        stubs.push(sandbox.stub(element.$.restAPI,
-            'getChangeFileContents',
-            function() { return Promise.resolve(mockFile2); }));
-        stubs.push(sandbox.stub(element.$.restAPI, '_getDiffComments',
-            function() { return Promise.resolve(mockComments); }));
-        stubs.push(sandbox.stub(element.$.restAPI, 'getDiffDrafts',
-            function() { return Promise.resolve(mockComments); }));
+        setup(function() {
+          mockFile1 = {
+            body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+            'wsAAAAAAAAAAAAAAAAA/w==',
+            type: 'image/bmp',
+          };
+          mockFile2 = {
+            body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+            'wsAAAAAAAAAAAAA/////w==',
+            type: 'image/bmp'
+          };
+          var mockCommit = {
+            commit: '9a1a1d10baece5efbba10bc4ccf808a67a50ac0a',
+            parents: [{
+              commit: '7338aa9adfe57909f1fdaf88975cdea467d3382f',
+              subject: 'Added a carrot',
+            }],
+            author: {
+              name: 'Wyatt Allen',
+              email: 'wyatta@google.com',
+              date: '2016-05-23 21:44:51.000000000',
+              tz: -420,
+            },
+            committer: {
+              name: 'Wyatt Allen',
+              email: 'wyatta@google.com',
+              date: '2016-05-25 00:25:41.000000000',
+              tz: -420,
+            },
+            subject: 'Updated the carrot',
+            message: 'Updated the carrot\n\nChange-Id: Iabcd123\n',
+          };
+          var mockComments = {baseComments: [], comments: []};
 
-        element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
+          stubs.push(sandbox.stub(element.$.restAPI, 'getCommitInfo',
+              function() { return Promise.resolve(mockCommit); }));
+          stubs.push(sandbox.stub(element.$.restAPI,
+              'getCommitFileContents',
+              function() { return Promise.resolve(mockFile1); }));
+          stubs.push(sandbox.stub(element.$.restAPI,
+              'getChangeFileContents',
+              function() { return Promise.resolve(mockFile2); }));
+          stubs.push(sandbox.stub(element.$.restAPI, '_getDiffComments',
+              function() { return Promise.resolve(mockComments); }));
+          stubs.push(sandbox.stub(element.$.restAPI, 'getDiffDrafts',
+              function() { return Promise.resolve(mockComments); }));
 
-        var rendered = function() {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(element.$.diffBuilder._builder, GrDiffBuilderImage);
+          element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
+        });
 
-          // Left image rendered with the parent commit's version of the file.
-          var leftInmage = element.$.diffTable.querySelector('td.left img');
-          assert.isOk(leftInmage);
-          assert.equal(leftInmage.getAttribute('src'),
-              'data:image/bmp;base64, ' + mockFile1.body);
+        test('renders image diffs with same file name', function(done) {
+          var mockDiff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+                lines: 560},
+            intraline_status: 'OK',
+            change_type: 'MODIFIED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot.jpg',
+              'index 2adc47d..f9c2f2c 100644',
+              '--- a/carrot.jpg',
+              '+++ b/carrot.jpg',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          stubs.push(sandbox.stub(element, '_getDiff',
+              function() { return Promise.resolve(mockDiff); }));
 
-          // Right image rendered with this change's revision of the image.
-          var rightInmage = element.$.diffTable.querySelector('td.right img');
-          assert.isOk(rightInmage);
-          assert.equal(rightInmage.getAttribute('src'),
-              'data:image/bmp;base64, ' + mockFile2.body);
+          var rendered = function() {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                  element.$.diffBuilder._builder, GrDiffBuilderImage);
 
-          // Cleanup.
-          element.removeEventListener('render', rendered);
+            // Left image rendered with the parent commit's version of the file.
+            var leftImage = element.$.diffTable.querySelector('td.left img');
+            var leftLabel = element.$.diffTable.querySelector('td.left label');
+            var leftLabelContent = leftLabel.querySelector('.label');
+            var leftLabelName = leftLabel.querySelector('.name');
 
-          done();
-        };
+            var rightImage = element.$.diffTable.querySelector('td.right img');
+            var rightLabel = element.$.diffTable.querySelector(
+                  'td.right label');
+            var rightLabelContent = rightLabel.querySelector('.label');
+            var rightLabelName = rightLabel.querySelector('.name');
 
-        element.addEventListener('render', rendered);
+            assert.isNotOk(rightLabelName);
+            assert.isNotOk(leftLabelName);
 
-        element.$.restAPI.getDiffPreferences().then(function(prefs) {
-          element.prefs = prefs;
-          element.reload();
+            var leftLoaded = false;
+            var rightLoaded = false;
+
+            leftImage.addEventListener('load', function() {
+              assert.isOk(leftImage);
+              assert.equal(leftImage.getAttribute('src'),
+                  'data:image/bmp;base64, ' + mockFile1.body);
+              assert.equal(leftLabelContent.textContent, '1⨉1 image/bmp');
+              leftLoaded = true;
+              if (rightLoaded) {
+                element.removeEventListener('render', rendered);
+                done();
+              }
+            });
+
+            rightImage.addEventListener('load', function() {
+              assert.isOk(rightImage);
+              assert.equal(rightImage.getAttribute('src'),
+                  'data:image/bmp;base64, ' + mockFile2.body);
+              assert.equal(rightLabelContent.textContent, '1⨉1 image/bmp');
+
+              rightLoaded = true;
+              if (leftLoaded) {
+                element.removeEventListener('render', rendered);
+                done();
+              }
+            });
+
+          };
+
+          element.addEventListener('render', rendered);
+
+          element.$.restAPI.getDiffPreferences().then(function(prefs) {
+            element.prefs = prefs;
+            element.reload();
+          });
+        });
+
+        test('renders image diffs with a different file name', function(done) {
+          var mockDiff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+            meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
+                lines: 560},
+            intraline_status: 'OK',
+            change_type: 'MODIFIED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot2.jpg',
+              'index 2adc47d..f9c2f2c 100644',
+              '--- a/carrot.jpg',
+              '+++ b/carrot2.jpg',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          stubs.push(sandbox.stub(element, '_getDiff',
+              function() { return Promise.resolve(mockDiff); }));
+
+          var rendered = function() {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                  element.$.diffBuilder._builder, GrDiffBuilderImage);
+
+            // Left image rendered with the parent commit's version of the file.
+            var leftImage = element.$.diffTable.querySelector('td.left img');
+            var leftLabel = element.$.diffTable.querySelector('td.left label');
+            var leftLabelContent = leftLabel.querySelector('.label');
+            var leftLabelName = leftLabel.querySelector('.name');
+
+            var rightImage = element.$.diffTable.querySelector('td.right img');
+            var rightLabel = element.$.diffTable.querySelector(
+                  'td.right label');
+            var rightLabelContent = rightLabel.querySelector('.label');
+            var rightLabelName = rightLabel.querySelector('.name');
+
+            assert.isOk(rightLabelName);
+            assert.isOk(leftLabelName);
+            assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
+            assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
+
+            var leftLoaded = false;
+            var rightLoaded = false;
+
+            leftImage.addEventListener('load', function() {
+              assert.isOk(leftImage);
+              assert.equal(leftImage.getAttribute('src'),
+                  'data:image/bmp;base64, ' + mockFile1.body);
+              assert.equal(leftLabelContent.textContent, '1⨉1 image/bmp');
+              leftLoaded = true;
+              if (rightLoaded) {
+                element.removeEventListener('render', rendered);
+                done();
+              }
+            });
+
+            rightImage.addEventListener('load', function() {
+              assert.isOk(rightImage);
+              assert.equal(rightImage.getAttribute('src'),
+                  'data:image/bmp;base64, ' + mockFile2.body);
+              assert.equal(rightLabelContent.textContent, '1⨉1 image/bmp');
+
+              rightLoaded = true;
+              if (leftLoaded) {
+                element.removeEventListener('render', rendered);
+                done();
+              }
+            });
+
+          };
+
+          element.addEventListener('render', rendered);
+
+          element.$.restAPI.getDiffPreferences().then(function(prefs) {
+            element.prefs = prefs;
+            element.reload();
+          });
+        });
+
+        test('renders added image', function(done) {
+          var mockDiff = {
+            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+                lines: 560},
+            intraline_status: 'OK',
+            change_type: 'ADDED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot.jpg',
+              'index 0000000..f9c2f2c 100644',
+              '--- /dev/null',
+              '+++ b/carrot.jpg',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          stubs.push(sandbox.stub(element, '_getDiff',
+              function() { return Promise.resolve(mockDiff); }));
+
+          element.addEventListener('render', function() {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                element.$.diffBuilder._builder, GrDiffBuilderImage);
+
+            var leftImage = element.$.diffTable.querySelector('td.left img');
+            var rightImage = element.$.diffTable.querySelector('td.right img');
+
+            assert.isNotOk(leftImage);
+            assert.isOk(rightImage);
+            done();
+          });
+
+          element.$.restAPI.getDiffPreferences().then(function(prefs) {
+            element.prefs = prefs;
+            element.reload();
+          });
+        });
+
+        test('renders removed image', function(done) {
+          var mockDiff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
+                lines: 560},
+            intraline_status: 'OK',
+            change_type: 'DELETED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot.jpg',
+              'index f9c2f2c..0000000 100644',
+              '--- a/carrot.jpg',
+              '+++ /dev/null',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          stubs.push(sandbox.stub(element, '_getDiff',
+              function() { return Promise.resolve(mockDiff); }));
+
+          element.addEventListener('render', function() {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                element.$.diffBuilder._builder, GrDiffBuilderImage);
+
+            var leftImage = element.$.diffTable.querySelector('td.left img');
+            var rightImage = element.$.diffTable.querySelector('td.right img');
+
+            assert.isOk(leftImage);
+            assert.isNotOk(rightImage);
+            done();
+          });
+
+          element.$.restAPI.getDiffPreferences().then(function(prefs) {
+            element.prefs = prefs;
+            element.reload();
+          });
+        });
+
+        test('does not render disallowed image type', function(done) {
+          var mockDiff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
+                lines: 560},
+            intraline_status: 'OK',
+            change_type: 'DELETED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot.jpg',
+              'index f9c2f2c..0000000 100644',
+              '--- a/carrot.jpg',
+              '+++ /dev/null',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          mockFile1.type = 'image/jpeg-evil';
+
+          stubs.push(sandbox.stub(element, '_getDiff',
+              function() { return Promise.resolve(mockDiff); }));
+
+          element.addEventListener('render', function() {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                element.$.diffBuilder._builder, GrDiffBuilderImage);
+            var leftImage = element.$.diffTable.querySelector('td.left img');
+            assert.isNotOk(leftImage);
+            done();
+          });
+
+          element.$.restAPI.getDiffPreferences().then(function(prefs) {
+            element.prefs = prefs;
+            element.reload();
+          });
         });
       });
 
@@ -580,6 +823,20 @@
         });
       });
 
+      test('addDraftAtLine', function(done) {
+        var fakeLineEl = {getAttribute: sandbox.stub().returns(42)};
+        sandbox.stub(element, '_selectLine');
+        sandbox.stub(element, '_addDraft');
+        var loggedInErrorSpy = sandbox.spy();
+        element.addEventListener('show-auth-required', loggedInErrorSpy);
+        element.addDraftAtLine(fakeLineEl);
+        flush(function() {
+          assert.isFalse(loggedInErrorSpy.called);
+          assert.isTrue(element._addDraft.calledWithExactly(fakeLineEl, 42));
+          done();
+        });
+      });
+
       suite('handle comment-update', function() {
 
         setup(function() {
@@ -633,5 +890,38 @@
         });
       });
     });
+
+    suite('diff header', function() {
+      setup(function() {
+        element._diff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+                lines: 560},
+            diff_header: [],
+            intraline_status: 'OK',
+            change_type: 'MODIFIED',
+            content: [{skip: 66}],
+          };
+      });
+
+      test('hidden', function() {
+        assert.equal(element._diffHeaderItems.length, 0);
+        element.push('_diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
+        assert.equal(element._diffHeaderItems.length, 0);
+        element.push('_diff.diff_header', 'index 2adc47d..f9c2f2c 100644');
+        assert.equal(element._diffHeaderItems.length, 0);
+        element.push('_diff.diff_header', '--- a/test.jpg');
+        assert.equal(element._diffHeaderItems.length, 0);
+        element.push('_diff.diff_header', '+++ b/test.jpg');
+        assert.equal(element._diffHeaderItems.length, 0);
+        element.push('_diff.diff_header', 'test');
+        assert.equal(element._diffHeaderItems.length, 1);
+        flushAsynchronousOperations();
+
+        assert.equal(element.$.diffHeader.textContent.trim(), 'test');
+        element.set('_diff.binary', true);
+        assert.equal(element._diffHeaderItems.length, 0);
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
index 5300ef6..1425a79 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
@@ -172,7 +172,7 @@
       var ranges = this.get(['_commentMap', side, lineNum]) || [];
       return ranges
           .map(function(range) {
-            var range = {
+            range = {
               start: range.start,
               end: range.end === -1 ? line.text.length : range.end,
               hovering: !!range.comment.__hovering,
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
index 0914846..808f689 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
@@ -78,7 +78,7 @@
   };
 
   var CPP_DIRECTIVE_WITH_LT_PATTERN = /^\s*#(if|define).*</;
-  var CPP_WCHAR_PATTERN = /L\'.\'/g;
+  var CPP_WCHAR_PATTERN = /L\'(\\)?.\'/g;
   var JAVA_PARAM_ANNOT_PATTERN = /(@[^\s]+)\(([^)]+)\)/g;
   var GO_BACKSLASH_LITERAL = '\'\\\\\'';
   var GLOBAL_LT_PATTERN = /</g;
@@ -360,7 +360,7 @@
          * {#see https://github.com/isagalaev/highlight.js/issues/1412}
          */
         if (CPP_WCHAR_PATTERN.test(line)) {
-          line = line.replace(CPP_WCHAR_PATTERN, 'L"."');
+          line = line.replace(CPP_WCHAR_PATTERN, 'L"$1."');
         }
 
         return line;
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
index eaa8d29..e618d3a 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
@@ -456,6 +456,11 @@
       line = 'wchar_t myChar = L\'#\'';
       var expected = 'wchar_t myChar = L"."';
       assert.equal(element._workaround('cpp', line), expected);
+
+      // Converts wchar_t character literal with escape sequence to string.
+      line = 'wchar_t myChar = L\'\\"\'';
+      expected = 'wchar_t myChar = L"\\."';
+      assert.equal(element._workaround('cpp', line), expected);
     });
 
     test('workaround go backslash character literals', function() {
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index c92610bf..bc88b14 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -18,6 +18,7 @@
 <link rel="import" href="../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../bower_components/polymer/polymer.html">
 <link rel="import" href="../styles/app-theme.html">
+<link rel="import" href="./plugins/gr-plugin-host/gr-plugin-host.html">
 
 <link rel="import" href="./admin/gr-admin-view/gr-admin-view.html">
 
@@ -171,6 +172,9 @@
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-reporting id="reporting"></gr-reporting>
     <gr-router id="router"></gr-router>
+    <gr-plugin-host id="plugins"
+        config="[[_serverConfig.plugin]]">
+    </gr-plugin-host>
   </template>
   <script src="gr-app.js" crossorigin="anonymous"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index d820bc7..90f641d 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -62,7 +62,6 @@
 
     observers: [
       '_viewChanged(params.view)',
-      '_loadPlugins(_serverConfig.plugin.js_resource_paths)',
     ],
 
     behaviors: [
@@ -95,6 +94,7 @@
           selectedFileIndex: 0,
           showReplyDialog: false,
           diffMode: null,
+          numFilesShown: null,
         },
         changeListView: {
           query: null,
@@ -114,7 +114,7 @@
       this.$.restAPI.getPreferences();
       this.$.restAPI.getDiffPreferences();
       this.$.errorManager.knownAccountId =
-        this._account && this._account._account_id || null;
+          this._account && this._account._account_id || null;
     },
 
     _viewChanged: function(view) {
@@ -131,17 +131,6 @@
       }
     },
 
-    _loadPlugins: function(plugins) {
-      Gerrit._setPluginsCount(plugins.length);
-      for (var i = 0; i < plugins.length; i++) {
-        var scriptEl = document.createElement('script');
-        scriptEl.defer = true;
-        scriptEl.src = '/' + plugins[i];
-        scriptEl.onerror = Gerrit._pluginInstalled;
-        document.body.appendChild(scriptEl);
-      }
-    },
-
     _loginTapHandler: function(e) {
       e.preventDefault();
       page.show('/login/' + encodeURIComponent(
diff --git a/polygerrit-ui/app/elements/gr-app_test.html b/polygerrit-ui/app/elements/gr-app_test.html
index 28251fe..35b5ab1 100644
--- a/polygerrit-ui/app/elements/gr-app_test.html
+++ b/polygerrit-ui/app/elements/gr-app_test.html
@@ -18,7 +18,7 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-app</title>
 
-<script src="../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="gr-app.html">
@@ -72,10 +72,8 @@
       element._path = '/test/path';
       flush(function() {
         var gwtLink = element.$$('#gwtLink');
-        assert.equal(
-          gwtLink.href,
-          'http://' + location.host + element.getBaseUrl() + '/?polygerrit=0#/test/path'
-        );
+        assert.equal(gwtLink.href, 'http://' + location.host +
+            element.getBaseUrl() + '/?polygerrit=0#/test/path');
         done();
       });
     });
@@ -100,10 +98,12 @@
       });
     });
 
-    test('sets plugins count', function() {
-      sandbox.stub(Gerrit, '_setPluginsCount');
-      element._loadPlugins([]);
-      assert.isTrue(Gerrit._setPluginsCount.calledWithExactly(0));
+    test('passes config to gr-plugin-host', function(done) {
+      element.$.restAPI.getConfig.lastCall.returnValue.then(function(config) {
+        var pluginConfig = config.plugin;
+        assert.deepEqual(element.$.plugins.config, pluginConfig);
+        done();
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.html b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.html
new file mode 100644
index 0000000..623d304
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.html
@@ -0,0 +1,25 @@
+<!--
+Copyright (C) 2017 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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+
+<dom-module id="gr-external-style">
+  <template>
+    <content></content>
+  </template>
+  <script src="gr-external-style.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
new file mode 100644
index 0000000..94bc534
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
@@ -0,0 +1,39 @@
+// Copyright (C) 2017 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-external-style',
+
+    properties: {
+      name: String,
+    },
+
+    _applyStyle: function(name) {
+      var s = document.createElement('style', 'custom-style');
+      s.setAttribute('include', name);
+      Polymer.dom(this.root).appendChild(s);
+    },
+
+    ready: function() {
+      Gerrit.awaitPluginsLoaded().then(function() {
+        var sharedStyles = Gerrit._styleModules[this.name];
+        if (sharedStyles) {
+          sharedStyles.map(this._applyStyle.bind(this));
+        }
+      }.bind(this));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
new file mode 100644
index 0000000..9c9ab35
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
@@ -0,0 +1,57 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-external-style</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-external-style.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-external-style name="foo"></gr-external-style>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-change-metadata integration tests', function() {
+    var sandbox;
+    var element;
+
+    setup(function() {
+      sandbox = sinon.sandbox.create();
+      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
+      element = fixture('basic');
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('applies plugin-provided styles', function(done) {
+      Gerrit._styleModules = {'foo': ['bar']};
+      sandbox.stub(element, '_applyStyle');
+      flush(function() {
+        assert.isTrue(element._applyStyle.calledWith('bar'));
+        done();
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.html b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.html
new file mode 100644
index 0000000..a3c44e2
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.html
@@ -0,0 +1,22 @@
+<!--
+Copyright (C) 2017 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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+
+<dom-module id="gr-plugin-host">
+  <script src="gr-plugin-host.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
new file mode 100644
index 0000000..ccfe604
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
@@ -0,0 +1,52 @@
+// Copyright (C) 2017 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-plugin-host',
+
+    properties: {
+      config: {
+        type: Object,
+        observer: '_configChanged',
+      },
+    },
+
+    _configChanged: function(config) {
+      var jsPlugins = config.js_resource_paths || [];
+      var htmlPlugins = config.html_resource_paths || [];
+      Gerrit._setPluginsCount(jsPlugins.length + htmlPlugins.length);
+      this._loadJsPlugins(jsPlugins);
+      this._importHtmlPlugins(htmlPlugins);
+    },
+
+    _importHtmlPlugins: function(plugins) {
+      plugins.forEach(function(url) {
+        this.importHref('/' + url, null, Gerrit._pluginInstalled, true);
+      }.bind(this));
+    },
+
+    _loadJsPlugins: function(plugins) {
+      for (var i = 0; i < plugins.length; i++) {
+        var url = plugins[i];
+        var scriptEl = document.createElement('script');
+        scriptEl.defer = true;
+        scriptEl.src = '/' + plugins[i];
+        scriptEl.onerror = Gerrit._pluginInstalled;
+        document.body.appendChild(scriptEl);
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
new file mode 100644
index 0000000..b0c7c71
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-plugin-host</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="gr-plugin-host.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-plugin-host></gr-plugin-host>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff tests', function() {
+    var element;
+    var sandbox;
+
+    setup(function() {
+      element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+      sandbox.stub(document.body, 'appendChild');
+      sandbox.stub(element, 'importHref');
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('counts plugins', function() {
+      sandbox.stub(Gerrit, '_setPluginsCount');
+      element.config = {
+        html_resource_paths: ['foo/bar', 'baz'],
+        js_resource_paths: ['42'],
+      };
+      assert.isTrue(Gerrit._setPluginsCount.calledWith(3));
+    });
+
+    test('imports html plugins from config', function() {
+      element.config = {
+        html_resource_paths: ['foo/bar', 'baz'],
+      };
+      assert.isTrue(element.importHref.calledWith(
+          '/foo/bar', null, Gerrit._pluginInstalled, true));
+      assert.isTrue(element.importHref.calledWith(
+          '/baz', null, Gerrit._pluginInstalled, true));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
index 84846fb..ee7fe97 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
@@ -43,6 +43,7 @@
         type: Function,
         value: function() { return this._handleTransitionEnd.bind(this); },
       },
+      _actionCallback: Function,
     },
 
     attached: function() {
@@ -54,10 +55,11 @@
           this._boundTransitionEndHandler);
     },
 
-    show: function(text, opt_actionText) {
+    show: function(text, opt_actionText, opt_actionCallback) {
       this.text = text;
       this.actionText = opt_actionText;
       this._hideActionButton = !opt_actionText;
+      this._actionCallback = opt_actionCallback;
       document.body.appendChild(this);
       this._setShown(true);
     },
@@ -84,7 +86,7 @@
 
     _handleActionTap: function(e) {
       e.preventDefault();
-      this.fire('action', null, {bubbles: false});
+      if (this._actionCallback) { this._actionCallback(); }
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
index 067ac5b..be21edd 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
@@ -50,9 +50,7 @@
 
     test('action event', function(done) {
       element.show();
-      element.addEventListener('action', function() {
-        done();
-      });
+      element._actionCallback = done;
       MockInteractions.tap(element.$$('.action'));
     });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
index aeb7e5f..d19afb7 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
@@ -83,7 +83,7 @@
         cursor-target-class="selected"
         scroll-behavior="keep-visible"
         focus-on-move
-        stops="[[_getSuggestionElems(_suggestions)]]"></gr-cursor-manager>
+        stops="[[_suggestionEls]]"></gr-cursor-manager>
   </template>
   <script src="gr-autocomplete.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
index 9ebb794..d7406db 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
@@ -110,6 +110,11 @@
         value: function() { return []; },
       },
 
+      _suggestionEls: {
+        type: Array,
+        value: function() { return []; },
+      },
+
       _index: Number,
 
       _disableSuggestions: {
@@ -178,6 +183,8 @@
           return;
         }
         this._suggestions = suggestions;
+        Polymer.dom.flush();
+        this._suggestionEls = this.$.suggestions.querySelectorAll('li');
         this.$.cursor.moveToStart();
         if (this._index === -1) {
           this.value = null;
@@ -193,11 +200,6 @@
       return borderless ? 'borderless' : '';
     },
 
-    _getSuggestionElems: function() {
-      Polymer.dom.flush();
-      return this.$.suggestions.querySelectorAll('li');
-    },
-
     /**
      * _handleKeydown used for key handling in the this.$.input AND all child
      * autocomplete options.
@@ -236,8 +238,11 @@
     },
 
     _cancel: function() {
-      this._suggestions = [];
-      this.fire('cancel');
+      if (this._suggestions.length) {
+        this._suggestions = [];
+      } else {
+        this.fire('cancel');
+      }
     },
 
     _updateValue: function(suggestions, index) {
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
index 4234388..14116cd 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
@@ -35,14 +35,20 @@
 <script>
   suite('gr-autocomplete tests', function() {
     var element;
+    var sandbox;
 
     setup(function() {
       element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(function() {
+      sandbox.restore();
     });
 
     test('renders', function(done) {
       var promise;
-      var queryStub = sinon.spy(function(input) {
+      var queryStub = sandbox.spy(function(input) {
         return promise = Promise.resolve([
           {name: input + ' 0', value: 0},
           {name: input + ' 1', value: 1},
@@ -76,9 +82,9 @@
       });
     });
 
-    test('emits cancel', function(done) {
+    test('esc key behavior', function(done) {
       var promise;
-      var queryStub = sinon.spy(function() {
+      var queryStub = sandbox.spy(function() {
         return promise = Promise.resolve([
           {name: 'blah', value: 123},
         ]);
@@ -93,20 +99,23 @@
       promise.then(function() {
         assert.isFalse(element.$.suggestions.hasAttribute('hidden'));
 
-        var cancelHandler = sinon.spy();
+        var cancelHandler = sandbox.spy();
         element.addEventListener('cancel', cancelHandler);
 
         MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
-        assert.isTrue(cancelHandler.called);
+        assert.isFalse(cancelHandler.called);
         assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
+        assert.equal(element._suggestions.length, 0);
 
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
+        assert.isTrue(cancelHandler.called);
         done();
       });
     });
 
     test('emits commit and handles cursor movement', function(done) {
       var promise;
-      var queryStub = sinon.spy(function(input) {
+      var queryStub = sandbox.spy(function(input) {
         return promise = Promise.resolve([
           {name: input + ' 0', value: 0},
           {name: input + ' 1', value: 1},
@@ -125,7 +134,7 @@
       promise.then(function() {
         assert.isFalse(element.$.suggestions.hasAttribute('hidden'));
 
-        var commitHandler = sinon.spy();
+        var commitHandler = sandbox.spy();
         element.addEventListener('commit', commitHandler);
 
         assert.equal(element.$.cursor.index, 0);
@@ -158,14 +167,14 @@
 
     test('clear-on-commit behavior (off)', function(done) {
       var promise;
-      var queryStub = sinon.spy(function() {
+      var queryStub = sandbox.spy(function() {
         return promise = Promise.resolve([{name: 'suggestion', value: 0}]);
       });
       element.query = queryStub;
       element.text = 'blah';
 
       promise.then(function() {
-        var commitHandler = sinon.spy();
+        var commitHandler = sandbox.spy();
         element.addEventListener('commit', commitHandler);
 
         MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
@@ -179,7 +188,7 @@
 
     test('clear-on-commit behavior (on)', function(done) {
       var promise;
-      var queryStub = sinon.spy(function() {
+      var queryStub = sandbox.spy(function() {
         return promise = Promise.resolve([{name: 'suggestion', value: 0}]);
       });
       element.query = queryStub;
@@ -187,7 +196,7 @@
       element.clearOnCommit = true;
 
       promise.then(function() {
-        var commitHandler = sinon.spy();
+        var commitHandler = sandbox.spy();
         element.addEventListener('commit', commitHandler);
 
         MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
@@ -200,7 +209,7 @@
     });
 
     test('threshold guards the query', function() {
-      var queryStub = sinon.spy(function() {
+      var queryStub = sandbox.spy(function() {
         return Promise.resolve([]);
       });
       element.query = queryStub;
@@ -223,7 +232,7 @@
     });
 
     test('undefined or empty text results in no suggestions', function() {
-      sinon.spy(element, '_updateSuggestions');
+      sandbox.spy(element, '_updateSuggestions');
       element.text = undefined;
       assert(element._updateSuggestions.calledOnce);
       assert.equal(element._suggestions.length, 0);
@@ -231,14 +240,14 @@
 
     test('multi completes only the last part of the query', function(done) {
       var promise;
-      var queryStub = sinon.stub()
+      var queryStub = sandbox.stub()
           .returns(promise = Promise.resolve([{name: 'suggestion', value: 0}]));
       element.query = queryStub;
       element.text = 'blah blah';
       element.multi = true;
 
       promise.then(function() {
-        var commitHandler = sinon.spy();
+        var commitHandler = sandbox.spy();
         element.addEventListener('commit', commitHandler);
 
         MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
@@ -251,18 +260,17 @@
     });
 
     test('tab key completes only when suggestions exist', function() {
-      var commitStub = sinon.stub(element, '_commit');
+      var commitStub = sandbox.stub(element, '_commit');
       element._suggestions = [];
       MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
       assert.isFalse(commitStub.called);
       element._suggestions = ['tunnel snakes rule!'];
       MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
       assert.isTrue(commitStub.called);
-      commitStub.restore();
     });
 
     test('tabCompleteWithoutCommit flag functions', function() {
-      var commitHandler = sinon.spy();
+      var commitHandler = sandbox.spy();
       element.addEventListener('commit', commitHandler);
       element._suggestions = ['tunnel snakes rule!'];
       element.tabCompleteWithoutCommit = true;
@@ -291,10 +299,11 @@
     });
 
     test('tap on suggestion commits and refocuses on input', function() {
-      var focusSpy = sinon.spy(element, 'focus');
-      var commitSpy = sinon.spy(element, '_commit');
+      var focusSpy = sandbox.spy(element, 'focus');
+      var commitSpy = sandbox.spy(element, '_commit');
       element._focused = true;
       element._suggestions = [{name: 'first suggestion'}];
+      Polymer.dom.flush();
       assert.isFalse(element.$.suggestions.hasAttribute('hidden'));
       MockInteractions.tap(element.$$('#suggestions li:first-child'));
       flushAsynchronousOperations();
@@ -302,12 +311,10 @@
       assert.isTrue(commitSpy.called);
       assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
       assert.isTrue(element._focused);
-      focusSpy.restore();
-      commitSpy.restore();
     });
 
     test('input-keydown event fired', function() {
-      var listener = sinon.spy();
+      var listener = sandbox.spy();
       element.addEventListener('input-keydown', listener);
       MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
       flushAsynchronousOperations();
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
index d4df97d..5a40e6f 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
@@ -23,6 +23,12 @@
      * @event tap-item-<id>
      */
 
+    /**
+     * Fired when a non-link dropdown item is tapped.
+     *
+     * @event tap-item
+     */
+
     properties: {
       items: Array,
       topContent: Object,
@@ -67,7 +73,11 @@
     },
 
     _handleDropdownTap: function(e) {
-      this.$.dropdown.close();
+      // async is needed so that that the click event is fired before the
+      // dropdown closes (This was a bug for touch devices).
+      this.async(function() {
+        this.$.dropdown.close();
+      }, 1);
     },
 
     _showDropdownTapHandler: function(e) {
@@ -100,7 +110,13 @@
 
     _handleItemTap: function(e) {
       var id = e.target.getAttribute('data-id');
+      var item = this.items.find(function(item) {
+        return item.id === id;
+      });
       if (id && this.disabledIds.indexOf(id) === -1) {
+        if (item) {
+          this.dispatchEvent(new CustomEvent('tap-item', {detail: item}));
+        }
         this.dispatchEvent(new CustomEvent('tap-item-' + id));
       }
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
index 74ad85e..5b2d048 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
@@ -90,13 +90,17 @@
     });
 
     test('non link items', function() {
-      element.items = [
-          {name: 'item one', id: 'foo'}, {name: 'item two', id: 'bar'}];
-      var stub = sinon.stub();
-      element.addEventListener('tap-item-foo', stub);
+      var item0 = {name: 'item one', id: 'foo'};
+      element.items = [item0, {name: 'item two', id: 'bar'}];
+      var fooTapped = sinon.stub();
+      var tapped = sinon.stub();
+      element.addEventListener('tap-item-foo', fooTapped);
+      element.addEventListener('tap-item', tapped);
       flushAsynchronousOperations();
       MockInteractions.tap(element.$$('.itemAction'));
-      assert.isTrue(stub.called);
+      assert.isTrue(fooTapped.called);
+      assert.isTrue(tapped.called);
+      assert.deepEqual(tapped.lastCall.args[0].detail, item0);
     });
 
     test('disabled non link item', function() {
@@ -104,10 +108,13 @@
       element.disabledIds = ['foo'];
 
       var stub = sinon.stub();
+      var tapped = sinon.stub();
       element.addEventListener('tap-item-foo', stub);
+      element.addEventListener('tap-item', tapped);
       flushAsynchronousOperations();
       MockInteractions.tap(element.$$('.itemAction'));
       assert.isFalse(stub.called);
+      assert.isFalse(tapped.called);
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html
index d719f70..7939175 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html
@@ -29,6 +29,11 @@
       gr-linked-text.pre {
         margin: 0 0 1.4em 0;
       }
+      p,
+      ul,
+      blockquote {
+        max-width: var(--gr-formatted-text-prose-max-width, none);
+      }
       :host.noTrailingMargin p:last-child,
       :host.noTrailingMargin ul:last-child,
       :host.noTrailingMargin blockquote:last-child,
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
index 72c7f6e..9bcde07 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
@@ -33,6 +33,16 @@
     });
   };
 
+  GrChangeActionsInterface.prototype.setActionOverflow = function(type, key,
+      overflow) {
+    return this._el.setActionOverflow(type, key, overflow);
+  };
+
+  GrChangeActionsInterface.prototype.setActionPriority = function(type, key,
+      priority) {
+    return this._el.setActionPriority(type, key, priority);
+  };
+
   GrChangeActionsInterface.prototype.setActionHidden = function(type, key,
       hidden) {
     return this._el.setActionHidden(type, key, hidden);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
index 20b5dcb..d964cde 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
@@ -130,8 +130,8 @@
         var button = element.$$('[data-action-key="' + key + '"]');
         assert.isOk(button);
         assert.isFalse(button.hasAttribute('hidden'));
-        changeActions.setActionHidden(changeActions.ActionType.REVISION, key,
-            true);
+        changeActions.setActionHidden(
+            changeActions.ActionType.REVISION, key, true);
         flush(function() {
           var button = element.$$('[data-action-key="' + key + '"]');
           assert.isNotOk(button);
@@ -139,5 +139,41 @@
         });
       });
     });
+
+    test('move action button to overflow', function(done) {
+      var key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      flush(function() {
+        assert.isTrue(element.$.moreActions.hidden);
+        assert.isOk(element.$$('[data-action-key="' + key + '"]'));
+        changeActions.setActionOverflow(
+            changeActions.ActionType.REVISION, key, true);
+        flush(function() {
+          assert.isNotOk(element.$$('[data-action-key="' + key + '"]'));
+          assert.isFalse(element.$.moreActions.hidden);
+          assert.strictEqual(element.$.moreActions.items[0].name, 'Bork!');
+          done();
+        });
+      });
+    });
+
+    test('change actions priority', function(done) {
+      var key1 = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      var key2 = changeActions.add(changeActions.ActionType.CHANGE, 'Squanch?');
+      flush(function() {
+        var buttons =
+            Polymer.dom(element.root).querySelectorAll('[data-action-key]');
+        assert.equal(buttons[0].getAttribute('data-action-key'), key1);
+        assert.equal(buttons[1].getAttribute('data-action-key'), key2);
+        changeActions.setActionPriority(
+            changeActions.ActionType.REVISION, key1, 10);
+        flush(function() {
+          buttons =
+              Polymer.dom(element.root).querySelectorAll('[data-action-key]');
+          assert.equal(buttons[0].getAttribute('data-action-key'), key2);
+          assert.equal(buttons[1].getAttribute('data-action-key'), key1);
+          done();
+        });
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
index b3ae649..3c8c4a0 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
@@ -54,6 +54,14 @@
     return this._name;
   };
 
+  Plugin.prototype.registerStyleModule =
+      function(stylingEndpointName, moduleName) {
+    if (!Gerrit._styleModules[stylingEndpointName]) {
+      Gerrit._styleModules[stylingEndpointName] = [];
+    }
+    Gerrit._styleModules[stylingEndpointName].push(moduleName);
+  };
+
   Plugin.prototype.getServerInfo = function() {
     return document.createElement('gr-rest-api-interface').getConfig();
   };
@@ -81,6 +89,9 @@
   // Number of plugins to initialize, -1 means 'not yet known'.
   Gerrit._pluginsPending = -1;
 
+  // Hash of style modules to be applied, insertion point to shared style name.
+  Gerrit._styleModules = {};
+
   Gerrit.getPluginName = function() {
     console.warn('Gerrit.getPluginName is not supported in PolyGerrit.',
         'Please use self.getPluginName() instead.');
@@ -107,7 +118,8 @@
     }
 
     // TODO(andybons): Polyfill currentScript for IE10/11 (edge supports it).
-    var src = opt_src || (document.currentScript && document.currentScript.src);
+    var src = opt_src || (document.currentScript &&
+         document.currentScript.src || document.currentScript.baseURI);
     var plugin = new Plugin(src);
     try {
       callback(plugin);
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index 66a69a1..dab22d6 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -443,7 +443,7 @@
         O: options,
         q: [
           'is:open owner:self',
-          'is:open ((reviewer:self -owner:self -star:ignore) OR assignee:self)',
+          'is:open ((reviewer:self -owner:self -is:ignored) OR assignee:self)',
           'is:closed (owner:self OR reviewer:self OR assignee:self) -age:4w ' +
             'limit:10',
         ],
@@ -583,7 +583,7 @@
           body = {reviewer: reviewerID};
           break;
         case 'DELETE':
-          url += '/' + reviewerID;
+          url += '/' + encodeURIComponent(reviewerID);
           break;
         default:
           throw Error('Unsupported HTTP method: ' + method);
@@ -666,6 +666,58 @@
       return this.send('POST', url, review, opt_errFn, opt_ctx);
     },
 
+    getFileInChangeEdit: function(changeNum, path) {
+      return this.send('GET',
+          this.getChangeActionURL(changeNum, null,
+              '/edit/' + encodeURIComponent(path)
+          ));
+    },
+
+    rebaseChangeEdit: function(changeNum) {
+      return this.send('POST',
+          this.getChangeActionURL(changeNum, null,
+              '/edit:rebase'
+          ));
+    },
+
+    deleteChangeEdit: function(changeNum) {
+      return this.send('DELETE',
+          this.getChangeActionURL(changeNum, null,
+              '/edit'
+          ));
+    },
+
+    restoreFileInChangeEdit: function(changeNum, restore_path) {
+      return this.send('POST',
+          this.getChangeActionURL(changeNum, null, '/edit'),
+          {restore_path: restore_path}
+      );
+    },
+
+    renameFileInChangeEdit: function(changeNum, old_path, new_path) {
+      return this.send('POST',
+          this.getChangeActionURL(changeNum, null, '/edit'),
+          {old_path: old_path},
+          {new_path: new_path}
+      );
+    },
+
+    deleteFileInChangeEdit: function(changeNum, path) {
+      return this.send('DELETE',
+          this.getChangeActionURL(changeNum, null,
+              '/edit/' + encodeURIComponent(path)
+          ));
+    },
+
+    saveChangeEdit: function(changeNum, path, contents) {
+      return this.send('PUT',
+          this.getChangeActionURL(changeNum, null,
+              '/edit/' + encodeURIComponent(path)
+          ),
+          contents
+      );
+    },
+
     saveChangeCommitMessageEdit: function(changeNum, message) {
       var url = this.getChangeActionURL(changeNum, null, '/edit:message');
       return this.send('PUT', url, {message: message});
@@ -911,7 +963,8 @@
     },
 
     _fetchB64File: function(url) {
-      return fetch(this.getBaseUrl() + url, {credentials: 'same-origin'}).then(function(response) {
+      return fetch(this.getBaseUrl() + url, {credentials: 'same-origin'}).then(
+            function(response) {
         var type = response.headers.get('X-FYI-Content-Type');
         return response.text()
           .then(function(text) {
@@ -975,9 +1028,11 @@
           // Sometimes the server doesn't send back the content type.
           if (baseImage) {
             baseImage._expectedType = diff.meta_a.content_type;
+            baseImage._name = diff.meta_a.name;
           }
           if (revisionImage) {
             revisionImage._expectedType = diff.meta_b.content_type;
+            revisionImage._name = diff.meta_b.name;
           }
 
           return {baseImage: baseImage, revisionImage: revisionImage};
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
index 31a98d9..0ff162d 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
@@ -595,5 +595,25 @@
         });
       });
     });
+
+    test('saveChangeEdit', function(done) {
+      var change_num = '1';
+      var file_name = 'index.php';
+      var file_contents = '<?php';
+      sandbox.stub(element, 'send').returns(
+          Promise.resolve([change_num, file_name, file_contents])
+      );
+      sandbox.stub(element, 'getResponseObject')
+          .returns(Promise.resolve([change_num, file_name, file_contents]));
+      element._cache['/changes/' + change_num + '/edit/' + file_name] = {};
+      element.saveChangeEdit(change_num, file_name, file_contents).then(
+          function() {
+            assert.isTrue(element.send.calledWith('PUT',
+                '/changes/' + change_num + '/edit/' + file_name,
+                file_contents));
+            done();
+          }
+      );
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html
index 81e65e3..58f8e39 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html
@@ -19,8 +19,8 @@
 
 <dom-module id="gr-tooltip-content">
   <template>
-    <content></content>
-    <span class="arrow" hidden$="[[!showIcon]]">&#9432;</span>
+    <content></content><!--
+ --><span class="arrow" hidden$="[[!showIcon]]">&#9432;</span>
   </template>
   <script src="gr-tooltip-content.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 4dcc9a8..aba33f6 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -34,13 +34,15 @@
     'change/gr-account-list/gr-account-list_test.html',
     'change/gr-change-actions/gr-change-actions_test.html',
     'change/gr-change-metadata/gr-change-metadata_test.html',
+    'change/gr-change-metadata/gr-change-metadata-it_test.html',
     'change/gr-change-view/gr-change-view_test.html',
     'change/gr-comment-list/gr-comment-list_test.html',
     'change/gr-commit-info/gr-commit-info_test.html',
+    'change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html',
     'change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html',
     'change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html',
-    'change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html',
     'change/gr-download-dialog/gr-download-dialog_test.html',
+    'change/gr-label-scores/gr-label-scores_test.html',
     'change/gr-file-list/gr-file-list_test.html',
     'change/gr-message/gr-message_test.html',
     'change/gr-messages-list/gr-messages-list_test.html',
@@ -53,8 +55,8 @@
     'core/gr-reporting/gr-reporting_test.html',
     'core/gr-search-bar/gr-search-bar_test.html',
     'diff/gr-diff-builder/gr-diff-builder_test.html',
-    'diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html',
     'diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html',
+    'diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html',
     'diff/gr-diff-comment/gr-diff-comment_test.html',
     'diff/gr-diff-cursor/gr-diff-cursor_test.html',
     'diff/gr-diff-highlight/gr-annotation_test.html',
@@ -71,6 +73,8 @@
     'diff/gr-syntax-layer/gr-syntax-layer_test.html',
     'diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html',
     'gr-app_test.html',
+    'plugins/gr-external-style/gr-external-style_test.html',
+    'plugins/gr-plugin-host/gr-plugin-host_test.html',
     'settings/gr-account-info/gr-account-info_test.html',
     'settings/gr-change-table-editor/gr-change-table-editor_test.html',
     'settings/gr-email-editor/gr-email-editor_test.html',
diff --git a/polygerrit-ui/app/wct_test.sh b/polygerrit-ui/app/wct_test.sh
index e6f3e0e..e81adfb 100755
--- a/polygerrit-ui/app/wct_test.sh
+++ b/polygerrit-ui/app/wct_test.sh
@@ -36,10 +36,10 @@
         'sauce': {
           'disabled': true,
           'browsers': [
-            'OS X 10.11/chrome',
+            'OS X 10.12/chrome',
             'Windows 10/chrome',
             'Linux/firefox',
-            'OS X 10.11/safari',
+            'OS X 10.12/safari',
             'Windows 10/microsoftedge'
           ]
         }
diff --git a/tools/bazel.rc b/tools/bazel.rc
index 4ed16cf..ab974d9 100644
--- a/tools/bazel.rc
+++ b/tools/bazel.rc
@@ -1,2 +1,2 @@
-build --workspace_status_command=./tools/workspace-status.sh
+build --workspace_status_command=./tools/workspace-status.sh --strategy=Closure=worker
 test --build_tests_only
diff --git a/tools/version.py b/tools/version.py
index 2603829..fed6d5d 100755
--- a/tools/version.py
+++ b/tools/version.py
@@ -53,7 +53,3 @@
 
 src_pattern = re.compile(r'^(GERRIT_VERSION = ")([-.\w]+)(")$', re.MULTILINE)
 replace_in_file('version.bzl', src_pattern)
-
-src_pattern = re.compile(r'^(\s*-DarchetypeVersion=)([-.\w]+)(\s*\\)$',
-                         re.MULTILINE)
-replace_in_file(os.path.join('Documentation', 'dev-plugins.txt'), src_pattern)
diff --git a/version.bzl b/version.bzl
index 96961f7..488a83f 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "2.14"
+GERRIT_VERSION = "2.15-SNAPSHOT"